Files
Jyotisha/frontend/tests/database-profile-gender.test.ts
T
Jesse_ChenandClaude Opus 5.5 371fe68b51 feat(profile): optional gender on the owner profile and every saved person (T1)
Additive migration: nullable profiles.gender / chart_profiles.gender with a
female/male CHECK, owner-only via existing table RLS, column grants mirroring
the neighbouring birth columns (service_role for the account PATCH, no
admin_runtime). Account PATCH/GET, people POST/PUT/GET, subject resolution
and the consult route carry each person's own value; a people write only
touches gender when the key is sent. DB test runs in the gate's DB job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 18:30:57 +08:00

223 lines
12 KiB
TypeScript

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