177 lines
8.1 KiB
TypeScript
177 lines
8.1 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { parseBulkApiKeys } from "../../src/shared/utils/bulkApiKeyParser.ts";
|
|
import { bulkCreateProviderSchema } from "../../src/shared/validation/schemas.ts";
|
|
|
|
// #6174 — Bulk Add API Keys for Cloudflare Workers AI.
|
|
// Cloudflare needs a per-key accountId, so the bulk rail parses a 3-field
|
|
// `name|accountId|apiKey` shape and threads a DISTINCT accountId per entry.
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// (a) 3-field parser — splits name|accountId|apiKey; 1-2 field shape unaffected
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
test("withAccountId: splits name|accountId|apiKey", () => {
|
|
const { entries, warnings } = parseBulkApiKeys("prod|acc-123|cf-token-abc", {
|
|
withAccountId: true,
|
|
});
|
|
assert.equal(entries.length, 1);
|
|
assert.deepEqual(
|
|
{ name: entries[0].name, accountId: entries[0].accountId, apiKey: entries[0].apiKey },
|
|
{ name: "prod", accountId: "acc-123", apiKey: "cf-token-abc" }
|
|
);
|
|
assert.equal(warnings.length, 0);
|
|
});
|
|
|
|
test("withAccountId: trims whitespace around each field", () => {
|
|
const { entries } = parseBulkApiKeys(" prod | acc-1 | cf-token ", {
|
|
withAccountId: true,
|
|
});
|
|
assert.equal(entries.length, 1);
|
|
assert.equal(entries[0].name, "prod");
|
|
assert.equal(entries[0].accountId, "acc-1");
|
|
assert.equal(entries[0].apiKey, "cf-token");
|
|
});
|
|
|
|
test("withAccountId: only the first two pipes are separators — apiKey keeps its own '|'", () => {
|
|
const { entries } = parseBulkApiKeys("prod|acc-1|cf|token|with|pipes", {
|
|
withAccountId: true,
|
|
});
|
|
assert.equal(entries.length, 1);
|
|
assert.equal(entries[0].accountId, "acc-1");
|
|
assert.equal(entries[0].apiKey, "cf|token|with|pipes");
|
|
});
|
|
|
|
test("withAccountId: auto-names when the name field is empty", () => {
|
|
const { entries } = parseBulkApiKeys("|acc-1|cf-token", { withAccountId: true });
|
|
assert.equal(entries.length, 1);
|
|
assert.equal(entries[0].name, "Key 1");
|
|
assert.equal(entries[0].accountId, "acc-1");
|
|
});
|
|
|
|
test("withAccountId: flags & skips a line missing the accountId/apiKey field", () => {
|
|
const { entries, warnings } = parseBulkApiKeys("prod|only-two-fields", {
|
|
withAccountId: true,
|
|
});
|
|
assert.equal(entries.length, 0);
|
|
assert.equal(warnings.length, 1);
|
|
assert.match(warnings[0], /Line 1/);
|
|
});
|
|
|
|
test("withAccountId: flags & skips a line with no pipe at all", () => {
|
|
const { entries, warnings } = parseBulkApiKeys("justonefield", { withAccountId: true });
|
|
assert.equal(entries.length, 0);
|
|
assert.equal(warnings.length, 1);
|
|
assert.match(warnings[0], /name\|accountId\|apiKey/);
|
|
});
|
|
|
|
test("withAccountId: flags & skips empty accountId or empty apiKey", () => {
|
|
const emptyAccount = parseBulkApiKeys("prod||cf-token", { withAccountId: true });
|
|
assert.equal(emptyAccount.entries.length, 0);
|
|
assert.match(emptyAccount.warnings[0], /accountId/);
|
|
|
|
const emptyKey = parseBulkApiKeys("prod|acc-1|", { withAccountId: true });
|
|
assert.equal(emptyKey.entries.length, 0);
|
|
assert.match(emptyKey.warnings[0], /apiKey/);
|
|
});
|
|
|
|
test("withAccountId: skips blank lines and # comments, still caps at 200", () => {
|
|
const good = parseBulkApiKeys("\n# a comment\nprod|acc-1|cf-token\n\n", {
|
|
withAccountId: true,
|
|
});
|
|
assert.equal(good.entries.length, 1);
|
|
assert.equal(good.entries[0].name, "prod");
|
|
|
|
const overCap = Array.from({ length: 205 }, (_, i) => `n${i}|acc-${i}|key-${i}`).join("\n");
|
|
const capped = parseBulkApiKeys(overCap, { withAccountId: true });
|
|
assert.equal(capped.entries.length, 200);
|
|
assert.ok(capped.warnings.some((w) => /200/.test(w)));
|
|
});
|
|
|
|
test("default (no options): non-cloudflare providers keep 1-2 field parsing, no accountId", () => {
|
|
const { entries } = parseBulkApiKeys("prod|sk-key-1\nsk-key-only");
|
|
assert.equal(entries.length, 2);
|
|
assert.equal(entries[0].name, "prod");
|
|
assert.equal(entries[0].apiKey, "sk-key-1");
|
|
assert.equal(entries[0].accountId, undefined);
|
|
assert.equal(entries[1].name, "Key 1");
|
|
assert.equal(entries[1].apiKey, "sk-key-only");
|
|
assert.equal(entries[1].accountId, undefined);
|
|
});
|
|
|
|
test("default: a pipe in a 2-field line is kept in the apiKey (first-pipe split)", () => {
|
|
const { entries } = parseBulkApiKeys("prod|sk|has|pipes");
|
|
assert.equal(entries.length, 1);
|
|
assert.equal(entries[0].apiKey, "sk|has|pipes");
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// (b) Per-entry providerSpecificData — DISTINCT accountId per entry, no shared-
|
|
// object bleed. This mirrors the exact merge performed in
|
|
// src/app/api/providers/bulk/route.ts (the core bug the feature fixes: the
|
|
// route previously reused ONE shared providerSpecificData object for every
|
|
// entry, so a per-key accountId would bleed across all connections).
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
test("per-entry PSD: each Cloudflare entry yields its own distinct accountId (no bleed)", () => {
|
|
const { entries } = parseBulkApiKeys("k1|acc-AAA|token-1\nk2|acc-BBB|token-2", {
|
|
withAccountId: true,
|
|
});
|
|
assert.equal(entries.length, 2);
|
|
|
|
// Base PSD shared across the batch (e.g. baseUrl) — must NOT be mutated/reused.
|
|
const baseProviderSpecificData: Record<string, unknown> = { baseUrl: "https://api.cf" };
|
|
|
|
// Exact per-entry merge from route.ts:
|
|
const perEntryPsd = entries.map((entry) => ({
|
|
...baseProviderSpecificData,
|
|
...(entry.accountId ? { accountId: entry.accountId } : {}),
|
|
}));
|
|
|
|
// Distinct accountId per entry.
|
|
assert.equal(perEntryPsd[0].accountId, "acc-AAA");
|
|
assert.equal(perEntryPsd[1].accountId, "acc-BBB");
|
|
|
|
// No shared-object bleed: distinct references, and mutating one leaves the
|
|
// other + the base untouched.
|
|
assert.notEqual(perEntryPsd[0], perEntryPsd[1]);
|
|
assert.notEqual(perEntryPsd[0], baseProviderSpecificData);
|
|
(perEntryPsd[0] as Record<string, unknown>).accountId = "MUTATED";
|
|
assert.equal(perEntryPsd[1].accountId, "acc-BBB");
|
|
assert.equal(baseProviderSpecificData.accountId, undefined);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Schema: cloudflare-ai requires accountId per entry; other providers unaffected
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
test("schema: cloudflare-ai bulk accepts entries carrying accountId", () => {
|
|
const result = bulkCreateProviderSchema.safeParse({
|
|
provider: "cloudflare-ai",
|
|
entries: [
|
|
{ name: "k1", apiKey: "token-1", accountId: "acc-AAA" },
|
|
{ name: "k2", apiKey: "token-2", accountId: "acc-BBB" },
|
|
],
|
|
});
|
|
assert.equal(result.success, true);
|
|
});
|
|
|
|
test("schema: cloudflare-ai bulk rejects an entry missing accountId", () => {
|
|
const result = bulkCreateProviderSchema.safeParse({
|
|
provider: "cloudflare-ai",
|
|
entries: [
|
|
{ name: "k1", apiKey: "token-1", accountId: "acc-AAA" },
|
|
{ name: "k2", apiKey: "token-2" },
|
|
],
|
|
});
|
|
assert.equal(result.success, false);
|
|
});
|
|
|
|
test("schema: accountId stays optional for non-cloudflare providers", () => {
|
|
const result = bulkCreateProviderSchema.safeParse({
|
|
provider: "anthropic",
|
|
entries: [{ name: "prod", apiKey: "sk-1" }],
|
|
});
|
|
assert.equal(result.success, true);
|
|
});
|