Files
Jyotisha/frontend/tests/model-configuration-security.test.ts
T
Jesse_Chen fc47939ba9
Staging Backend Quality Gate / validate (push) Successful in 14m16s
Staging Backend Quality Gate / publish (push) Successful in 1h2m51s
fix(admin): repair model discovery and simplify model setup
2026-08-08 17:29:17 +08:00

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/);
});