import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts"; // TASK-consult-gender-optional-20260927 T1. Fictional people only; no real // birth data or gender. Runs in the gate's DB job (needs Docker). const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); const migrationSql = readFileSync( new URL("../supabase/migrations/20260927010000_profile_gender.sql", import.meta.url), "utf8", ); const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0; const APP = ["app_runtime", "app-runtime-test-password"] as const; const SERVICE = ["service_runtime", "service-runtime-test-password"] as const; const ADMIN = ["admin_runtime", "admin-runtime-test-password"] as const; function applyMigrations(fixture: PostgresFixture): void { const result = spawnSync(process.execPath, [runner], { encoding: "utf8", env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }, }); assert.equal(result.status, 0, result.stderr || result.stdout); } function lastLine(value: string): string { const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); return lines[lines.length - 1] ?? ""; } function asUser(fixture: PostgresFixture, userId: string, sql: string): string { return lastLine(fixture.psqlAs(...APP, `set role authenticated; select set_config('request.jwt.claim.sub', '${userId}', true); ${sql}`)); } function createUser(fixture: PostgresFixture, label: string): string { const id = randomUUID(); fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at) values ('${id}', 'Fictional ${label}', '${id}@example.invalid', true, now());`); return id; } /** The column set the deployed (pre-gender) people route inserts. */ function legacySubjectInsert(id: string, owner: string, name: string): string { return `insert into public.chart_profiles (id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time, birth_time_source, latitude, longitude, timezone_id, timezone_offset, birth_place_label, ayanamsa, birth_time_status) values ('${id}', '${owner}', 'other', '{"name":"${name}"}'::jsonb, now(), '${name}', '1990-01-01', '08:00', 'family_exact', 22.3, 114.1, 'Asia/Shanghai', 8, '虚构港', 'raman', 'reported');`; } test("gender migration is additive: nullable, checked, owner-only, and legacy writes still work", { skip: docker ? false : "docker unavailable", }, () => { const fixture = startPostgresFixture(); try { assert.doesNotMatch(migrationSql, /drop column|alter column|rename|set not null|update public\./i); assert.doesNotMatch(migrationSql, /grant[^;]*gender[^;]*admin_runtime/i); applyMigrations(fixture); for (const table of ["profiles", "chart_profiles"]) { assert.equal( fixture.psql(`select is_nullable || '|' || coalesce(column_default, 'none') || '|' || data_type from information_schema.columns where table_schema = 'public' and table_name = '${table}' and column_name = 'gender'`), "YES|none|text", `${table}.gender must be a nullable text column without a default`, ); // Same privacy level as birth_date: no admin_runtime read, no anon read. assert.equal(fixture.psql(`select has_column_privilege('admin_runtime', 'public.${table}', 'gender', 'select')`), "f"); assert.equal(fixture.psql(`select has_column_privilege('anon', 'public.${table}', 'gender', 'select')`), "f"); assert.equal(fixture.psql(`select has_column_privilege('authenticated', 'public.${table}', 'gender', 'select')`), "t"); assert.equal(fixture.psql(`select has_column_privilege('authenticated', 'public.${table}', 'gender', 'update')`), "t"); } assert.equal(fixture.psql("select has_column_privilege('service_role', 'public.profiles', 'gender', 'update')"), "t"); assert.equal(fixture.psql("select has_column_privilege('service_role', 'public.profiles', 'gender', 'select')"), "t"); const owner = createUser(fixture, "gender owner"); const stranger = createUser(fixture, "gender stranger"); // Existing rows read as "not filled". assert.equal(fixture.psql(`select gender is null from public.profiles where id = '${owner}'`), "t"); // The deployed (pre-gender) people insert still succeeds and leaves it empty. const legacyId = randomUUID(); fixture.psqlAs(...APP, `set role authenticated; select set_config('request.jwt.claim.sub', '${owner}', true); ${legacySubjectInsert(legacyId, owner, "虚构甲")}`); assert.equal(fixture.psql(`select gender is null from public.chart_profiles where id = '${legacyId}'`), "t"); // CHECK: only female / male / null. assert.throws(() => fixture.psql(`update public.profiles set gender = 'other' where id = '${owner}'`), /profiles_gender_check/); assert.throws(() => fixture.psql(`update public.chart_profiles set gender = '女' where id = '${legacyId}'`), /chart_profiles_gender_check/); // Owner reads and writes their own; a stranger sees nothing and changes nothing. asUser(fixture, owner, `update public.profiles set gender = 'female' where id = '${owner}';`); assert.equal(asUser(fixture, owner, `select gender from public.profiles where id = '${owner}';`), "female"); assert.equal(asUser(fixture, stranger, `select count(*) from public.profiles where id = '${owner}';`), "0"); asUser(fixture, stranger, `update public.profiles set gender = 'male' where id = '${owner}';`); assert.equal(fixture.psql(`select gender from public.profiles where id = '${owner}'`), "female"); asUser(fixture, owner, `update public.chart_profiles set gender = 'male' where id = '${legacyId}';`); assert.equal(asUser(fixture, owner, `select gender from public.chart_profiles where id = '${legacyId}';`), "male"); assert.equal(asUser(fixture, stranger, `select count(*) from public.chart_profiles where id = '${legacyId}';`), "0"); asUser(fixture, stranger, `update public.chart_profiles set gender = 'female' where id = '${legacyId}';`); assert.equal(fixture.psql(`select gender from public.chart_profiles where id = '${legacyId}'`), "male"); // Clearing back to "not filled" is allowed. asUser(fixture, owner, `update public.chart_profiles set gender = null where id = '${legacyId}';`); assert.equal(fixture.psql(`select gender is null from public.chart_profiles where id = '${legacyId}'`), "t"); // The account PATCH path writes through service_role. fixture.psqlAs(...SERVICE, `set role service_role; update public.profiles set gender = 'male' where id = '${owner}';`); assert.equal(fixture.psql(`select gender from public.profiles where id = '${owner}'`), "male"); assert.throws( () => fixture.psqlAs(...ADMIN, `select gender from public.profiles where id = '${owner}';`), /permission denied/, ); } finally { fixture.stop(); } }); test("people and account routes read and write gender on real PostgreSQL without cross-person leaks", { skip: docker ? false : "docker unavailable", }, () => { const fixture = startPostgresFixture(); try { applyMigrations(fixture); const owner = createUser(fixture, "route owner"); const stranger = createUser(fixture, "route stranger"); const script = ` import { mock } from 'node:test'; import { createLocalPostgresDataClient, closeLocalPostgresDataPools } from './src/lib/db/local-postgres-client-core.ts'; const appUrl = ${JSON.stringify(fixture.connectionUrl(...APP))}; const serviceUrl = ${JSON.stringify(fixture.connectionUrl(...SERVICE))}; const owner = ${JSON.stringify(owner)}; const stranger = ${JSON.stringify(stranger)}; let current = owner; const clientFor = (id) => { const local = createLocalPostgresDataClient(appUrl, { id }); return { from: local.from.bind(local), rpc: local.rpc.bind(local), auth: { getUser: async () => ({ data: { user: { id, email: id + '@example.invalid' } }, error: null }) }, }; }; mock.module('server-only', { namedExports: {} }); mock.module('@/lib/supabase/server', { namedExports: { createServerSupabaseClient: async () => clientFor(current) } }); mock.module('@/lib/supabase/admin', { namedExports: { createAdminSupabaseClient: () => createLocalPostgresDataClient(serviceUrl, null, 'service_role'), isAdminUser: async () => false, isAdminEmail: () => false, } }); const people = await import('./src/app/api/chart-profiles/route.ts'); const person = await import('./src/app/api/chart-profiles/[id]/route.ts'); const account = await import('./src/app/api/account/route.ts'); const json = (method, body) => new Request('https://example.invalid/api', { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); const profile = (extra) => ({ name: '虚构乙', date: '1992-06-15', reportedTime: '07:40', birthTimeSource: 'family_exact', birthPlaceLabel: '虚构港', latitude: 22.3, longitude: 114.1, timezoneOffset: 8, timezoneId: 'Asia/Shanghai', ayanamsa: 'lahiri', ...extra, }); const out = {}; const created = await people.POST(json('POST', { role: 'other', profile: profile({ gender: 'female' }) })); const createdBody = await created.json(); out.createdStatus = created.status; out.createdGender = createdBody.profile?.profile?.gender ?? null; const id = createdBody.profile?.id; const params = { params: Promise.resolve({ id }) }; const kept = await person.PUT(json('PUT', { profile: profile({ name: '虚构乙改' }) }), params); out.keptGender = (await kept.json()).profile?.profile?.gender ?? null; const cleared = await person.PUT(json('PUT', { profile: profile({ gender: null }) }), params); out.clearedHasGender = 'gender' in ((await cleared.json()).profile?.profile ?? {}); const male = await person.PUT(json('PUT', { profile: profile({ gender: 'male' }) }), params); out.maleGender = (await male.json()).profile?.profile?.gender ?? null; const invalid = await person.PUT(json('PUT', { profile: profile({ gender: 'x' }) }), params); out.invalidStatus = invalid.status; const listed = await (await people.GET()).json(); out.listedGender = listed.profiles?.[0]?.profile?.gender ?? null; const patched = await account.PATCH(json('PATCH', { gender: 'female' })); out.patchStatus = patched.status; const badPatch = await account.PATCH(json('PATCH', { gender: 'other' })); out.badPatchStatus = badPatch.status; current = stranger; const foreign = await person.GET(new Request('https://example.invalid/api'), params); out.foreignStatus = foreign.status; const foreignList = await (await people.GET()).json(); out.foreignCount = foreignList.profiles?.length ?? -1; out.id = id; console.log(JSON.stringify(out)); await closeLocalPostgresDataPools(); `; const result = spawnSync( process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], { encoding: "utf8", env: { ...process.env, AUTH_PROVIDER: "self-hosted" } }, ); assert.equal(result.status, 0, result.stderr); const out = JSON.parse(result.stdout.trim().split("\n").at(-1) || "{}"); assert.equal(out.createdStatus, 200); assert.equal(out.createdGender, "female"); assert.equal(out.keptGender, "female", "a PUT without the gender key keeps the stored value"); assert.equal(out.clearedHasGender, false, "gender null clears it (not filled)"); assert.equal(out.maleGender, "male"); assert.equal(out.invalidStatus, 400); assert.equal(out.listedGender, "male"); assert.equal(out.patchStatus, 200); assert.equal(out.badPatchStatus, 400); assert.equal(out.foreignStatus, 404); assert.equal(out.foreignCount, 0); assert.equal(fixture.psql(`select gender from public.profiles where id = '${owner}'`), "female"); assert.equal(fixture.psql(`select gender from public.chart_profiles where id = '${out.id}'`), "male"); assert.equal(fixture.psql(`select gender is null from public.profiles where id = '${stranger}'`), "t"); } finally { fixture.stop(); } });