Files
OmniRoute/tests/unit/bulkApiKeyParser.test.ts
diegosouzapw fc9f8d91f7 feat(providers): bulk add API keys with Single/Bulk tabs
Mirrors the 9router UX (one textarea, name|apiKey per line) but goes
further: dedicated server-side endpoint with Zod validation, partial-
failure semantics, audit log, and provider whitelist.

UI (src/app/(dashboard)/dashboard/providers/[id]/page.tsx):
- AddApiKeyModal now switches between Single (existing behaviour) and
  Bulk Add via tab strip. Tabs hide for providers that don't support
  bulk (Vertex, web-session, OAuth, multi-field).
- Bulk pane: textarea, shared Priority + "validate each key" checkbox,
  result panel with per-line errors (truncated at 10).

Backend:
- POST /api/providers/bulk: iterates entries through createProviderConnection
  with the same provider-specific normalization as the single endpoint.
  Returns {success, failed, total, created, errors[]}. Optional pre-save
  validation via /api/providers/validate when validateKeys=true. Each
  entry succeeds/fails independently — no transaction rollback.
- Bulk audit event logged once per request plus per-entry success events.

Schemas:
- bulkCreateProviderSchema (src/shared/validation/schemas.ts): max 200
  entries, mandatory name+apiKey per entry, google-pse-search cx guard.
- supportsBulkApiKey() helper (src/shared/constants/providers.ts) with
  explicit deny-list for OAuth/web-session/multi-field providers.

Parser:
- parseBulkApiKeys() (src/shared/utils/bulkApiKeyParser.ts) handles
  CRLF, # comments, blank lines, pipe inside apiKey, empty-name fallback,
  and caps input at BULK_API_KEY_MAX_LINES (200) with a warning.

Tests:
- tests/unit/bulkApiKeyParser.test.ts: 12 cases (format, edge cases, cap)
- tests/unit/providers-bulk-route.test.ts: 12 cases (schema, whitelist,
  response shape, apiKey leak guard)

i18n:
- en.json: bulkTabSingle, bulkTabBulkAdd, bulkAddFormatHint,
  bulkValidateKeys, bulkAddAllKeys, bulkAddedCount, bulkFailedCount,
  adding
2026-05-17 11:30:49 -03:00

90 lines
3.0 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import {
parseBulkApiKeys,
BULK_API_KEY_MAX_LINES,
} from "../../src/shared/utils/bulkApiKeyParser.ts";
test("parses name|apiKey lines", () => {
const { entries, warnings } = parseBulkApiKeys("prod|sk-1\nstaging|sk-2");
assert.equal(warnings.length, 0);
assert.deepEqual(entries, [
{ name: "prod", apiKey: "sk-1", lineNumber: 1 },
{ name: "staging", apiKey: "sk-2", lineNumber: 2 },
]);
});
test("auto-names lines without pipe (Key 1, Key 2, ...)", () => {
const { entries } = parseBulkApiKeys("sk-a\nsk-b\nsk-c");
assert.deepEqual(
entries.map((e) => e.name),
["Key 1", "Key 2", "Key 3"]
);
});
test("auto-name index only advances on unnamed lines", () => {
const { entries } = parseBulkApiKeys("named|sk-1\nsk-2\nnamed2|sk-3\nsk-4");
assert.deepEqual(
entries.map((e) => e.name),
["named", "Key 1", "named2", "Key 2"]
);
});
test("apiKey may contain | — only first separator counts", () => {
const { entries } = parseBulkApiKeys("key1|sk-with|pipe|inside");
assert.equal(entries.length, 1);
assert.equal(entries[0].name, "key1");
assert.equal(entries[0].apiKey, "sk-with|pipe|inside");
});
test("skips blank lines and # comments", () => {
const { entries } = parseBulkApiKeys("# header\nsk-1\n\n# inline comment\nsk-2");
assert.equal(entries.length, 2);
assert.equal(entries[0].lineNumber, 2);
assert.equal(entries[1].lineNumber, 5);
});
test("handles CRLF line endings", () => {
const { entries } = parseBulkApiKeys("prod|sk-1\r\nstaging|sk-2\r\n");
assert.equal(entries.length, 2);
});
test("warns on empty apiKey after pipe", () => {
const { entries, warnings } = parseBulkApiKeys("prod|\nstaging|sk-2");
assert.equal(entries.length, 1);
assert.equal(entries[0].name, "staging");
assert.equal(warnings.length, 1);
assert.match(warnings[0], /Line 1.*empty apiKey/);
});
test("empty name falls back to auto-name", () => {
const { entries } = parseBulkApiKeys("|sk-1");
assert.equal(entries[0].name, "Key 1");
assert.equal(entries[0].apiKey, "sk-1");
});
test("trims whitespace around name and apiKey", () => {
const { entries } = parseBulkApiKeys(" prod | sk-1 ");
assert.equal(entries[0].name, "prod");
assert.equal(entries[0].apiKey, "sk-1");
});
test("empty input returns empty entries", () => {
const { entries, warnings } = parseBulkApiKeys("");
assert.equal(entries.length, 0);
assert.equal(warnings.length, 0);
});
test("only whitespace returns empty entries", () => {
const { entries } = parseBulkApiKeys(" \n\t\n \n");
assert.equal(entries.length, 0);
});
test("input exceeding cap is truncated with warning", () => {
const lines = Array.from({ length: BULK_API_KEY_MAX_LINES + 5 }, (_, i) => `sk-${i}`);
const { entries, warnings } = parseBulkApiKeys(lines.join("\n"));
assert.equal(entries.length, BULK_API_KEY_MAX_LINES);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /only the first/);
});