Files
esign/packages/prisma/__tests__/iam-only-migration.test.ts
zooqueenandHanzo Dev 472b2a4643 Admit by one rule on every sign-in and every use of a credential
A person is admitted when IAM says they administer the app's org, or when
an ADMIN vouches for them: through a membership that came from that ADMIN's
invitation, or through such an invitation pending for their verified
address. The rule (isAdmitted, with vouches: an ADMIN who is not disabled)
decides every IAM sign-in, linked or new. A linked account that fails it is
refused and its sessions and API tokens are deleted.

Sessions and API tokens work only while their holder is linked to IAM, not
disabled and admitted (credentialIsCurrent over readStanding); a team
token's holder is the organisation's owner. Accepting an invitation needs
its sender to vouch for it now, and the membership records that sender as
the member's inviter.

The cutoff is the migration that ships with this: 1_iam_only_sign_in
deletes every session and API token and adds OrganisationMember.invitedById
beside OrganisationMemberInvite.inviterId, NULL for every row already
standing, so nothing from before it vouches for anyone. IAM_ONLY_SINCE goes.
IAM_PROVIDER names the one provider in lib.

The e2e fixture admits its users through one ADMIN voucher. Tests cover the
decision for returning and first sign-ins, vouching, credentials, and the
migration against real SQLite.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 06:51:56 -07:00

92 lines
3.7 KiB
TypeScript

/**
* Migration 1_iam_only_sign_in is the cutoff for IAM-only sign-in.
*
* It deletes every session and API token — all issued under the old sign-in
* rules — and adds the columns that record who sent an invitation and who
* invited a member, NULL for every row already standing, so nothing from before
* it vouches for anyone. These tests run the REAL migrations (0_init, then this
* one) against a real SQLite database that holds rows from before it.
*
* Run: npx tsx --test packages/prisma/__tests__/iam-only-migration.test.ts
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import assert from 'node:assert/strict';
import { before, describe, test } from 'node:test';
const migration = (name: string) =>
readFileSync(path.join(__dirname, '..', 'migrations', name, 'migration.sql'), 'utf8');
const count = (db: DatabaseSync, table: string) =>
Number((db.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get() as { n: number }).n);
let db: DatabaseSync;
before(() => {
db = new DatabaseSync(':memory:');
db.exec('PRAGMA foreign_keys=OFF'); // seed the rows below without their whole graph
db.exec(migration('0_init'));
db.prepare('INSERT INTO "User" (id, email, roles) VALUES (?, ?, ?)').run(1, '[email protected]', '["USER"]');
db.prepare('INSERT INTO "User" (id, email, roles) VALUES (?, ?, ?)').run(2, '[email protected]', '["USER"]');
db.prepare(
'INSERT INTO "Session" (id, sessionToken, userId, expiresAt, updatedAt) VALUES (?, ?, ?, ?, ?)',
).run('s1', 's1', 1, Date.now() + 86_400_000, Date.now());
db.prepare('INSERT INTO "ApiToken" (name, token, userId, teamId) VALUES (?, ?, ?, ?)').run(
'ci',
'hash',
1,
1,
);
db.prepare(
'INSERT INTO "OrganisationMemberInvite" (id, email, token, organisationId, organisationRole) VALUES (?, ?, ?, ?, ?)',
).run('inv1', '[email protected]', 'tok', 'org1', 'MEMBER');
db.prepare(
'INSERT INTO "OrganisationMember" (id, userId, organisationId, updatedAt) VALUES (?, ?, ?, ?)',
).run('mem1', 2, 'org1', Date.now());
db.exec(migration('1_iam_only_sign_in'));
db.exec('PRAGMA foreign_keys=ON');
});
describe('1_iam_only_sign_in', () => {
test('deletes every session', () => {
assert.equal(count(db, 'Session'), 0);
});
test('deletes every API token', () => {
assert.equal(count(db, 'ApiToken'), 0);
});
test('keeps the people, invitations and memberships themselves', () => {
assert.equal(count(db, 'User'), 2);
assert.equal(count(db, 'OrganisationMemberInvite'), 1);
assert.equal(count(db, 'OrganisationMember'), 1);
});
test('an invitation from before it has no sender', () => {
const row = db.prepare('SELECT inviterId FROM "OrganisationMemberInvite" WHERE id = ?').get('inv1');
assert.deepEqual({ ...(row as object) }, { inviterId: null });
});
test('a membership from before it has no inviter', () => {
const row = db.prepare('SELECT invitedById FROM "OrganisationMember" WHERE id = ?').get('mem1');
assert.deepEqual({ ...(row as object) }, { invitedById: null });
});
test('deleting a sender clears what they vouched for rather than the rows', () => {
db.prepare('UPDATE "OrganisationMemberInvite" SET inviterId = 1 WHERE id = ?').run('inv1');
db.prepare('UPDATE "OrganisationMember" SET invitedById = 1 WHERE id = ?').run('mem1');
db.prepare('DELETE FROM "User" WHERE id = ?').run(1);
const invite = db.prepare('SELECT inviterId FROM "OrganisationMemberInvite" WHERE id = ?').get('inv1');
const member = db.prepare('SELECT invitedById FROM "OrganisationMember" WHERE id = ?').get('mem1');
assert.deepEqual({ ...(invite as object) }, { inviterId: null });
assert.deepEqual({ ...(member as object) }, { invitedById: null });
});
});