Skip to content
Closed
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
80 changes: 80 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,86 @@ describe('production mode', () => {
});
});

const SAMPLE_SOURCE = {
id: 'csmrpd_001',
name: 'Checking 1234',
type: 'bank_account',
capabilities: {
transactions: { status: 'eligible' },
},
external_connection: { status: 'active' },
};

describe('sources list', () => {
it('GETs the Link API endpoint with bearer auth', async () => {
setResponseForUrl('/sources', 200, {
data: [SAMPLE_SOURCE],
has_more: true,
});

const result = await runProdCli('sources', 'list', '--json');

expect(result.exitCode).toBe(0);
expect(lastRequest.method).toBe('GET');
expect(lastRequest.url).toBe('/sources');
expect(lastRequest.headers.authorization).toBe(
'Bearer prod_test_access_token',
);

const output = parseJson(result.stdout) as Record<string, unknown>;
expect(output.has_more).toBe(true);
expect(Array.isArray(output.data)).toBe(true);
const data = output.data as Record<string, unknown>[];
expect(data).toHaveLength(1);
expect(data[0].id).toBe('csmrpd_001');
expect(data[0].type).toBe('bank_account');
});

it('forwards pagination flags into the query string', async () => {
setNextResponse(200, {
data: [],
});

const result = await runProdCli(
'sources',
'list',
'--limit',
'5',
'--starting-after',
'csmrpd_cursor',
'--ending-before',
'csmrpd_prev',
'--json',
);

expect(result.exitCode).toBe(0);
expect(lastRequest.url).toContain('/sources?');
expect(lastRequest.url).toContain('limit=5');
expect(lastRequest.url).toContain('starting_after=csmrpd_cursor');
expect(lastRequest.url).toContain('ending_before=csmrpd_prev');
});

it('rejects unauthenticated requests before hitting the API', async () => {
storage.clearAuth();

const result = await runProdCli('sources', 'list', '--json');

expect(result.exitCode).toBe(1);
const output = parseJson(result.stdout) as Record<string, unknown>;
expect(output.code).toBe('NOT_AUTHENTICATED');
expect(String(output.message)).toMatch(/auth login/i);
const sourcesRequest = requests.find((r) => r.url === '/sources');
expect(sourcesRequest).toBeUndefined();
});

it('does not show sources in root help', async () => {
const result = await runProdCli('--help');

expect(result.exitCode).toBe(0);
expect(result.stdout + result.stderr).not.toContain('sources');
});
});

