Skip to content

Commit d8d282e

Browse files
committed
fix(auth): preserve typed API error envelope when key verification fails
1 parent 18f6e6e commit d8d282e

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
@@ -268,6 +268,46 @@ describe('runConfigure', () => {
268268
expect(capture.stderr.join('\n')).toContain('profile NOT updated');
269269
});
270270

271+
it('key-rejected error preserves the typed ApiError envelope (JSON contract)', async () => {
272+
const { deps } = makeCapture();
273+
const rejectedFetch: AuthDeps['fetchImpl'] = vi.fn(
274+
async () =>
275+
new Response(
276+
JSON.stringify({
277+
error: {
278+
code: 'AUTH_INVALID',
279+
message: 'API key is invalid or revoked.',
280+
nextAction: 'Rotate your key.',
281+
requestId: 'req_reject',
282+
details: { reason: 'malformed' },
283+
},
284+
}),
285+
{ status: 401, headers: { 'content-type': 'application/json' } },
286+
),
287+
) as unknown as AuthDeps['fetchImpl'];
288+
289+
// The thrown error must be an ApiError (with code, nextAction, requestId)
290+
// — not a CLIError wrapper that drops those fields. Under --output json,
291+
// index.ts renders ApiError as the full typed envelope; CLIError would
292+
// render only {"error":"...string..."}, violating the JSON contract.
293+
await expect(
294+
runConfigure(
295+
{ profile: 'default', output: 'json', debug: false, fromEnv: true },
296+
{
297+
...deps,
298+
env: { TESTSPRITE_API_KEY: 'sk-bad' },
299+
credentialsPath,
300+
fetchImpl: rejectedFetch,
301+
},
302+
),
303+
).rejects.toMatchObject({
304+
code: 'AUTH_INVALID',
305+
exitCode: 3,
306+
nextAction: 'Rotate your key.',
307+
requestId: 'req_reject',
308+
});
309+
});
310+
271311
// The old "run `testsprite agent install`" self-bootstrap tip was removed with
272312
// the setup consolidation — runConfigure now runs ONLY as part of `setup`,
273313
// 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
@@ -158,13 +158,22 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
158158
} catch (err) {
159159
const message = err instanceof Error ? err.message : String(err);
160160
stderr(`API key rejected by ${apiUrl}: ${message} — profile NOT updated`);
161-
const exitCode = err instanceof ApiError ? err.exitCode : 3;
162-
// Include the resolved endpoint in the thrown message so the user knows
163-
// which host rejected the key. This prevents the "invalid or revoked"
164-
// message from being ambiguous when the key is valid for a different env.
161+
// When the verification call returned a typed API error (AUTH_INVALID,
162+
// AUTH_FORBIDDEN, etc.), re-throw it directly so `index.ts` renders the
163+
// full typed envelope under `--output json` (code, nextAction, requestId,
164+
// details). Previously wrapping it in CLIError discarded those fields and
165+
// emitted a bare `{"error":"...string..."}` — violating the JSON contract.
166+
// Augment the message with the endpoint context so text-mode users still
167+
// see which host rejected the key.
168+
if (err instanceof ApiError) {
169+
err.message = `API key rejected by ${apiUrl}: ${message} — did you mean to set TESTSPRITE_API_URL?`;
170+
throw err;
171+
}
172+
// Non-ApiError (truly unexpected throws like a TypeError from a
173+
// misconfigured fetchImpl). Exit 3 (auth family).
165174
throw new CLIError(
166175
`API key rejected by ${apiUrl}: ${message} — did you mean to set TESTSPRITE_API_URL?`,
167-
exitCode,
176+
3,
168177
);
169178
}
170179

0 commit comments

Comments
 (0)