diff --git a/packages/cli/README.md b/packages/cli/README.md index fcfcb34b..d923c071 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -91,6 +91,65 @@ confidence quickstart # Launch the interactive setup wizard | `confidence warehouse assignment-table create` | Create an assignment table for experiment analysis | | `confidence warehouse crypto-key create` | Create a crypto key (Snowflake) | +### Fact Tables + +| Command | Description | +| ----------------------------------------------------------------- | ----------------------------------------------- | +| `confidence data fact-tables list` | List one page of fact tables | +| `confidence data fact-tables get factTables/` | Get the complete resource and lifecycle state | +| `confidence data fact-tables create --from-file fact.json [--id]` | Create one fact table from a resource JSON file | + +List supports `--page-size`, `--page-token`, `--filter` (Lucene query string), +`--show-deleted`, and `--order-by` (for example `displayName:desc,createTime:asc`). +Pagination is opt-in: `--all --max-pages 10 --max-items 1000` traverses only within +those explicit bounds (maximum 1000 pages / 100000 resources). If a bound is reached, +`nextPageToken` is retained; use it to continue. Repeated tokens fail instead of looping. + +Create reads the **resource itself**, with camelCase proto-JSON keys, not a +`factTable` wrapper or CLI-option keys: + +```json +{ + "displayName": "Purchases", + "sql": "SELECT purchased_at, user_id, amount, country FROM purchases", + "timestampColumn": { "name": "purchased_at" }, + "entities": [{ "column": { "name": "user_id" }, "entity": "entities/user" }], + "measures": [{ "column": { "name": "amount" }, "unit": { "currencyCode": "USD" } }], + "dimensions": [{ "name": "country" }] +} +``` + +`sql`, `displayName`, `timestampColumn.name`, and at least one entity mapping are +required. Optional fields include `description`, `measures` (`column`, `displayName`, +`unit`, `declaredType`), `dimensions`, `labels` (string values), `owner` +(`identities/`), `factDataDeliveredUntilTime` (RFC 3339 timestamp), and +`dataDeliveredUntilUpdateStrategyConfig` (`strategy: MANUAL|AUTOMATIC`, optional +`disableEmptyPartitions`, and at most one `automaticUpdateConfig`, `dailyUpdateConfig`, +or `hourlyUpdateConfig`). Automatic config requires `commitDelay`; it also supports +`incrementDuration` and `changeThreshold: { "value": "0.01" }`. Daily/hourly config +supports `maxZeroDuration`. Durations use protobuf strings such as `3600s`. Enums use +their symbolic proto names. Columns accept only `name` and optional boolean `repeated`. +Unknown fields, output-only lifecycle fields, and column annotations are rejected, +not silently dropped. `skipSqlPreview` and `column.type` are **not supported** by +this command: omit both and let the service validate SQL and infer column types. +Use `--id purchases` to select an ID; omit it for a server-generated ID. IDs must +start with an ASCII letter or digit and contain only letters, digits, `_`, or `-`. +Do not set `name` in create input. + +These commands support `--profile`, `--json` / `--output`, and `--dry-run`. +Dry-run validates input and previews the request without authentication or HTTP. +The selected profile's token determines the Metrics API region. JSON output keeps +the complete remote resource, including state, errors, and job references. A +successful GET of a resource in `TABLE_STATE_FAILED` still exits successfully; +inspect its `state` and `error` to diagnose warehouse validation. + +Create is an explicit, single write, not an approval workflow. It does not retry or +poll for activation. On a transport/5xx/unreadable-response failure (including a +malformed successful response), the write outcome may be unknown: inspect list/get +before deciding whether to retry. When `--id` is set, the error includes the qualified +resource name to look up. Responses must contain resource objects with qualified +`factTables/` names; malformed get/list responses are errors, not successful output. + ### SDK | Command | Description | diff --git a/packages/cli/__tests__/commands/data.test.ts b/packages/cli/__tests__/commands/data.test.ts new file mode 100644 index 00000000..afbac471 --- /dev/null +++ b/packages/cli/__tests__/commands/data.test.ts @@ -0,0 +1,52 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import yargs from 'yargs'; +import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { dataCommand } from '@commands/index.js'; + +function run(args: string[]) { + let output = ''; + const sut = yargs(args).scriptName('confidence').command(dataCommand).help().exitProcess(false); + return sut + .parseAsync(args, (_error: Error | undefined, _argv: unknown, text: string) => { + output = text; + }) + .then(() => output); +} + +describe('data command group', () => { + it('is registered in the real root help alongside existing commands', () => { + using _auth = prepareAuthTokens('none'); + const sut = fileURLToPath(new URL('../../bin/cli.ts', import.meta.url)); + + const output = execFileSync(process.execPath, ['--import', 'tsx', sut, '--help'], { + encoding: 'utf8', + env: { ...process.env, CONFIDENCE_TELEMETRY: 'false' }, + }); + + expect(output).toContain('confidence data'); + expect(output).toContain('confidence warehouse'); + expect(output).toContain('confidence flags'); + }); + + it('shows help without authentication or placeholder API subcommands', async () => { + using _auth = prepareAuthTokens('none'); + const sut = run; + + const output = await sut(['data', '--help']); + + expect(output).toContain('confidence data '); + expect(output).toContain('confidence data fact-tables'); + expect(output).not.toMatch(/onboard|discover|proposal|workflow/iu); + }); + + it('shows guidance when invoked without a subcommand', async () => { + const sut = run; + expect(await sut(['data'])).toContain('confidence data --help'); + }); + + it('rejects unimplemented subcommands', async () => { + const sut = run; + expect(await sut(['data', 'invented'])).toContain('Unknown argument: invented'); + }); +}); diff --git a/packages/cli/__tests__/commands/fact-tables.test.ts b/packages/cli/__tests__/commands/fact-tables.test.ts new file mode 100644 index 00000000..4ab4ba81 --- /dev/null +++ b/packages/cli/__tests__/commands/fact-tables.test.ts @@ -0,0 +1,799 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { server, http, HttpResponse } from '@spotify-confidence/testing'; +import { buildTestJwt, prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { createProjectDir, writeJsonFile } from '@spotify-confidence/testing/scaffold'; +import { dataCommand } from '@commands/data.js'; +import { captureOutput } from '../helpers/capture.js'; +import { createRunner } from '../helpers/run-command.js'; + +const sut = createRunner(dataCommand); +const BASE = 'https://metrics.eu.confidence.dev/v1/factTables'; +const malformedResources = [ + null, + [], + 'not a resource', + {}, + { name: 5 }, + { name: '' }, + { name: 'purchases' }, + { name: 'entities/user' }, + { name: 'factTables/../unsafe' }, + { name: 'factTables/a/b' }, + { name: 'factTables/%2funsafe' }, + { name: 'https://evil.test/factTables/a' }, +]; + +function resource() { + return { + sql: 'SELECT ts, user_id, amount FROM purchases', + displayName: 'Purchases', + timestampColumn: { name: 'ts' }, + entities: [{ column: { name: 'user_id' }, entity: 'entities/user' }], + measures: [{ column: { name: 'amount' }, unit: { currencyCode: 'USD' } }], + dimensions: [{ name: 'product', repeated: true }], + description: 'Orders placed', + labels: { team: 'growth', state: 'custom label' }, + owner: 'identities/owner', + factDataDeliveredUntilTime: '2026-05-01T00:00:00Z', + dataDeliveredUntilUpdateStrategyConfig: { + strategy: 'AUTOMATIC', + automaticUpdateConfig: { + commitDelay: '3600s', + incrementDuration: '60s', + changeThreshold: { value: '0.01' }, + }, + disableEmptyPartitions: true, + }, + }; +} + +function observeRequests() { + const requests: string[] = []; + const listener = ({ request }: { request: Request }) => + requests.push(`${request.method} ${request.url}`); + server.events.on('request:start', listener); + return { + requests, + [Symbol.dispose]() { + server.events.removeListener('request:start', listener); + }, + }; +} + +describe('data fact-tables list', () => { + it('prints the full page and sends supported query parameters to Metrics', async () => { + // Arrange + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + const data = { + factTables: [ + { name: 'factTables/purchases', state: 'TABLE_STATE_ACTIVE', labels: { team: 'growth' } }, + ], + nextPageToken: 'next', + otherMetadata: { total: 5 }, + }; + const received: URL[] = []; + server.use( + http.get(BASE, ({ request }) => { + received.push(new URL(request.url)); + return HttpResponse.json(data); + }), + ); + + // Act + await sut([ + 'data', + 'fact-tables', + 'list', + '--page-size', + '5', + '--page-token', + 'cursor +/=', + '--filter', + 'displayName:Purchases', + '--show-deleted', + '--order-by', + 'displayName:desc,createTime:asc', + '--json', + ]); + + // Assert + expect(JSON.parse(output.stdout)).toEqual({ data }); + expect(received).toHaveLength(1); + expect(Object.fromEntries(received[0].searchParams)).toEqual({ + pageSize: '5', + pageToken: 'cursor +/=', + filter: 'displayName:Purchases', + showDeleted: 'true', + orderBy: 'displayName:desc,createTime:asc', + }); + }); + + it.each([{}, { factTables: [] }])('prints an empty list for %j', async (data) => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + server.use(http.get(BASE, () => HttpResponse.json(data))); + + await sut(['data', 'fact-tables', 'list', '--json']); + + expect(JSON.parse(output.stdout).data.factTables).toEqual([]); + expect(process.exitCode ?? 0).toBe(0); + }); + + it('traverses pages only with --all and keeps list options on every page', async () => { + // Arrange + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + const received: URL[] = []; + server.use( + http.get(BASE, ({ request }) => { + const url = new URL(request.url); + received.push(url); + return HttpResponse.json( + url.searchParams.has('pageToken') + ? { factTables: [{ name: 'factTables/second', state: 'TABLE_STATE_FAILED' }] } + : { factTables: [{ name: 'factTables/first' }], nextPageToken: 'second' }, + ); + }), + ); + + // Act + await sut([ + 'data', + 'fact-tables', + 'list', + '--all', + '--max-pages', + '3', + '--max-items', + '5', + '--filter', + 'team:growth', + '--show-deleted=false', + '--order-by', + 'displayName:asc', + '--json', + ]); + + // Assert + expect(JSON.parse(output.stdout).data.factTables).toEqual([ + { name: 'factTables/first' }, + { name: 'factTables/second', state: 'TABLE_STATE_FAILED' }, + ]); + expect(received.map((url) => url.searchParams.get('pageToken'))).toEqual([null, 'second']); + expect( + received.every( + (url) => + url.searchParams.get('filter') === 'team:growth' && + url.searchParams.get('showDeleted') === 'false' && + url.searchParams.get('orderBy') === 'displayName:asc', + ), + ).toBe(true); + }); + + it.each([ + ['--max-pages', '1', '--max-items', '10'], + ['--max-pages', '10', '--max-items', '1'], + ])('retains the continuation token when a bound is reached: %j', async (...bounds) => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + using requests = observeRequests(); + server.use( + http.get(BASE, () => + HttpResponse.json({ factTables: [{ name: 'factTables/first' }], nextPageToken: 'more' }), + ), + ); + + await sut(['data', 'fact-tables', 'list', '--all', ...bounds, '--json']); + + expect(JSON.parse(output.stdout).data.nextPageToken).toBe('more'); + expect(requests.requests).toHaveLength(1); + }); + + it('reduces page size to the remaining item bound and preserves the continuation token', async () => { + // Arrange + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + const received: URL[] = []; + server.use( + http.get(BASE, ({ request }) => { + const url = new URL(request.url); + received.push(url); + return HttpResponse.json({ + factTables: Array.from({ length: Number(url.searchParams.get('pageSize')) }, (_, i) => ({ + name: `factTables/page${received.length}-item${i}`, + })), + nextPageToken: `page${received.length + 1}`, + }); + }), + ); + + // Act + await sut([ + 'data', + 'fact-tables', + 'list', + '--all', + '--page-size', + '2', + '--max-pages', + '10', + '--max-items', + '3', + '--json', + ]); + + // Assert + expect(received.map((url) => url.searchParams.get('pageSize'))).toEqual(['2', '1']); + expect(JSON.parse(output.stdout).data).toEqual({ + factTables: [ + { name: 'factTables/page1-item0' }, + { name: 'factTables/page1-item1' }, + { name: 'factTables/page2-item0' }, + ], + nextPageToken: 'page3', + }); + }); + + it('fails instead of silently truncating if the service exceeds the item bound', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + using requests = observeRequests(); + server.use( + http.get(BASE, () => + HttpResponse.json({ + factTables: [{ name: 'factTables/first' }, { name: 'factTables/second' }], + }), + ), + ); + + await sut([ + 'data', + 'fact-tables', + 'list', + '--all', + '--max-pages', + '2', + '--max-items', + '1', + '--json', + ]); + + expect(output.stderr).toContain('service did not honor pageSize'); + expect(output.stdout).toBe(''); + expect(requests.requests).toHaveLength(1); + expect(process.exitCode).toBe(1); + }); + + it.each([[], { factTables: null }, { factTables: {} }, { nextPageToken: 5 }])( + 'reports malformed list response %j without printing success', + async (data) => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + server.use(http.get(BASE, () => HttpResponse.json(data))); + + await sut(['data', 'fact-tables', 'list', '--json']); + + expect(output.stderr).toContain('Fact table list response'); + expect(output.stdout).toBe(''); + expect(process.exitCode).toBe(1); + }, + ); + + it.each(malformedResources.map((item) => ({ item })))( + 'rejects a malformed list entry $item even after a valid entry', + async ({ item }) => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + using requests = observeRequests(); + server.use( + http.get(BASE, () => + HttpResponse.json({ + factTables: [{ name: 'factTables/valid' }, item], + nextPageToken: 'more', + }), + ), + ); + + await sut(['data', 'fact-tables', 'list', '--json']); + + expect(output.stderr).not.toBe(''); + expect(output.stdout).toBe(''); + expect(requests.requests).toHaveLength(1); + expect(process.exitCode).toBe(1); + }, + ); + + it('stops on a repeated token rather than looping', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + using requests = observeRequests(); + server.use(http.get(BASE, () => HttpResponse.json({ factTables: [], nextPageToken: 'same' }))); + + await sut([ + 'data', + 'fact-tables', + 'list', + '--all', + '--max-pages', + '5', + '--max-items', + '10', + '--json', + ]); + + expect(output.stderr).toContain('repeated page token'); + expect(requests.requests).toHaveLength(2); + expect(output.stdout).toBe(''); + expect(process.exitCode).toBe(1); + }); + + it('does not print a misleading partial success when a later page fails', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + server.use( + http.get(BASE, ({ request }) => + new URL(request.url).searchParams.has('pageToken') + ? HttpResponse.json({ code: 403, message: 'Permission denied' }, { status: 403 }) + : HttpResponse.json({ + factTables: [{ name: 'factTables/first' }], + nextPageToken: 'second', + }), + ), + ); + + await sut([ + 'data', + 'fact-tables', + 'list', + '--all', + '--max-pages', + '3', + '--max-items', + '5', + '--json', + ]); + + expect(output.stderr).toContain('Permission denied'); + expect(output.stdout).toBe(''); + expect(process.exitCode).toBe(1); + }); + + it.each([ + ['--all'], + ['--all', '--max-pages', '2'], + ['--max-pages', '2'], + ['--page-size', '0'], + ['--page-size', '1.5'], + ['--all', '--max-pages', '-1', '--max-items', '5'], + ['--all', '--max-pages', '1001', '--max-items', '5'], + ['--all', '--max-pages', '2', '--max-items', '0'], + ])('rejects invalid pagination before authentication: %j', async (...options) => { + using _auth = prepareAuthTokens('none'); + using output = captureOutput(); + using requests = observeRequests(); + + await sut(['data', 'fact-tables', 'list', ...options]); + + expect(output.stderr).toContain('--'); + expect(output.stderr).not.toContain('Not logged in'); + expect(requests.requests).toEqual([]); + expect(process.exitCode).toBe(1); + }); +}); + +describe('data fact-tables get', () => { + it.each(['TABLE_STATE_ACTIVE', 'TABLE_STATE_CREATING', 'TABLE_STATE_FAILED'])( + 'preserves %s and lifecycle details without treating it as an HTTP error', + async (state) => { + // Arrange + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + const data = { + name: 'factTables/purchases', + state, + error: { message: 'warehouse validation detail', details: ['column not found'] }, + schemaValidationJob: 'sqlJobs/job', + createTime: '2026-05-01T00:00:00Z', + partitionedUpdateStrategyState: { job: 'sqlJobs/update' }, + }; + server.use(http.get(`${BASE}/purchases`, () => HttpResponse.json(data))); + + // Act + await sut(['data', 'fact-tables', 'get', '--name', 'factTables/purchases', '--json']); + + // Assert + expect(JSON.parse(output.stdout)).toEqual({ data }); + expect(output.stderr).toBe(''); + expect(process.exitCode ?? 0).toBe(0); + }, + ); + + it('renders lifecycle fields in human-readable output too', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + server.use( + http.get(`${BASE}/purchases`, () => + HttpResponse.json({ + name: 'factTables/purchases', + state: 'TABLE_STATE_FAILED', + error: { message: 'Invalid SQL' }, + }), + ), + ); + + await sut(['data', 'fact-tables', 'get', 'factTables/purchases', '--output', 'table']); + + expect(output.stdout).toContain('TABLE_STATE_FAILED'); + expect(output.stdout).toContain('Invalid SQL'); + expect(process.exitCode ?? 0).toBe(0); + }); + + it.each([ + 'purchases', + 'factTables/../purchases', + 'factTables/a/b', + 'factTables/%2fsecret', + 'https://evil.test/factTables/a', + 'entities/a', + ])('rejects unsafe or unqualified name %s without HTTP', async (name) => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + using requests = observeRequests(); + + await sut(['data', 'fact-tables', 'get', name]); + + expect(output.stderr).toContain('Resource name'); + expect(requests.requests).toEqual([]); + expect(process.exitCode).toBe(1); + }); + + it.each(malformedResources.map((data) => ({ data })))( + 'rejects malformed successful get response $data', + async ({ data }) => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + server.use(http.get(`${BASE}/purchases`, () => HttpResponse.json(data))); + + await sut(['data', 'fact-tables', 'get', 'factTables/purchases', '--json']); + + expect(output.stderr).not.toBe(''); + expect(output.stdout).toBe(''); + expect(process.exitCode).toBe(1); + }, + ); + + it('reports a remote 404 as a command failure', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + server.use( + http.get(`${BASE}/missing`, () => + HttpResponse.json({ code: 404, message: 'Fact table not found' }, { status: 404 }), + ), + ); + + await sut(['data', 'fact-tables', 'get', 'factTables/missing', '--json']); + + expect(output.stderr).toContain('Fact table not found'); + expect(output.stdout).toBe(''); + expect(process.exitCode).toBe(1); + }); +}); + +describe('data fact-tables create', () => { + it.each([undefined, 'purchases'])( + 'creates exactly one resource with optional ID %s and preserves the remote result', + async (id) => { + // Arrange + using _auth = prepareAuthTokens('valid'); + using project = createProjectDir('empty'); + using output = captureOutput(); + const input = resource(); + const file = writeJsonFile(project.path, 'fact.json', input); + const created: unknown[] = []; + const urls: URL[] = []; + const remote = { + ...input, + name: `factTables/${id ?? 'generated'}`, + state: 'TABLE_STATE_CREATING', + schemaValidationJob: 'sqlJobs/validation', + creator: 'identities/me', + }; + server.use( + http.post(BASE, async ({ request }) => { + created.push(await request.json()); + urls.push(new URL(request.url)); + return HttpResponse.json(remote); + }), + ); + + // Act + await sut([ + 'data', + 'fact-tables', + 'create', + '--from-file', + file, + ...(id ? ['--id', id] : []), + '--json', + ]); + + // Assert + expect(created).toEqual([input]); + expect(Object.fromEntries(urls[0].searchParams)).toEqual(id ? { factTableId: id } : {}); + expect(JSON.parse(output.stdout)).toEqual({ data: remote }); + expect(output.stderr).toBe(''); + }, + ); + + it('uses the chosen profile token and its region rather than the default account', async () => { + // Arrange + using _auth = prepareAuthTokens('valid'); + using project = createProjectDir('empty'); + using output = captureOutput(); + const token = buildTestJwt({ 'https://confidence.dev/region': 'US' }); + const profileDir = `${process.env.CONFIDENCE_CONFIG_DIR}/profiles/analytics`; + mkdirSync(profileDir, { recursive: true }); + writeJsonFile(profileDir, 'credentials.json', { accessToken: token }); + const file = writeJsonFile(project.path, 'fact.json', resource()); + const authorizations: (string | null)[] = []; + server.use( + http.post('https://metrics.us.confidence.dev/v1/factTables', ({ request }) => { + authorizations.push(request.headers.get('Authorization')); + return HttpResponse.json({ name: 'factTables/us-purchases' }); + }), + ); + + // Act + await sut([ + 'data', + 'fact-tables', + 'create', + '--from-file', + file, + '--profile', + 'analytics', + '--json', + ]); + + // Assert + expect(JSON.parse(output.stdout).data.name).toBe('factTables/us-purchases'); + expect(authorizations).toEqual([`Bearer ${token}`]); + }); + + it.each([ + { ...resource(), entities: [{ column: 'user_id', entity: 'entities/user' }] }, + { ...resource(), state: 'TABLE_STATE_ACTIVE' }, + { ...resource(), unknownOption: true }, + { ...resource(), skipSqlPreview: true }, + { ...resource(), measures: [{ column: { name: 'amount', type: 'COLUMN_TYPE_DOUBLE' } }] }, + { + ...resource(), + entities: [{ column: { name: 'user_id' }, entity: 'entities/user', extra: true }], + }, + { + ...resource(), + dataDeliveredUntilUpdateStrategyConfig: { + strategy: 'AUTOMATIC', + automaticUpdateConfig: { + commitDelay: '60s', + changeThreshold: { value: '0.01', extra: true }, + }, + }, + }, + ])('rejects invalid resource input before authentication or HTTP: %j', async (input) => { + using _auth = prepareAuthTokens('none'); + using project = createProjectDir('empty'); + using output = captureOutput(); + using requests = observeRequests(); + const file = writeJsonFile(project.path, 'fact.json', input); + + await sut(['data', 'fact-tables', 'create', '--from-file', file]); + + expect(output.stderr).not.toBe(''); + expect(output.stderr).not.toContain('Not logged in'); + expect(requests.requests).toEqual([]); + expect(process.exitCode).toBe(1); + }); + + it('rejects malformed JSON without HTTP', async () => { + using _auth = prepareAuthTokens('valid'); + using project = createProjectDir('empty'); + using output = captureOutput(); + using requests = observeRequests(); + const file = `${project.path}/fact.json`; + writeFileSync(file, '{broken'); + + await sut(['data', 'fact-tables', 'create', '--from-file', file]); + + expect(output.stderr).toContain('Cannot read fact table JSON'); + expect(requests.requests).toEqual([]); + expect(process.exitCode).toBe(1); + }); + + it.each(['../escape', 'factTables/id', 'x?query=1', '%2f', 'a b'])( + 'rejects unsafe create ID %s', + async (id) => { + using _auth = prepareAuthTokens('valid'); + using project = createProjectDir('empty'); + using output = captureOutput(); + using requests = observeRequests(); + const file = writeJsonFile(project.path, 'fact.json', resource()); + + await sut(['data', 'fact-tables', 'create', '--from-file', file, '--id', id]); + + expect(output.stderr).toContain('Resource name'); + expect(requests.requests).toEqual([]); + expect(process.exitCode).toBe(1); + }, + ); + + it.each([400, 403, 409])('reports HTTP %s without retrying the create', async (status) => { + using _auth = prepareAuthTokens('valid'); + using project = createProjectDir('empty'); + using output = captureOutput(); + using requests = observeRequests(); + const file = writeJsonFile(project.path, 'fact.json', resource()); + server.use( + http.post(BASE, () => + HttpResponse.json({ code: status, message: 'Create rejected' }, { status }), + ), + ); + + await sut(['data', 'fact-tables', 'create', '--from-file', file, '--json']); + + expect(output.stderr).toContain('Create rejected'); + expect(requests.requests).toHaveLength(1); + expect(output.stdout).toBe(''); + expect(process.exitCode).toBe(1); + }); + + describe.each([undefined, 'purchases'])('malformed create response with ID %s', (id) => { + it.each(malformedResources.map((data) => ({ data })))( + 'reports an unknown outcome for successful HTTP with malformed resource $data', + async ({ data }) => { + // Arrange + using _auth = prepareAuthTokens('valid'); + using project = createProjectDir('empty'); + using output = captureOutput(); + using requests = observeRequests(); + const file = writeJsonFile(project.path, 'fact.json', resource()); + server.use(http.post(BASE, () => HttpResponse.json(data, { status: 201 }))); + + // Act + await sut([ + 'data', + 'fact-tables', + 'create', + '--from-file', + file, + ...(id === undefined ? [] : ['--id', id]), + '--json', + ]); + + // Assert + expect(output.stderr).toContain('Create outcome is unknown'); + expect(output.stderr).toContain('no retry was attempted'); + expect(output.stderr).toContain( + id === undefined ? 'Check fact-tables list/get' : 'fact-tables get factTables/purchases', + ); + expect(requests.requests).toHaveLength(1); + expect(output.stdout).toBe(''); + expect(process.exitCode).toBe(1); + }, + ); + }); + + it.each(['network', 'server', 'invalid response'])( + 'reports an unknown write outcome on %s failure without retrying', + async (failure) => { + using _auth = prepareAuthTokens('valid'); + using project = createProjectDir('empty'); + using output = captureOutput(); + using requests = observeRequests(); + const file = writeJsonFile(project.path, 'fact.json', resource()); + server.use( + http.post(BASE, () => + failure === 'network' + ? HttpResponse.error() + : failure === 'server' + ? HttpResponse.json({ message: 'Unavailable' }, { status: 503 }) + : HttpResponse.text('not JSON'), + ), + ); + + await sut([ + 'data', + 'fact-tables', + 'create', + '--from-file', + file, + '--id', + 'purchases', + '--json', + ]); + + expect(output.stderr).toContain('Create outcome is unknown'); + expect(output.stderr).toContain('no retry was attempted'); + expect(output.stderr).toContain('fact-tables get factTables/purchases'); + expect(requests.requests).toHaveLength(1); + expect(output.stdout).toBe(''); + expect(process.exitCode).toBe(1); + }, + ); +}); + +describe('fact table authentication and previews', () => { + it.each(['list', 'get', 'create'])( + 'requires authentication for %s without sending HTTP', + async (action) => { + using _auth = prepareAuthTokens('none'); + using project = createProjectDir('empty'); + using output = captureOutput(); + using requests = observeRequests(); + const file = writeJsonFile(project.path, 'fact.json', resource()); + const args = + action === 'create' + ? ['--from-file', file] + : action === 'get' + ? ['factTables/purchases'] + : []; + + await sut(['data', 'fact-tables', action, ...args]); + + expect(output.stderr).toContain('Not logged in'); + expect(requests.requests).toEqual([]); + expect(process.exitCode).toBe(1); + }, + ); + + it.each(['list', 'get', 'create'])( + 'previews validated %s without authentication or HTTP', + async (action) => { + // Arrange + using _auth = prepareAuthTokens('none'); + using project = createProjectDir('empty'); + using output = captureOutput(); + using requests = observeRequests(); + const input = resource(); + const file = writeJsonFile(project.path, 'fact.json', input); + const args = + action === 'create' + ? ['--from-file', file, '--id', 'purchases'] + : action === 'get' + ? ['factTables/purchases'] + : ['--page-size', '5']; + + // Act + await sut(['data', 'fact-tables', action, ...args, '--dry-run', '--json']); + + // Assert + const preview = JSON.parse(output.stdout).data; + expect(preview.method).toBe(action === 'create' ? 'POST' : 'GET'); + expect(preview.path).toBe(action === 'get' ? '/v1/factTables/purchases' : '/v1/factTables'); + if (action === 'create') + expect(preview).toMatchObject({ params: { factTableId: 'purchases' }, body: input }); + expect(output.stderr).toBe(''); + expect(requests.requests).toEqual([]); + expect(process.exitCode ?? 0).toBe(0); + }, + ); + + it.each([ + { ...resource(), state: 'TABLE_STATE_ACTIVE' }, + { ...resource(), dimensions: [{ name: 'product', extra: true }] }, + ])('still deeply validates dry-run resource input: %j', async (input) => { + using _auth = prepareAuthTokens('none'); + using project = createProjectDir('empty'); + using output = captureOutput(); + using requests = observeRequests(); + const file = writeJsonFile(project.path, 'fact.json', input); + + await sut(['data', 'fact-tables', 'create', '--from-file', file, '--dry-run', '--json']); + + expect(output.stderr).toContain('is unsupported or output-only'); + expect(output.stdout).toBe(''); + expect(requests.requests).toEqual([]); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/cli/__tests__/features/data/dry-run.test.ts b/packages/cli/__tests__/features/data/dry-run.test.ts new file mode 100644 index 00000000..ad31223c --- /dev/null +++ b/packages/cli/__tests__/features/data/dry-run.test.ts @@ -0,0 +1,284 @@ +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { http, HttpResponse, server } from '@spotify-confidence/testing'; +import { buildTestJwt, prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { resetTelemetry } from '@spotify-confidence/core'; +import { executeDataRequest, printData } from '@features/data/index.js'; +import { metricsRequest } from '@network/metrics.js'; +import { requireRecord, requireString } from '@network/metrics-types.js'; +import { flushTelemetry, telemetryMiddleware } from '@utils/telemetry.js'; +import { captureOutput } from '../../helpers/capture.js'; + +const TEST_URL = 'https://metrics.eu.confidence.dev/v1/testResources'; + +function inputFile(contents: string) { + const dir = mkdtempSync(join(tmpdir(), 'confidence-data-test-')); + const path = join(dir, 'input.json'); + writeFileSync(path, contents); + return { + path, + [Symbol.dispose]() { + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +// A feature-like caller validates real input before the shared execution helper. +async function runValidatedFile(argv: Record) { + const input = requireRecord( + JSON.parse(readFileSync(argv['from-file'] as string, 'utf8')), + 'Input', + ); + requireString(input.displayName, 'displayName'); + await executeDataRequest( + argv, + (token) => metricsRequest(token, { path: '/v1/testResources', method: 'POST', body: input }), + { method: 'POST', path: '/v1/testResources', body: input }, + ); +} + +afterEach(() => { + resetTelemetry(); + vi.unstubAllEnvs(); +}); + +describe('Data dry-runs', () => { + it('previews validated input before auth with no HTTP or file writes', async () => { + // Arrange + using _auth = prepareAuthTokens('none'); + using file = inputFile('{"displayName":"Example"}'); + using output = captureOutput(); + const configDir = process.env.CONFIDENCE_CONFIG_DIR!; + const before = readdirSync(configDir, { recursive: true }); + let calls = 0; + server.use( + http.all('*', () => { + calls++; + return HttpResponse.json({}); + }), + ); + const sut = runValidatedFile; + + // Act + await sut({ 'dry-run': true, 'from-file': file.path, json: true, profile: '../invalid' }); + + // Assert + expect(JSON.parse(output.stdout)).toEqual({ + data: { method: 'POST', path: '/v1/testResources', body: { displayName: 'Example' } }, + }); + expect(output.stderr).toBe(''); + expect(calls).toBe(0); + expect(readdirSync(configDir, { recursive: true })).toEqual(before); + expect(readFileSync(file.path, 'utf8')).toBe('{"displayName":"Example"}'); + }); + + it.each(['invalid JSON', '[]', '{"displayName":42}'])( + 'still rejects invalid local input %s before preview or auth', + async (contents) => { + using _auth = prepareAuthTokens('none'); + using file = inputFile(contents); + using output = captureOutput(); + const sut = runValidatedFile; + + await expect(sut({ 'dry-run': true, 'from-file': file.path, json: true })).rejects.toThrow(); + + expect(output.stdout).toBe(''); + expect(output.stderr).toBe(''); + }, + ); + + it('has a useful local preview when none is supplied', async () => { + using _auth = prepareAuthTokens('none'); + using output = captureOutput(); + let calls = 0; + const sut = executeDataRequest; + + await sut({ 'dry-run': true, json: true }, async () => { + calls++; + return { ok: true, data: {} }; + }); + + expect(JSON.parse(output.stdout)).toEqual({ data: { dryRun: true } }); + expect(calls).toBe(0); + }); +}); + +describe('Data request execution', () => { + it.each(['none', 'expired'] as const)( + 'fails without usable %s credentials before sending', + async (auth) => { + using _auth = prepareAuthTokens(auth); + using output = captureOutput(); + let calls = 0; + const sut = executeDataRequest; + + await sut({}, async () => { + calls++; + return { ok: true, data: {} }; + }); + + expect(calls).toBe(0); + expect(output.stderr).toMatch(/Not logged in|Token expired/u); + expect(process.exitCode).toBe(1); + }, + ); + + it('sends once and preserves state, errors, and pagination in the JSON envelope', async () => { + // Arrange + using _auth = prepareAuthTokens('valid'); + using file = inputFile('{"displayName":"Example"}'); + using output = captureOutput(); + let calls = 0; + const data = { state: 'FAILED', error: { message: 'Resource failed' }, nextPageToken: 'next' }; + server.use( + http.post(TEST_URL, () => { + calls++; + return HttpResponse.json(data); + }), + ); + const previousExitCode = process.exitCode; + const sut = runValidatedFile; + + // Act + await sut({ 'from-file': file.path, json: true }); + + // Assert + expect(JSON.parse(output.stdout)).toEqual({ data }); + expect(output.stderr).toBe(''); + expect(process.exitCode).toBe(previousExitCode); + expect(calls).toBe(1); + }); + + it('uses the requested auth profile', async () => { + // Arrange + using _auth = prepareAuthTokens('none'); + using output = captureOutput(); + const profileDir = join(process.env.CONFIDENCE_CONFIG_DIR!, 'profiles', 'metrics'); + const token = buildTestJwt({ email: 'profile@example.com' }); + mkdirSync(profileDir, { recursive: true }); + writeFileSync(join(profileDir, 'credentials.json'), JSON.stringify({ accessToken: token })); + server.use( + http.get(TEST_URL, ({ request }) => + HttpResponse.json({ + authorized: request.headers.get('Authorization') === `Bearer ${token}`, + }), + ), + ); + const sut = executeDataRequest; + + // Act + await sut({ profile: 'metrics', json: true }, (value) => + metricsRequest(value, { path: '/v1/testResources' }), + ); + + // Assert + expect(JSON.parse(output.stdout)).toEqual({ data: { authorized: true } }); + }); + + it('prints a sanitized failure, omits arbitrary details, and exits nonzero without replay', async () => { + // Arrange + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + let calls = 0; + server.use( + http.get(TEST_URL, () => { + calls++; + return HttpResponse.json( + { + code: 7, + message: 'Denied\n\u001b[31m Bearer private-credential', + details: [{ secret: 'private' }], + }, + { status: 403 }, + ); + }), + ); + const sut = executeDataRequest; + + // Act + await sut({ json: true }, (token) => metricsRequest(token, { path: '/v1/testResources' })); + + // Assert + expect(output.stdout).toBe(''); + expect(output.stderr).toContain('Denied'); + expect(output.stderr).not.toContain('private'); + expect(output.stderr).not.toContain('\u001b'); + expect(process.exitCode).toBe(1); + expect(calls).toBe(1); + }); + + it('reports thrown response validation errors as nonzero failures', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + const sut = executeDataRequest; + + await sut({}, async () => { + throw new Error('Invalid resource response'); + }); + + expect(output.stderr).toContain('Invalid resource response'); + expect(process.exitCode).toBe(1); + }); + + it('renders nested resources as readable output when requested', () => { + using output = captureOutput(); + const sut = printData; + + sut({ name: 'testResources/a', status: { state: 'PENDING' } }, { output: 'table' }); + + expect(output.stdout).toContain('Field'); + expect(output.stdout).toContain('testResources/a'); + expect(output.stdout).toContain('PENDING'); + expect(output.stdout).not.toContain('[object Object]'); + }); +}); + +describe('dry-run telemetry suppression', () => { + it.each([ + ['data', 'entities', 'list'], + ['data', 'sql', 'preview'], + ['warehouse', 'list'], + ['warehouse', 'get'], + ])('sends no telemetry for %j even when telemetry is enabled', async (...command) => { + vi.stubEnv('CONFIDENCE_TELEMETRY', 'true'); + let calls = 0; + server.use( + http.all('*', () => { + calls++; + return HttpResponse.json({ clientSecret: 'test' }); + }), + ); + const sut = telemetryMiddleware; + + sut({ _: command, telemetry: true, 'dry-run': true }); + await flushTelemetry(); + + expect(calls).toBe(0); + }); + + it.each([ + { command: ['flags', 'list'], dryRun: true }, + { command: ['warehouse', 'create'], dryRun: true }, + { command: ['data', 'entities', 'list'], dryRun: false }, + ])('preserves existing telemetry behavior for %j', async ({ command, dryRun }) => { + vi.stubEnv('CONFIDENCE_TELEMETRY', 'true'); + const steps: string[] = []; + server.use( + http.post('https://onboarding.confidence.dev/v1/agentTelemetryKey:acquire', () => + HttpResponse.json({ clientSecret: 'test' }), + ), + http.post('https://events.eu.confidence.dev/v1/events:publish', async ({ request }) => { + const body = (await request.json()) as { events: { payload: { step: string } }[] }; + steps.push(body.events[0].payload.step); + return HttpResponse.json({}); + }), + ); + const sut = telemetryMiddleware; + + sut({ _: command, telemetry: true, 'dry-run': dryRun }); + await flushTelemetry(); + + expect(steps).toEqual([command.join('.')]); + }); +}); diff --git a/packages/cli/__tests__/features/data/fact-input.test.ts b/packages/cli/__tests__/features/data/fact-input.test.ts new file mode 100644 index 00000000..32284b16 --- /dev/null +++ b/packages/cli/__tests__/features/data/fact-input.test.ts @@ -0,0 +1,308 @@ +import { writeFileSync } from 'node:fs'; +import { createProjectDir, writeJsonFile } from '@spotify-confidence/testing/scaffold'; +import { readFactTable, validateFactTable } from '@features/data/fact-input.js'; + +function resource() { + return { + sql: 'SELECT happened_at, user_id, amount FROM purchases', + displayName: 'Purchases', + timestampColumn: { name: 'happened_at' }, + entities: [{ column: { name: 'user_id' }, entity: 'entities/user' }], + }; +} + +describe('fact table resource input', () => { + it('reads camelCase resource keys without normalizing or dropping mappings', () => { + using project = createProjectDir('empty'); + const input = { + ...resource(), + description: 'Orders placed', + measures: [ + { + column: { name: 'amount' }, + displayName: 'Revenue', + declaredType: 'COLUMN_TYPE_DECIMAL', + unit: { currencyCode: 'USD', baseUnitMultiplier: 1 }, + }, + ], + dimensions: [{ name: 'product', repeated: true }], + labels: { costCenter: 'growth', state: 'custom label', unknownKey: 'allowed map key' }, + owner: 'identities/owner', + factDataDeliveredUntilTime: '2026-05-01T00:00:00Z', + dataDeliveredUntilUpdateStrategyConfig: { + strategy: 'AUTOMATIC', + dailyUpdateConfig: { maxZeroDuration: '86400s' }, + disableEmptyPartitions: true, + }, + }; + const file = writeJsonFile(project.path, 'fact.json', input); + const sut = readFactTable; + + const actual = sut(file); + + expect(actual).toEqual(input); + }); + + it.each([ + { strategy: 'MANUAL' }, + { + strategy: 'AUTOMATIC', + automaticUpdateConfig: { + commitDelay: '3600s', + incrementDuration: '60s', + changeThreshold: { value: '0.01' }, + }, + }, + { strategy: 'AUTOMATIC', hourlyUpdateConfig: {} }, + ])('preserves supported delivery strategy %j', (config) => { + const input = { ...resource(), dataDeliveredUntilUpdateStrategyConfig: config }; + const sut = validateFactTable; + expect(sut(input)).toEqual(input); + }); + + it.each([ + { baseUnit: 'SECOND', baseUnitMultiplier: 0.001 }, + { currencyCode: 'EUR' }, + { customUnit: 'Click' }, + { entity: 'entities/user' }, + { factTable: 'factTables/purchases' }, + ])('preserves supported measure unit %j', (unit) => { + const input = { ...resource(), measures: [{ column: { name: 'amount' }, unit }] }; + const sut = validateFactTable; + expect(sut(input)).toEqual(input); + }); + + it.each<[string, unknown]>([ + ['timestampColumn', { ...resource(), timestampColumn: { name: 'ts', extra: true } }], + [ + 'entities[0]', + { + ...resource(), + entities: [{ column: { name: 'user_id' }, entity: 'entities/user', extra: true }], + }, + ], + [ + 'entities[0].column', + { + ...resource(), + entities: [{ column: { name: 'user_id', extra: true }, entity: 'entities/user' }], + }, + ], + ['measures[0]', { ...resource(), measures: [{ column: { name: 'amount' }, extra: true }] }], + [ + 'measures[0].column', + { ...resource(), measures: [{ column: { name: 'amount', extra: true } }] }, + ], + [ + 'measures[0].unit', + { ...resource(), measures: [{ column: { name: 'amount' }, unit: { extra: true } }] }, + ], + ['dimensions[0]', { ...resource(), dimensions: [{ name: 'country', extra: true }] }], + [ + 'dataDeliveredUntilUpdateStrategyConfig', + { + ...resource(), + dataDeliveredUntilUpdateStrategyConfig: { strategy: 'MANUAL', extra: true }, + }, + ], + ...['automaticUpdateConfig', 'dailyUpdateConfig', 'hourlyUpdateConfig'].map( + (key): [string, unknown] => [ + `dataDeliveredUntilUpdateStrategyConfig.${key}`, + { + ...resource(), + dataDeliveredUntilUpdateStrategyConfig: { + strategy: 'AUTOMATIC', + [key]: { extra: true }, + }, + }, + ], + ), + [ + 'dataDeliveredUntilUpdateStrategyConfig.automaticUpdateConfig.changeThreshold', + { + ...resource(), + dataDeliveredUntilUpdateStrategyConfig: { + strategy: 'AUTOMATIC', + automaticUpdateConfig: { + commitDelay: '3600s', + changeThreshold: { value: '0.01', extra: true }, + }, + }, + }, + ], + ])('rejects unknown fields in %s', (label, input) => { + const sut = validateFactTable; + expect(() => sut(input)).toThrow(`${label}.extra is unsupported or output-only`); + }); + + it.each([ + { timestampColumn: { name: 'ts', type: 'COLUMN_TYPE_TIMESTAMP' } }, + { + entities: [ + { column: { name: 'user_id', type: 'COLUMN_TYPE_STRING' }, entity: 'entities/user' }, + ], + }, + { measures: [{ column: { name: 'amount', type: 'COLUMN_TYPE_DOUBLE' } }] }, + { dimensions: [{ name: 'country', type: 'COLUMN_TYPE_STRING' }] }, + ])('rejects output-only column types in every column location: %j', (mapping) => { + const sut = validateFactTable; + expect(() => sut({ ...resource(), ...mapping })).toThrow('type is unsupported or output-only'); + }); + + it.each([ + ['null', null, 'must be an object'], + ['array', [], 'must be an object'], + ['wrapper', { factTable: resource() }, 'factTable'], + ['missing SQL', { ...resource(), sql: ' ' }, 'sql'], + ['missing displayName', { ...resource(), displayName: undefined }, 'displayName'], + [ + 'invalid timestamp mapping', + { ...resource(), timestampColumn: 'happened_at' }, + 'timestampColumn', + ], + ['missing entities', { ...resource(), entities: undefined }, 'entities'], + ['empty entities', { ...resource(), entities: [] }, 'at least one'], + [ + 'invalid entity mapping', + { ...resource(), entities: [{ column: 'user_id', entity: 'entities/user' }] }, + 'column', + ], + [ + 'missing entity reference', + { ...resource(), entities: [{ column: { name: 'user_id' } }] }, + 'entity', + ], + [ + 'unsafe entity reference', + { ...resource(), entities: [{ column: { name: 'user_id' }, entity: 'entities/../user' }] }, + 'Resource name', + ], + ['unknown field', { ...resource(), display_name: 'Other' }, 'display_name'], + ['invalid labels', { ...resource(), labels: { team: 5 } }, 'labels.team'], + ['invalid owner', { ...resource(), owner: 'owner' }, 'Resource name'], + ['invalid timestamp', { ...resource(), factDataDeliveredUntilTime: 'yesterday' }, 'timestamp'], + ['invalid dimensions', { ...resource(), dimensions: ['product'] }, 'dimensions[0]'], + [ + 'column annotations', + { ...resource(), dimensions: [{ name: 'product', annotations: {} }] }, + 'annotations', + ], + [ + 'column type', + { ...resource(), timestampColumn: { name: 'happened_at', type: 'COLUMN_TYPE_TIMESTAMP' } }, + 'type', + ], + [ + 'invalid repeated', + { ...resource(), timestampColumn: { name: 'happened_at', repeated: 'yes' } }, + 'boolean', + ], + ['invalid measure mapping', { ...resource(), measures: [{ name: 'amount' }] }, 'name'], + [ + 'unknown measure field', + { ...resource(), measures: [{ column: { name: 'amount' }, expression: 'SUM(amount)' }] }, + 'expression', + ], + [ + 'unknown declared type', + { ...resource(), measures: [{ column: { name: 'amount' }, declaredType: 'money' }] }, + 'declaredType', + ], + [ + 'invalid unit oneof', + { + ...resource(), + measures: [ + { column: { name: 'amount' }, unit: { currencyCode: 'USD', customUnit: 'dollar' } }, + ], + }, + 'at most one', + ], + [ + 'unknown unit field', + { ...resource(), measures: [{ column: { name: 'amount' }, unit: { currency: 'USD' } }] }, + 'currency', + ], + [ + 'invalid strategy', + { ...resource(), dataDeliveredUntilUpdateStrategyConfig: { strategy: 'eventually' } }, + 'strategy', + ], + [ + 'missing commit delay', + { + ...resource(), + dataDeliveredUntilUpdateStrategyConfig: { + strategy: 'AUTOMATIC', + automaticUpdateConfig: {}, + }, + }, + 'commitDelay', + ], + [ + 'invalid duration', + { + ...resource(), + dataDeliveredUntilUpdateStrategyConfig: { + strategy: 'AUTOMATIC', + hourlyUpdateConfig: { maxZeroDuration: '1 hour' }, + }, + }, + 'duration', + ], + [ + 'invalid config oneof', + { + ...resource(), + dataDeliveredUntilUpdateStrategyConfig: { + strategy: 'AUTOMATIC', + hourlyUpdateConfig: {}, + dailyUpdateConfig: {}, + }, + }, + 'at most one', + ], + ])('rejects %s rather than silently dropping input', (_label, input, error) => { + const sut = validateFactTable; + expect(() => sut(input)).toThrow(error); + }); + + it.each([ + 'name', + 'state', + 'error', + 'schemaValidationJob', + 'createTime', + 'updateTime', + 'deleteTime', + 'creator', + 'updater', + 'systemCreated', + 'partitionedUpdateStrategyState', + ])('rejects server-managed %s', (field) => { + const sut = validateFactTable; + expect(() => sut({ ...resource(), [field]: 'server value' })).toThrow( + `${field} is unsupported or output-only`, + ); + }); + + it.each([true, false])('explicitly rejects skipSqlPreview=%s', (skipSqlPreview) => { + const sut = validateFactTable; + expect(() => sut({ ...resource(), skipSqlPreview })).toThrow('skipSqlPreview is unsupported'); + }); + + it('reports unreadable files', () => { + using project = createProjectDir('empty'); + const sut = readFactTable; + expect(() => sut(`${project.path}/missing.json`)).toThrow('Cannot read fact table JSON'); + }); + + it('reports malformed JSON', () => { + using project = createProjectDir('empty'); + const path = `${project.path}/broken.json`; + writeFileSync(path, '{broken'); + const sut = readFactTable; + + expect(() => sut(path)).toThrow('Cannot read fact table JSON'); + }); +}); diff --git a/packages/cli/__tests__/network/metrics.test.ts b/packages/cli/__tests__/network/metrics.test.ts new file mode 100644 index 00000000..ea9cc088 --- /dev/null +++ b/packages/cli/__tests__/network/metrics.test.ts @@ -0,0 +1,194 @@ +import { http, HttpResponse, server } from '@spotify-confidence/testing'; +import { buildTestJwt } from '@spotify-confidence/testing/auth'; +import { metricsRequest } from '@network/metrics.js'; +import { isRecord, requireRecord, requireString, resourcePath } from '@network/metrics-types.js'; + +afterEach(() => vi.unstubAllEnvs()); + +describe('Metrics requests', () => { + it.each(['EU', 'US'])('routes to %s and authenticates the request', async (region) => { + const token = buildTestJwt({ 'https://confidence.dev/region': region }); + server.use( + http.get( + `https://metrics.${region.toLowerCase()}.confidence.dev/v1/testResources`, + ({ request }) => + HttpResponse.json({ + authorized: request.headers.get('Authorization') === `Bearer ${token}`, + }), + ), + ); + const sut = metricsRequest; + + const result = await sut(token, { path: '/v1/testResources' }); + + expect(result).toEqual({ ok: true, data: { authorized: true } }); + }); + + it('honors the API base override and sends body and explicit pagination parameters', async () => { + vi.stubEnv('CONFIDENCE_API_BASE_URL', 'http://localhost:9876'); + server.use( + http.post('http://localhost:9876/v1/testResources', async ({ request }) => + HttpResponse.json({ + body: await request.json(), + params: Object.fromEntries(new URL(request.url).searchParams), + }), + ), + ); + const sut = metricsRequest; + + const result = await sut(buildTestJwt(), { + path: '/v1/testResources', + method: 'POST', + body: { displayName: 'Example' }, + params: { page_size: '10', page_token: 'opaque?token/#' }, + }); + + expect(result).toEqual({ + ok: true, + data: { + body: { displayName: 'Example' }, + params: { page_size: '10', page_token: 'opaque?token/#' }, + }, + }); + }); + + it('preserves structured failures without retrying writes', async () => { + let calls = 0; + const error = { code: 3, message: 'Invalid input', details: [{ field: 'name' }] }; + server.use( + http.post('https://metrics.eu.confidence.dev/v1/testResources', () => { + calls++; + return HttpResponse.json(error, { status: 400 }); + }), + ); + const sut = metricsRequest; + + const result = await sut(buildTestJwt(), { path: '/v1/testResources', method: 'POST' }); + + expect(result).toEqual({ ok: false, status: 400, error }); + expect(calls).toBe(1); + }); + + it('does not replay a redirected write or send credentials to its location', async () => { + let redirectedCalls = 0; + server.use( + http.post( + 'https://metrics.eu.confidence.dev/v1/testResources', + () => + new HttpResponse(null, { + status: 307, + headers: { Location: 'https://metrics.eu.confidence.dev/v1/redirected' }, + }), + ), + http.post('https://metrics.eu.confidence.dev/v1/redirected', () => { + redirectedCalls++; + return HttpResponse.json({}); + }), + ); + const sut = metricsRequest; + + const result = await sut(buildTestJwt(), { + path: '/v1/testResources', + method: 'POST', + body: { displayName: 'Example' }, + }); + + expect(result).toMatchObject({ ok: false, status: 0 }); + expect(redirectedCalls).toBe(0); + }); + + it('passes cancellation through to the HTTP client', async () => { + const sut = metricsRequest; + + const result = await sut(buildTestJwt(), { + path: '/v1/testResources', + signal: AbortSignal.abort(), + timeoutMs: 100, + }); + + expect(result).toMatchObject({ ok: false, status: 0 }); + }); + + it.each([ + 'https://other.example/v1/testResources', + '//other.example/v1/testResources', + '/v1/../admin', + '/v1/%2e%2e/admin', + '/v1/testResources?secret=value', + '/v1/testResources#fragment', + '/v1/\\other.example', + ])('rejects unsafe API path %s before sending', async (path) => { + const sut = metricsRequest; + await expect(sut(buildTestJwt(), { path })).rejects.toThrow('local /v1/ API path'); + }); +}); + +describe('resource paths', () => { + it('builds a path from a qualified resource with exactly one ID', () => { + const sut = resourcePath; + expect(sut('testResources/abc-123_A', 'testResources')).toBe('/v1/testResources/abc-123_A'); + }); + + it.each([ + '', + 'abc', + '/testResources/abc', + 'https://evil.example/testResources/abc', + '//evil.example/testResources/abc', + 'other/abc', + 'testResources/', + 'testResources/.', + 'testResources/..', + 'testResources/../other', + 'testResources/abc/child', + 'testResources/%2e%2e', + 'testResources/%252fadmin', + 'testResources/a%2fb', + 'testResources/a?query=x', + 'testResources/a#fragment', + 'testResources/a\\b', + 'testResources/a b', + 'testResources/abc\n', + ])('rejects invalid resource name %j', (name) => { + const sut = resourcePath; + expect(() => sut(name, 'testResources')).toThrow('single valid ID'); + }); + + it.each(['../testResources', '/testResources', 'testResources/child', 'testResources?x=y'])( + 'rejects invalid collection %s', + (collection) => { + const sut = resourcePath; + expect(() => sut('testResources/abc', collection)).toThrow('Invalid resource collection'); + }, + ); +}); + +describe('response guards', () => { + it.each([null, [], 'text', 42, undefined].map((value) => ({ value })))( + 'rejects non-record value $value', + ({ value }) => { + const sut = requireRecord; + expect(isRecord(value)).toBe(false); + expect(() => sut(value, 'Response')).toThrow('Response must be an object'); + }, + ); + + it('preserves resource fields rather than discarding unknown ones', () => { + const resource = { name: 'testResources/a', state: 'FAILED', extra: { reason: 'example' } }; + const sut = requireRecord; + expect(sut(resource)).toBe(resource); + }); + + it.each(['', ' ', null, [], 42].map((value) => ({ value })))( + 'rejects empty or non-string value $value', + ({ value }) => { + const sut = requireString; + expect(() => sut(value, 'Name')).toThrow('Name must be a non-empty string'); + }, + ); + + it('does not silently rewrite strings', () => { + const sut = requireString; + expect(sut(' original ')).toBe(' original '); + }); +}); diff --git a/packages/cli/bin/cli.ts b/packages/cli/bin/cli.ts index 1e9f9157..f52a9689 100644 --- a/packages/cli/bin/cli.ts +++ b/packages/cli/bin/cli.ts @@ -20,6 +20,7 @@ import { pluginCommand, migrateCommand, warehouseCommand, + dataCommand, } from '../src/commands/index.js'; const cli = yargs(hideBin(process.argv)) @@ -88,6 +89,7 @@ const cli = yargs(hideBin(process.argv)) .command(pluginCommand) .command(migrateCommand) .command(warehouseCommand) + .command(dataCommand) .example('$0 login', 'Sign in to Confidence') .example('$0 flags setup', 'Set up feature flags in your project') .example('$0 docs search "feature flags"', 'Search the documentation') diff --git a/packages/cli/src/commands/data.ts b/packages/cli/src/commands/data.ts new file mode 100644 index 00000000..d4d29687 --- /dev/null +++ b/packages/cli/src/commands/data.ts @@ -0,0 +1,16 @@ +import type { Argv } from 'yargs'; +import { noop } from '@spotify-confidence/shared-kernel'; +import { factTablesCommand } from './data/fact-tables.js'; +export const dataCommand = { + command: 'data', + describe: 'Work with Confidence data resources', + builder(yargs: Argv) { + return yargs + .usage('$0 data [options]') + .command(factTablesCommand) + .demandCommand(1, 'Run "confidence data --help" to see available commands.') + .strict() + .help(); + }, + handler: noop, +}; diff --git a/packages/cli/src/commands/data/fact-tables.ts b/packages/cli/src/commands/data/fact-tables.ts new file mode 100644 index 00000000..d5cb5caa --- /dev/null +++ b/packages/cli/src/commands/data/fact-tables.ts @@ -0,0 +1,65 @@ +import type { Argv } from 'yargs'; +import { noop } from '@spotify-confidence/shared-kernel'; +import { createFactTableCmd, getFactTableCmd, listFactTablesCmd } from '@features/data/index.js'; +import { safely } from '@utils/index.js'; + +export const factTablesCommand = { + command: 'fact-tables', + describe: 'List, inspect, and create fact tables', + builder(yargs: Argv) { + return yargs + .command( + 'list', + 'List fact tables (one page unless --all is specified)', + (y: Argv) => + y + .option('page-size', { type: 'number', describe: 'Maximum resources per page' }) + .option('page-token', { type: 'string', describe: 'Continuation token' }) + .option('filter', { type: 'string', describe: 'Lucene query string filter' }) + .option('show-deleted', { type: 'boolean', describe: 'Include deleted fact tables' }) + .option('order-by', { + type: 'string', + describe: 'Sort fields, e.g. displayName:desc,createTime:asc', + }) + .option('all', { type: 'boolean', describe: 'Traverse pages within explicit bounds' }) + .option('max-pages', { type: 'number', describe: 'Page bound for --all (1–1000)' }) + .option('max-items', { + type: 'number', + describe: 'Resource bound for --all (1–100000)', + }), + safely(listFactTablesCmd), + ) + .command( + 'get [name]', + 'Get a fact table, including its current lifecycle state', + (y: Argv) => + y + .positional('name', { type: 'string', describe: 'Resource name: factTables/' }) + .option('name', { + type: 'string', + demandOption: true, + describe: 'Resource name: factTables/ (or supply it positionally)', + }), + safely(getFactTableCmd), + ) + .command( + 'create', + 'Create one fact table from a resource JSON file', + (y: Argv) => + y + .option('from-file', { + type: 'string', + demandOption: true, + describe: 'FactTable resource JSON file', + }) + .option('id', { + type: 'string', + describe: 'Optional resource ID (otherwise generated by the service)', + }), + safely(createFactTableCmd), + ) + .demandCommand(1, 'Available actions: list, get, create') + .strict(); + }, + handler: noop, +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 84dd9b05..fbbee90a 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -13,4 +13,5 @@ export { pluginCommand } from './plugin.js'; export { updateCommand } from './update.js'; export { migrateCommand } from './migrate.js'; export { warehouseCommand } from './warehouse.js'; +export { dataCommand } from './data.js'; export type { GlobalFlags } from './types.js'; diff --git a/packages/cli/src/features/data/fact-input.ts b/packages/cli/src/features/data/fact-input.ts new file mode 100644 index 00000000..d7f18dd6 --- /dev/null +++ b/packages/cli/src/features/data/fact-input.ts @@ -0,0 +1,222 @@ +import { readFileSync } from 'node:fs'; +import { requireRecord, requireString, resourcePath } from '@network/metrics-types.js'; + +/** Input is the proto-JSON FactTable resource, not CLI options or a request wrapper. */ +export function readFactTable(file: unknown): Record { + const path = requireString(file, '--from-file'); + let input: unknown; + try { + input = JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + throw new Error(`Cannot read fact table JSON: ${(error as Error).message}`, { cause: error }); + } + return validateFactTable(input); +} + +function fields( + input: unknown, + allowed: readonly string[], + label: string, +): Record { + const value = requireRecord(input, label); + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) throw new Error(`${label}.${key} is unsupported or output-only.`); + } + return value; +} + +function optionalString(value: Record, key: string, label: string): void { + if (value[key] !== undefined && typeof value[key] !== 'string') { + throw new Error(`${label}.${key} must be a string.`); + } +} + +function optionalBoolean(value: Record, key: string, label: string): void { + if (value[key] !== undefined && typeof value[key] !== 'boolean') { + throw new Error(`${label}.${key} must be a boolean.`); + } +} + +function column(input: unknown, label: string): void { + const value = fields(input, ['name', 'repeated'], label); + requireString(value.name, `${label}.name`); + optionalBoolean(value, 'repeated', label); +} + +function array( + input: unknown, + label: string, + validate: (item: unknown, label: string) => void, +): void { + if (!Array.isArray(input)) throw new Error(`${label} must be an array.`); + input.forEach((item, index) => validate(item, `${label}[${index}]`)); +} + +function reference(input: unknown, collection: string, label: string): void { + resourcePath(requireString(input, label), collection); +} + +function oneof(value: Record, keys: readonly string[], label: string): void { + if (keys.filter((key) => value[key] !== undefined).length > 1) { + throw new Error(`${label} accepts at most one of ${keys.join(', ')}.`); + } +} + +function enumValue(input: unknown, allowed: readonly string[], label: string): void { + if (typeof input !== 'string' || !allowed.includes(input)) { + throw new Error(`${label} must be one of ${allowed.join(', ')}.`); + } +} + +const COLUMN_TYPES = [ + 'COLUMN_TYPE_UNSPECIFIED', + 'COLUMN_TYPE_STRING', + 'COLUMN_TYPE_INT32', + 'COLUMN_TYPE_INT64', + 'COLUMN_TYPE_DOUBLE', + 'COLUMN_TYPE_BOOLEAN', + 'COLUMN_TYPE_DATE', + 'COLUMN_TYPE_TIMESTAMP', + 'COLUMN_TYPE_BYTES', + 'COLUMN_TYPE_TIMESTAMP_WITHOUT_TIMEZONE', + 'COLUMN_TYPE_DECIMAL', + 'COLUMN_TYPE_HLL_SKETCH', +]; + +function unit(input: unknown, label: string): void { + const variants = ['baseUnit', 'currencyCode', 'customUnit', 'entity', 'factTable']; + const value = fields(input, ['baseUnitMultiplier', ...variants], label); + oneof(value, variants, label); + if ( + value.baseUnitMultiplier !== undefined && + (typeof value.baseUnitMultiplier !== 'number' || !Number.isFinite(value.baseUnitMultiplier)) + ) { + throw new Error(`${label}.baseUnitMultiplier must be a finite number.`); + } + if (value.baseUnit !== undefined) { + enumValue( + value.baseUnit, + ['BASE_UNIT_UNSPECIFIED', 'NONE', 'PERCENT', 'SECOND', 'BYTE', 'BIT'], + `${label}.baseUnit`, + ); + } + for (const key of ['currencyCode', 'customUnit']) { + if (value[key] !== undefined) requireString(value[key], `${label}.${key}`); + } + if (value.entity !== undefined) reference(value.entity, 'entities', `${label}.entity`); + if (value.factTable !== undefined) reference(value.factTable, 'factTables', `${label}.factTable`); +} + +function measure(input: unknown, label: string): void { + const value = fields(input, ['column', 'displayName', 'unit', 'declaredType'], label); + column(value.column, `${label}.column`); + optionalString(value, 'displayName', label); + if (value.unit !== undefined) unit(value.unit, `${label}.unit`); + if (value.declaredType !== undefined) + enumValue(value.declaredType, COLUMN_TYPES, `${label}.declaredType`); +} + +function duration(input: unknown, label: string): void { + const value = requireString(input, label); + if (!/^(?:0|[1-9]\d*)(?:\.\d{1,9})?s$/u.test(value) || Number.parseFloat(value) > 315576000000) { + throw new Error(`${label} must be a non-negative protobuf duration, e.g. 3600s.`); + } +} + +function deliveryConfig(input: unknown, label: string): void { + const variants = ['automaticUpdateConfig', 'dailyUpdateConfig', 'hourlyUpdateConfig']; + const value = fields(input, ['strategy', 'disableEmptyPartitions', ...variants], label); + enumValue(value.strategy, ['MANUAL', 'AUTOMATIC'], `${label}.strategy`); + optionalBoolean(value, 'disableEmptyPartitions', label); + oneof(value, variants, label); + for (const key of variants) { + if (value[key] === undefined) continue; + const nestedLabel = `${label}.${key}`; + if (key !== 'automaticUpdateConfig') { + const nested = fields(value[key], ['maxZeroDuration'], nestedLabel); + if (nested.maxZeroDuration !== undefined) + duration(nested.maxZeroDuration, `${nestedLabel}.maxZeroDuration`); + continue; + } + const nested = fields( + value[key], + ['commitDelay', 'incrementDuration', 'changeThreshold'], + nestedLabel, + ); + duration(nested.commitDelay, `${nestedLabel}.commitDelay`); + if (nested.incrementDuration !== undefined) + duration(nested.incrementDuration, `${nestedLabel}.incrementDuration`); + if (nested.changeThreshold !== undefined) { + const decimal = fields(nested.changeThreshold, ['value'], `${nestedLabel}.changeThreshold`); + const text = requireString(decimal.value, `${nestedLabel}.changeThreshold.value`); + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u.test(text)) { + throw new Error(`${nestedLabel}.changeThreshold.value must be a decimal string.`); + } + } + } +} + +/** Validate without remapping or silently discarding any resource fields. */ +export function validateFactTable(input: unknown): Record { + const object = requireRecord(input, 'Fact table'); + if ('skipSqlPreview' in object) { + throw new Error( + 'skipSqlPreview is unsupported. Omit it and column.type; the service must validate SQL and infer column types.', + ); + } + const value = fields( + object, + [ + 'sql', + 'displayName', + 'description', + 'timestampColumn', + 'entities', + 'measures', + 'dimensions', + 'factDataDeliveredUntilTime', + 'dataDeliveredUntilUpdateStrategyConfig', + 'labels', + 'owner', + ], + 'Fact table', + ); + requireString(value.sql, 'sql'); + requireString(value.displayName, 'displayName'); + column(value.timestampColumn, 'timestampColumn'); + array(value.entities, 'entities', (item, label) => { + const mapping = fields(item, ['column', 'entity'], label); + column(mapping.column, `${label}.column`); + reference(mapping.entity, 'entities', `${label}.entity`); + }); + if ((value.entities as unknown[]).length === 0) + throw new Error('entities must contain at least one mapping.'); + optionalString(value, 'description', 'Fact table'); + if (value.measures !== undefined) array(value.measures, 'measures', measure); + if (value.dimensions !== undefined) array(value.dimensions, 'dimensions', column); + if (value.labels !== undefined) { + const labels = requireRecord(value.labels, 'labels'); + for (const [key, label] of Object.entries(labels)) { + if (typeof label !== 'string') throw new Error(`labels.${key} must be a string.`); + } + } + if (value.owner !== undefined) reference(value.owner, 'identities', 'owner'); + if (value.factDataDeliveredUntilTime !== undefined) { + const timestamp = requireString(value.factDataDeliveredUntilTime, 'factDataDeliveredUntilTime'); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/u.test( + timestamp, + ) || + !Number.isFinite(Date.parse(timestamp)) + ) { + throw new Error('factDataDeliveredUntilTime must be an RFC 3339 timestamp.'); + } + } + if (value.dataDeliveredUntilUpdateStrategyConfig !== undefined) { + deliveryConfig( + value.dataDeliveredUntilUpdateStrategyConfig, + 'dataDeliveredUntilUpdateStrategyConfig', + ); + } + return value; +} diff --git a/packages/cli/src/features/data/fact-tables.ts b/packages/cli/src/features/data/fact-tables.ts new file mode 100644 index 00000000..e79add0a --- /dev/null +++ b/packages/cli/src/features/data/fact-tables.ts @@ -0,0 +1,69 @@ +import { + createFactTable, + factTableListParams, + getFactTable, + listFactTables, + type FactTableListOptions, +} from '@network/metrics-fact-tables.js'; +import { requireString, resourcePath } from '@network/metrics-types.js'; +import { readFactTable } from './fact-input.js'; +import { executeDataRequest } from './request.js'; + +function positiveInteger(value: unknown, label: string, maximum: number): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 || value > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return value; +} + +export async function listFactTablesCmd(argv: Record): Promise { + const all = argv.all === true; + const options: FactTableListOptions = { + pageSize: positiveInteger(argv['page-size'], '--page-size', 2147483647), + maxPages: positiveInteger(argv['max-pages'], '--max-pages', 1000), + maxItems: positiveInteger(argv['max-items'], '--max-items', 100000), + }; + if (all && (options.maxPages === undefined || options.maxItems === undefined)) { + throw new Error('--all requires explicit --max-pages and --max-items bounds.'); + } + if (!all && (options.maxPages !== undefined || options.maxItems !== undefined)) { + throw new Error('--max-pages and --max-items require --all.'); + } + for (const [key, option] of [ + ['page-token', 'pageToken'], + ['filter', 'filter'], + ['order-by', 'orderBy'], + ] as const) { + if (argv[key] !== undefined) options[option] = requireString(argv[key], `--${key}`); + } + if (argv['show-deleted'] !== undefined) { + if (typeof argv['show-deleted'] !== 'boolean') + throw new Error('--show-deleted must be a boolean.'); + options.showDeleted = argv['show-deleted']; + } + await executeDataRequest(argv, (token) => listFactTables(token, options), { + method: 'GET', + path: '/v1/factTables', + params: factTableListParams(options), + ...(all ? { maxPages: options.maxPages, maxItems: options.maxItems } : {}), + }); +} + +export async function getFactTableCmd(argv: Record): Promise { + const name = requireString(argv.name, 'name'); + const path = resourcePath(name, 'factTables'); + await executeDataRequest(argv, (token) => getFactTable(token, name), { method: 'GET', path }); +} + +export async function createFactTableCmd(argv: Record): Promise { + const resource = readFactTable(argv['from-file']); + const id = argv.id === undefined ? undefined : requireString(argv.id, '--id'); + if (id !== undefined) resourcePath(`factTables/${id}`, 'factTables'); + await executeDataRequest(argv, (token) => createFactTable(token, resource, id), { + method: 'POST', + path: '/v1/factTables', + params: id === undefined ? {} : { factTableId: id }, + body: resource, + }); +} diff --git a/packages/cli/src/features/data/index.ts b/packages/cli/src/features/data/index.ts new file mode 100644 index 00000000..210000a9 --- /dev/null +++ b/packages/cli/src/features/data/index.ts @@ -0,0 +1,3 @@ +export { createFactTableCmd, getFactTableCmd, listFactTablesCmd } from './fact-tables.js'; +export { printData } from './output.js'; +export { executeDataRequest } from './request.js'; diff --git a/packages/cli/src/features/data/output.ts b/packages/cli/src/features/data/output.ts new file mode 100644 index 00000000..06ba6ba0 --- /dev/null +++ b/packages/cli/src/features/data/output.ts @@ -0,0 +1,26 @@ +import { inspect } from 'node:util'; +import { isRecord } from '@network/metrics-types.js'; +import { extractFlags, formatJson, message, print, resolveFormat } from '@output/index.js'; + +/** Preserve the full response in JSON; render generic resources as key/value rows otherwise. */ +export function printData(data: unknown, argv: Record): void { + const flags = extractFlags(argv); + if (resolveFormat(flags) === 'json') { + message(formatJson(data)); + return; + } + + const fields = isRecord(data) ? data : { value: data }; + const rows = Object.entries(fields).map(([field, value]) => ({ + field, + value: typeof value === 'string' ? value : inspect(value, { depth: null, colors: false }), + })); + print({ + data: rows, + columns: [ + { key: 'field', header: 'Field' }, + { key: 'value', header: 'Value' }, + ], + flags, + }); +} diff --git a/packages/cli/src/features/data/request.ts b/packages/cli/src/features/data/request.ts new file mode 100644 index 00000000..6739ebcb --- /dev/null +++ b/packages/cli/src/features/data/request.ts @@ -0,0 +1,33 @@ +import type { ApiResponse } from '@spotify-confidence/core'; +import { printApiError } from '@utils/api-error.js'; +import { withAuth } from '@utils/auth.js'; +import { printData } from './output.js'; + +/** Callers must validate arguments and input before invoking this helper, including dry-runs. */ +export async function executeDataRequest( + argv: Record, + request: (token: string) => Promise>, + preview?: unknown, +): Promise { + if (argv['dry-run'] === true) { + printData(preview ?? { dryRun: true }, argv); + return; + } + + await withAuth(async (_argv, token) => { + try { + const result = await request(token); + if (!result.ok) { + printApiError(result); + return; + } + printData(result.data, argv); + } catch (err) { + printApiError({ + ok: false, + status: 0, + error: { code: 0, message: err instanceof Error ? err.message : 'Data request failed.' }, + }); + } + })(argv); +} diff --git a/packages/cli/src/network/index.ts b/packages/cli/src/network/index.ts index 609492b2..a63a9054 100644 --- a/packages/cli/src/network/index.ts +++ b/packages/cli/src/network/index.ts @@ -1,4 +1,6 @@ export { fetchLatestVersion } from './registry.js'; +export { metricsRequest } from './metrics.js'; +export { isRecord, requireRecord, requireString, resourcePath } from './metrics-types.js'; export { searchDocumentation, grepDocumentation, getFullSource } from './docs.js'; diff --git a/packages/cli/src/network/metrics-fact-tables.ts b/packages/cli/src/network/metrics-fact-tables.ts new file mode 100644 index 00000000..f6e40368 --- /dev/null +++ b/packages/cli/src/network/metrics-fact-tables.ts @@ -0,0 +1,143 @@ +import type { ApiResponse } from '@spotify-confidence/core'; +import { metricsRequest } from './metrics.js'; +import { requireRecord, requireString, resourcePath } from './metrics-types.js'; + +export type FactTableListOptions = { + pageSize?: number; + pageToken?: string; + filter?: string; + showDeleted?: boolean; + orderBy?: string; + maxPages?: number; + maxItems?: number; +}; + +export function factTableListParams(options: FactTableListOptions): Record { + const params: Record = {}; + for (const key of ['pageSize', 'pageToken', 'filter', 'showDeleted', 'orderBy'] as const) { + if (options[key] !== undefined) params[key] = String(options[key]); + } + return params; +} + +/** Guard the resource envelope without restricting lifecycle or future response fields. */ +function factTableResource( + input: unknown, + label: string, + expectedName?: string, +): Record { + const resource = requireRecord(input, label); + resourcePath(requireString(resource.name, `${label}.name`), 'factTables'); + if (expectedName !== undefined && resource.name !== expectedName) { + throw new Error(`${label}.name does not match the requested resource.`); + } + return resource; +} + +/** A single page by default; bounded traversal only when explicitly requested. */ +export async function listFactTables( + token: string, + options: FactTableListOptions, +): Promise>> { + const tables: unknown[] = []; + const seen = new Set(); + let pageToken = options.pageToken; + if (pageToken) seen.add(pageToken); + for (let page = 0; page < (options.maxPages ?? 1); page++) { + const remaining = options.maxItems === undefined ? undefined : options.maxItems - tables.length; + const pageSize = + remaining === undefined ? options.pageSize : Math.min(options.pageSize ?? 100, remaining); + const response = await metricsRequest(token, { + path: '/v1/factTables', + params: factTableListParams({ ...options, pageToken, pageSize }), + }); + if (!response.ok) return response; + const data = requireRecord(response.data, 'Fact table list response'); + const items = data.factTables === undefined ? [] : data.factTables; + if (!Array.isArray(items)) + throw new Error('Fact table list response.factTables must be an array.'); + items.forEach((item, index) => + factTableResource(item, `Fact table list response.factTables[${index}]`), + ); + if (data.nextPageToken !== undefined && typeof data.nextPageToken !== 'string') { + throw new Error('Fact table list response.nextPageToken must be a string.'); + } + if (options.maxPages === undefined) return { ok: true, data: { ...data, factTables: items } }; + tables.push(...items); + if (options.maxItems !== undefined && tables.length > options.maxItems) { + throw new Error('Fact table list exceeded --max-items; the service did not honor pageSize.'); + } + pageToken = data.nextPageToken as string | undefined; + if (pageToken && seen.has(pageToken)) + throw new Error('Fact table pagination returned a repeated page token.'); + if (!pageToken || page + 1 === options.maxPages || tables.length === options.maxItems) { + return { ok: true, data: { ...data, factTables: tables } }; + } + seen.add(pageToken); + } + throw new Error('Fact table pagination requires a positive page bound.'); +} + +export async function getFactTable( + token: string, + name: string, +): Promise>> { + const response = await metricsRequest(token, { path: resourcePath(name, 'factTables') }); + if (!response.ok) return response; + return { ok: true, data: factTableResource(response.data, 'Fact table get response', name) }; +} + +/** POST exactly once: a transport failure can leave the remote write outcome unknown. */ +export async function createFactTable( + token: string, + resource: Record, + id?: string, +): Promise>> { + if (id !== undefined) resourcePath(`factTables/${id}`, 'factTables'); + try { + const response = await metricsRequest(token, { + path: '/v1/factTables', + method: 'POST', + body: resource, + params: id === undefined ? undefined : { factTableId: id }, + }); + if (!response.ok) { + if ( + response.status === 0 || + (response.status >= 200 && response.status < 300) || + response.status >= 500 + ) { + return unknownCreateOutcome(response.error.message, id); + } + return response; + } + return { + ok: true, + data: factTableResource( + response.data, + 'Fact table create response', + id === undefined ? undefined : `factTables/${id}`, + ), + }; + } catch (error) { + return unknownCreateOutcome( + error instanceof Error ? error.message : 'The create response could not be read.', + id, + ); + } +} + +function unknownCreateOutcome(message: string, id?: string): ApiResponse { + const lookup = + id === undefined + ? 'fact-tables list/get' + : `fact-tables get factTables/${id} or fact-tables list`; + return { + ok: false, + status: 0, + error: { + code: 0, + message: `${message} Create outcome is unknown; no retry was attempted. Check ${lookup} before retrying.`, + }, + }; +} diff --git a/packages/cli/src/network/metrics-types.ts b/packages/cli/src/network/metrics-types.ts new file mode 100644 index 00000000..ac6f61dd --- /dev/null +++ b/packages/cli/src/network/metrics-types.ts @@ -0,0 +1,28 @@ +/** Small response/input guards; resource-specific validation belongs to each feature. */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function requireRecord(value: unknown, label = 'Value'): Record { + if (!isRecord(value)) throw new Error(`${label} must be an object.`); + return value; +} + +export function requireString(value: unknown, label = 'Value'): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`${label} must be a non-empty string.`); + } + return value; +} + +/** A qualified resource name must contain exactly one unescaped resource ID. */ +export function resourcePath(name: string, collection: string): string { + if (!/^[A-Za-z][A-Za-z0-9]*$/u.test(collection)) { + throw new Error('Invalid resource collection.'); + } + const prefix = `${collection}/`; + if (!name.startsWith(prefix) || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(name.slice(prefix.length))) { + throw new Error(`Resource name must be ${collection}/ with a single valid ID.`); + } + return `/v1/${name}`; +} diff --git a/packages/cli/src/network/metrics.ts b/packages/cli/src/network/metrics.ts new file mode 100644 index 00000000..30b32781 --- /dev/null +++ b/packages/cli/src/network/metrics.ts @@ -0,0 +1,30 @@ +import { + apiRequest, + extractRegion, + type ApiRequestOptions, + type ApiResponse, +} from '@spotify-confidence/core'; + +/** Send one authenticated request to the token's regional Metrics API. */ +export async function metricsRequest( + token: string, + options: Omit, +): Promise> { + // Accept API paths, never absolute URLs or response-supplied pagination URLs. + if ( + !options.path.startsWith('/v1/') || + /[\\%?#\s]/u.test(options.path) || + options.path.split('/').some((part) => part === '.' || part === '..') + ) { + throw new Error('Metrics requests require a local /v1/ API path.'); + } + + return apiRequest({ + ...options, + token, + region: extractRegion(token), + service: 'metrics', + // Redirects could replay writes or send credentials to an API-supplied URL. + redirect: 'error', + }); +} diff --git a/packages/cli/src/utils/api-error.ts b/packages/cli/src/utils/api-error.ts new file mode 100644 index 00000000..4022ce40 --- /dev/null +++ b/packages/cli/src/utils/api-error.ts @@ -0,0 +1,11 @@ +import type { ApiFailure } from '@spotify-confidence/core'; +import { fail } from '@output/index.js'; + +/** Render only the API message, not arbitrary response bodies or error details. */ +export function printApiError(failure: ApiFailure): void { + const message = failure.error.message + .replace(/Bearer\s+\S+/giu, 'Bearer [redacted]') + .replace(/[\p{Cc}\p{Cf}]/gu, ' ') + .trim(); + fail(message || `API request failed (HTTP ${failure.status}).`); +} diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 248d517a..90f45a7e 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './auth.js'; +export * from './api-error.js'; export * from './mcp-error.js'; export * from './safely.js'; export * from './telemetry.js'; diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts index 6733d36d..b0381671 100644 --- a/packages/cli/src/utils/telemetry.ts +++ b/packages/cli/src/utils/telemetry.ts @@ -2,7 +2,18 @@ import { initTelemetry, isTelemetryEnabled, getTelemetry, flush } from '@spotify export { flush as flushTelemetry }; -export function telemetryMiddleware(argv: { telemetry: boolean; _: (string | number)[] }) { +export function telemetryMiddleware(argv: { + telemetry: boolean; + _: (string | number)[]; + 'dry-run'?: boolean; +}) { + const [group, action] = argv._; + if ( + argv['dry-run'] === true && + (group === 'data' || (group === 'warehouse' && (action === 'list' || action === 'get'))) + ) { + return; + } if (!isTelemetryEnabled() || argv.telemetry === false) return; initTelemetry({ source: 'cli' }); diff --git a/packages/core/__tests__/api/client.test.ts b/packages/core/__tests__/api/client.test.ts index 981da382..77849826 100644 --- a/packages/core/__tests__/api/client.test.ts +++ b/packages/core/__tests__/api/client.test.ts @@ -1,7 +1,14 @@ import { http, HttpResponse, server } from '@spotify-confidence/testing'; import { resolveBaseUrl, apiRequest } from '@api/client.js'; +import type { ApiRequestOptions } from '@api/types.js'; const TEST_BASE = 'https://test-svc.eu.confidence.dev'; +const requestOptions: ApiRequestOptions = { + token: 'test-token', + region: 'EU', + service: 'test-svc', + path: '/v1/items', +}; beforeEach(() => { server.use( @@ -54,6 +61,175 @@ describe('resolveBaseUrl', () => { }); }); +describe('apiRequest response safety', () => { + it.each(['not JSON', '', 'private upstream error'])( + 'returns a controlled failure for malformed success body %j', + async (body) => { + server.use(http.get(`${TEST_BASE}/v1/items`, () => new HttpResponse(body))); + const sut = apiRequest; + + const result = await sut(requestOptions); + + expect(result).toEqual({ + ok: false, + status: 200, + error: { code: 200, message: 'Invalid JSON response from API.' }, + }); + }, + ); + + it.each( + [null, [], 'error', { message: {} }, { code: 'bad', message: 'invalid' }].map((body) => ({ + body, + })), + )('normalizes malformed error response $body', async ({ body }) => { + server.use( + http.get(`${TEST_BASE}/v1/items`, () => + HttpResponse.json(body, { status: 503, statusText: 'Service Unavailable' }), + ), + ); + const sut = apiRequest; + + const result = await sut(requestOptions); + + expect(result).toEqual({ + ok: false, + status: 503, + error: { code: 503, message: 'Service Unavailable' }, + }); + }); + + it('does not expose non-JSON error bodies', async () => { + server.use( + http.get( + `${TEST_BASE}/v1/items`, + () => new HttpResponse('upstream private details', { status: 502 }), + ), + ); + const sut = apiRequest; + + const result = await sut(requestOptions); + + expect(result).toEqual({ + ok: false, + status: 502, + error: { code: 502, message: 'Bad Gateway' }, + }); + }); + + it('preserves structured API errors and details', async () => { + const error = { code: 3, message: 'Invalid input', details: [{ field: 'name' }] }; + server.use(http.get(`${TEST_BASE}/v1/items`, () => HttpResponse.json(error, { status: 400 }))); + const sut = apiRequest; + + const result = await sut(requestOptions); + + expect(result).toEqual({ ok: false, status: 400, error }); + }); + + it('discards malformed error details', async () => { + server.use( + http.get(`${TEST_BASE}/v1/items`, () => + HttpResponse.json({ code: 3, message: 'Invalid input', details: 'bad' }, { status: 400 }), + ), + ); + const sut = apiRequest; + + const result = await sut(requestOptions); + + expect(result).toEqual({ + ok: false, + status: 400, + error: { code: 3, message: 'Invalid input' }, + }); + }); + + it('returns a controlled failure if reading the body fails', async () => { + server.use( + http.get( + `${TEST_BASE}/v1/items`, + () => + new HttpResponse( + new ReadableStream({ + start(controller) { + controller.error(new Error('Connection closed')); + }, + }), + ), + ), + ); + const sut = apiRequest; + + const result = await sut(requestOptions); + + expect(result).toMatchObject({ ok: false, status: 0, error: { code: 0 } }); + if (!result.ok) expect(result.error.message).toContain('Network error'); + }); +}); + +describe('apiRequest cancellation', () => { + it('honors an already cancelled caller signal', async () => { + const sut = apiRequest; + + const result = await sut({ ...requestOptions, signal: AbortSignal.abort() }); + + expect(result).toMatchObject({ ok: false, status: 0, error: { code: 0 } }); + }); + + it.each(['timeout', 'caller'] as const)('cancels before headers via %s', async (mode) => { + const controller = new AbortController(); + server.use( + http.get(`${TEST_BASE}/v1/items`, async ({ request }) => { + if (mode === 'caller') controller.abort(); + await new Promise((resolve) => { + if (request.signal.aborted) resolve(); + else request.signal.addEventListener('abort', () => resolve(), { once: true }); + }); + return HttpResponse.json({}); + }), + ); + const sut = apiRequest; + + const result = await sut({ + ...requestOptions, + signal: controller.signal, + timeoutMs: mode === 'timeout' ? 50 : 1000, + }); + + expect(result).toMatchObject({ ok: false, status: 0, error: { code: 0 } }); + }); + + it.each(['timeout', 'caller'] as const)('cancels body consumption via %s', async (mode) => { + const controller = new AbortController(); + server.use( + http.get( + `${TEST_BASE}/v1/items`, + ({ request }) => + new HttpResponse( + new ReadableStream({ + start(body) { + body.enqueue(new TextEncoder().encode('{')); + request.signal.addEventListener('abort', () => body.error(request.signal.reason), { + once: true, + }); + if (mode === 'caller') queueMicrotask(() => controller.abort()); + }, + }), + ), + ), + ); + const sut = apiRequest; + + const result = await sut({ + ...requestOptions, + signal: controller.signal, + timeoutMs: mode === 'timeout' ? 50 : 1000, + }); + + expect(result).toMatchObject({ ok: false, status: 0, error: { code: 0 } }); + }); +}); + describe('apiRequest', () => { it('sends an authorized GET request', async () => { const result = await apiRequest<{ auth: string }>({ diff --git a/packages/core/src/api/client.ts b/packages/core/src/api/client.ts index a4501eee..71aecdbf 100644 --- a/packages/core/src/api/client.ts +++ b/packages/core/src/api/client.ts @@ -25,32 +25,68 @@ export async function apiRequest(opts: ApiRequestOptions): Promise; + /** Cancels both the request and response body consumption. */ + signal?: AbortSignal; + /** Redirect policy. Defaults to fetch's existing follow behavior. */ + redirect?: 'follow' | 'error'; + /** Overall deadline in milliseconds, including response body consumption. Defaults to 30 seconds. */ + timeoutMs?: number; };