353 lines
21 KiB
TypeScript
353 lines
21 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
|
|
import { handleAdminModelMutation } from "../src/lib/admin/model-mutation-handler.ts";
|
|
import { assertAllowedModelProviderUrl } from "../src/lib/epay/gateway-policy.ts";
|
|
import { encryptModelProviderApiKey } from "../src/lib/model-provider-policy.ts";
|
|
import { runMigrations } from "../scripts/db-migrate.mjs";
|
|
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
|
|
|
const migrationPath = fileURLToPath(new URL("../supabase/migrations/20260807160000_model_provider_encrypted_credentials.sql", import.meta.url));
|
|
const emergencyDisableMigrationPath = fileURLToPath(new URL("../supabase/migrations/20260808010000_model_provider_emergency_disable.sql", import.meta.url));
|
|
const handlerPath = fileURLToPath(new URL("../src/lib/admin/model-mutation-handler.ts", import.meta.url));
|
|
const routePath = fileURLToPath(new URL("../src/app/api/admin/models/route.ts", import.meta.url));
|
|
const catalogPath = fileURLToPath(new URL("../src/lib/model-catalog.ts", import.meta.url));
|
|
const componentPath = fileURLToPath(new URL("../src/components/admin/model-management.tsx", import.meta.url));
|
|
const actorId = "10000000-0000-4000-8000-000000000003";
|
|
const deniedActorId = "10000000-0000-4000-8000-000000000004";
|
|
const encryptionKey = Buffer.alloc(32, 17).toString("base64");
|
|
const environment = { MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY: encryptionKey };
|
|
const publicLookup = async () => [{ address: "93.184.216.34", family: 4 }] as const;
|
|
|
|
async function migrateModelConfigurationFixture(connectionString: string) {
|
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
const temporaryRoot = mkdtempSync(join(tmpdir(), "jyotisha-model-migrations-"));
|
|
const directories = ["db/migrations", "supabase/migrations"].map((relative) => {
|
|
const source = join(root, relative);
|
|
const target = join(temporaryRoot, relative.replace("/", "-"));
|
|
mkdirSync(target, { recursive: true });
|
|
for (const filename of readdirSync(source)) {
|
|
if (!filename.endsWith(".sql") || filename > "20260808020000_model_admin_runtime_read_policies.sql") continue;
|
|
if ([
|
|
"20260806030000_settle_order_usage_authorization.sql",
|
|
"20260806050000_operations_feature_flags.sql",
|
|
"20260806060000_unified_rectification_usage.sql",
|
|
"20260807010000_retire_legacy_rectification_runtime.sql",
|
|
].includes(filename)) continue;
|
|
cpSync(join(source, filename), join(target, filename));
|
|
}
|
|
return target;
|
|
});
|
|
try {
|
|
await runMigrations({
|
|
connectionString,
|
|
migrationsDirectory: undefined,
|
|
migrationsDirectories: directories,
|
|
logger: console,
|
|
});
|
|
} finally {
|
|
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test("model provider URLs allow arbitrary public HTTPS origins but retain SSRF boundaries", async () => {
|
|
let lookupOptions: unknown;
|
|
const resolved = await assertAllowedModelProviderUrl(
|
|
"https://attacker.example/v1",
|
|
{},
|
|
async (_hostname, options) => {
|
|
lookupOptions = options;
|
|
return publicLookup();
|
|
},
|
|
);
|
|
assert.equal(resolved.url.origin, "https://attacker.example");
|
|
assert.deepEqual(lookupOptions, { all: true, verbatim: true });
|
|
|
|
for (const value of [
|
|
"http://attacker.example/v1",
|
|
"https://user:password@attacker.example/v1",
|
|
"https://localhost/v1",
|
|
"https://service.internal/v1",
|
|
"https://10.0.0.1/v1",
|
|
"https://192.168.1.1/v1",
|
|
"https://169.254.169.254/v1",
|
|
"https://192.0.2.1/v1",
|
|
"https://[2001:db8::1]/v1",
|
|
]) {
|
|
await assert.rejects(
|
|
assertAllowedModelProviderUrl(value, {}, publicLookup),
|
|
/HTTPS|本机|内网|内部域名|保留地址/,
|
|
value,
|
|
);
|
|
}
|
|
|
|
await assert.rejects(
|
|
assertAllowedModelProviderUrl(
|
|
"https://public.example/v1",
|
|
{},
|
|
async () => [{ address: "169.254.169.254", family: 4 }],
|
|
),
|
|
/内网|保留地址/,
|
|
);
|
|
|
|
const gatewayPolicy = readFileSync(new URL("../src/lib/epay/gateway-policy.ts", import.meta.url), "utf8");
|
|
assert.match(gatewayPolicy, /lookup: pinnedAddressLookup\(pinned\)/);
|
|
assert.match(gatewayPolicy, /status >= 300 && status < 400/);
|
|
assert.match(gatewayPolicy, /不允许重定向/);
|
|
});
|
|
|
|
test("admin and runtime source expose only secretConfigured and contain no secretRef contract", () => {
|
|
const route = readFileSync(routePath, "utf8");
|
|
const handler = readFileSync(handlerPath, "utf8");
|
|
const catalog = readFileSync(catalogPath, "utf8");
|
|
const component = readFileSync(componentPath, "utf8");
|
|
|
|
assert.doesNotMatch(`${route}\n${handler}\n${catalog}`, /secretRef|secret_ref/);
|
|
assert.doesNotMatch(component, /name="secretRef"/);
|
|
assert.match(route, /\(encrypted_api_key is not null\) secret_configured/);
|
|
assert.match(route, /secretConfigured:/);
|
|
assert.doesNotMatch(route, /encryptedApiKey|encrypted_api_key:\s*provider/);
|
|
assert.match(handler, /p\.encrypted_api_key/);
|
|
assert.match(catalog, /resolveSessionLanguageModel/);
|
|
});
|
|
|
|
test("mutation handler preserves an omitted key on edit and requires a key for enabled creates", async () => {
|
|
let createQueries = 0;
|
|
const missing = await handleAdminModelMutation({
|
|
action: "saveProvider",
|
|
name: "OpenAI",
|
|
providerType: "openai",
|
|
enabled: true,
|
|
reason: "create provider",
|
|
}, { actorUserId: actorId, requestId: "provider-missing-key" }, {
|
|
queryRows: async () => { createQueries += 1; return []; },
|
|
assertAllowedUrl: async () => undefined,
|
|
probeAllowed: async () => 200,
|
|
invalidateCatalog: () => undefined,
|
|
environment,
|
|
});
|
|
assert.equal(missing.status, 409);
|
|
assert.equal(createQueries, 0);
|
|
|
|
const calls: Array<{ sql: string; values?: readonly unknown[] }> = [];
|
|
let invalidations = 0;
|
|
const edited = await handleAdminModelMutation({
|
|
action: "saveProvider",
|
|
id: "40000000-0000-4000-8000-000000000001",
|
|
name: "OpenAI Renamed",
|
|
providerType: "openai",
|
|
apiKey: " ",
|
|
enabled: true,
|
|
reason: "rename provider",
|
|
}, { actorUserId: actorId, requestId: "provider-edit" }, {
|
|
queryRows: async (sql, values) => {
|
|
calls.push({ sql, values });
|
|
return [{ id: "40000000-0000-4000-8000-000000000001" }];
|
|
},
|
|
assertAllowedUrl: async () => undefined,
|
|
probeAllowed: async () => 200,
|
|
invalidateCatalog: () => { invalidations += 1; },
|
|
environment,
|
|
});
|
|
assert.equal(edited.status, 200);
|
|
assert.equal(invalidations, 1);
|
|
assert.match(calls[0]!.sql, /admin_save_model_provider\(\$1,\$2,\$3,\$4,\$5,\$6,\$7,\$8,\$9\)/);
|
|
assert.equal(calls[0]!.values?.[5], null);
|
|
});
|
|
|
|
test("mutation handler decrypts only the database ciphertext and records failed probes", async () => {
|
|
const encrypted = encryptModelProviderApiKey("database-provider-key", environment);
|
|
const calls: Array<{ sql: string; values?: readonly unknown[] }> = [];
|
|
let observedHeaders: Readonly<Record<string, string>> | undefined;
|
|
const failed = await handleAdminModelMutation({
|
|
action: "test",
|
|
versionId: "30000000-0000-4000-8000-000000000001",
|
|
}, { actorUserId: actorId, requestId: "test-unauthorized" }, {
|
|
queryRows: async (sql, values) => {
|
|
calls.push({ sql, values });
|
|
if (sql.includes("from public.model_config_versions")) {
|
|
return [{
|
|
id: "40000000-0000-4000-8000-000000000001",
|
|
code: "anthropic",
|
|
provider_type: "anthropic",
|
|
base_url: null,
|
|
encrypted_api_key: encrypted,
|
|
enabled: false,
|
|
version_id: "30000000-0000-4000-8000-000000000001",
|
|
version_enabled: false,
|
|
}];
|
|
}
|
|
return [{ id: "50000000-0000-4000-8000-000000000001" }];
|
|
},
|
|
assertAllowedUrl: async () => undefined,
|
|
probeAllowed: async (_url, headers) => { observedHeaders = headers; return 401; },
|
|
invalidateCatalog: () => undefined,
|
|
environment,
|
|
});
|
|
assert.equal(failed.status, 409);
|
|
assert.deepEqual(observedHeaders, {
|
|
"x-api-key": "database-provider-key",
|
|
"anthropic-version": "2023-06-01",
|
|
});
|
|
assert.match(calls[1]!.sql, /admin_record_model_connection_test/);
|
|
assert.deepEqual(calls[1]!.values, [actorId, "30000000-0000-4000-8000-000000000001", 401, "test-unauthorized"]);
|
|
});
|
|
|
|
test("database drops secret refs, generates immutable codes, and invalidates evidence after key rotation", async () => {
|
|
const fixture = startPostgresFixture();
|
|
const sql = (statement: string) => fixture.psql(statement);
|
|
const sqlAsOwner = (statement: string) => fixture.psqlAs("schema_owner", "schema-owner-test-password", statement);
|
|
const expectSqlError = (statement: string, pattern: RegExp) => assert.throws(() => sqlAsOwner(statement), pattern);
|
|
const sqlAsAdminRuntime = (statement: string) => fixture.psqlAs("admin_runtime", "admin-runtime-test-password", statement);
|
|
|
|
try {
|
|
await migrateModelConfigurationFixture(
|
|
fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
|
|
);
|
|
|
|
assert.equal(sql("select count(*) from information_schema.columns where table_schema='public' and table_name='model_providers' and column_name='secret_ref'"), "0");
|
|
assert.equal(sql("select to_regprocedure('public.expected_model_provider_secret_ref(text,text)') is null"), "t");
|
|
assert.equal(sql("select to_regprocedure('public.admin_save_model_provider(uuid,uuid,text,text,text,text,text,boolean,text,text)') is null"), "t");
|
|
assert.equal(sql("select to_regprocedure('public.admin_save_model_provider(uuid,uuid,text,text,text,text,boolean,text,text)') is not null"), "t");
|
|
|
|
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
|
|
insert into identity.users (id,name,email,email_verified,email_verified_at,role)
|
|
values ('${actorId}','Model Admin','model-admin@example.com',true,now(),'user')
|
|
`);
|
|
sql(`
|
|
insert into public.admin_users(user_id,created_by) values ('${actorId}','${actorId}');
|
|
insert into public.admin_user_roles(admin_user_id,role_id,assigned_by)
|
|
select '${actorId}',id,'${actorId}' from public.admin_roles where code='model_admin'
|
|
`);
|
|
|
|
expectSqlError(
|
|
`select public.admin_save_model_provider('${deniedActorId}',null,'Denied','openai',null,null,false,'no permission','denied-provider')`,
|
|
/admin_permission_denied/,
|
|
);
|
|
expectSqlError(
|
|
`select public.admin_save_model_provider('${actorId}',null,'Missing Key','openai',null,null,true,'missing key','missing-key')`,
|
|
/model_provider_key_required/,
|
|
);
|
|
|
|
const provider = sqlAsAdminRuntime(`select public.admin_save_model_provider('${actorId}',null,'OpenAI','openai',null,'ciphertext-v1',true,'create provider','provider-create')`);
|
|
assert.equal(sqlAsAdminRuntime(`select code from public.model_providers where id='${provider}'`), "openai");
|
|
const conflictingProvider = sql(`select public.admin_save_model_provider('${actorId}',null,'OpenAI','openai',null,'ciphertext-other',true,'create collision','provider-collision')`);
|
|
assert.equal(sql(`select code from public.model_providers where id='${provider}'`), "openai");
|
|
assert.equal(sql(`select code from public.model_providers where id='${conflictingProvider}'`), "openai-2");
|
|
|
|
const numericProvider = sql(`select public.admin_save_model_provider('${actorId}',null,'123 AI','openai',null,'ciphertext-v1',true,'create numeric provider','provider-numeric')`);
|
|
const numericCollision = sql(`select public.admin_save_model_provider('${actorId}',null,'123 AI','openai',null,'ciphertext-v1',true,'create numeric collision','provider-numeric-collision')`);
|
|
const oneLetterProvider = sql(`select public.admin_save_model_provider('${actorId}',null,'A','openai',null,'ciphertext-v1',true,'create one-letter provider','provider-one-letter')`);
|
|
const chineseProvider = sql(`select public.admin_save_model_provider('${actorId}',null,'纯中文供应商','anthropic',null,'ciphertext-v1',true,'create Chinese provider','provider-chinese')`);
|
|
assert.equal(sql(`select code from public.model_providers where id='${numericProvider}'`), "p-123-ai");
|
|
assert.equal(sql(`select code from public.model_providers where id='${numericCollision}'`), "p-123-ai-2");
|
|
assert.equal(sql(`select code from public.model_providers where id='${oneLetterProvider}'`), "a-p");
|
|
assert.equal(sql(`select code from public.model_providers where id='${chineseProvider}'`), "anthropic");
|
|
assert.equal(sql("select bool_and(code ~ '^[a-z][a-z0-9_-]{1,63}$') from public.model_providers"), "t");
|
|
|
|
const anthropic = sql(`select public.admin_save_model_provider('${actorId}',null,'Claude','anthropic','https://ignored.example/v1','anthropic-ciphertext',true,'create anthropic','provider-anthropic')`);
|
|
assert.equal(sql(`select provider_type||':'||coalesce(base_url,'native') from public.model_providers where id='${anthropic}'`), "anthropic:native");
|
|
|
|
sql(`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,true,'rename and preserve key','provider-edit')`);
|
|
assert.equal(sql(`select code||':'||encrypted_api_key from public.model_providers where id='${provider}'`), "openai:ciphertext-v1");
|
|
|
|
const versionOne = sql(`select public.admin_save_model_draft('${actorId}','default-model',null,'${provider}','Default v1','', 'model-v1','standard',1,64000,0,0,true,true,null,'{}','create v1','draft-v1')`);
|
|
sql(`select public.admin_record_model_connection_test('${actorId}','${versionOne}',200,'evidence-before-key-change')`);
|
|
assert.equal(sql(`select public.model_connection_test_is_fresh('${versionOne}',null)`), "t");
|
|
sql(`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,'ciphertext-v2',true,'rotate key','provider-rotate')`);
|
|
assert.equal(sql(`select public.model_connection_test_is_fresh('${versionOne}',null)`), "f");
|
|
sql(`select public.admin_record_model_connection_test('${actorId}','${versionOne}',200,'publish-v1')`);
|
|
sql(`select public.admin_publish_model('${actorId}','${versionOne}','publish v1','publish-v1')`);
|
|
assert.equal(sqlAsAdminRuntime(`select model_id from public.model_configs where model_id='default-model'`), "default-model");
|
|
assert.equal(sqlAsAdminRuntime(`select id from public.model_config_versions where id='${versionOne}'`), versionOne);
|
|
assert.equal(sqlAsAdminRuntime(`select to_version_id from public.model_publish_events where to_version_id='${versionOne}' and action='publish'`), versionOne);
|
|
assert.equal(sqlAsAdminRuntime(`select version_id from public.model_connection_test_evidence where version_id='${versionOne}' and request_id='publish-v1'`), versionOne);
|
|
for (const mutation of [
|
|
`update public.model_providers set code='mutated' where id='${provider}'`,
|
|
`update public.model_providers set provider_type='anthropic' where id='${provider}'`,
|
|
`update public.model_providers set base_url='https://changed.example/v1' where id='${provider}'`,
|
|
]) {
|
|
expectSqlError(mutation, /model_provider_runtime_immutable/);
|
|
}
|
|
|
|
sql(`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,false,'emergency disable','provider-disable')`);
|
|
assert.equal(sql(`select enabled::text from public.model_providers where id='${provider}'`), "f");
|
|
assert.equal(sql(`select public.model_connection_test_is_fresh('${versionOne}',null)`), "t");
|
|
assert.equal(sql(`select exists(select 1 from public.model_connection_test_evidence e join public.model_providers p on p.id=e.provider_id where e.provider_id='${provider}' and e.version_id='${versionOne}' and e.request_id='publish-v1' and e.tested_at<=p.updated_at)::text`), "true");
|
|
expectSqlError(
|
|
`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,'untested-ciphertext',true,'unsafe key and enable','provider-reenable-new-key')`,
|
|
/model_provider_reenable_test_required/,
|
|
);
|
|
expectSqlError(
|
|
`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,true,'unsafe re-enable with pre-disable evidence','provider-reenable-pre-disable-test')`,
|
|
/model_provider_reenable_test_required/,
|
|
);
|
|
sql(`select public.admin_record_model_connection_test('${actorId}','${versionOne}',200,'provider-reenable-test')`);
|
|
assert.equal(sql(`select exists(select 1 from public.model_connection_test_evidence e join public.model_providers p on p.id=e.provider_id where e.provider_id='${provider}' and e.version_id='${versionOne}' and e.request_id='provider-reenable-test' and e.tested_at>p.updated_at)::text`), "true");
|
|
sql(`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,true,'tested re-enable','provider-reenable-tested')`);
|
|
assert.equal(sql(`select enabled::text from public.model_providers where id='${provider}'`), "true");
|
|
|
|
const providerTwo = conflictingProvider;
|
|
const versionTwo = sql(`select public.admin_save_model_draft('${actorId}','default-model',null,'${providerTwo}','Default v2','', 'model-v2','standard',1,64000,0,0,true,true,null,'{}','create v2','draft-v2')`);
|
|
sql(`select public.admin_record_model_connection_test('${actorId}','${versionTwo}',204,'publish-v2')`);
|
|
sql(`select public.admin_publish_model('${actorId}','${versionTwo}','publish v2','publish-v2')`);
|
|
|
|
sql(`update public.model_providers set encrypted_api_key=null where id='${provider}'`);
|
|
const configId = sql("select id from public.model_configs where model_id='default-model'");
|
|
expectSqlError(
|
|
`select public.admin_rollback_model('${actorId}','${configId}',1,'rollback without database key','rollback-v1')`,
|
|
/model_provider_unavailable/,
|
|
);
|
|
sql(`update public.model_providers set encrypted_api_key='ciphertext-v3' where id='${provider}'`);
|
|
sql(`select public.admin_record_model_connection_test('${actorId}','${versionOne}',200,'rollback-v1')`);
|
|
sql(`select public.admin_rollback_model('${actorId}','${configId}',1,'rollback with database key','rollback-v1')`);
|
|
assert.equal(sql(`select version from public.model_config_versions where config_id='${configId}' and status='published'`), "1");
|
|
|
|
const noKeyProvider = sql(`select public.admin_save_model_provider('${actorId}',null,'No Key','openai',null,null,false,'create disabled','provider-no-key')`);
|
|
sql(`update public.model_providers set enabled=true where id='${noKeyProvider}'`);
|
|
const noKeyVersion = sql(`select public.admin_save_model_draft('${actorId}','no-key-model',null,'${noKeyProvider}','No key','', 'model','standard',1,64000,0,0,true,false,null,'{}','create no-key draft','draft-no-key')`);
|
|
sql(`select public.admin_record_model_connection_test('${actorId}','${noKeyVersion}',200,'publish-no-key')`);
|
|
expectSqlError(
|
|
`select public.admin_publish_model('${actorId}','${noKeyVersion}','publish no-key','publish-no-key')`,
|
|
/model_provider_unavailable/,
|
|
);
|
|
|
|
const audit = sql("select coalesce(string_agg(after_value::text,' '),'') from audit.admin_audit_logs where target_type='model_provider'");
|
|
assert.doesNotMatch(audit, /ciphertext-v1|ciphertext-v2|ciphertext-v3|anthropic-ciphertext|encrypted_api_key/i);
|
|
} finally {
|
|
fixture.stop();
|
|
}
|
|
});
|
|
|
|
test("forward migration removes secret-ref dependencies and hashes credential state into evidence", () => {
|
|
const migration = readFileSync(migrationPath, "utf8");
|
|
assert.match(migration, /drop trigger if exists model_providers_prevent_published_runtime_mutation/);
|
|
assert.match(migration, /drop function if exists public\.prevent_published_model_provider_runtime_mutation\(\)/);
|
|
assert.match(migration, /alter table public\.model_providers drop column if exists secret_ref/);
|
|
assert.match(migration, /drop function if exists public\.expected_model_provider_secret_ref\(text,text\)/);
|
|
assert.match(migration, /'providerCredentialHash',[\s\S]*digest\(convert_to\(p\.encrypted_api_key/);
|
|
assert.doesNotMatch(migration, /'providerSecretRef'|secretRef/);
|
|
assert.match(migration, /p\.encrypted_api_key is not null/);
|
|
assert.match(migration, /models\.write/);
|
|
assert.match(migration, /audit\.admin_audit_logs/);
|
|
});
|
|
|
|
|
|
test("emergency-disable migration keeps runtime identity immutable and gates re-enable on fresh evidence", () => {
|
|
const migration = readFileSync(emergencyDisableMigrationPath, "utf8");
|
|
assert.match(migration, /\(new\.code,new\.provider_type,new\.base_url\)/);
|
|
assert.doesNotMatch(migration, /\(new\.code,new\.provider_type,new\.base_url,new\.enabled\)/);
|
|
assert.match(migration, /old\.enabled and not new\.enabled/);
|
|
assert.match(migration, /new\.updated_at:=clock_timestamp\(\)/);
|
|
assert.match(migration, /not old\.enabled and new\.enabled/);
|
|
assert.match(migration, /e\.tested_at>old\.updated_at/);
|
|
assert.match(migration, /e\.http_status between 200 and 299/);
|
|
assert.match(migration, /e\.config_hash=public\.model_version_config_hash\(v\.id\)/);
|
|
assert.match(migration, /new\.encrypted_api_key is distinct from old\.encrypted_api_key/);
|
|
assert.match(migration, /model_provider_reenable_test_required/);
|
|
});
|