diff --git a/cli/README.md b/cli/README.md index 012302b8e..e4cf47a62 100644 --- a/cli/README.md +++ b/cli/README.md @@ -301,6 +301,76 @@ grid receiver lookup-uma '$user@domain.com' grid receiver lookup-account ``` +### Cards + +```bash +# List cards +grid cards list [--cardholder-id ] [--state ACTIVE] + +# Get a card +grid cards get + +# Issue a virtual card +grid cards create \ + --cardholder-id \ + --funding-sources "InternalAccount:1,InternalAccount:2" + +# Freeze / unfreeze / close, or replace funding sources +grid cards update --state FROZEN +grid cards update --state ACTIVE +grid cards update --state CLOSED +grid cards update --funding-sources "InternalAccount:3" + +# Reveal card details — prints a short-lived panEmbedUrl to render in an iframe. +# Do not store or log it. +grid cards reveal +``` + +`cards update` (and the signed `auth` operations below) use Grid's signed-retry +flow: the first call returns a `202` challenge with a `payloadToSign`. Sign it +with your embedded-wallet key (e.g. via `scripts/embedded-wallet-sign.js`) and +re-run the command with `--wallet-signature --request-id ` to +complete. The CLI forwards those as the `Grid-Wallet-Signature` / `Request-Id` +headers — it does not compute the stamp for you. + +### Auth + +```bash +# Credentials +grid auth credentials list --account-id +grid auth credentials create --type OAUTH --account-id --oidc-token +grid auth credentials challenge # e.g. resend an OTP +grid auth credentials verify --type EMAIL_OTP \ + --encrypted-otp-bundle '' \ + --wallet-signature --request-id +grid auth credentials revoke --wallet-signature --request-id + +# Delegated signing keys +grid auth delegated-keys list --account-id +grid auth delegated-keys get +grid auth delegated-keys create \ + --card-id --internal-account-id --nickname "Card key" \ + --spending-limit USD:5000 --spending-limit EUR:4000 \ + --wallet-signature --request-id +grid auth delegated-keys revoke # no signature needed + +# Sessions +grid auth sessions list --account-id +grid auth sessions refresh --client-public-key \ + --wallet-signature --request-id +grid auth sessions revoke --wallet-signature --request-id +``` + +Passkey (WebAuthn) create/verify accept the attestation/assertion as JSON +(`--attestation` / `--assertion`) that you produce client-side; the CLI cannot +run WebAuthn itself. + +Some operations need more than one signed retry — notably `auth delegated-keys +create` has two successive signed legs (a single `--wallet-signature` retry +stops at the second `202` and leaves the key `PENDING`). Run the command once +per signed leg, supplying the next `--wallet-signature` / `--request-id` each +time, until it returns the created key. + ### Sandbox Testing ```bash diff --git a/cli/src/client.ts b/cli/src/client.ts index dd44785a3..253a1ecc1 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -57,6 +57,7 @@ export class GridClient { options?: { params?: Record; body?: unknown; + headers?: Record; } ): Promise> { const url = this.buildUrl(path, options?.params); @@ -76,6 +77,12 @@ export class GridClient { fetchOptions.body = JSON.stringify(options.body); } + if (options?.headers) { + for (const [key, value] of Object.entries(options.headers)) { + if (value !== undefined) headers[key] = value; + } + } + try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); @@ -134,20 +141,32 @@ export class GridClient { async get( path: string, - params?: Record + params?: Record, + headers?: Record ): Promise> { - return this.request("GET", path, { params }); + return this.request("GET", path, { params, headers }); } - async post(path: string, body?: unknown): Promise> { - return this.request("POST", path, { body }); + async post( + path: string, + body?: unknown, + headers?: Record + ): Promise> { + return this.request("POST", path, { body, headers }); } - async patch(path: string, body?: unknown): Promise> { - return this.request("PATCH", path, { body }); + async patch( + path: string, + body?: unknown, + headers?: Record + ): Promise> { + return this.request("PATCH", path, { body, headers }); } - async delete(path: string): Promise> { - return this.request("DELETE", path); + async delete( + path: string, + headers?: Record + ): Promise> { + return this.request("DELETE", path, { headers }); } } diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts new file mode 100644 index 000000000..d682f0421 --- /dev/null +++ b/cli/src/commands/auth.ts @@ -0,0 +1,394 @@ +import { Command, InvalidArgumentError } from "commander"; +import { GridClient } from "../client"; +import { outputResponse, formatError, output } from "../output"; +import { GlobalOptions } from "../index"; +import { confirm } from "../prompt"; +import { addSignedOptions, signedHeaders } from "../signed"; + +interface AuthListResponse { + data: T[]; +} + +interface AuthMethod { + id: string; + accountId: string; + type: string; + nickname?: string; + createdAt: string; + updatedAt: string; +} + +interface DelegatedKey { + id: string; + cardId: string; + accountId: string; + nickname: string; + status: string; + createdAt: string; + updatedAt: string; +} + +interface AuthSession { + id: string; + accountId: string; + type: string; + createdAt: string; + expiresAt: string; +} + +// Collects a repeated "CURRENCY:amount" flag into spending-limit objects. +// Amounts are integers in the smallest currency unit; a malformed or non-integer +// value is rejected up front rather than silently sent as null. +function collectSpendingLimit( + value: string, + previous: Array<{ currencyCode: string; maxPerTransaction: number }> = [] +): Array<{ currencyCode: string; maxPerTransaction: number }> { + const match = /^([A-Z0-9]{3,16}):(\d+)$/.exec(value); + if (!match) { + throw new InvalidArgumentError( + `expected CURRENCY:amount with an uppercase code and an integer amount, e.g. USD:5000 (got "${value}")` + ); + } + const maxPerTransaction = Number(match[2]); + if (!Number.isSafeInteger(maxPerTransaction) || maxPerTransaction < 1) { + throw new InvalidArgumentError( + `spending limit amount must be a positive integer within the safe range (got "${value}")` + ); + } + return [...previous, { currencyCode: match[1], maxPerTransaction }]; +} + +export function registerAuthCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const authCmd = program + .command("auth") + .description("Authentication commands (credentials, delegated keys, sessions)"); + + const credentialsCmd = authCmd + .command("credentials") + .description("Authentication credential commands"); + + credentialsCmd + .command("list") + .description("List authentication credentials for an internal account") + .requiredOption("--account-id ", "Internal account ID") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get>( + "/auth/credentials", + { accountId: options.accountId } + ); + outputResponse(response); + }); + + addSignedOptions( + credentialsCmd + .command("create") + .description("Create an authentication credential") + .requiredOption("--type ", "Credential type: EMAIL_OTP, SMS_OTP, OAUTH, PASSKEY") + .requiredOption("--account-id ", "Internal account ID") + .option("--oidc-token ", "OIDC ID token (OAUTH)") + .option("--nickname ", "Credential nickname (PASSKEY)") + .option("--challenge ", "Registration challenge (PASSKEY)") + .option("--attestation ", "WebAuthn attestation as JSON (PASSKEY)") + ).action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + type: options.type, + accountId: options.accountId, + }; + if (options.oidcToken) body.oidcToken = options.oidcToken; + if (options.nickname) body.nickname = options.nickname; + if (options.challenge) body.challenge = options.challenge; + if (options.attestation) { + const parsed = parseJson(options.attestation, "attestation"); + if ("error" in parsed) { + output(formatError(parsed.error)); + process.exitCode = 1; + return; + } + body.attestation = parsed.value; + } + + const response = await client.post( + "/auth/credentials", + body, + signedHeaders(options) + ); + outputResponse(response); + }); + + credentialsCmd + .command("challenge ") + .description("Re-issue a credential challenge (e.g. resend an OTP)") + .option( + "--client-public-key ", + "Client P-256 public key — required to re-challenge a PASSKEY credential" + ) + .action(async (credentialId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body = options.clientPublicKey + ? { clientPublicKey: options.clientPublicKey } + : undefined; + + const response = await client.post( + `/auth/credentials/${credentialId}/challenge`, + body + ); + outputResponse(response); + }); + + addSignedOptions( + credentialsCmd + .command("verify ") + .description("Verify an authentication credential") + .requiredOption("--type ", "Credential type: EMAIL_OTP, SMS_OTP, OAUTH, PASSKEY") + .option("--encrypted-otp-bundle ", "HPKE-sealed OTP bundle (EMAIL_OTP/SMS_OTP)") + .option("--oidc-token ", "OIDC ID token (OAUTH)") + .option("--client-public-key ", "Client P-256 public key (OAUTH)") + .option("--assertion ", "WebAuthn assertion as JSON (PASSKEY)") + ).action(async (credentialId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { type: options.type }; + if (options.encryptedOtpBundle) + body.encryptedOtpBundle = options.encryptedOtpBundle; + if (options.oidcToken) body.oidcToken = options.oidcToken; + if (options.clientPublicKey) body.clientPublicKey = options.clientPublicKey; + if (options.assertion) { + const parsed = parseJson(options.assertion, "assertion"); + if ("error" in parsed) { + output(formatError(parsed.error)); + process.exitCode = 1; + return; + } + body.assertion = parsed.value; + } + + const response = await client.post( + `/auth/credentials/${credentialId}/verify`, + body, + signedHeaders(options) + ); + outputResponse(response); + }); + + addSignedOptions( + credentialsCmd + .command("revoke ") + .description("Revoke an authentication credential") + .option("-y, --yes", "Skip confirmation prompt") + ).action(async (credentialId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + if (!options.yes) { + const confirmed = await confirm( + `Revoke credential ${credentialId}? This cannot be undone.` + ); + if (!confirmed) { + console.log("Aborted."); + return; + } + } + + const response = await client.delete( + `/auth/credentials/${credentialId}`, + signedHeaders(options) + ); + outputResponse(response); + }); + + const delegatedKeysCmd = authCmd + .command("delegated-keys") + .description("Delegated signing key commands"); + + delegatedKeysCmd + .command("list") + .description("List delegated signing keys") + .option("--account-id ", "Internal account ID") + .option("--funding-source-id ", "Funding source ID") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + if (!options.accountId && !options.fundingSourceId) { + output( + formatError("Provide --account-id or --funding-source-id") + ); + process.exitCode = 1; + return; + } + + const response = await client.get>( + "/auth/delegated-keys", + { + accountId: options.accountId, + fundingSourceId: options.fundingSourceId, + } + ); + outputResponse(response); + }); + + delegatedKeysCmd + .command("get ") + .description("Get a delegated signing key") + .action(async (delegatedKeyId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get( + `/auth/delegated-keys/${delegatedKeyId}` + ); + outputResponse(response); + }); + + addSignedOptions( + delegatedKeysCmd + .command("create") + .description("Create a delegated signing key") + .requiredOption("--card-id ", "Card ID") + .requiredOption("--internal-account-id ", "Embedded Wallet internal account ID") + .requiredOption("--nickname ", "Human-readable label for the key") + .option( + "--spending-limit ", + "Per-transaction limit, e.g. USD:5000 (repeatable)", + collectSpendingLimit + ) + ).action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + cardId: options.cardId, + internalAccountId: options.internalAccountId, + nickname: options.nickname, + }; + if (options.spendingLimit) body.spendingLimits = options.spendingLimit; + + const response = await client.post( + "/auth/delegated-keys", + body, + signedHeaders(options) + ); + outputResponse(response); + }); + + delegatedKeysCmd + .command("revoke ") + .description("Revoke a delegated signing key") + .option("-y, --yes", "Skip confirmation prompt") + .action(async (delegatedKeyId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + if (!options.yes) { + const confirmed = await confirm( + `Revoke delegated key ${delegatedKeyId}? This cannot be undone.` + ); + if (!confirmed) { + console.log("Aborted."); + return; + } + } + + const response = await client.delete( + `/auth/delegated-keys/${delegatedKeyId}` + ); + outputResponse(response); + }); + + const sessionsCmd = authCmd + .command("sessions") + .description("Authentication session commands"); + + sessionsCmd + .command("list") + .description("List active authentication sessions") + .requiredOption("--account-id ", "Internal account ID") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get>( + "/auth/sessions", + { accountId: options.accountId } + ); + outputResponse(response); + }); + + addSignedOptions( + sessionsCmd + .command("revoke ") + .description("Revoke an authentication session") + .option("-y, --yes", "Skip confirmation prompt") + ).action(async (sessionId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + if (!options.yes) { + const confirmed = await confirm( + `Revoke session ${sessionId}? This cannot be undone.` + ); + if (!confirmed) { + console.log("Aborted."); + return; + } + } + + const response = await client.delete( + `/auth/sessions/${sessionId}`, + signedHeaders(options) + ); + outputResponse(response); + }); + + addSignedOptions( + sessionsCmd + .command("refresh ") + .description("Refresh an authentication session") + .requiredOption("--client-public-key ", "Client-generated P-256 public key (uncompressed SEC1 hex)") + ).action(async (sessionId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.post( + `/auth/sessions/${sessionId}/refresh`, + { clientPublicKey: options.clientPublicKey }, + signedHeaders(options) + ); + outputResponse(response); + }); +} + +function parseJson( + value: string, + flag: string +): { value: unknown } | { error: string } { + try { + return { value: JSON.parse(value) }; + } catch { + return { error: `Invalid JSON for --${flag}` }; + } +} diff --git a/cli/src/commands/cards.ts b/cli/src/commands/cards.ts new file mode 100644 index 000000000..a0ddc537e --- /dev/null +++ b/cli/src/commands/cards.ts @@ -0,0 +1,158 @@ +import { Command } from "commander"; +import { GridClient, PaginatedResponse } from "../client"; +import { outputResponse, formatError, output } from "../output"; +import { GlobalOptions } from "../index"; +import { addSignedOptions, signedHeaders } from "../signed"; +import { parseList } from "../parse"; + +interface Card { + id: string; + cardholderId: string; + platformCardId?: string; + state: "PENDING_KYC" | "PROCESSING" | "ACTIVE" | "FROZEN" | "CLOSED"; + form: "VIRTUAL"; + last4?: string; + fundingSources: string[]; + currency?: string; + createdAt: string; + updatedAt: string; +} + +interface CardRevealResponse { + panEmbedUrl: string; + expiresAt: string; +} + +export function registerCardsCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const cardsCmd = program.command("cards").description("Card management commands"); + + cardsCmd + .command("list") + .description("List cards") + .option("--cardholder-id ", "Filter by cardholder (customer) ID") + .option("--account-id ", "Filter by a bound funding-source account ID") + .option("--platform-card-id ", "Filter by platform card ID") + .option("--state ", "Filter by state (PENDING_KYC, PROCESSING, ACTIVE, FROZEN, CLOSED)") + .option("-l, --limit ", "Maximum results (default 20, max 100)", "20") + .option("--cursor ", "Pagination cursor") + .option("--sort ", "Sort order: asc or desc") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const limit = parseInt(options.limit, 10); + if (Number.isNaN(limit)) { + output(formatError("--limit must be a number")); + process.exitCode = 1; + return; + } + + const params: Record = { + cardholderId: options.cardholderId, + accountId: options.accountId, + platformCardId: options.platformCardId, + state: options.state, + limit, + cursor: options.cursor, + sortOrder: options.sort, + }; + + const response = await client.get>("/cards", params); + outputResponse(response); + }); + + cardsCmd + .command("get ") + .description("Get card details") + .action(async (cardId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get(`/cards/${cardId}`); + outputResponse(response); + }); + + cardsCmd + .command("create") + .description("Issue a card") + .requiredOption("--cardholder-id ", "Cardholder (customer) ID") + .requiredOption("--funding-sources ", "Comma-separated internal account IDs, in priority order") + .option("--form
", "Card form (VIRTUAL)", "VIRTUAL") + .option("--platform-card-id ", "Your platform's identifier for the card") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const fundingSources = parseList(options.fundingSources); + if (!fundingSources) { + output(formatError("--funding-sources must list at least one internal account ID")); + process.exitCode = 1; + return; + } + + const body: Record = { + cardholderId: options.cardholderId, + form: options.form, + fundingSources, + }; + if (options.platformCardId) body.platformCardId = options.platformCardId; + + const response = await client.post("/cards", body); + outputResponse(response); + }); + + addSignedOptions( + cardsCmd + .command("update ") + .description("Update a card (freeze/unfreeze, replace funding sources, or close)") + .option("--state ", "Target state: ACTIVE, FROZEN, or CLOSED") + .option("--funding-sources ", "Comma-separated internal account IDs (fully replaces the binding)") + ).action(async (cardId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const fundingSources = parseList(options.fundingSources); + if (!options.state && options.fundingSources === undefined) { + output(formatError("Provide --state and/or --funding-sources")); + process.exitCode = 1; + return; + } + if (options.state === "CLOSED" && options.fundingSources !== undefined) { + output(formatError("--state CLOSED cannot be combined with --funding-sources")); + process.exitCode = 1; + return; + } + + const body: Record = {}; + if (options.state) body.state = options.state; + if (fundingSources) body.fundingSources = fundingSources; + + const response = await client.patch( + `/cards/${cardId}`, + body, + signedHeaders(options) + ); + outputResponse(response); + }); + + cardsCmd + .command("reveal ") + .description("Reveal card details — prints a short-lived panEmbedUrl to render in an iframe (do not store it)") + .action(async (cardId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.post( + `/cards/${cardId}/reveal` + ); + outputResponse(response); + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index d4f40f7e9..8e3d90578 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -68,6 +68,8 @@ export async function buildProgram(): Promise { const { registerTransfersCommand } = await import("./commands/transfers"); const { registerSandboxCommand } = await import("./commands/sandbox"); const { registerReceiverCommand } = await import("./commands/receiver"); + const { registerCardsCommand } = await import("./commands/cards"); + const { registerAuthCommand } = await import("./commands/auth"); registerConfigureCommand(program); registerConfigCommand(program, getClient); @@ -78,6 +80,8 @@ export async function buildProgram(): Promise { registerTransfersCommand(program, getClient); registerSandboxCommand(program, getClient); registerReceiverCommand(program, getClient); + registerCardsCommand(program, getClient); + registerAuthCommand(program, getClient); const customersCmd = program.commands.find((c) => c.name() === "customers"); const transactionsCmd = program.commands.find( diff --git a/cli/src/signed.ts b/cli/src/signed.ts new file mode 100644 index 000000000..3aaecffff --- /dev/null +++ b/cli/src/signed.ts @@ -0,0 +1,29 @@ +import { Command } from "commander"; + +/** + * Options for the signed-retry leg of a mutating flow. The CLI does not compute + * the Turnkey/HPKE stamp itself — the caller supplies one (e.g. via + * scripts/embedded-wallet-sign.js) and it is forwarded verbatim as the + * `Grid-Wallet-Signature` / `Request-Id` headers. + */ +export function addSignedOptions(cmd: Command): Command { + return cmd + .option( + "--wallet-signature ", + "Grid-Wallet-Signature header for the signed retry of this operation" + ) + .option( + "--request-id ", + "Request-Id header echoing the challenge's requestId" + ); +} + +export function signedHeaders(options: { + walletSignature?: string; + requestId?: string; +}): Record { + return { + "Grid-Wallet-Signature": options.walletSignature, + "Request-Id": options.requestId, + }; +} diff --git a/cli/test/auth.test.ts b/cli/test/auth.test.ts new file mode 100644 index 000000000..497c8f023 --- /dev/null +++ b/cli/test/auth.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("auth credentials", () => { + it("list passes accountId", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "list", + "--account-id", + "InternalAccount:1", + ]); + expect(request?.path).toBe("/grid/v1/auth/credentials"); + expect(request?.query).toMatchObject({ accountId: "InternalAccount:1" }); + }); + + it("create builds an OAuth credential body", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "create", + "--type", + "OAUTH", + "--account-id", + "InternalAccount:1", + "--oidc-token", + "tok123", + ]); + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/auth/credentials"); + expect(request?.body).toMatchObject({ + type: "OAUTH", + accountId: "InternalAccount:1", + oidcToken: "tok123", + }); + }); + + it("challenge posts an empty body (OTP resend)", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "challenge", + "Credential:1", + ]); + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/auth/credentials/Credential:1/challenge"); + expect(request?.body).toBeUndefined(); + }); + + it("challenge sends clientPublicKey when provided (passkey re-challenge)", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "challenge", + "Credential:1", + "--client-public-key", + "04abcd", + ]); + expect(request?.body).toMatchObject({ clientPublicKey: "04abcd" }); + }); + + it("verify sends the OTP bundle plus signed-retry headers", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "verify", + "Credential:1", + "--type", + "EMAIL_OTP", + "--encrypted-otp-bundle", + '{"encappedPublic":"04ab","ciphertext":"1fa1"}', + "--wallet-signature", + "stamp", + "--request-id", + "req-1", + ]); + expect(request?.body).toMatchObject({ + type: "EMAIL_OTP", + encryptedOtpBundle: '{"encappedPublic":"04ab","ciphertext":"1fa1"}', + }); + expect(request?.headers["Grid-Wallet-Signature"]).toBe("stamp"); + expect(request?.headers["Request-Id"]).toBe("req-1"); + }); + + it("revoke issues a DELETE with the signed-retry headers", async () => { + const { request } = await runCli([ + "auth", + "credentials", + "revoke", + "Credential:1", + "--yes", + "--wallet-signature", + "stamp", + "--request-id", + "req-1", + ]); + expect(request?.method).toBe("DELETE"); + expect(request?.path).toBe("/grid/v1/auth/credentials/Credential:1"); + expect(request?.headers["Grid-Wallet-Signature"]).toBe("stamp"); + }); +}); + +describe("auth delegated-keys", () => { + it("list requires and passes accountId/fundingSourceId", async () => { + const { request } = await runCli([ + "auth", + "delegated-keys", + "list", + "--account-id", + "InternalAccount:1", + ]); + expect(request?.path).toBe("/grid/v1/auth/delegated-keys"); + expect(request?.query).toMatchObject({ accountId: "InternalAccount:1" }); + }); + + it("list without any filter does not call the API", async () => { + const { calls } = await runCli(["auth", "delegated-keys", "list"]); + expect(calls).toBe(0); + }); + + it("create builds the body with parsed spending limits", async () => { + const { request } = await runCli([ + "auth", + "delegated-keys", + "create", + "--card-id", + "Card:1", + "--internal-account-id", + "InternalAccount:1", + "--nickname", + "Card key", + "--spending-limit", + "USD:5000", + "--spending-limit", + "EUR:4000", + ]); + expect(request?.body).toMatchObject({ + cardId: "Card:1", + internalAccountId: "InternalAccount:1", + nickname: "Card key", + spendingLimits: [ + { currencyCode: "USD", maxPerTransaction: 5000 }, + { currencyCode: "EUR", maxPerTransaction: 4000 }, + ], + }); + }); + + it("revoke issues a plain DELETE (no signature needed)", async () => { + const { request } = await runCli([ + "auth", + "delegated-keys", + "revoke", + "DelegatedKey:1", + "--yes", + ]); + expect(request?.method).toBe("DELETE"); + expect(request?.path).toBe("/grid/v1/auth/delegated-keys/DelegatedKey:1"); + }); + + it("rejects a malformed spending limit before sending", async () => { + await expect( + runCli([ + "auth", + "delegated-keys", + "create", + "--card-id", + "Card:1", + "--internal-account-id", + "InternalAccount:1", + "--nickname", + "Card key", + "--spending-limit", + "USD5000", + ]) + ).rejects.toThrow(); + }); +}); + +describe("auth sessions", () => { + it("list passes accountId", async () => { + const { request } = await runCli([ + "auth", + "sessions", + "list", + "--account-id", + "InternalAccount:1", + ]); + expect(request?.path).toBe("/grid/v1/auth/sessions"); + expect(request?.query).toMatchObject({ accountId: "InternalAccount:1" }); + }); + + it("revoke issues a signed DELETE with confirmation skipped", async () => { + const { request } = await runCli([ + "auth", + "sessions", + "revoke", + "Session:1", + "--yes", + "--wallet-signature", + "stamp", + "--request-id", + "req-1", + ]); + expect(request?.method).toBe("DELETE"); + expect(request?.path).toBe("/grid/v1/auth/sessions/Session:1"); + expect(request?.headers["Grid-Wallet-Signature"]).toBe("stamp"); + expect(request?.headers["Request-Id"]).toBe("req-1"); + }); + + it("refresh sends clientPublicKey and signed-retry headers", async () => { + const { request } = await runCli([ + "auth", + "sessions", + "refresh", + "Session:1", + "--client-public-key", + "04abcd", + "--wallet-signature", + "stamp", + "--request-id", + "req-1", + ]); + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/auth/sessions/Session:1/refresh"); + expect(request?.body).toMatchObject({ clientPublicKey: "04abcd" }); + expect(request?.headers["Request-Id"]).toBe("req-1"); + }); +}); diff --git a/cli/test/cards.test.ts b/cli/test/cards.test.ts new file mode 100644 index 000000000..114e9435c --- /dev/null +++ b/cli/test/cards.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { runCli } from "./helpers"; + +describe("cards list", () => { + it("passes filters as query params", async () => { + const { request } = await runCli([ + "cards", + "list", + "--cardholder-id", + "Customer:abc", + "--state", + "ACTIVE", + ]); + + expect(request?.path).toBe("/grid/v1/cards"); + expect(request?.query).toMatchObject({ + cardholderId: "Customer:abc", + state: "ACTIVE", + }); + }); +}); + +describe("cards create", () => { + it("builds the create body with funding sources and default form", async () => { + const { request } = await runCli([ + "cards", + "create", + "--cardholder-id", + "Customer:abc", + "--funding-sources", + "InternalAccount:1,InternalAccount:2", + ]); + + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/cards"); + expect(request?.body).toMatchObject({ + cardholderId: "Customer:abc", + form: "VIRTUAL", + fundingSources: ["InternalAccount:1", "InternalAccount:2"], + }); + }); +}); + +describe("cards update", () => { + it("freezes a card via state", async () => { + const { request } = await runCli([ + "cards", + "update", + "Card:1", + "--state", + "FROZEN", + ]); + + expect(request?.method).toBe("PATCH"); + expect(request?.path).toBe("/grid/v1/cards/Card:1"); + expect(request?.body).toMatchObject({ state: "FROZEN" }); + }); + + it("forwards a supplied wallet signature and request id as headers", async () => { + const { request } = await runCli([ + "cards", + "update", + "Card:1", + "--state", + "CLOSED", + "--wallet-signature", + "stamp123", + "--request-id", + "req-1", + ]); + + expect(request?.headers["Grid-Wallet-Signature"]).toBe("stamp123"); + expect(request?.headers["Request-Id"]).toBe("req-1"); + }); + + it("rejects an update with no state or funding sources", async () => { + const { calls } = await runCli(["cards", "update", "Card:1"]); + expect(calls).toBe(0); + }); + + it("rejects CLOSED combined with funding sources", async () => { + const { calls } = await runCli([ + "cards", + "update", + "Card:1", + "--state", + "CLOSED", + "--funding-sources", + "InternalAccount:1", + ]); + expect(calls).toBe(0); + }); +}); + +describe("cards reveal", () => { + it("posts to the reveal endpoint with no body", async () => { + const { request } = await runCli(["cards", "reveal", "Card:1"]); + + expect(request?.method).toBe("POST"); + expect(request?.path).toBe("/grid/v1/cards/Card:1/reveal"); + expect(request?.body).toBeUndefined(); + }); +});