Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 109 additions & 26 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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,
})
})
20 changes: 2 additions & 18 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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(/\/+$/, '')
Expand All @@ -28,22 +27,7 @@ export async function getWorkspaceForApiKey(
try {
return await getApi(apiKey).get()
} catch (error) {
if (
error instanceof SeamHttpInvalidTokenError ||

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we replacing our default errors with custom ones

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intent was to distinguish local token-format failures from actual 401 responses and keep SDK/request details out of display and telemetry. Main already wrapped SDK errors in ApiKeyError, but those goals don't require new exception classes.

Revised in 58e9f3e: native SDK/filesystem errors now propagate unchanged, preserving type, stack, cause and details. The known stage is associated without mutating the error; classification and fixed safe messages live at the display/analytics boundary. No raw error text, causes, URLs, keys or fingerprints are reported.

248 tests pass locally, including preserved native causes and credential-safe browser/paste telemetry; typecheck, lint and build pass. CI also passes on this exact head (Node 22/24 tests, lint/typecheck, package and install checks).

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')
}
}

Expand Down
28 changes: 8 additions & 20 deletions src/lib/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 })
}
Expand All @@ -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 () => {
Expand All @@ -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)
Expand All @@ -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 () => {
Expand Down
Loading
Loading