diff --git a/README.md b/README.md index 341274d..b997b45 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ server-side records. | `markpost sync [--dry-run]` | Fetch all pending records, write each to a markdown file, and (when `autoDelete` is enabled) delete the written records from the server. `--dry-run` reports the exact write/delete plan without writing or mutating anything | | `markpost push ` | Create records from one or more markdown files, directories, or glob patterns | | `markpost get [--json]` | Fetch and display a single record; pass `--json` for machine-readable output | -| `markpost sources [uuid]` | Manage sources; `sources list --json` prints machine-readable output | +| `markpost sources [uuid]` | Manage sources; `sources list --json` prints machine-readable output. `rotate-secret [uuid]` mints/replaces the signing secret of a provider source (github/zapier/shortcuts reveal a fresh secret once; stripe prompts for the new value) | | `markpost records list [--source ] [--status ] [--search ] [--json]` | List records without deleting them, optionally filtered by source, status, or search text; pass `--json` for machine-readable output | | `markpost config [key] [value]` | View or change the stored API token and output directory | | `markpost settings [key=value ...]` | View or change server-side sync settings (`autoSync`, `autoDelete`, `frontmatter`, `conflictStrategy`) | diff --git a/src/commands/sources.ts b/src/commands/sources.ts index 449c051..8e1d7c3 100644 --- a/src/commands/sources.ts +++ b/src/commands/sources.ts @@ -1,10 +1,11 @@ import { parseArgs } from 'node:util'; import chalk from 'chalk'; -import { input, select } from '@inquirer/prompts'; +import { input, password, select } from '@inquirer/prompts'; import { createSource, deleteSource, fetchSources, + rotateSourceSecret, updateSource, } from '@/libs/sources.js'; import { checkConfig } from '@/libs/config.js'; @@ -12,7 +13,15 @@ import { failWithMessage } from '@/libs/errors.js'; import { sanitizeForTerminal } from '@/libs/terminal.js'; import { failWithSubcommandUsage, failWithUsage } from '@/libs/usage.js'; import { hasJsonFlag, printJson } from '@/libs/output.js'; -import { Source, SOURCE_TYPES, SourceType } from '@/types/sources.types.js'; +import { + isManualSecretProvider, + isRotatableProvider, + ROTATABLE_PROVIDERS, + RotateSourceSecretInput, + Source, + SOURCE_TYPES, + SourceType, +} from '@/types/sources.types.js'; // Mirror the endpoint constants markpost's web app uses in // app/composables/useSources.ts so the CLI shows the same URL a user would @@ -20,12 +29,13 @@ import { Source, SOURCE_TYPES, SourceType } from '@/types/sources.types.js'; const WEBHOOK_INGEST_BASE = 'https://ingest.markpost.io/v1/hooks'; const EMAIL_DOMAIN = 'in.markpost.io'; -export const USAGE = `Usage: markpost sources [uuid] +export const USAGE = `Usage: markpost sources [uuid] - list List all sources (pass --json for machine-readable output) - create Create a new source (prompts for details) - update [uuid] Update a source's route folder; prompts to pick one if uuid is omitted - delete [uuid] Delete a source; prompts to pick one if uuid is omitted`; + list List all sources (pass --json for machine-readable output) + create Create a new source (prompts for details) + update [uuid] Update a source's route folder; prompts to pick one if uuid is omitted + delete [uuid] Delete a source; prompts to pick one if uuid is omitted + rotate-secret [uuid] Rotate a provider source's signing secret; prompts to pick one if uuid is omitted`; export const buildEndpointUrl = ( sourceType: SourceType, @@ -54,6 +64,7 @@ const SOURCES_HANDLERS = new Map< ['create', () => createSourceCommand()], ['update', (uuid) => updateSourceCommand(uuid)], ['delete', (uuid) => deleteSourceCommand(uuid)], + ['rotate-secret', (uuid) => rotateSecretCommand(uuid)], ]); export const runSourcesCommand = async (args: string[]): Promise => { @@ -249,13 +260,29 @@ const createSourceCommand = async (): Promise => { printProviderSecret(providerSecret); }; -// Shared by update and delete: list existing sources and let the user pick -// one, or report there's nothing to act on. -const promptForSource = async (action: string): Promise => { - const sources = await fetchSources(); +// Shared by update, delete, and rotate-secret: list existing sources and let +// the user pick one, or report there's nothing to act on. `filter` narrows the +// choices to the sources an action can apply to (rotate-secret only offers +// provider-backed sources); it defaults to every source for update/delete. +// `emptyFilteredMessage` replaces the generic "no sources" line when sources +// exist but the filter removed all of them — so a user with only webhook/email +// sources learns rotate-secret needs a provider source, instead of being told +// they have none at all. +const promptForSource = async ( + action: string, + filter: (source: Source) => boolean = () => true, + emptyFilteredMessage?: string, +): Promise => { + const allSources = await fetchSources(); + const sources = allSources.filter(filter); if (sources.length === 0) { - console.log(`No sources to ${action}.`); + const filteredOutSome = allSources.length > 0; + console.log( + filteredOutSome && emptyFilteredMessage + ? emptyFilteredMessage + : `No sources to ${action}.`, + ); return null; } @@ -349,3 +376,112 @@ const deleteSourceCommand = async (uuid?: string): Promise => { console.log(chalk.greenBright(`Deleted ${meta.deleted} source(s).`)); }; + +// A manual-secret provider (stripe) issues its own secret, so rotation collects +// the new value from the user; a generated provider (github/zapier/shortcuts) +// sends no attributes and lets markpost mint one. Returns null when the user +// leaves a required secret blank, so the caller aborts without a doomed request +// (mirrors updateSource's empty-route-folder guard). +const collectRotateInput = async ( + target: Source, +): Promise => { + if (!isManualSecretProvider(target.provider)) { + return {}; + } + + // Masked: this is the one place the CLI accepts a signing secret, so it must + // not echo it into terminal scrollback, `script`/tmux captures, or CI logs. + const providerSecret = ( + await password({ + message: `New signing secret from ${sanitizeForTerminal(target.provider)}`, + mask: true, + }) + ).trim(); + + if (!providerSecret) { + console.error(chalk.redBright('Signing secret cannot be empty.')); + return null; + } + + return { providerSecret }; +}; + +const rotateSecretForSource = async (target: Source): Promise => { + if (!isRotatableProvider(target.provider)) { + console.error( + chalk.redBright( + `Source "${sanitizeForTerminal(target.name)}" has no rotatable secret — only ${ROTATABLE_PROVIDERS.join(', ')} sources do.`, + ), + ); + return; + } + + const rotateInput = await collectRotateInput(target); + + if (!rotateInput) { + return; + } + + const rotated = await rotateSourceSecret(target.uuid, rotateInput); + + if (!rotated) { + // Exit non-zero (via failWithMessage) so a wrapper script/cron never reads + // a failed rotation as success. Unlike a failed create (nothing depended on + // the source yet), a failed rotate may already have committed server-side — + // a 5xx or unparseable body after the secret was replaced — leaving the old + // secret dead, so warn conditionally rather than implying nothing changed. + failWithMessage( + 'Failed to rotate source secret. If the rotation was applied server-side the previous secret no longer works — run `markpost sources rotate-secret ` again to mint a secret you can copy.', + ); + return; + } + + // Peel the one-time secret off before the shared `printSource`, exactly as + // `createSourceCommand` does, so no printer that receives the source ever + // sees it. It is null for a manual-secret provider (the user already has it). + const { providerSecret, ...source } = rotated; + const isManual = isManualSecretProvider(target.provider); + + // A generated provider's whole point is the one-time reveal; a response that + // omits it means the secret was rotated but is now unrecoverable, so the live + // integration is broken. Fail before printing any success line, so stdout + // never ends on "Rotated ..." for a broken integration. + if (!isManual && !providerSecret) { + failWithMessage( + 'The secret was rotated but the server did not return it — the previous secret no longer works. Run `markpost sources rotate-secret ` again to mint one you can copy.', + ); + return; + } + + console.log( + chalk.greenBright( + `Rotated signing secret for "${sanitizeForTerminal(source.name)}"`, + ), + ); + printSource(source); + + // Reveal only for a generated provider. A manual provider (stripe) issues its + // own secret — the user already has it — and an off-contract echo of it must + // never be printed, so suppress the reveal entirely here. + if (isManual) { + return; + } + + printProviderSecret(providerSecret); +}; + +const rotateSecretCommand = async (uuid?: string): Promise => { + const target = uuid + ? await findSourceByUuid(uuid) + : await promptForSource( + 'rotate the secret for', + (source) => isRotatableProvider(source.provider), + `None of your sources have a rotatable secret — only ${ROTATABLE_PROVIDERS.join(', ')} sources do.`, + ); + + if (!target) { + return; + } + + await rotateSecretForSource(target); +}; diff --git a/src/libs/sources.ts b/src/libs/sources.ts index 21e7b6a..6dfaa05 100644 --- a/src/libs/sources.ts +++ b/src/libs/sources.ts @@ -4,83 +4,112 @@ import { unwrapResourceAttributes, unwrapResourceCollection, } from '@/libs/api.js'; -import { ApiDeleteMeta, ApiDeleteResponse } from '@/types/api.types.js'; +import { + ApiDeleteMeta, + ApiDeleteResponse, + ApiResponse, +} from '@/types/api.types.js'; import { CreatedSource, - CreateSourceApiResponse, + CreatedSourceResource, CreateSourceInput, + RotateSourceSecretInput, Source, - SourceApiResponse, SourceListApiResponse, + SourceResource, UpdateSourceInput, } from '@/types/sources.types.js'; -export const fetchSources = async (): Promise => { - try { - const body = (await authedRequest('/api/sources')) as SourceListApiResponse; - - return unwrapResourceCollection('fetchSources', body, 'source'); - } catch (error) { - logApiFailure('fetchSources', error); +const JSON_API_CONTENT_TYPE = 'application/vnd.api+json'; - return []; - } -}; - -export const createSource = async ( - input: CreateSourceInput, -): Promise => { +// The shared write seam for source POST/PATCH endpoints: they all send the same +// JSON:API `{ data: { type: 'sources', attributes } }` envelope and unwrap the +// resource attributes off the response, falling back to null (and logging) on +// failure. `context` labels the caller in the log line; `TResource` is the +// JSON:API resource the endpoint returns (`SourceResource`, or +// `CreatedSourceResource` for the two endpoints that reveal a one-time secret) — +// keeping those envelope types live so they still guard against markpost's +// `sourceSerializer` drifting (see src/types/sources.types.ts). +const writeSourceRequest = async < + TInput extends object, + TResource extends { attributes: unknown }, +>( + context: string, + path: string, + method: 'POST' | 'PATCH', + attributes: TInput, +): Promise => { try { - const body = (await authedRequest('/api/sources', { - method: 'POST', + const body = (await authedRequest(path, { + method, headers: { - 'Content-Type': 'application/vnd.api+json', + 'Content-Type': JSON_API_CONTENT_TYPE, }, body: JSON.stringify({ data: { type: 'sources', - attributes: input, + attributes, }, }), - })) as CreateSourceApiResponse; + })) as ApiResponse; return unwrapResourceAttributes(body); } catch (error) { - logApiFailure(`createSource["${input.name}"]`, error); + logApiFailure(context, error); return null; } }; -export const updateSource = async ( - uuid: string, - input: UpdateSourceInput, -): Promise => { +export const fetchSources = async (): Promise => { try { - const body = (await authedRequest( - `/api/sources/${encodeURIComponent(uuid)}`, - { - method: 'PATCH', - headers: { - 'Content-Type': 'application/vnd.api+json', - }, - body: JSON.stringify({ - data: { - type: 'sources', - attributes: input, - }, - }), - }, - )) as SourceApiResponse; + const body = (await authedRequest('/api/sources')) as SourceListApiResponse; - return unwrapResourceAttributes(body); + return unwrapResourceCollection('fetchSources', body, 'source'); } catch (error) { - logApiFailure(`updateSource["${uuid}"]`, error); + logApiFailure('fetchSources', error); - return null; + return []; } }; +export const createSource = async ( + input: CreateSourceInput, +): Promise => + writeSourceRequest( + `createSource["${input.name}"]`, + '/api/sources', + 'POST', + input, + ); + +export const updateSource = async ( + uuid: string, + input: UpdateSourceInput, +): Promise => + writeSourceRequest( + `updateSource["${uuid}"]`, + `/api/sources/${encodeURIComponent(uuid)}`, + 'PATCH', + input, + ); + +// Rotation reveals the freshly-generated signing secret exactly once, so its +// response carries `providerSecret` like `createSource` does — hence the +// `CreatedSource` shape rather than the base `Source`. `input` is empty for a +// generated provider and carries the pasted value for a manual-secret provider +// (stripe). +export const rotateSourceSecret = async ( + uuid: string, + input: RotateSourceSecretInput = {}, +): Promise => + writeSourceRequest( + `rotateSourceSecret["${uuid}"]`, + `/api/sources/${encodeURIComponent(uuid)}/rotate-secret`, + 'POST', + input, + ); + export const deleteSource = async ( uuid: string, ): Promise => { diff --git a/src/types/sources.types.ts b/src/types/sources.types.ts index c446b52..3d7ff93 100644 --- a/src/types/sources.types.ts +++ b/src/types/sources.types.ts @@ -15,6 +15,43 @@ export const SOURCE_TYPES = [ export type SourceType = (typeof SOURCE_TYPES)[number]; +// Providers whose signing secret the user pastes in (the provider issues it), +// so rotation collects a new value rather than revealing a generated one. +// Mirrors markpost's MANUAL_SECRET_PROVIDER_IDS +// (shared/utils/webhookSecrets.ts); keep in lockstep — see +// tests/types/sources.types.test.ts. +export const MANUAL_SECRET_PROVIDERS = ['stripe'] as const; + +// Providers whose secret markpost generates and reveals exactly once on +// rotation. Mirrors markpost's SECRET_BACKED_PROVIDER_IDS +// (shared/utils/webhookSecrets.ts); keep in lockstep. +export const SECRET_BACKED_PROVIDERS = [ + 'github', + 'zapier', + 'shortcuts', +] as const; + +// Every provider a source can rotate a secret for — the union of the manual +// and generated sets, mirroring markpost's ROTATABLE_PROVIDER_IDS. A source +// with any other provider (or none, e.g. a plain webhook/email source) has no +// rotatable secret. +export const ROTATABLE_PROVIDERS = [ + ...MANUAL_SECRET_PROVIDERS, + ...SECRET_BACKED_PROVIDERS, +] as const; + +export const isManualSecretProvider = ( + provider: string | null, +): provider is (typeof MANUAL_SECRET_PROVIDERS)[number] => + provider !== null && + (MANUAL_SECRET_PROVIDERS as readonly string[]).includes(provider); + +export const isRotatableProvider = ( + provider: string | null, +): provider is (typeof ROTATABLE_PROVIDERS)[number] => + provider !== null && + (ROTATABLE_PROVIDERS as readonly string[]).includes(provider); + export type Source = { uuid: string; createdAt: string; @@ -53,6 +90,14 @@ export type UpdateSourceInput = { fieldMapping?: unknown; }; +// Mirrors markpost's POST /api/sources/[uuid]/rotate-secret payload. Only a +// manual-secret provider (stripe) supplies `providerSecret`; for a generated +// provider (github/zapier/shortcuts) it is omitted and markpost mints a fresh +// secret it reveals once. See markpost server/api/sources/[uuid]/rotate-secret.post.ts. +export type RotateSourceSecretInput = { + providerSecret?: string; +}; + // The JSON:API resource object markpost's `sourceSerializer` // (`server/utils/response.ts`) actually produces for a source: `attributes` // plus the `type`/`id`/`links` envelope fields the old `ApiData` type dropped. @@ -61,15 +106,12 @@ export type SourceResource = ApiResourceObject & { attributes: Source; }; -export type SourceApiResponse = ApiResponse; - export type SourceListApiResponse = ApiResponse; -// The create response is the one place the serializer reveals `providerSecret`, -// so its resource attributes are `CreatedSource`, not the base `Source`. +// The create and rotate-secret responses are the only places the serializer +// reveals `providerSecret`, so their resource attributes are `CreatedSource`, +// not the base `Source`. export type CreatedSourceResource = ApiResourceObject & { type: 'sources'; attributes: CreatedSource; }; - -export type CreateSourceApiResponse = ApiResponse; diff --git a/tests/commands/sources.test.ts b/tests/commands/sources.test.ts index d8fd905..905b728 100644 --- a/tests/commands/sources.test.ts +++ b/tests/commands/sources.test.ts @@ -8,8 +8,13 @@ vi.mock('@/libs/sources.js', () => ({ createSource: vi.fn(), updateSource: vi.fn(), deleteSource: vi.fn(), + rotateSourceSecret: vi.fn(), +})); +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + password: vi.fn(), + select: vi.fn(), })); -vi.mock('@inquirer/prompts', () => ({ input: vi.fn(), select: vi.fn() })); vi.mock('chalk', () => ({ default: { redBright: vi.fn((value: unknown) => value), @@ -59,6 +64,20 @@ const githubSource: CreatedSource = { recordCount: 0, }; +// A manual-secret provider (stripe): markpost issues the secret, so rotation +// prompts the user to paste the new value and the response reveals nothing. +const stripeSource: Source = { + uuid: 'str-123', + createdAt: '2024-01-04T00:00:00Z', + type: 'stripe', + name: 'Stripe Source', + provider: 'stripe', + endpointSlug: 'st_123abc', + routeFolder: '96-incoming/', + lastHitAt: null, + recordCount: 0, +}; + // Collapses every argument of every console.log AND console.error call into // one searchable string, so a leak assertion can't be dodged by the secret // landing in a second argument, a later call, or the other stream. @@ -736,6 +755,281 @@ describe('runSourcesCommand', () => { }); }); + describe('rotate-secret', () => { + it('rotates by uuid for a generated provider and reveals the new secret once', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + vi.mocked(fetchSources).mockResolvedValue([githubSource]); + vi.mocked(rotateSourceSecret).mockResolvedValue({ + ...githubSource, + providerSecret: 'whsec_rotated_value', + }); + const { select, password } = await import('@inquirer/prompts'); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', 'ghi-789']); + + // Generated providers send no attributes; markpost mints the secret. + expect(rotateSourceSecret).toHaveBeenCalledWith('ghi-789', {}); + // No picker and no secret prompt for a generated provider. + expect(select).not.toHaveBeenCalled(); + expect(password).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Rotated signing secret for "GitHub Source"'), + ); + const secretMentions = loggedText() + .split('\n') + .filter((line) => line.includes('whsec_rotated_value')); + expect(secretMentions).toHaveLength(1); + }); + + it('prompts (masked) for the new secret and sends it for a manual-secret provider', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + const { password } = await import('@inquirer/prompts'); + vi.mocked(fetchSources).mockResolvedValue([stripeSource]); + vi.mocked(password).mockResolvedValueOnce('whsec_pasted_stripe'); + // A hostile/off-contract server that echoes the pasted secret back must + // still never have it printed — the manual-provider early return skips the + // reveal entirely, and the secret is peeled off before printSource. + vi.mocked(rotateSourceSecret).mockResolvedValue({ + ...stripeSource, + providerSecret: 'whsec_echoed_by_server', + }); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', 'str-123']); + + // A masked prompt keeps the pasted secret out of terminal scrollback. + expect(password).toHaveBeenCalledWith( + expect.objectContaining({ mask: true }), + ); + expect(rotateSourceSecret).toHaveBeenCalledWith('str-123', { + providerSecret: 'whsec_pasted_stripe', + }); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Rotated signing secret for "Stripe Source"'), + ); + // Nothing to reveal for a manual provider — the user already has it — and + // an echoed value must not surface anywhere on stdout/stderr. + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('shown once'), + ); + expect(loggedText()).not.toContain('whsec_echoed_by_server'); + }); + + it('does not raise the missing-secret alarm for a manual provider (its response has none)', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + const { password } = await import('@inquirer/prompts'); + vi.mocked(fetchSources).mockResolvedValue([stripeSource]); + vi.mocked(password).mockResolvedValueOnce('whsec_pasted_stripe'); + // markpost never echoes a manual secret back, so the response omits it — + // and that omission must not be treated as the "server did not return it" + // failure that applies only to generated providers. + vi.mocked(rotateSourceSecret).mockResolvedValue({ + ...stripeSource, + providerSecret: null, + }); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', 'str-123']); + + expect(console.error).not.toHaveBeenCalledWith( + expect.stringContaining('did not return it'), + ); + expect(process.exitCode).toBeUndefined(); + }); + + it('aborts without calling the API when a manual secret is left blank', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + const { password } = await import('@inquirer/prompts'); + vi.mocked(fetchSources).mockResolvedValue([stripeSource]); + vi.mocked(password).mockResolvedValueOnce(' '); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', 'str-123']); + + expect(rotateSourceSecret).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + 'Signing secret cannot be empty.', + ); + }); + + it('refuses a source with no rotatable secret and skips the API call', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + vi.mocked(fetchSources).mockResolvedValue([webhookSource]); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', 'abc-123']); + + expect(rotateSourceSecret).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('has no rotatable secret'), + ); + }); + + it('reports not-found when the uuid does not match any source', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + vi.mocked(fetchSources).mockResolvedValue([githubSource]); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', 'unknown-uuid']); + + expect(rotateSourceSecret).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + 'Source not found, or the source list could not be loaded.', + ); + }); + + it('offers only rotatable sources in the interactive picker', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + const { select } = await import('@inquirer/prompts'); + vi.mocked(fetchSources).mockResolvedValue([ + webhookSource, + emailSource, + githubSource, + ]); + vi.mocked(select).mockResolvedValue('ghi-789'); + vi.mocked(rotateSourceSecret).mockResolvedValue({ + ...githubSource, + providerSecret: 'whsec_rotated_value', + }); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret']); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + choices: [ + expect.objectContaining({ value: 'ghi-789' }), + ], + }), + ); + expect(rotateSourceSecret).toHaveBeenCalledWith('ghi-789', {}); + }); + + it('explains rotate-secret needs a provider source when only non-rotatable sources exist', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + vi.mocked(fetchSources).mockResolvedValue([webhookSource, emailSource]); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret']); + + expect(rotateSourceSecret).not.toHaveBeenCalled(); + // Not the bare "No sources..." line: the user has sources, just none + // with a rotatable secret. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('None of your sources have a rotatable secret'), + ); + }); + + it('falls back to the plain empty message when there are no sources at all', async () => { + const { fetchSources } = await import('@/libs/sources.js'); + vi.mocked(fetchSources).mockResolvedValue([]); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret']); + + expect(console.log).toHaveBeenCalledWith( + 'No sources to rotate the secret for.', + ); + }); + + it('warns when a generated rotation succeeds but the response omits the secret', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + vi.mocked(fetchSources).mockResolvedValue([githubSource]); + // Server rotated the secret (old one now dead) but returned no plaintext. + vi.mocked(rotateSourceSecret).mockResolvedValue({ + ...githubSource, + providerSecret: null, + }); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', 'ghi-789']); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('did not return it'), + ); + expect(process.exitCode).toBe(1); + }); + + it('reports an error when the rotation fails', async () => { + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + vi.mocked(fetchSources).mockResolvedValue([githubSource]); + vi.mocked(rotateSourceSecret).mockResolvedValue(null); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', 'ghi-789']); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to rotate source secret.'), + ); + // A failed rotation must exit non-zero so a wrapper never reads it as + // success (the previous secret may already be dead). + expect(process.exitCode).toBe(1); + }); + + it('strips control characters from a hostile rotated secret before printing', async () => { + const control = String.fromCharCode(0x1b); + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + vi.mocked(fetchSources).mockResolvedValue([githubSource]); + vi.mocked(rotateSourceSecret).mockResolvedValue({ + ...githubSource, + providerSecret: `whsec_${control}[2J`, + }); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', 'ghi-789']); + + const printedControl = vi + .mocked(console.log) + .mock.calls.some( + ([arg]) => typeof arg === 'string' && arg.includes(control), + ); + expect(printedControl).toBe(false); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('whsec_ [2J'), + ); + }); + + // rotate-secret reveals a one-time secret, so like `create` it must reject + // --json before doing anything — a `| jq` pipeline would lose the secret. + it('rejects --json on rotate-secret before prompting or calling the API', async () => { + const { checkConfig } = await import('@/libs/config.js'); + const { fetchSources, rotateSourceSecret } = await import( + '@/libs/sources.js' + ); + const { runSourcesCommand } = await import('@/commands/sources.js'); + + await runSourcesCommand(['rotate-secret', '--json']); + + expect(checkConfig).not.toHaveBeenCalled(); + expect(fetchSources).not.toHaveBeenCalled(); + expect(rotateSourceSecret).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + }); + it('catches and logs unexpected errors (e.g. checkConfig failing)', async () => { const { checkConfig } = await import('@/libs/config.js'); vi.mocked(checkConfig).mockRejectedValue(new Error('boom')); diff --git a/tests/libs/sources.test.ts b/tests/libs/sources.test.ts index 6c014d3..cb01a96 100644 --- a/tests/libs/sources.test.ts +++ b/tests/libs/sources.test.ts @@ -4,6 +4,7 @@ import { createSource, deleteSource, fetchSources, + rotateSourceSecret, updateSource, } from '@/libs/sources.js'; import { ApiTimeoutError } from '@/libs/api.js'; @@ -101,6 +102,13 @@ describe('sources API timeout propagation', () => { ApiTimeoutError, ); }); + + it('rotateSourceSecret rejects with ApiTimeoutError instead of returning null', async () => { + mockFetchTimeout(); + await expect(rotateSourceSecret('abc-123')).rejects.toBeInstanceOf( + ApiTimeoutError, + ); + }); }); describe('fetchSources', () => { @@ -410,6 +418,95 @@ describe('updateSource', () => { }); }); +describe('rotateSourceSecret', () => { + const githubSource = { + ...mockSource, + uuid: 'ghi-789', + type: 'github' as SourceType, + provider: 'github', + providerSecret: 'whsec_new_generated', + }; + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('POSTs to the rotate-secret path with empty attributes when no secret is supplied', async () => { + mockFetch({ data: { attributes: githubSource } }); + await rotateSourceSecret('ghi-789'); + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/api/sources/ghi-789/rotate-secret', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.api+json', + Authorization: 'Bearer test-token', + }, + body: JSON.stringify({ + data: { type: 'sources', attributes: {} }, + }), + }), + ); + }); + + it('sends the supplied providerSecret for a manual-secret provider', async () => { + mockFetch({ data: { attributes: mockSource } }); + await rotateSourceSecret('str-123', { providerSecret: 'whsec_pasted' }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/api/sources/str-123/rotate-secret', + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: 'sources', + attributes: { providerSecret: 'whsec_pasted' }, + }, + }), + }), + ); + }); + + it('encodes the uuid into the URL path', async () => { + mockFetch({ data: { attributes: githubSource } }); + await rotateSourceSecret('a/../b'); + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/api/sources/a%2F..%2Fb/rotate-secret', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns the rotated source attributes, including the revealed secret', async () => { + mockFetch({ data: { attributes: githubSource } }); + expect(await rotateSourceSecret('ghi-789')).toEqual(githubSource); + }); + + it('returns null and surfaces error details when the source has no rotatable secret', async () => { + mockFetch( + { + data: { + errors: [ + { + title: 'Invalid Attribute', + detail: 'This source has no provider set, so it has no secret to rotate.', + }, + ], + }, + }, + false, + ); + const result = await rotateSourceSecret('abc-123'); + expect(result).toBeNull(); + expect(logErrorMessage).toHaveBeenCalledWith( + 'rotateSourceSecret["abc-123"]', + 'Invalid Attribute: This source has no provider set, so it has no secret to rotate.', + ); + }); + + it('returns null on network failure', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + expect(await rotateSourceSecret('ghi-789')).toBeNull(); + }); +}); + describe('deleteSource', () => { beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/tests/types/sources.types.test.ts b/tests/types/sources.types.test.ts index c237a20..6f10570 100644 --- a/tests/types/sources.types.test.ts +++ b/tests/types/sources.types.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { SOURCE_TYPES } from '@/types/sources.types.js'; +import { + isManualSecretProvider, + isRotatableProvider, + MANUAL_SECRET_PROVIDERS, + ROTATABLE_PROVIDERS, + SECRET_BACKED_PROVIDERS, + SOURCE_TYPES, +} from '@/types/sources.types.js'; // Locks SOURCE_TYPES to the set markpost's server accepts (see the rationale // on SOURCE_TYPES in src/types/sources.types.ts), so any addition — most @@ -21,3 +28,44 @@ describe('SOURCE_TYPES', () => { expect([...SOURCE_TYPES].sort()).toEqual([...ACCEPTED_SOURCE_TYPES].sort()); }); }); + +// Locks the provider-classification sets to markpost's +// shared/utils/webhookSecrets.ts (MANUAL_SECRET_PROVIDER_IDS / +// SECRET_BACKED_PROVIDER_IDS / ROTATABLE_PROVIDER_IDS). A drift here means the +// rotate-secret command prompts for a secret on the wrong provider — or offers +// rotation on a source markpost has no rotatable secret for. +describe('rotatable provider sets', () => { + it('classifies stripe as the only manual-secret provider', () => { + expect([...MANUAL_SECRET_PROVIDERS].sort()).toEqual(['stripe']); + }); + + it('classifies github/zapier/shortcuts as the generated secret-backed providers', () => { + expect([...SECRET_BACKED_PROVIDERS].sort()).toEqual( + ['github', 'shortcuts', 'zapier'], + ); + }); + + it('treats every manual and generated provider as rotatable, and nothing else', () => { + expect([...ROTATABLE_PROVIDERS].sort()).toEqual( + ['github', 'shortcuts', 'stripe', 'zapier'], + ); + }); + + it('recognises stripe as manual-secret and the generated providers as not', () => { + expect(isManualSecretProvider('stripe')).toBe(true); + expect(isManualSecretProvider('github')).toBe(false); + }); + + it('treats a null or non-provider source as neither manual nor rotatable', () => { + expect(isManualSecretProvider(null)).toBe(false); + expect(isRotatableProvider(null)).toBe(false); + expect(isRotatableProvider('webhook')).toBe(false); + }); + + it('recognises each rotatable provider', () => { + expect(isRotatableProvider('stripe')).toBe(true); + expect(isRotatableProvider('github')).toBe(true); + expect(isRotatableProvider('zapier')).toBe(true); + expect(isRotatableProvider('shortcuts')).toBe(true); + }); +});