diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index bb55ea2..577dc5d 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1,24 +1,11 @@ -import { beforeEach, expect, test, vi } from 'vitest' - -const { get, post } = vi.hoisted(() => ({ - get: vi.fn(), - post: vi.fn(), -})) - -vi.mock('@seamapi/http', () => ({ - isSeamHttpApiError: () => false, - isSeamHttpUnauthorizedError: () => false, - SeamHttpInvalidTokenError: class extends Error {}, - SeamHttpWorkspaces: class { - get = get - client = { post } - }, -})) +import { SeamHttpInvalidTokenError } from '@seamapi/http' +import { afterEach, expect, test, vi } from 'vitest' import { exchangeWizardInferenceToken, getWorkspaceForApiKey } from './api.js' -beforeEach(() => vi.clearAllMocks()) +afterEach(() => vi.unstubAllGlobals()) +// Fixed noncredential fixtures; all requests stop at the fetch boundary. test('uses the workspace SDK and its raw client', async () => { const workspace = { workspace_id: 'workspace-1', @@ -33,18 +20,114 @@ test('uses the workspace SDK and its raw client', async () => { embed_customer_portal: null, device_categories: ['locks'], } - get.mockResolvedValue(workspace) - post.mockResolvedValue({ - data: { - wizard_session: { token: 'token', expires_at: 'tomorrow', onboarding }, - }, - }) - - await expect(getWorkspaceForApiKey('seam_key')).resolves.toBe(workspace) + const requests: Request[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (request: Request) => { + requests.push(request) + return Response.json( + request.url.endsWith('/session') + ? { + wizard_session: { + token: 'token', + expires_at: 'tomorrow', + onboarding, + }, + } + : { workspace }, + ) + }), + ) + await expect(getWorkspaceForApiKey('seam_key')).resolves.toEqual(workspace) await expect(exchangeWizardInferenceToken('seam_key')).resolves.toEqual({ token: 'token', expires_at: 'tomorrow', onboarding, }) - expect(post).toHaveBeenCalledWith('/seam/wizard/v1/session', {}) + expect(new URL(requests[1]?.url ?? '').pathname).toBe( + '/seam/wizard/v1/session', + ) + expect(await requests[1]?.json()).toEqual({}) +}) + +test('malformed and wrong token types preserve the native local error', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + for (const token of ['not-a-key', 'seam_pk_not-a-key']) { + await expect(getWorkspaceForApiKey(token)).rejects.toBeInstanceOf( + SeamHttpInvalidTokenError, + ) + } + expect(fetch).not.toHaveBeenCalled() +}) + +test.each(['json', 'text'])( + 'an actual unauthorized %s response reports 401', + async (format) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + format === 'json' + ? Response.json({}, { status: 401 }) + : new Response('secret-marker', { status: 401 }), + ), + ) + await expect(getWorkspaceForApiKey('seam_key')).rejects.toMatchObject({ + name: 'SeamHttpUnauthorizedError', + statusCode: 401, + }) + }, +) + +test('a 5xx response preserves the native SDK exception and details', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json( + { error: { type: 'internal_error', message: 'secret-marker' } }, + { status: 503 }, + ), + ), + ) + await expect(getWorkspaceForApiKey('seam_key')).rejects.toMatchObject({ + name: 'SeamHttpApiError', + statusCode: 503, + message: 'secret-marker', + }) +}) + +test('fetch transport failures preserve the SDK transport exception', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('Failed to fetch secret-marker') + }), + ) + await expect(getWorkspaceForApiKey('seam_key')).rejects.toMatchObject({ + code: 'ERR_NETWORK', + }) +}) + +test('non-JSON HTTP failures retain their known status', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('secret-marker', { status: 502 })), + ) + await expect(getWorkspaceForApiKey('seam_key')).rejects.toMatchObject({ + response: { status: 502 }, + }) +}) + +test('the SDK transport exception retains its original cause', async () => { + const original = new TypeError('secret-marker') + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw original + }), + ) + await expect(getWorkspaceForApiKey('seam_key')).rejects.toMatchObject({ + isAxiosError: true, + cause: original, + }) }) diff --git a/src/lib/api.ts b/src/lib/api.ts index 6e3e2e4..b2756da 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,12 +1,11 @@ import { isSeamHttpApiError, - isSeamHttpUnauthorizedError, - SeamHttpInvalidTokenError, SeamHttpWorkspaces, type Workspace, } from '@seamapi/http' import { getAuth } from 'lib/adapter.js' +import { markConnectionFailure } from 'lib/connection-error.js' export function getApiBaseUrl(): string { return getAuth().endpoint.replace(/\/+$/, '') @@ -28,22 +27,7 @@ export async function getWorkspaceForApiKey( try { return await getApi(apiKey).get() } catch (error) { - if ( - error instanceof SeamHttpInvalidTokenError || - isSeamHttpUnauthorizedError(error) - ) { - throw new ApiKeyError( - 'That key was rejected (401). Make sure you copied the full key, including the seam_ prefix.', - ) - } - if (isSeamHttpApiError(error)) { - throw new ApiKeyError( - `The Seam API returned ${error.statusCode}. Please try again in a moment.`, - ) - } - throw new ApiKeyError( - 'Could not reach the Seam API. Check your network connection and try again.', - ) + throw markConnectionFailure(error, 'key_validation') } } diff --git a/src/lib/app.tsx b/src/lib/app.tsx index 4a82370..35320eb 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -19,13 +19,13 @@ import { trackScreen, } from './analytics.js' import { - ApiKeyError, exchangeWizardInferenceToken, getInferenceBaseUrl, looksLikeSeamApiKey, type SeamWorkspace, type WizardInferenceSession, } from './api.js' +import { describeConnectionFailure } from './connection-error.js' import { ensureProjectEnvConventions, ENV_EXAMPLE_SYMLINK_REFUSAL_MESSAGE, @@ -649,13 +649,10 @@ export function App({ ) } catch (error) { if (!cancelled) { - const message = - error instanceof Error - ? error.message - : 'Browser connection failed.' + const { message, properties } = describeConnectionFailure(error) track('wizard_connect_failed', { method: 'browser', - reason: message, + ...properties, }) setPhase({ t: 'error', message }) } @@ -665,10 +662,7 @@ export function App({ if (cancelled) return setPhase({ t: 'error', - message: - error instanceof Error - ? error.message - : 'The wizard hit an unexpected error.', + message: describeConnectionFailure(error).message, }) }) return () => { @@ -695,20 +689,17 @@ export function App({ ) } catch (error) { if (cancelled) return - const message = - error instanceof ApiKeyError - ? error.message - : "Couldn't verify the key." + const { message, properties } = describeConnectionFailure(error) track('wizard_connect_failed', { method: 'paste', - reason: message, + ...properties, attempt: attemptRef.current, gave_up: attemptRef.current >= MAX_ATTEMPTS, }) if (attemptRef.current >= MAX_ATTEMPTS) { setPhase({ t: 'error', - message: 'Too many attempts. Re-run with a valid key.', + message: `Too many attempts. ${message} Re-run the wizard to try again.`, }) } else { setPasteError(message) @@ -721,10 +712,7 @@ export function App({ if (cancelled) return setPhase({ t: 'error', - message: - error instanceof Error - ? error.message - : 'The wizard hit an unexpected error.', + message: describeConnectionFailure(error).message, }) }) return () => { diff --git a/src/lib/connection-error.test.ts b/src/lib/connection-error.test.ts new file mode 100644 index 0000000..2166538 --- /dev/null +++ b/src/lib/connection-error.test.ts @@ -0,0 +1,142 @@ +import { + SeamHttpApiError, + SeamHttpInvalidTokenError, + SeamHttpUnauthorizedError, +} from '@seamapi/http' +import { expect, test } from 'vitest' + +import { + classifyKeyValidationError, + describeConnectionFailure, + markConnectionFailure, +} from './connection-error.js' + +test('unexpected exceptions do not disclose messages or invent a stage', () => { + const error = Object.assign( + new Error('secret-marker https://private.invalid'), + { + api_key: 'secret-marker', + fingerprint: 'secret-marker', + }, + ) + expect(describeConnectionFailure(error)).toEqual({ + message: 'An unexpected error occurred while connecting. Please try again.', + properties: { + reason: 'unknown', + failure_stage: 'unknown', + http_status: null, + }, + }) + expect(JSON.stringify(describeConnectionFailure(error))).not.toContain( + 'secret-marker', + ) +}) + +test('callback and saving failures report their known stage', () => { + expect( + describeConnectionFailure( + markConnectionFailure( + new Error('secret-marker'), + 'browser_callback', + 'timeout', + ), + ), + ).toMatchObject({ + message: expect.stringContaining('Timed out'), + properties: { + reason: 'timeout', + failure_stage: 'browser_callback', + http_status: null, + }, + }) + expect( + describeConnectionFailure( + markConnectionFailure(new Error('secret-marker'), 'env_write'), + ), + ).toMatchObject({ + message: expect.stringContaining('key was verified'), + properties: { + reason: 'unknown', + failure_stage: 'env_write', + http_status: null, + }, + }) +}) +test.each([ + [ + new SeamHttpInvalidTokenError('secret-marker'), + 'invalid_token_format', + null, + ], + [new SeamHttpUnauthorizedError('secret-marker'), 'unauthorized', 401], + [ + new SeamHttpApiError( + { + type: 'internal_error', + message: 'secret-marker', + data: { key: 'secret-marker' }, + }, + 500, + 'secret-marker', + ), + 'api_error', + 500, + ], + [ + Object.assign(new Error('secret-marker'), { + code: 'ERR_NETWORK', + config: { url: 'secret-marker' }, + }), + 'transport_error', + null, + ], + [ + Object.assign(new Error('secret-marker'), { code: 'ETIMEDOUT' }), + 'transport_error', + null, + ], + [ + Object.assign(new Error('secret-marker'), { + isAxiosError: true, + response: { status: 401, data: 'secret-marker' }, + }), + 'unauthorized', + 401, + ], + [new Error('secret-marker'), 'unknown', null], + [new TypeError('secret-marker'), 'unknown', null], + ['secret-marker', 'unknown', null], +])('normalizes SDK failures safely (%#)', (error, category, statusCode) => { + const result = classifyKeyValidationError(error) + expect(result).toMatchObject({ category, statusCode }) + expect(JSON.stringify(result)).not.toContain('secret-marker') + expect(result).not.toHaveProperty('cause') +}) + +test('stage annotation preserves the original exception, cause and own properties', () => { + const cause = new Error('cause-secret-marker') + const error = new SeamHttpApiError( + { + type: 'internal_error', + message: 'secret-marker', + data: { key: 'secret-marker' }, + }, + 503, + 'secret-marker', + ) + error.cause = cause + const originalProperties = Object.getOwnPropertyDescriptors(error) + expect(markConnectionFailure(error, 'key_validation')).toBe(error) + expect(Object.getOwnPropertyDescriptors(error)).toEqual(originalProperties) + const report = describeConnectionFailure(error) + expect(report).toMatchObject({ + message: 'The Seam API returned 503. Please try again in a moment.', + properties: { + reason: 'api_error', + failure_stage: 'key_validation', + http_status: 503, + }, + }) + expect(JSON.stringify(report)).not.toContain('secret-marker') + expect(error.cause).toBe(cause) +}) diff --git a/src/lib/connection-error.ts b/src/lib/connection-error.ts new file mode 100644 index 0000000..893e9d3 --- /dev/null +++ b/src/lib/connection-error.ts @@ -0,0 +1,147 @@ +import { + isSeamHttpApiError, + isSeamHttpUnauthorizedError, + SeamHttpInvalidTokenError, +} from '@seamapi/http' + +export type ConnectionFailureStage = + 'browser_callback' | 'key_validation' | 'env_write' | 'unknown' + +export type KeyValidationCategory = + | 'invalid_token_format' + | 'unauthorized' + | 'api_error' + | 'transport_error' + | 'unknown' + +interface KeyValidationFailure { + category: KeyValidationCategory + statusCode: number | null + message: string +} + +// Associate a stage with the original exception without mutating, wrapping or +// serializing it. Native type, stack, cause and SDK details remain available to +// callers; only the safe descriptor below is used for display and analytics. +const contexts = new WeakMap< + object, + { stage: ConnectionFailureStage; category: 'unknown' | 'timeout' } +>() + +export function markConnectionFailure( + error: T, + stage: ConnectionFailureStage, + category: 'unknown' | 'timeout' = 'unknown', +): T { + if (typeof error === 'object' && error != null && !contexts.has(error)) { + contexts.set(error, { stage, category }) + } + return error +} + +function keyValidationFailure( + category: KeyValidationCategory, + statusCode: number | null = null, +): KeyValidationFailure { + const messages: Record = { + invalid_token_format: + 'That value is not a supported Seam API key. Copy the full API key, including the seam_ prefix.', + unauthorized: + 'The Seam API rejected that key (401). Check that the key is valid and active.', + api_error: + statusCode == null + ? 'The Seam API returned an error. Please try again in a moment.' + : `The Seam API returned ${statusCode}. Please try again in a moment.`, + transport_error: + 'Could not reach the Seam API. Check your connection and try again.', + unknown: + 'An unexpected error occurred while verifying the key. Please try again.', + } + return { category, statusCode, message: messages[category] } +} + +export function classifyKeyValidationError( + error: unknown, +): KeyValidationFailure { + if (error instanceof SeamHttpInvalidTokenError) { + return keyValidationFailure('invalid_token_format') + } + if (isSeamHttpUnauthorizedError(error)) { + return keyValidationFailure('unauthorized', 401) + } + if (isSeamHttpApiError(error)) { + return keyValidationFailure('api_error', knownHttpStatus(error.statusCode)) + } + // A non-JSON HTTP failure can remain an Axios error instead of becoming a + // SeamHttpApiError. Its response status is still evidence of an HTTP failure. + if ( + error instanceof Error && + 'isAxiosError' in error && + error.isAxiosError === true && + 'response' in error && + typeof error.response === 'object' && + error.response != null && + 'status' in error.response + ) { + const status = knownHttpStatus(error.response.status) + if (status === 401) return keyValidationFailure('unauthorized', 401) + if (status != null) return keyValidationFailure('api_error', status) + } + // Axios' fetch adapter identifies transport failures by code. An arbitrary + // Error or TypeError is not evidence of a network failure. + if ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + ['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT'].includes(error.code) + ) { + return keyValidationFailure('transport_error') + } + return keyValidationFailure('unknown') +} + +function knownHttpStatus(status: unknown): number | null { + return typeof status === 'number' && + Number.isInteger(status) && + status >= 100 && + status <= 599 + ? status + : null +} + +export function describeConnectionFailure(error: unknown): { + message: string + properties: { + reason: KeyValidationCategory | 'timeout' + failure_stage: ConnectionFailureStage + http_status: number | null + } +} { + const context = + typeof error === 'object' && error != null ? contexts.get(error) : undefined + const stage = context?.stage ?? 'unknown' + const category = context?.category ?? 'unknown' + if (stage === 'key_validation') { + const failure = classifyKeyValidationError(error) + return { + message: failure.message, + properties: { + reason: failure.category, + failure_stage: stage, + http_status: failure.statusCode, + }, + } + } + const message = + stage === 'env_write' + ? 'The key was verified, but the wizard could not save it to the project. Check file permissions and try again.' + : stage === 'browser_callback' + ? category === 'timeout' + ? 'Timed out waiting for the browser to return a key. Please try again.' + : 'The browser handoff could not complete. Please try again or paste your API key.' + : 'An unexpected error occurred while connecting. Please try again.' + return { + message, + properties: { reason: category, failure_stage: stage, http_status: null }, + } +} diff --git a/src/lib/steps/authenticate.ts b/src/lib/steps/authenticate.ts index 83914fb..aa70345 100644 --- a/src/lib/steps/authenticate.ts +++ b/src/lib/steps/authenticate.ts @@ -1,5 +1,6 @@ import { getAuth } from 'lib/adapter.js' import { getWorkspaceForApiKey, type SeamWorkspace } from 'lib/api.js' +import { markConnectionFailure } from 'lib/connection-error.js' import { findExistingApiKey, type ProjectEnvResult, @@ -60,7 +61,15 @@ export async function verifyAndSaveKey( ): Promise { const trimmed = apiKey.trim() const workspace = await getWorkspaceForApiKey(trimmed) - return { workspace, api_key: trimmed, env: saveProjectApiKey(root, trimmed) } + try { + return { + workspace, + api_key: trimmed, + env: saveProjectApiKey(root, trimmed), + } + } catch (error) { + throw markConnectionFailure(error, 'env_write') + } } export function saveVerifiedKey( diff --git a/src/lib/steps/connect-web.ts b/src/lib/steps/connect-web.ts index 007a26e..fd46e7e 100644 --- a/src/lib/steps/connect-web.ts +++ b/src/lib/steps/connect-web.ts @@ -8,6 +8,7 @@ import { getWorkspaceForApiKey, type SeamWorkspace, } from 'lib/api.js' +import { markConnectionFailure } from 'lib/connection-error.js' import { type ProjectEnvResult, saveProjectApiKey } from 'lib/env-file.js' // The dashboard "wizard" page mints a key and posts it back to the local @@ -108,16 +109,28 @@ export async function connectViaWeb( const timeout = setTimeout(() => { server.close() - reject(new Error('Timed out waiting for the browser.')) + reject( + markConnectionFailure( + new Error('Timed out waiting for the browser.'), + 'browser_callback', + 'timeout', + ), + ) }, CALLBACK_TIMEOUT_MS) timeout.unref() + }).catch((error: unknown) => { + throw markConnectionFailure(error, 'browser_callback') }) const workspace = await getWorkspaceForApiKey(payload.api_key) - return { - workspace, - api_key: payload.api_key, - env: saveProjectApiKey(root, payload.api_key), + try { + return { + workspace, + api_key: payload.api_key, + env: saveProjectApiKey(root, payload.api_key), + } + } catch (error) { + throw markConnectionFailure(error, 'env_write') } } diff --git a/test/app-connection.test.tsx b/test/app-connection.test.tsx new file mode 100644 index 0000000..bc8346b --- /dev/null +++ b/test/app-connection.test.tsx @@ -0,0 +1,189 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { request } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { gunzipSync } from 'node:zlib' + +import { render } from 'ink-testing-library' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { createMemoryAdapter, resetAdapter, setAdapter } from 'lib/adapter.js' +import { + flushAnalytics, + resetAnalytics, + startAnalytics, +} from 'lib/analytics.js' +import { App } from 'lib/app.js' + +const { openBrowser } = vi.hoisted(() => ({ + openBrowser: vi.fn(async (_url: string) => undefined), +})) +vi.mock('open', () => ({ default: openBrowser })) + +let sdkRequests = 0 +let root = '' +let cleanup: (() => void) | undefined +let posted: Array<{ event: string; properties: Record }> = [] + +beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'wizard-connect-')) + setAdapter(createMemoryAdapter()) + vi.stubEnv('SEAM_API_KEY', '') + vi.stubEnv('SEAM_WIZARD_POSTHOG_KEY', 'phc_test_project') + posted = [] + const captured = posted + sdkRequests = 0 + openBrowser.mockClear() + vi.stubGlobal( + 'fetch', + vi.fn(async (input: Request | string, init?: RequestInit) => { + if (typeof input !== 'string') { + sdkRequests++ + return Response.json( + { error: { type: 'unauthorized', message: 'secret-marker' } }, + { status: 401 }, + ) + } + const body = JSON.parse( + gunzipSync(init?.body as Uint8Array).toString('utf8'), + ) as { batch: typeof posted } + captured.push(...body.batch) + return new Response('{}', { status: 200 }) + }), + ) + await startAnalytics({ command: 'seam wizard' }) +}) + +afterEach(async () => { + cleanup?.() + cleanup = undefined + await flushAnalytics() + process.exitCode = 0 + resetAnalytics() + resetAdapter() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(root, { recursive: true, force: true }) +}) + +test('paste retries and give-up preserve 401 and send only safe failure metadata', async () => { + const reports: string[][] = [] + const { stdin, lastFrame, unmount } = render( + reports.push(lines)} />, + ) + cleanup = unmount + // Wait for Ink to attach its input listener before each input transition. + await vi.waitFor(() => expect(lastFrame()).toContain('Press any key')) + await new Promise((resolve) => setTimeout(resolve, 50)) + stdin.write('x') + await vi.waitFor(() => + expect(lastFrame()).toContain('How do you want to connect'), + ) + await new Promise((resolve) => setTimeout(resolve, 100)) + stdin.write('\u001b[B') + await new Promise((resolve) => setTimeout(resolve, 50)) + stdin.write('\r') + await vi.waitFor(() => + expect(lastFrame()).toContain('Paste your Seam API key'), + ) + for (let attempt = 1; attempt <= 3; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 50)) + stdin.write('seam_fixture_secret-marker') + await new Promise((resolve) => setTimeout(resolve, 50)) + stdin.write('\r') + await vi.waitFor(() => expect(sdkRequests).toBe(attempt)) + if (attempt < 3) { + await vi.waitFor(() => { + expect(lastFrame()).toContain('Paste your Seam API key') + expect(lastFrame()).toContain('rejected that key (401)') + }) + } else { + await vi.waitFor(() => + expect(reports.flat().join(' ')).toContain('Too many attempts'), + ) + expect(reports.flat().join(' ')).toContain('rejected that key (401)') + } + } + unmount() + cleanup = undefined + await flushAnalytics() + const failures = posted.filter( + ({ event }) => event === 'wizard_connect_failed', + ) + expect(failures).toHaveLength(3) + failures.forEach(({ properties }, index) => { + expect(properties).toMatchObject({ + method: 'paste', + reason: 'unauthorized', + failure_stage: 'key_validation', + http_status: 401, + attempt: index + 1, + gave_up: index === 2, + }) + }) + expect(JSON.stringify(posted)).not.toContain('secret-marker') + expect(JSON.stringify(posted)).not.toContain(root) + expect(lastFrame()).not.toContain('secret-marker') +}, 15000) + +test('browser key receipt followed by local validation failure reports the validation stage safely', async () => { + const reports: string[][] = [] + const { stdin, lastFrame, unmount } = render( + reports.push(lines)} />, + ) + cleanup = unmount + await new Promise((resolve) => setTimeout(resolve, 50)) + stdin.write('x') + await vi.waitFor(() => + expect(lastFrame()).toContain('How do you want to connect'), + ) + await new Promise((resolve) => setTimeout(resolve, 100)) + stdin.write('\r') + await vi.waitFor(() => expect(openBrowser.mock.calls).toHaveLength(1)) + const url = new URL(openBrowser.mock.calls[0]?.[0] ?? '') + await new Promise((resolve, reject) => { + const callback = request( + { + hostname: '127.0.0.1', + port: url.searchParams.get('cli_port') ?? '', + method: 'POST', + path: '/', + headers: { 'content-type': 'application/json' }, + }, + (response) => { + response.resume() + response.on('end', () => resolve()) + }, + ) + callback.on('error', reject) + callback.end( + JSON.stringify({ + state: url.searchParams.get('cli_state'), + api_key: 'seam_pk_secret-marker', + }), + ) + }) + await vi.waitFor(() => + expect(reports.flat().join(' ')).toContain('not a supported Seam API key'), + ) + unmount() + cleanup = undefined + await flushAnalytics() + expect(sdkRequests).toBe(0) + expect( + posted.filter(({ event }) => event === 'wizard_browser_key_received'), + ).toHaveLength(1) + expect( + posted.find(({ event }) => event === 'wizard_connect_failed')?.properties, + ).toMatchObject({ + method: 'browser', + reason: 'invalid_token_format', + failure_stage: 'key_validation', + http_status: null, + }) + expect(JSON.stringify(posted)).not.toContain('secret-marker') + expect(JSON.stringify(posted)).not.toContain( + url.searchParams.get('cli_state'), + ) + expect(JSON.stringify(posted)).not.toContain(url.href) +}, 10000) diff --git a/test/steps/authenticate-errors.test.ts b/test/steps/authenticate-errors.test.ts new file mode 100644 index 0000000..95a7c42 --- /dev/null +++ b/test/steps/authenticate-errors.test.ts @@ -0,0 +1,51 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, expect, test, vi } from 'vitest' + +import { createMemoryAdapter, resetAdapter, setAdapter } from 'lib/adapter.js' +import { describeConnectionFailure } from 'lib/connection-error.js' +import { verifyAndSaveKey } from 'lib/steps/authenticate.js' + +let root = '' +afterEach(() => { + resetAdapter() + vi.unstubAllGlobals() + rmSync(root, { recursive: true, force: true }) +}) + +test('saving failure after validation is distinct from a rejected key and excludes disk errors', async () => { + setAdapter(createMemoryAdapter()) + root = mkdtempSync(join(tmpdir(), 'wizard-auth-errors-')) + mkdirSync(join(root, '.env')) + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + workspace: { + workspace_id: 'workspace-1', + name: 'Test', + is_sandbox: true, + }, + }), + ), + ) + let failure: unknown + try { + await verifyAndSaveKey(root, 'seam_fixture_secret-marker') + } catch (error) { + failure = error + } + const result = describeConnectionFailure(failure) + expect(result).toMatchObject({ + message: expect.stringContaining('key was verified'), + properties: { + reason: 'unknown', + failure_stage: 'env_write', + http_status: null, + }, + }) + expect(JSON.stringify(result)).not.toContain(root) + expect(JSON.stringify(result)).not.toContain('secret-marker') +})