Skip to content
Open
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
40 changes: 40 additions & 0 deletions src/commands/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,46 @@ describe('runConfigure', () => {
expect(capture.stderr.join('\n')).toContain('profile NOT updated');
});

it('key-rejected error preserves the typed ApiError envelope (JSON contract)', async () => {
const { deps } = makeCapture();
const rejectedFetch: AuthDeps['fetchImpl'] = vi.fn(
async () =>
new Response(
JSON.stringify({
error: {
code: 'AUTH_INVALID',
message: 'API key is invalid or revoked.',
nextAction: 'Rotate your key.',
requestId: 'req_reject',
details: { reason: 'malformed' },
},
}),
{ status: 401, headers: { 'content-type': 'application/json' } },
),
) as unknown as AuthDeps['fetchImpl'];

// The thrown error must be an ApiError (with code, nextAction, requestId)
// — not a CLIError wrapper that drops those fields. Under --output json,
// index.ts renders ApiError as the full typed envelope; CLIError would
// render only {"error":"...string..."}, violating the JSON contract.
await expect(
runConfigure(
{ profile: 'default', output: 'json', debug: false, fromEnv: true },
{
...deps,
env: { TESTSPRITE_API_KEY: 'sk-bad' },
credentialsPath,
fetchImpl: rejectedFetch,
},
),
).rejects.toMatchObject({
code: 'AUTH_INVALID',
exitCode: 3,
nextAction: 'Rotate your key.',
requestId: 'req_reject',
});
});

// The old "run `testsprite agent install`" self-bootstrap tip was removed with
// the setup consolidation — runConfigure now runs ONLY as part of `setup`,
// which installs the skill itself. These guard that the tip stays gone.
Expand Down
19 changes: 14 additions & 5 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,22 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
stderr(`API key rejected by ${apiUrl}: ${message} — profile NOT updated`);
const exitCode = err instanceof ApiError ? err.exitCode : 3;
// Include the resolved endpoint in the thrown message so the user knows
// which host rejected the key. This prevents the "invalid or revoked"
// message from being ambiguous when the key is valid for a different env.
// When the verification call returned a typed API error (AUTH_INVALID,
// AUTH_FORBIDDEN, etc.), re-throw it directly so `index.ts` renders the
// full typed envelope under `--output json` (code, nextAction, requestId,
// details). Previously wrapping it in CLIError discarded those fields and
// emitted a bare `{"error":"...string..."}` — violating the JSON contract.
// Augment the message with the endpoint context so text-mode users still
// see which host rejected the key.
if (err instanceof ApiError) {
err.message = `API key rejected by ${apiUrl}: ${message} — did you mean to set TESTSPRITE_API_URL?`;
throw err;
}
// Non-ApiError (truly unexpected throws like a TypeError from a
// misconfigured fetchImpl). Exit 3 (auth family).
throw new CLIError(
`API key rejected by ${apiUrl}: ${message} — did you mean to set TESTSPRITE_API_URL?`,
exitCode,
3,
);
}

Expand Down