Files
HealthLog/e2e/setup/managed-profile-fixture.ts
T
Marc-André Bombeck 8793bbf229 fix: close four small gaps around the managed-profile surface
- bound profile creation at ten an hour per account, the ceiling its
  guardian sibling has carried since it shipped
- distinguish a failed roster read from a slow one, with the way back
- cover the three caller states the roster read has to refuse: an
  unaccepted invitation, an expired grant, and MANAGE held elsewhere
- cover what deletion does to a guardian standing inside the record
2026-08-07 03:56:39 +02:00

112 lines
4.2 KiB
TypeScript

/**
* v1.37.0 — the step-up the managed-profile journey needs, and nothing else.
*
* ## Why a fixture exists at all
*
* Every route in the managed-profile family resolves
* `requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS)`. That gate has two halves:
* the account must have a second factor enrolled, and the SESSION must carry a
* `mfaVerifiedAt` newer than five minutes. `global-setup.ts` supplies the first
* half once; the second cannot be supplied once, because a full suite run takes
* longer than the window and the journey would be gated on how long the specs
* ahead of it took.
*
* So the freshness stamp is refreshed immediately before the journey acts. It
* is not a bypass and it does not weaken the route: the route still runs its
* gate, the session still has to carry a stamp, and a session without one is
* still refused. What it simulates is the person having just proved their
* factor — which is the state the product requires and the only state in which
* this surface is usable at all.
*
* Seeded through `pg`, the same transport `global-setup.ts` and
* `vault-fixture.ts` use, because there is no product endpoint that says "I
* proved a factor" without a real factor to prove.
*/
import pg from "pg";
import { E2E_GUARDIAN } from "./global-setup";
function connect(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("[managed-profile-fixture] DATABASE_URL is not set");
}
return new pg.Pool({ connectionString: url });
}
/**
* Make the guardian's step-up fresh, and clear anything an earlier run left.
*
* Idempotent, and safe to call from a `beforeEach`: both statements are
* unconditional writes scoped to this one fixture account.
*/
export async function refreshGuardianStepUp(): Promise<void> {
const pool = connect();
try {
const stamped = await pool.query(
`UPDATE sessions SET mfa_verified_at = NOW()
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[E2E_GUARDIAN.username],
);
// A zero-row update means the jar this journey runs under points at a
// session that is not there, and every act below would be refused for a
// reason that has nothing to do with what it tests. Fail loudly here.
if ((stamped.rowCount ?? 0) === 0) {
throw new Error(
`[managed-profile-fixture] no session for ${E2E_GUARDIAN.username}`,
);
}
// And the factor itself, in case a spec ran before global setup stamped it.
await pool.query(
`UPDATE users SET totp_confirmed_at = COALESCE(totp_confirmed_at, NOW())
WHERE username = $1`,
[E2E_GUARDIAN.username],
);
// The guardian-invitation bucket, for the same reason `global-setup.ts`
// clears the auth bucket between its own logins: the ceiling is ten
// invitations an hour per caller (and ten creations), and this journey spends
// one of each every time it
// runs. On CI that never matters; locally, the eleventh run inside an hour
// is answered by the product's own 429 rather than by what the test is
// about, and the failure reads as a broken invitation form. Only this one
// fixture account's bucket is touched.
await pool.query(
`DELETE FROM rate_limits
WHERE key IN (
'managed-profile:guardian-invite:' ||
(SELECT id FROM users WHERE username = $1),
'managed-profile:create:' ||
(SELECT id FROM users WHERE username = $1)
)`,
[E2E_GUARDIAN.username],
);
} finally {
await pool.end();
}
}
/**
* Remove every managed profile this account looks after.
*
* A managed profile is a real account row, so a journey that created one and
* left it behind makes the next run's roster assertions read the wrong record.
* Deleting the profile takes its grants with it.
*/
export async function clearGuardianProfiles(): Promise<void> {
const pool = connect();
try {
await pool.query(
`DELETE FROM users
WHERE managed_profile_at IS NOT NULL
AND id IN (
SELECT g.grantor_id FROM account_grants g
JOIN users u ON u.id = g.grantee_id
WHERE u.username = $1
)`,
[E2E_GUARDIAN.username],
);
} finally {
await pool.end();
}
}