|
15 | 15 | * (4) pagination follows next_page and concatenates pages, and exceeding the |
16 | 16 | * page cap throws rather than returning a partial (understated) bill; |
17 | 17 | * (5) a non-2xx response throws with the status, and malformed money throws |
18 | | - * rather than silently dropping a charge. |
| 18 | + * rather than silently dropping a charge; |
| 19 | + * (6) the thrown non-2xx error is scrubbed of any key-shaped token the vendor |
| 20 | + * echoes back in its error body, so the Admin key never lands in an error |
| 21 | + * message (and from there an IPC reply or the log file). |
19 | 22 | * |
20 | 23 | * The network is never touched: a recording fake fetch returns canned bodies. |
21 | 24 | */ |
22 | 25 | import assert from "node:assert/strict"; |
23 | 26 | import { test } from "node:test"; |
24 | | -import { assertAllowedAdminHost } from "../src/main/admin-billing.js"; |
| 27 | +import { |
| 28 | + assertAllowedAdminHost, |
| 29 | + redactKeyLikeTokens, |
| 30 | +} from "../src/main/admin-billing.js"; |
25 | 31 | import { AnthropicAdminClient } from "../src/main/anthropic-admin-client.js"; |
26 | 32 | import { OpenAiAdminClient } from "../src/main/openai-admin-client.js"; |
27 | 33 | import { makeFetch } from "./helpers/admin-fetch.js"; |
@@ -154,15 +160,51 @@ test("Anthropic: follows next_page pagination and concatenates results", async ( |
154 | 160 | assert.ok(calls[1].url.includes("page=PAGE_2_TOKEN")); |
155 | 161 | }); |
156 | 162 |
|
157 | | -test("Anthropic: a non-2xx response throws with the status", async () => { |
| 163 | +test("Anthropic: a non-2xx response throws with the status, key scrubbed from the body", async () => { |
| 164 | + // Model a vendor 401 body that echoes the key it received (OpenAI does this; |
| 165 | + // we treat any vendor body as untrusted and scrub it). |
158 | 166 | const { fetch } = makeFetch([{ error: "nope" }], { |
159 | 167 | status: 401, |
160 | | - bodyText: '{"error":"invalid x-api-key"}', |
| 168 | + bodyText: |
| 169 | + '{"error":"Incorrect API key provided: sk-ant-admin-TEST. Check your key."}', |
161 | 170 | }); |
162 | 171 | const client = new AnthropicAdminClient({ apiKey: "sk-ant-admin-TEST", fetch }); |
163 | 172 | await assert.rejects( |
164 | 173 | () => client.fetchCostReport({ startingAt: "2026-05-20T00:00:00Z" }), |
165 | | - /Anthropic admin API HTTP 401/, |
| 174 | + (err: unknown) => { |
| 175 | + const message = (err as Error).message; |
| 176 | + assert.match(message, /Anthropic admin API HTTP 401/); |
| 177 | + // The exact key string must NOT appear; it is redacted instead. |
| 178 | + assert.ok( |
| 179 | + !message.includes("sk-ant-admin-TEST"), |
| 180 | + "error message must not contain the Admin key", |
| 181 | + ); |
| 182 | + assert.match(message, /sk-\[redacted\]/); |
| 183 | + return true; |
| 184 | + }, |
| 185 | + ); |
| 186 | +}); |
| 187 | + |
| 188 | +test("redactKeyLikeTokens scrubs plain and masked keys but leaves prose intact", () => { |
| 189 | + // Plain key. |
| 190 | + assert.equal( |
| 191 | + redactKeyLikeTokens("Incorrect API key provided: sk-ant-admin-TEST."), |
| 192 | + "Incorrect API key provided: sk-[redacted].", |
| 193 | + ); |
| 194 | + // OpenAI-style asterisk-masked key. |
| 195 | + assert.equal( |
| 196 | + redactKeyLikeTokens("provided: sk-admin-****************************tK8F."), |
| 197 | + "provided: sk-[redacted].", |
| 198 | + ); |
| 199 | + // Both vendor prefixes in one body. |
| 200 | + assert.equal( |
| 201 | + redactKeyLikeTokens("sk-admin-AAAA and sk-ant-admin-BBBB"), |
| 202 | + "sk-[redacted] and sk-[redacted]", |
| 203 | + ); |
| 204 | + // Non-key text is untouched (no false positives on ordinary words). |
| 205 | + assert.equal( |
| 206 | + redactKeyLikeTokens("rate limit exceeded for this organization"), |
| 207 | + "rate limit exceeded for this organization", |
166 | 208 | ); |
167 | 209 | }); |
168 | 210 |
|
|
0 commit comments