Files
OmniRoute/tests/unit/bulkApiKeyParser.test.ts
Diego Rodrigues de Sa e Souza c3fabf34ca fix(api): bulk-add API keys no longer overwrite existing connections (#7234)
* fix(api): bulk-add API keys no longer overwrite existing connections

createProviderConnection upserts apikey connections BY NAME (same provider +
auth_type "apikey" + same name updates the row in place, replacing its
apiKey/priority/testStatus instead of inserting). The bulk-add route
auto-names unnamed lines "Key 1", "Key 2", ... restarting from 1 on every
request, blind to names already saved for the provider — so re-running a
bulk paste against a provider that already had "Key 1" silently replaced it
instead of adding a new connection alongside it. The same collision could
also happen within one batch for two identical custom name|apiKey lines.

Add resolveBulkNameCollisions (src/shared/utils/bulkApiKeyParser.ts): gap-fills
the smallest free "<name> <n>" suffix against both existing connection names
and names already assigned earlier in the same batch, so a name is never
reused. Wire it into POST /api/providers/bulk before the create loop, fetching
existing apikey connection names via the existing getProviderConnections db
module (no raw SQL added to the route).

Co-authored-by: asynx6 <sahrulbeni656@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2587

* chore(changelog): fragment for #7234

---------

Co-authored-by: asynx6 <sahrulbeni656@gmail.com>
2026-07-17 10:40:29 -03:00

154 lines
5.6 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import {
parseBulkApiKeys,
resolveBulkNameCollisions,
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/);
});
// #2587 — resolveBulkNameCollisions: guards against createProviderConnection's
// name-based upsert silently replacing an existing connection when bulk-add
// auto-naming ("Key 1", "Key 2", ...) restarts from 1 and collides with a
// name already saved for the provider.
test("resolveBulkNameCollisions: no-op when nothing collides", () => {
const entries = [{ name: "Key 1" }, { name: "Key 2" }];
const out = resolveBulkNameCollisions(entries, ["Prod"]);
assert.deepEqual(
out.map((e) => e.name),
["Key 1", "Key 2"]
);
});
test("resolveBulkNameCollisions: renames an auto-named entry colliding with an existing connection", () => {
// Provider already has "Key 1" saved; a fresh bulk parse restarts auto-naming
// at "Key 1" too. Without renaming, createProviderConnection would upsert
// into the existing row instead of inserting.
const entries = [{ name: "Key 1" }, { name: "Key 2" }];
const out = resolveBulkNameCollisions(entries, ["Key 1"]);
assert.deepEqual(
out.map((e) => e.name),
["Key 2", "Key 3"]
);
// Never reuses an existing name.
assert.ok(!out.some((e) => e.name === "Key 1"));
});
test("resolveBulkNameCollisions: renames a custom name colliding with an existing connection", () => {
const entries = [{ name: "Prod" }];
const out = resolveBulkNameCollisions(entries, ["Prod"]);
assert.deepEqual(out.map((e) => e.name), ["Prod 1"]);
});
test("resolveBulkNameCollisions: dedupes two identical custom names within the same batch", () => {
// Two "Prod|apiKey" lines pasted in the same batch previously collided with
// EACH OTHER (custom names get no auto-index), so the second entry would
// upsert into the first instead of inserting a second connection.
const entries = [{ name: "Prod" }, { name: "Prod" }];
const out = resolveBulkNameCollisions(entries, []);
const names = out.map((e) => e.name);
assert.equal(new Set(names).size, names.length);
assert.deepEqual(names, ["Prod", "Prod 1"]);
});
test("resolveBulkNameCollisions: preserves non-name fields on renamed entries", () => {
const entries = [{ name: "Key 1", apiKey: "sk-new" }];
const out = resolveBulkNameCollisions(entries, ["Key 1"]);
assert.equal(out[0].apiKey, "sk-new");
assert.equal(out[0].name, "Key 2");
});
test("resolveBulkNameCollisions: name comparison is case-insensitive", () => {
const entries = [{ name: "key 1" }];
const out = resolveBulkNameCollisions(entries, ["KEY 1"]);
assert.equal(out[0].name, "key 2");
});
test("resolveBulkNameCollisions: tolerates non-array existingNames", () => {
const entries = [{ name: "Key 1" }];
const out = resolveBulkNameCollisions(entries, null);
assert.equal(out[0].name, "Key 1");
});