Skip to content

Commit 33b7848

Browse files
authored
fix(auth): preserve typed API error envelope when key verification fails (#38)
1 parent 9102430 commit 33b7848

2 files changed

Lines changed: 54 additions & 5 deletions

File tree

src/commands/auth.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,46 @@ describe('runConfigure', () => {
312312
expect(capture.stderr.join('\n')).toContain('profile NOT updated');
313313
});
314314

315+
it('key-rejected error preserves the typed ApiError envelope (JSON contract)', async () => {
316+
const { deps } = makeCapture();
317+
const rejectedFetch: AuthDeps['fetchImpl'] = vi.fn(
318+
async () =>
319+
new Response(
320+
JSON.stringify({
321+
error: {
322+
code: 'AUTH_INVALID',
323+
message: 'API key is invalid or revoked.',
324+
nextAction: 'Rotate your key.',
325+
requestId: 'req_reject',
326+
details: { reason: 'malformed' },
327+
},
328+
}),
329+
{ status: 401, headers: { 'content-type': 'application/json' } },
330+
),
331+
) as unknown as AuthDeps['fetchImpl'];
332+
333+
// The thrown error must be an ApiError (with code, nextAction, requestId)
334+
// — not a CLIError wrapper that drops those fields. Under --output json,
335+
// index.ts renders ApiError as the full typed envelope; CLIError would
336+
// render only {"error":"...string..."}, violating the JSON contract.
337+
await expect(
338+
runConfigure(
339+
{ profile: 'default', output: 'json', debug: false, fromEnv: true },
340+
{
341+
...deps,
342+
env: { TESTSPRITE_API_KEY: 'sk-bad' },
343+
credentialsPath,
344+
fetchImpl: rejectedFetch,
345+
},
346+
),
347+
).rejects.toMatchObject({
348+
code: 'AUTH_INVALID',
349+
exitCode: 3,
350+
nextAction: 'Rotate your key.',
351+
requestId: 'req_reject',
352+
});
353+
});
354+
315355
// The old "run `testsprite agent install`" self-bootstrap tip was removed with
316356
// the setup consolidation — runConfigure now runs ONLY as part of `setup`,
317357
// which installs the skill itself. These guard that the tip stays gone.

src/commands/auth.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,22 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
159159
} catch (err) {
160160
const message = err instanceof Error ? err.message : String(err);
161161
stderr(`API key rejected by ${apiUrl}: ${message} — profile NOT updated`);
162-
const exitCode = err instanceof ApiError ? err.exitCode : 3;
163-
// Include the resolved endpoint in the thrown message so the user knows
164-
// which host rejected the key. This prevents the "invalid or revoked"
165-
// message from being ambiguous when the key is valid for a different env.
162+
// When the verification call returned a typed API error (AUTH_INVALID,
163+
// AUTH_FORBIDDEN, etc.), re-throw it directly so `index.ts` renders the
164+
// full typed envelope under `--output json` (code, nextAction, requestId,
165+
// details). Previously wrapping it in CLIError discarded those fields and
166+
// emitted a bare `{"error":"...string..."}` — violating the JSON contract.
167+
// Augment the message with the endpoint context so text-mode users still
168+
// see which host rejected the key.
169+
if (err instanceof ApiError) {
170+
err.message = `API key rejected by ${apiUrl}: ${message} — did you mean to set TESTSPRITE_API_URL?`;
171+
throw err;
172+
}
173+
// Non-ApiError (truly unexpected throws like a TypeError from a
174+
// misconfigured fetchImpl). Exit 3 (auth family).
166175
throw new CLIError(
167176
`API key rejected by ${apiUrl}: ${message} — did you mean to set TESTSPRITE_API_URL?`,
168-
exitCode,
177+
3,
169178
);
170179
}
171180

0 commit comments

Comments
 (0)