describe('auth login', () => {
const DEVICE_CODE_RESPONSE = {
device_code: 'test_device_code',
Expand Down
17 changes: 12 additions & 5 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createPaymentMethodsCli } from './commands/payment-methods';
import { createReportCli } from './commands/report';
import { createServeCli } from './commands/serve';
import { createShippingAddressCli } from './commands/shipping-address';
import { createSourcesCli } from './commands/sources';
import { createSpendRequestCli } from './commands/spend-request';
import { createTransactionsCli } from './commands/transactions';
import { createUserInfoCli } from './commands/user-info';
Expand Down Expand Up @@ -62,20 +63,26 @@ const authRepo = factory.createAuthResource();
const spendRequestRepo = factory.createSpendRequestResource();

const requestedCommand = process.argv[2];
const transactionsCli =
const hiddenCli =
requestedCommand === 'transactions'
? createTransactionsCli(
() => factory.createTransactionsResource(),
authStorage,
envAccessToken,
)
: null;
if (transactionsCli) {
: requestedCommand === 'sources'
? createSourcesCli(
() => factory.createSourcesResource(),
authStorage,
envAccessToken,
)
: null;
if (hiddenCli) {
process.argv.splice(2, 1);
}

const cli =
transactionsCli ??
hiddenCli ??
Cli.create('link-cli', {
description:
'Create a secure, one-time payment credential from a Link wallet to let agents complete purchases on behalf of users.',
Expand All @@ -98,7 +105,7 @@ if (!isAgent && process.stdout.isTTY) {
}
}

if (!transactionsCli) {
if (!hiddenCli) {
cli.command(
createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken),
);
Expand Down
160 changes: 160 additions & 0 deletions packages/cli/src/commands/sources/__tests__/sources.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { ISourcesResource, SourcesPage } from '@stripe/link-sdk';
import { render } from 'ink-testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { sanitizeResource } from '../../../utils/resource-factory';
import { SourcesList } from '../list';

const ESCAPE_PAYLOAD = '\x1b[2JEvil\rHidden';
const CLEAN_TEXT = 'EvilHidden';

function makeResource(page: SourcesPage): ISourcesResource {
return sanitizeResource({
listSources: vi.fn(async () => page),
} as unknown as ISourcesResource);
}

function setTerminalWidth(columns: number) {
Object.defineProperty(process.stdout, 'columns', {
configurable: true,
value: columns,
});
}

describe('sources list component', () => {
afterEach(() => {
Object.defineProperty(process.stdout, 'columns', {
configurable: true,
value: undefined,
});
});

it('renders source details', async () => {
setTerminalWidth(160);
const resource = makeResource({
data: [
{
id: 'csmrpd_1',
name: 'Checking 1234',
type: 'bank_account',
capabilities: {
balances: { status: 'eligible' },
transactions: { status: 'pending' },
},
external_connection: { status: 'active' },
},
],
});

const { lastFrame } = render(
<SourcesList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Name');
expect(frame).toContain('Type');
expect(frame).toContain('ID');
expect(frame).toContain('Checking 1234');
expect(frame).toContain('bank_account');
expect(frame).toContain('csmrpd_1');
expect(frame).toContain('balances:eligible');
expect(frame).toContain('transactions');
expect(frame).toContain('active');
});
});

it('clips wide table columns in narrow terminals', async () => {
setTerminalWidth(72);
const resource = makeResource({
data: [
{
id: 'csmrpd_1',
name: 'Checking 1234',
type: 'bank_account',
capabilities: {
transactions: { status: 'pending' },
},
external_connection: { status: 'active' },
},
],
});

const { lastFrame } = render(
<SourcesList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Checking 1234');
expect(frame).toContain('Name');
expect(frame).toContain('Type');
expect(frame).toContain('ID');
expect(frame).toContain('Capabi');
expect(frame).not.toContain('Details');

const tableLines = frame
?.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0 && line !== 'Sources');
expect(tableLines?.every((line) => line.length <= 72)).toBe(true);
});
});

it('renders an empty state when there are no sources', async () => {
const resource = makeResource({ data: [] });

const { lastFrame } = render(
<SourcesList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
expect(lastFrame()).toContain('No sources found');
});
});

it('sanitizes escape sequences in source fields', async () => {
const resource = makeResource({
data: [
{
id: 'csmrpd_1',
name: ESCAPE_PAYLOAD,
type: ESCAPE_PAYLOAD,
},
],
});

const { lastFrame } = render(
<SourcesList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain(CLEAN_TEXT);
expect(frame).not.toContain('\x1b[2J');
expect(frame).not.toContain('\r');
});
});

it('renders pagination metadata when available', async () => {
const resource = makeResource({
data: [
{
id: 'csmrpd_2',
name: 'Savings',
type: 'bank_account',
},
],
has_more: true,
});

const { lastFrame } = render(
<SourcesList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('has_more: true');
expect(frame).toContain('--starting-after csmrpd_2');
});
});
});
54 changes: 54 additions & 0 deletions packages/cli/src/commands/sources/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type {
AuthStorage,
ISourcesResource,
ListSourcesParams,
} from '@stripe/link-sdk';
import { Cli } from 'incur';
import React from 'react';
import { renderInteractive } from '../../utils/render-interactive';
import { requireAuth } from '../../utils/require-auth';
import { SourcesList } from './list';
import { listOptions } from './schema';

export function createSourcesCli(
createResource: () => ISourcesResource,
authStorage?: AuthStorage,
envAccessToken?: string,
) {
const cli = Cli.create('sources', {
description: 'List sources from your Link wallet',
});

cli.command('list', {
description: 'List sources from your Link wallet',
options: listOptions,
outputPolicy: 'agent-only' as const,
middleware: [requireAuth(authStorage, envAccessToken)],
async run(c) {
const opts = c.options;
const resource = createResource();

const params: ListSourcesParams = {};
if (opts.limit !== undefined) params.limit = opts.limit;
if (opts.startingAfter !== undefined)
params.starting_after = opts.startingAfter;
if (opts.endingBefore !== undefined)
params.ending_before = opts.endingBefore;

if (!c.agent && !c.formatExplicit) {
return renderInteractive(
<SourcesList
resource={resource}
params={params}
onComplete={() => {}}
/>,
() => resource.listSources(params),
);
}

return resource.listSources(params);
},
});

return cli;
}
Loading
Loading