Files
Jyotisha/frontend/tests/model-configuration-security.test.ts
Jesse_Chen 9d8c73561f
Staging Backend Quality Gate / validate (push) Failing after 7m30s
Staging Backend Quality Gate / publish (push) Has been skipped
test(deploy): integrate billing admin rollout checks
2026-08-06 20:15:50 +08:00

381 lines
19 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 { runMigrations } from "../scripts/db-migrate.mjs";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const migrationPath = fileURLToPath(new URL("../supabase/migrations/20260806040000_model_configuration.sql", 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 sessionOneId = "20000000-0000-4000-8000-000000000001";
const sessionTwoId = "20000000-0000-4000-8000-000000000002";
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 > "20260806040000_model_configuration.sql") continue;
if (filename === "20260806030000_settle_order_usage_authorization.sql") 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 require a server-owned public allowlist", async () => {
await assertAllowedModelProviderUrl(
"https://models.example.com/v1",
{ MODEL_PROVIDER_BASE_URL_ALLOWLIST: "https://models.example.com" },
publicLookup,
);
await assert.rejects(
assertAllowedModelProviderUrl("https://attacker.example/v1", {}, publicLookup),
/允许列表/,
);
await assert.rejects(
assertAllowedModelProviderUrl(
"https://models.example.com/v1",
{ MODEL_PROVIDER_BASE_URL_ALLOWLIST: "https://models.example.com" },
async () => [{ address: "169.254.169.254", family: 4 }],
),
/内网|保留地址/,
);
await assert.rejects(
assertAllowedModelProviderUrl(
"https://service.internal/v1",
{ MODEL_PROVIDER_BASE_URL_ALLOWLIST: "https://service.internal" },
publicLookup,
),
/内部域名/,
);
});
test("admin and runtime source keep refs private and test exact model versions", () => {
const route = readFileSync(routePath, "utf8");
const catalog = readFileSync(catalogPath, "utf8");
const component = readFileSync(componentPath, "utf8");
assert.doesNotMatch(route, /secretRef:\s*provider\.secret_ref,\s*enabled:/);
assert.doesNotMatch(component, /name="secretRef"|providerId:\s*item\.id/);
assert.match(component, /action: "test", versionId: item\.id/);
assert.match(route, /sanitizeModelSettings\(row\.settings\)/);
assert.match(route, /requireHighRiskAdminMutation/);
assert.match(route, /handleAdminModelMutation/);
assert.match(catalog, /models\.database_catalog/);
assert.match(catalog, /models\.circuit_breaker/);
assert.match(catalog, /assertAllowedModelProviderUrl/);
assert.match(catalog, /resolveSessionLanguageModel/);
});
test("mutation handler rejects arbitrary env refs and persists failed connection evidence", async () => {
let queryCount = 0;
const rejected = await handleAdminModelMutation({
action: "saveProvider",
code: "openai",
name: "OpenAI",
providerType: "openai",
secretRef: "env:DATABASE_URL",
enabled: true,
reason: "unsafe ref",
}, { actorUserId: actorId, requestId: "provider-dangerous-ref" }, {
queryRows: async () => { queryCount += 1; return []; },
assertAllowedUrl: async () => undefined,
probeAllowed: async () => 200,
invalidateCatalog: () => undefined,
environment: { OPENAI_API_KEY: "model-key" },
});
assert.equal(rejected.status, 400);
assert.equal(queryCount, 0);
const calls: Array<{ sql: string; values?: readonly unknown[] }> = [];
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: "openai",
provider_type: "openai",
base_url: null,
secret_ref: "env:OPENAI_API_KEY",
enabled: true,
version_id: "30000000-0000-4000-8000-000000000001",
version_enabled: true,
}];
}
return [{ id: "50000000-0000-4000-8000-000000000001" }];
},
assertAllowedUrl: async () => undefined,
probeAllowed: async () => 401,
invalidateCatalog: () => undefined,
environment: { OPENAI_API_KEY: "model-key" },
});
assert.equal(failed.status, 409);
assert.equal(calls.length, 2);
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("provider mutation returns a clear immutable-version conflict", async () => {
const response = await handleAdminModelMutation({
action: "saveProvider",
id: "40000000-0000-4000-8000-000000000001",
code: "openai-next",
name: "OpenAI Next",
providerType: "openai",
enabled: true,
reason: "rotate provider",
}, { actorUserId: actorId, requestId: "provider-immutable" }, {
queryRows: async () => {
throw Object.assign(new Error("model_provider_runtime_immutable"), { code: "23514" });
},
assertAllowedUrl: async () => undefined,
probeAllowed: async () => 200,
invalidateCatalog: () => undefined,
environment: { OPENAI_API_KEY: "model-key" },
});
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), {
error: "已发布或已退役版本使用的供应商连接配置不可修改,请新建供应商和模型版本后重新测试并发布",
code: "model_provider_runtime_immutable",
});
});
test("rollback handler probes and records same-request evidence before changing publication", async () => {
const events: string[] = [];
const providerRow = {
id: "40000000-0000-4000-8000-000000000001",
code: "openai",
provider_type: "openai" as const,
base_url: null,
secret_ref: "env:OPENAI_API_KEY",
enabled: true,
version_id: "30000000-0000-4000-8000-000000000001",
version_enabled: true,
};
const dependencies = (status: number) => ({
queryRows: async (sql: string, values?: readonly unknown[]) => {
if (sql.includes("from public.model_config_versions")) {
events.push("lookup");
return [providerRow];
}
if (sql.includes("admin_record_model_connection_test")) {
events.push(`evidence:${String(values?.[3])}`);
return [{ id: "50000000-0000-4000-8000-000000000001" }];
}
if (sql.includes("admin_rollback_model")) {
events.push("rollback");
return [{ id: providerRow.version_id }];
}
return [];
},
assertAllowedUrl: async () => undefined,
probeAllowed: async () => { events.push(`probe:${status}`); return status; },
invalidateCatalog: () => events.push("invalidate"),
environment: { OPENAI_API_KEY: "model-key" },
});
const action = {
action: "rollback" as const,
configId: "60000000-0000-4000-8000-000000000001",
targetVersion: 1,
reason: "runtime rollback",
};
const failed = await handleAdminModelMutation(
action,
{ actorUserId: actorId, requestId: "rollback-runtime-check" },
dependencies(403),
);
assert.equal(failed.status, 409);
assert.deepEqual(events, ["lookup", "probe:403", "evidence:rollback-runtime-check"]);
events.length = 0;
const passed = await handleAdminModelMutation(
action,
{ actorUserId: actorId, requestId: "rollback-runtime-check" },
dependencies(200),
);
assert.equal(passed.status, 200);
assert.deepEqual(events, ["lookup", "probe:200", "evidence:rollback-runtime-check", "rollback", "invalidate"]);
});
test("database enforces fixed secrets, fresh evidence, rollback viability, and session version pinning", 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);
try {
await migrateModelConfigurationFixture(
fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
);
assert.equal(sql("select public.expected_model_provider_secret_ref('openai','openai')"), "env:OPENAI_API_KEY");
assert.equal(sql("select public.expected_model_provider_secret_ref('deepseek','openai-compatible')"), "env:DEEPSEEK_API_KEY");
assert.equal(sql("select public.expected_model_provider_secret_ref('trusted-edge','openai-compatible')"), "env:MODEL_PROVIDER_TRUSTED_EDGE_API_KEY");
assert.equal(sql("select public.model_provider_base_url_is_safe('https://api.example.com/v1')"), "t");
assert.equal(sql("select public.model_provider_base_url_is_safe('https://127.0.0.1/v1')"), "f");
assert.equal(sql("select public.model_provider_base_url_is_safe('https://metadata.google.internal/v1')"), "f");
assert.equal(sql("select public.model_settings_contain_secrets('{\"nested\":{\"authorization\":\"Bearer nope\"}}')"), "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'
`);
for (const [index, secretRef] of ["env:DATABASE_URL", "env:ADMIN_DATABASE_URL", "env:SUPABASE_SERVICE_ROLE_KEY", "env:RESEND_API_KEY", "env:EPAY_KEY"].entries()) {
expectSqlError(
`select public.admin_save_model_provider('${actorId}',null,'openai','OpenAI','openai',null,'${secretRef}',false,'reject dangerous ref','danger-${index}')`,
/model_provider_secret_ref_forbidden/,
);
}
expectSqlError(
`select public.admin_save_model_provider('${actorId}',null,'unsafe-provider','Unsafe','openai-compatible','https://127.0.0.1/v1','env:MODEL_PROVIDER_UNSAFE_PROVIDER_API_KEY',false,'reject unsafe','unsafe-provider')`,
/model_provider_url_unsafe/,
);
const provider = sql(`select public.admin_save_model_provider('${actorId}',null,'openai','OpenAI','openai',null,'env:OPENAI_API_KEY',true,'create provider','provider-create')`);
expectSqlError(
`select public.admin_save_model_draft('${actorId}','default-model',null,'${provider}','Default','', 'model-v1','standard',1,64000,0,0,false,true,null,'{}','disabled default','disabled-default')`,
/default_model_disabled/,
);
expectSqlError(
`select public.admin_save_model_draft('${actorId}','secret-model',null,'${provider}','Secret','', 'model-v1','standard',1,64000,0,0,true,false,null,'{\"nested\":{\"apiKey\":\"nope\"}}','secret settings','secret-settings')`,
/model_settings_secret_forbidden/,
);
const v1 = 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')`);
expectSqlError(
`select public.admin_publish_model('${actorId}','${v1}','untested publish','publish-untested')`,
/model_connection_test_required/,
);
sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',401,'test-v1-401')`);
expectSqlError(
`select public.admin_publish_model('${actorId}','${v1}','401 publish','publish-401')`,
/model_connection_test_required/,
);
sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',200,'test-v1-before-change')`);
sql(`update public.model_config_versions set label='Default v1 changed' where id='${v1}'`);
expectSqlError(
`select public.admin_publish_model('${actorId}','${v1}','changed config publish','publish-changed')`,
/model_connection_test_required/,
);
const expiringEvidence = sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',200,'test-v1-expired')`);
sql(`update public.model_connection_test_evidence set tested_at=clock_timestamp()-interval '20 minutes',expires_at=clock_timestamp()-interval '10 minutes' where id='${expiringEvidence}'`);
expectSqlError(
`select public.admin_publish_model('${actorId}','${v1}','expired publish','publish-expired')`,
/model_connection_test_required/,
);
sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',200,'test-v1-fresh')`);
sql(`select public.admin_save_model_provider('${actorId}','${provider}','openai','OpenAI renamed','openai',null,'env:OPENAI_API_KEY',true,'rename provider','provider-rename')`);
assert.equal(sql(`select public.model_connection_test_is_fresh('${v1}',null)`), "t");
sql(`select public.admin_publish_model('${actorId}','${v1}','publish v1','publish-v1')`);
sql(`insert into public.chat_sessions(id,user_id,title,theme,messages,model_id) values('${sessionOneId}','${actorId}','v1 session','general','[]','default-model')`);
assert.equal(sql(`select model_id||':'||model_config_version from public.chat_sessions where id='${sessionOneId}'`), "default-model:1");
for (const mutation of [
"code='openai-mutated'",
"provider_type='openai-compatible'",
"base_url='https://models.example.com/v1'",
"secret_ref='env:MODEL_PROVIDER_OPENAI_MUTATED_API_KEY'",
"enabled=false",
]) {
expectSqlError(
`update public.model_providers set ${mutation} where id='${provider}'`,
/model_provider_runtime_immutable/,
);
}
expectSqlError(
`select public.admin_save_model_provider('${actorId}','${provider}','openai','OpenAI','openai',null,'env:OPENAI_API_KEY',false,'disable published provider','provider-disable')`,
/model_provider_runtime_immutable/,
);
const providerV2 = sql(`select public.admin_save_model_provider('${actorId}',null,'openai-next','OpenAI Next','openai',null,'env:OPENAI_API_KEY',true,'create replacement provider','provider-v2')`);
const v2 = sql(`select public.admin_save_model_draft('${actorId}','default-model',null,'${providerV2}','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}','${v2}',403,'test-v2-403')`);
expectSqlError(
`select public.admin_publish_model('${actorId}','${v2}','403 publish','publish-v2-403')`,
/model_connection_test_required/,
);
sql(`select public.admin_record_model_connection_test('${actorId}','${v2}',204,'test-v2-fresh')`);
sql(`select public.admin_publish_model('${actorId}','${v2}','publish v2','publish-v2')`);
expectSqlError(
`update public.model_providers set enabled=false where id='${provider}'`,
/model_provider_runtime_immutable/,
);
sql(`update public.chat_sessions set messages='[{\"role\":\"user\",\"content\":\"hello\"}]',model_id='default-model',model_config_version=999 where id='${sessionOneId}'`);
assert.equal(sql(`select model_config_version from public.chat_sessions where id='${sessionOneId}'`), "1");
assert.equal(sql(`select p.code||':'||coalesce(p.base_url,'native') from public.chat_sessions s join public.model_configs c on c.model_id=s.model_id join public.model_config_versions v on v.config_id=c.id and v.version=s.model_config_version join public.model_providers p on p.id=v.provider_id where s.id='${sessionOneId}'`), "openai:native");
sql(`insert into public.chat_sessions(id,user_id,title,theme,messages,model_id) values('${sessionTwoId}','${actorId}','v2 session','general','[]','default-model')`);
assert.equal(sql(`select model_config_version from public.chat_sessions where id='${sessionTwoId}'`), "2");
assert.equal(sql(`select p.code from public.chat_sessions s join public.model_configs c on c.model_id=s.model_id join public.model_config_versions v on v.config_id=c.id and v.version=s.model_config_version join public.model_providers p on p.id=v.provider_id where s.id='${sessionTwoId}'`), "openai-next");
assert.equal(sql("select has_column_privilege('authenticated','public.chat_sessions','model_config_version','INSERT')"), "f");
assert.equal(sql("select has_column_privilege('authenticated','public.chat_sessions','model_config_version','UPDATE')"), "f");
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 same request evidence','rollback-v1')`,
/model_connection_test_required/,
);
sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',403,'rollback-v1')`);
expectSqlError(
`select public.admin_rollback_model('${actorId}','${configId}',1,'rollback with failed evidence','rollback-v1')`,
/model_connection_test_required/,
);
sql(`select public.admin_record_model_connection_test('${actorId}','${v1}',200,'rollback-v1')`);
sql(`select public.admin_rollback_model('${actorId}','${configId}',1,'rollback with fresh evidence','rollback-v1')`);
assert.equal(sql(`select version from public.model_config_versions where config_id='${configId}' and status='published'`), "1");
assert.equal(sql(`select model_config_version from public.chat_sessions where id='${sessionTwoId}'`), "2");
} finally {
fixture.stop();
}
});
test("migration contains database-enforced secret, evidence, and version-pin contracts", () => {
const migration = readFileSync(migrationPath, "utf8");
assert.match(migration, /expected_model_provider_secret_ref/);
assert.match(migration, /model_connection_test_evidence/);
assert.match(migration, /model_version_config_hash/);
assert.match(migration, /model_connection_test_is_fresh/);
assert.match(migration, /pin_chat_session_model_config_version/);
assert.match(migration, /model_connection_test_required/);
assert.match(migration, /model_provider_runtime_immutable/);
assert.match(migration, /where p\.id=v_draft\.provider_id[\s\S]*for update/);
});