mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +00:00
242 lines
9.0 KiB
TypeScript
242 lines
9.0 KiB
TypeScript
/**
|
|
* Custody client tests.
|
|
*
|
|
* Runner: Node's built-in test runner (`node --test`) — the repo's one way
|
|
* (see lib/derive.test.ts, store/send.test.ts). Node 22+ strips TS types and
|
|
* `node:test` is stdlib, so no extra dependency.
|
|
*
|
|
* We mock only `fetch` (the client takes an injectable `fetchImpl`) and seed a
|
|
* lux.id token into a sessionStorage polyfill so the REAL `lib/iam`
|
|
* `getAccessToken` path runs. Assertions:
|
|
* - bearer header attached on every request,
|
|
* - org/owner/tenant is NEVER sent by the client (server derives it),
|
|
* - exact endpoints + bodies match `apps/backend/internal/api/api.go`,
|
|
* - sign refuses without an idempotency key (anti-replay), and sends it when
|
|
* present,
|
|
* - backend `{error}` bodies become typed CustodyError,
|
|
* - returned EVM addresses are validated at the boundary.
|
|
*/
|
|
import { test, beforeEach } from "node:test"
|
|
import assert from "node:assert/strict"
|
|
import { brand } from "@luxfi/wallet-brand"
|
|
import {
|
|
createWallet,
|
|
listWallets,
|
|
getWallet,
|
|
signPayload,
|
|
CustodyError,
|
|
NotAuthenticatedError,
|
|
} from "./custody"
|
|
|
|
/* ── sessionStorage polyfill + token seeding ─────────────────────────────── */
|
|
|
|
class MemStorage {
|
|
private m = new Map<string, string>()
|
|
getItem(k: string): string | null {
|
|
return this.m.has(k) ? this.m.get(k)! : null
|
|
}
|
|
setItem(k: string, v: string): void {
|
|
this.m.set(k, String(v))
|
|
}
|
|
removeItem(k: string): void {
|
|
this.m.delete(k)
|
|
}
|
|
clear(): void {
|
|
this.m.clear()
|
|
}
|
|
}
|
|
;(globalThis as unknown as { sessionStorage: MemStorage }).sessionStorage = new MemStorage()
|
|
|
|
const SS_TOKENS = "lux-wallet/oidc/tokens"
|
|
const TEST_TOKEN = "test.jwt.token"
|
|
|
|
function seedToken(): void {
|
|
sessionStorage.setItem(
|
|
SS_TOKENS,
|
|
JSON.stringify({ accessToken: TEST_TOKEN, expiresAt: Date.now() + 3_600_000 }),
|
|
)
|
|
}
|
|
|
|
/** A recorded fetch call + a programmable response factory. */
|
|
interface Captured {
|
|
url: string
|
|
init: RequestInit
|
|
}
|
|
|
|
function mockFetch(
|
|
status: number,
|
|
body: unknown,
|
|
captured: Captured[],
|
|
): typeof fetch {
|
|
return (async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
|
captured.push({ url: String(input), init })
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
text: async () => (body === undefined ? "" : JSON.stringify(body)),
|
|
} as Response
|
|
}) as unknown as typeof fetch
|
|
}
|
|
|
|
beforeEach(() => {
|
|
sessionStorage.clear()
|
|
brand.walletApi = "https://wallet-api.test"
|
|
brand.iamIssuer = "https://lux.id"
|
|
brand.iamClientId = "lux-wallet"
|
|
seedToken()
|
|
})
|
|
|
|
/* ── auth ─────────────────────────────────────────────────────────────────── */
|
|
|
|
test("unauthenticated → NotAuthenticatedError, no fetch issued", async () => {
|
|
sessionStorage.clear() // no token
|
|
const captured: Captured[] = []
|
|
await assert.rejects(
|
|
() => listWallets(mockFetch(200, { wallets: [] }, captured)),
|
|
NotAuthenticatedError,
|
|
)
|
|
assert.equal(captured.length, 0, "must not call backend without a token")
|
|
})
|
|
|
|
test("every request attaches the lux.id bearer token", async () => {
|
|
const captured: Captured[] = []
|
|
await listWallets(mockFetch(200, { wallets: [] }, captured))
|
|
const auth = new Headers(captured[0]!.init.headers).get("authorization")
|
|
assert.equal(auth, `Bearer ${TEST_TOKEN}`)
|
|
})
|
|
|
|
/* ── createWallet ───────────────────────────────────────────────────────────── */
|
|
|
|
test("createWallet: POST /v1/wallets, NO body (org from token), validates result", async () => {
|
|
const captured: Captured[] = []
|
|
const wallet = {
|
|
walletId: "w-1",
|
|
ecdsaPubKey: "0xpub",
|
|
addresses: { evm: "0x52908400098527886E0F7030069857D2E4169EE7", btc: "bc1xyz" },
|
|
}
|
|
const w = await createWallet(mockFetch(201, wallet, captured))
|
|
|
|
assert.equal(captured.length, 1)
|
|
assert.equal(captured[0]!.url, "https://wallet-api.test/v1/wallets")
|
|
assert.equal(captured[0]!.init.method, "POST")
|
|
// Org/owner/tenant must NEVER be sent — the server derives it from the JWT.
|
|
assert.equal(captured[0]!.init.body, undefined, "create must send no body")
|
|
assert.equal(w.walletId, "w-1")
|
|
assert.equal(w.addresses.evm, "0x52908400098527886E0F7030069857D2E4169EE7")
|
|
})
|
|
|
|
test("createWallet: rejects an invalid EVM address at the boundary", async () => {
|
|
const captured: Captured[] = []
|
|
const bad = { walletId: "w-1", addresses: { evm: "0xdeadbeef" } } // too short
|
|
await assert.rejects(
|
|
() => createWallet(mockFetch(201, bad, captured)),
|
|
(e: unknown) => e instanceof CustodyError && /invalid EVM address/.test((e as Error).message),
|
|
)
|
|
})
|
|
|
|
test("createWallet: rejects a response missing walletId", async () => {
|
|
const captured: Captured[] = []
|
|
await assert.rejects(
|
|
() => createWallet(mockFetch(201, { addresses: {} }, captured)),
|
|
(e: unknown) => e instanceof CustodyError && /missing walletId/.test((e as Error).message),
|
|
)
|
|
})
|
|
|
|
/* ── listWallets / getWallet ─────────────────────────────────────────────────── */
|
|
|
|
test("listWallets: GET /v1/wallets, unwraps { wallets }", async () => {
|
|
const captured: Captured[] = []
|
|
const wallets = [
|
|
{ walletId: "w-1", addresses: { evm: "0x52908400098527886E0F7030069857D2E4169EE7" } },
|
|
{ walletId: "w-2", addresses: {} },
|
|
]
|
|
const out = await listWallets(mockFetch(200, { wallets }, captured))
|
|
assert.equal(captured[0]!.url, "https://wallet-api.test/v1/wallets")
|
|
assert.equal(captured[0]!.init.method, "GET")
|
|
assert.equal(out.length, 2)
|
|
assert.equal(out[1]!.walletId, "w-2")
|
|
})
|
|
|
|
test("getWallet: GET /v1/wallets/{id} with URL-encoded id", async () => {
|
|
const captured: Captured[] = []
|
|
const wallet = { walletId: "w/1", addresses: {} }
|
|
await getWallet("w/1", mockFetch(200, wallet, captured))
|
|
assert.equal(captured[0]!.url, "https://wallet-api.test/v1/wallets/w%2F1")
|
|
assert.equal(captured[0]!.init.method, "GET")
|
|
})
|
|
|
|
/* ── signPayload ─────────────────────────────────────────────────────────────── */
|
|
|
|
test("signPayload: refuses without an idempotency key (no fetch issued)", async () => {
|
|
const captured: Captured[] = []
|
|
await assert.rejects(
|
|
() =>
|
|
signPayload(
|
|
"w-1",
|
|
{ scheme: "secp256k1", chainId: 96369, payloadHash: "0xabc", idempotencyKey: "" },
|
|
mockFetch(200, { signature: "0xsig" }, captured),
|
|
),
|
|
(e: unknown) => e instanceof CustodyError && /idempotencyKey required/.test((e as Error).message),
|
|
)
|
|
assert.equal(captured.length, 0)
|
|
})
|
|
|
|
test("signPayload: POST /v1/wallets/{id}/sign with exact body, returns signature", async () => {
|
|
const captured: Captured[] = []
|
|
const res = await signPayload(
|
|
"w-1",
|
|
{ scheme: "secp256k1", chainId: 96369, payloadHash: "0xabc123", idempotencyKey: "idem-1" },
|
|
mockFetch(200, { signature: "0xdeadbeef", sessionId: "s-9" }, captured),
|
|
)
|
|
assert.equal(captured[0]!.url, "https://wallet-api.test/v1/wallets/w-1/sign")
|
|
assert.equal(captured[0]!.init.method, "POST")
|
|
const sent = JSON.parse(String(captured[0]!.init.body))
|
|
// Exact wire shape per backend signBody — and NO org field.
|
|
assert.deepEqual(sent, {
|
|
scheme: "secp256k1",
|
|
chainId: 96369,
|
|
payloadHash: "0xabc123",
|
|
idempotencyKey: "idem-1",
|
|
})
|
|
assert.equal("org" in sent, false)
|
|
assert.equal("owner" in sent, false)
|
|
assert.equal(res.signature, "0xdeadbeef")
|
|
assert.equal(res.sessionId, "s-9")
|
|
})
|
|
|
|
test("signPayload: PQ scheme mldsa65 is carried through", async () => {
|
|
const captured: Captured[] = []
|
|
await signPayload(
|
|
"w-1",
|
|
{ scheme: "mldsa65", chainId: 96369, payloadHash: "0xabc", idempotencyKey: "k" },
|
|
mockFetch(200, { signature: "0xsig" }, captured),
|
|
)
|
|
assert.equal(JSON.parse(String(captured[0]!.init.body)).scheme, "mldsa65")
|
|
})
|
|
|
|
/* ── error mapping ───────────────────────────────────────────────────────────── */
|
|
|
|
test("backend {error} body → typed CustodyError with status", async () => {
|
|
const captured: Captured[] = []
|
|
await assert.rejects(
|
|
() =>
|
|
signPayload(
|
|
"w-1",
|
|
{ scheme: "secp256k1", chainId: 96369, payloadHash: "0xabc", idempotencyKey: "k" },
|
|
mockFetch(400, { error: "idempotencyKey required" }, captured),
|
|
),
|
|
(e: unknown) =>
|
|
e instanceof CustodyError &&
|
|
e.status === 400 &&
|
|
/idempotencyKey required/.test((e as Error).message),
|
|
)
|
|
})
|
|
|
|
test("cross-org 404 from backend surfaces as CustodyError(404)", async () => {
|
|
const captured: Captured[] = []
|
|
await assert.rejects(
|
|
() => getWallet("someone-elses", mockFetch(404, { error: "wallet not found" }, captured)),
|
|
(e: unknown) => e instanceof CustodyError && e.status === 404,
|
|
)
|
|
})
|