Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/api/src/__tests__/session-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,79 @@ describe("SessionService", () => {
[userId],
);

await storage.disconnect();
}
});
it("rejects refresh after the session is revoked for device loss", async () => {
const storage = new PostgresStorage(databaseUrl);
const repository = new SessionRepository(storage);
const service = new SessionService(repository);

const userId = randomUUID();
const deviceId = randomUUID();
const refreshToken = generateRefreshToken();

try {
await storage.connect();

await storage.query(
`
INSERT INTO users (id)
VALUES ($1)
`,
[userId],
);

await storage.query(
`
INSERT INTO devices (
id,
user_id,
platform,
name
)
VALUES ($1, $2, $3, $4)
`,
[deviceId, userId, "test", "device-loss-test"],
);

const session = await repository.createSession({
userId,
deviceId,
refreshTokenHash: hashRefreshToken(refreshToken),
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
idleExpiresAt: new Date(Date.now() + 15 * 60 * 1000),
});

const revoked = await service.revoke(session.id, "device_lost");

expect(revoked).not.toBeNull();
expect(revoked?.status).toBe("revoked");
expect(revoked?.revokedReason).toBe("device_lost");

await expect(
service.refresh({
refreshToken,
idleTimeoutMs: 15 * 60 * 1000,
}),
).rejects.toThrow("Auth session is not active");
} finally {
await storage.query(
`
DELETE FROM devices
WHERE id = $1
`,
[deviceId],
);

await storage.query(
`
DELETE FROM users
WHERE id = $1
`,
[userId],
);

await storage.disconnect();
}
});
Expand Down
115 changes: 115 additions & 0 deletions packages/wallet-core/src/__tests__/transaction-engine-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";

import { DefaultTransactionEngine } from "../transaction-engine.js";

describe("transaction engine end-to-end", () => {
function createTransaction(
overrides: Partial<{
id: string;
chain: "evm" | "solana" | "bitcoin";
status: "draft" | "signed" | "submitted" | "pending" | "confirmed" | "failed";
assetId: string;
amount: string;
}> = {},
) {
return {
id: overrides.id ?? "tx-day28-001",
chain: overrides.chain ?? "bitcoin",
status: overrides.status ?? "draft",
assetId: overrides.assetId ?? "btc",
amount: overrides.amount ?? "100000",
createdAt: new Date().toISOString(),
};
}

it("creates and tracks a transaction through the lifecycle", () => {
const engine = new DefaultTransactionEngine();

const created = engine.create(createTransaction());

expect(created.status).toBe("draft");
expect(engine.getById(created.id)).toEqual(created);

const signed = engine.transition(created.id, "signed");
expect(signed.status).toBe("signed");

const submitted = engine.transition(created.id, "submitted");
expect(submitted.status).toBe("submitted");

const pending = engine.transition(created.id, "pending");
expect(pending.status).toBe("pending");

const confirmed = engine.transition(created.id, "confirmed");
expect(confirmed.status).toBe("confirmed");

expect(engine.getById(created.id)?.status).toBe("confirmed");
});

it("handles duplicate requests idempotently", () => {
const engine = new DefaultTransactionEngine();

const transaction = createTransaction({
id: "tx-day28-idempotent",
});

const first = engine.createIdempotent("request-001", transaction);
const second = engine.createIdempotent("request-001", transaction);

expect(second).toEqual(first);
expect(second.id).toBe(first.id);
});

it("rejects reuse of an idempotency key for a different transaction", () => {
const engine = new DefaultTransactionEngine();

engine.createIdempotent(
"request-002",
createTransaction({
id: "tx-day28-original",
amount: "100000",
}),
);

expect(() =>
engine.createIdempotent(
"request-002",
createTransaction({
id: "tx-day28-different",
amount: "200000",
}),
),
).toThrow("Idempotency key already used: request-002");
});

it("rejects invalid lifecycle transitions", () => {
const engine = new DefaultTransactionEngine();

const transaction = engine.create(createTransaction());

expect(() => engine.transition(transaction.id, "confirmed")).toThrow(
"Invalid transaction transition: draft -> confirmed",
);

expect(() => engine.transition("missing-transaction", "signed")).toThrow(
"Transaction not found: missing-transaction",
);
});

it("prevents changes after terminal confirmation", () => {
const engine = new DefaultTransactionEngine();

const transaction = engine.create(createTransaction());

engine.transition(transaction.id, "signed");
engine.transition(transaction.id, "submitted");
engine.transition(transaction.id, "pending");

const confirmed = engine.transition(transaction.id, "confirmed");

expect(() => engine.transition(confirmed.id, "pending")).toThrow(
"Invalid transaction transition: confirmed -> pending",
);

expect(engine.getById(confirmed.id)?.status).toBe("confirmed");
});
});
77 changes: 77 additions & 0 deletions packages/wallet-core/src/__tests__/wallet-lifecycle-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
MemorySecureStorageAdapter,
type SecureStorageOptions,
} from "@crypto-wallet/secure-storage";
import { describe, expect, it } from "vitest";

import { createWallet, MNEMONIC_STORAGE_KEY } from "../index.js";

describe("wallet lifecycle end-to-end", () => {
const options: SecureStorageOptions = {
inactivityTimeoutMs: 60_000,
};

it("creates, persists, locks, unlocks, and recovers a wallet", async () => {
const adapter = new MemorySecureStorageAdapter();
const password = "day28-test-password";

const firstSession = createWallet(adapter, options);

expect(await firstSession.lifecycle.exists()).toBe(false);

const created = await firstSession.lifecycle.create(password);

expect(created.mnemonic).toBeTruthy();
expect(await firstSession.lifecycle.exists()).toBe(true);

firstSession.vault.lock();

expect(firstSession.vault.state.locked).toBe(true);

const secondSession = createWallet(adapter, options);

expect(await secondSession.lifecycle.exists()).toBe(true);

await secondSession.vault.unlock(password);

const storedMnemonic = secondSession.vault.get(MNEMONIC_STORAGE_KEY);

expect(storedMnemonic).not.toBeNull();
expect(new TextDecoder().decode(storedMnemonic!)).toBe(created.mnemonic);
});

it("restores a wallet from a valid recovery mnemonic", async () => {
const sourceAdapter = new MemorySecureStorageAdapter();
const sourceSession = createWallet(sourceAdapter, options);

const { mnemonic } = await sourceSession.lifecycle.create("source-password");

const restoreAdapter = new MemorySecureStorageAdapter();
const restoreSession = createWallet(restoreAdapter, options);

expect(await restoreSession.lifecycle.exists()).toBe(false);

await restoreSession.lifecycle.restore("restore-password", mnemonic);

expect(await restoreSession.lifecycle.exists()).toBe(true);

const storedMnemonic = restoreSession.vault.get(MNEMONIC_STORAGE_KEY);

expect(storedMnemonic).not.toBeNull();
expect(new TextDecoder().decode(storedMnemonic!)).toBe(mnemonic);
});

it("rejects an invalid recovery mnemonic without persisting it", async () => {
const adapter = new MemorySecureStorageAdapter();
const session = createWallet(adapter, options);

await expect(
session.lifecycle.restore(
"restore-password",
"this is definitely not a valid recovery mnemonic",
),
).rejects.toThrow("Invalid wallet mnemonic");

expect(await session.lifecycle.exists()).toBe(false);
});
});
Loading
Loading