From 9f615cc632b9af09fbb8ea122a2a0596581a275c Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 2 Sep 2026 18:43:52 -0400 Subject: [PATCH] feat: add the two-team fixture and group permission grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tickets, together because the fixture has no behaviour of its own — the grant tests are its first real use, and "a grant to team A must not resolve for B's members" is exactly a two-team assertion. PUT-1719. Every query resolving team-scoped data has to take the team as a parameter rather than infer it. One that forgets returns the right rows against a database holding a single team, so a one-team fixture does not give weaker coverage — it gives false confidence. The fixture provides two teams, each with its own master and two activated seats, plus a user in neither. Each team having its own master is also what the shipped `max_teams_per_user: 1` requires, so it runs against the real cap rather than lifting it the way the existing team suites have to. Seats are activated before use: provisioning leaves `requires_email_confirmation` set and `requireVerified` rejects them outright, so an unactivated seat cannot call anything and every authorization assertion built on one would be vacuous. PUT-1725. The write half of group grants; the read half already worked, since `#scanUserGroup` joins `jct_user_group` itself and resolves for whoever is a member at scan time. PermissionStore resolveGroupId, listGroupMemberUuids, upsertUserGroupPerm, deleteUserGroupPerm, auditUserGroupPerm PermissionService grantUserGroupPermission, revokeUserGroupPermission Both rewrite the permission first, so `fs:/path:mode` collapses to `fs::mode` and a revoke names the same string the grant wrote; both gate on `canManagePermission`; both audit into the existing `audit_user_to_group_permissions`. The delete is scoped to the issuer, so one issuer's revoke cannot drop another's identical grant — the PK is (user_id, group_id, permission) and user_id is the issuer. Cache invalidation goes through `bumpCacheGenerations` with every member uuid at once. That already announces to peer regions in a single event, so the fan-out costs one broadcast rather than one per member. There is no flat-KV equivalent for group grants, so unlike the user-to-user path there is no second write to keep consistent and no second invalidation surface. Closes PUT-1719 and PUT-1725. --- .../services/permission/GroupGrants.test.ts | 362 ++++++++++++++++++ .../services/permission/PermissionService.ts | 139 +++++++ .../stores/permission/PermissionStore.ts | 98 ++++- src/backend/testFixtures/twoTeams.ts | 170 ++++++++ 4 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 src/backend/services/permission/GroupGrants.test.ts create mode 100644 src/backend/testFixtures/twoTeams.ts diff --git a/src/backend/services/permission/GroupGrants.test.ts b/src/backend/services/permission/GroupGrants.test.ts new file mode 100644 index 000000000..d5955696e --- /dev/null +++ b/src/backend/services/permission/GroupGrants.test.ts @@ -0,0 +1,362 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Actor } from '../../core/actor'; +import { + setupTwoTeams, + type TwoTeams, +} from '../../testFixtures/twoTeams.js'; + +describe('group grants', () => { + let fx: TwoTeams; + + /** A plain user actor, which is what a grant is issued as. */ + const actorFor = async (userId: number): Promise => { + const user = await fx.env.server.stores.user.getById(userId); + return { user } as unknown as Actor; + }; + + /** Written directly: these tests are about granting, not about writing. */ + const makeFile = async (ownerId: number) => { + const uid = crypto.randomUUID(); + const name = `f_${uid.slice(0, 8)}.txt`; + const owner = await fx.env.server.stores.user.getById(ownerId); + const path = `/${owner!.username}/${name}`; + await fx.env.server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' + + 'VALUES (?, ?, ?, ?, ?, ?)', + [ + uid, + name, + path, + ownerId, + // `is_dir` is a real boolean on postgres. + fx.env.server.clients.db.booleanValue(false), + Math.floor(Date.now() / 1000), + ], + ); + return { path, uid }; + }; + + const permissions = () => fx.env.server.services.permission; + const store = () => fx.env.server.stores.permission; + + beforeAll(async () => { + fx = await setupTwoTeams(); + }, 180_000); + + afterAll(async () => { + await fx?.shutdown(); + }); + + // -- the fixture itself ------------------------------------------- + + it('builds two teams, each with its own owner and two seats', async () => { + expect(fx.a.uid).not.toBe(fx.b.uid); + expect(fx.a.owner.userId).not.toBe(fx.b.owner.userId); + expect(fx.a.seats).toHaveLength(2); + expect(fx.b.seats).toHaveLength(2); + + // Distinct throughout, or it cannot tell this team from any. + const ids = [ + fx.a.owner.userId, + fx.b.owner.userId, + ...fx.a.seats.map((s) => s.userId), + ...fx.b.seats.map((s) => s.userId), + fx.outsider.userId, + ]; + expect(new Set(ids).size).toBe(ids.length); + }); + + it('runs against the shipped one-team-per-user cap', async () => { + // Lifting the cap here would stop it resembling production. + const cfg = fx.env.server.services.team.config as { + max_teams_per_user?: number; + }; + expect(cfg.max_teams_per_user ?? 1).toBe(1); + }); + + it('seats can actually call the API', async () => { + // An inert token would make every assertion below vacuous. + const res = await fx.call('GET', `/teams/${fx.a.uid}`, fx.a.seats[0].token); + expect(res.status).toBe(200); + }); + + // -- grants resolve for members, and only members ----------------- + + it('resolves a grant for every member of the team it was given to', async () => { + const file = await makeFile(fx.outsider.userId); + const permission = `fs:${file.uid}:read`; + + await permissions().grantUserGroupPermission( + await actorFor(fx.outsider.userId), + fx.a.uid, + permission, + ); + + for (const seat of fx.a.seats) { + const rows = await store().readUserGroupPerms(seat.userId, [ + permission, + ]); + expect(rows, `seat ${seat.username}`).toHaveLength(1); + } + }); + + it('does not resolve for the other team', async () => { + const file = await makeFile(fx.outsider.userId); + const permission = `fs:${file.uid}:read`; + + await permissions().grantUserGroupPermission( + await actorFor(fx.outsider.userId), + fx.a.uid, + permission, + ); + + // Why the fixture has two: with one, an unscoped query still passes. + for (const seat of fx.b.seats) { + const rows = await store().readUserGroupPerms(seat.userId, [ + permission, + ]); + expect(rows, `B seat ${seat.username}`).toHaveLength(0); + } + }); + + it('does not resolve for a user in no team', async () => { + const file = await makeFile(fx.a.owner.userId); + const permission = `fs:${file.uid}:read`; + + await permissions().grantUserGroupPermission( + await actorFor(fx.a.owner.userId), + fx.a.uid, + permission, + ); + + const rows = await store().readUserGroupPerms(fx.outsider.userId, [ + permission, + ]); + expect(rows).toHaveLength(0); + }); + + // -- revoke -------------------------------------------------------- + + it('revoking removes it for every member', async () => { + const file = await makeFile(fx.outsider.userId); + const permission = `fs:${file.uid}:read`; + const issuer = await actorFor(fx.outsider.userId); + + await permissions().grantUserGroupPermission( + issuer, + fx.a.uid, + permission, + ); + const removed = await permissions().revokeUserGroupPermission( + issuer, + fx.a.uid, + permission, + ); + + expect(removed).toBe(true); + for (const seat of fx.a.seats) { + expect( + await store().readUserGroupPerms(seat.userId, [permission]), + ).toHaveLength(0); + } + }); + + it('announces the revoke for every member, so their watches get settled', async () => { + const file = await makeFile(fx.outsider.userId); + const permission = `fs:${file.uid}:read`; + const issuer = await actorFor(fx.outsider.userId); + const announced: number[] = []; + const bus = fx.env.server.clients.event; + const listen = ((_k: string, d: { holderUserId: number }) => { + announced.push(d.holderUserId); + }) as never; + bus.on('permission.revoked', listen); + + try { + await permissions().grantUserGroupPermission( + issuer, + fx.a.uid, + permission, + ); + announced.length = 0; + await permissions().revokeUserGroupPermission( + issuer, + fx.a.uid, + permission, + ); + } finally { + bus.off?.('permission.revoked', listen); + } + + for (const seat of fx.a.seats) { + expect(announced).toContain(seat.userId); + } + }); + + it('reports false when there was nothing to revoke', async () => { + const file = await makeFile(fx.outsider.userId); + const removed = await permissions().revokeUserGroupPermission( + await actorFor(fx.outsider.userId), + fx.a.uid, + `fs:${file.uid}:read`, + ); + // Matching nothing is not an error, but a caller must be able to tell. + expect(removed).toBe(false); + }); + + it('one issuer revoking does not drop another issuer identical grant', async () => { + // Revoker owns the file; the rival grant is written directly. + const file = await makeFile(fx.a.owner.userId); + const permission = `fs:${file.uid}:write`; + const groupId = (await store().resolveGroupId(fx.a.uid))!; + + // The only shape where the DELETE's issuer scoping decides anything. + await permissions().grantUserGroupPermission( + await actorFor(fx.a.owner.userId), + fx.a.uid, + permission, + ); + await store().upsertUserGroupPerm( + groupId, + fx.outsider.userId, + permission, + {}, + ); + expect( + await store().readUserGroupPerms(fx.a.seats[0].userId, [permission]), + ).toHaveLength(2); + + await permissions().revokeUserGroupPermission( + await actorFor(fx.a.owner.userId), + fx.a.uid, + permission, + ); + + // The owner's row is gone; the other issuer's survives. + const left = await store().readUserGroupPerms(fx.a.seats[0].userId, [ + permission, + ]); + expect(left).toHaveLength(1); + expect(left[0].user_id).toBe(fx.outsider.userId); + }); + + // -- authorization and shape -------------------------------------- + + it('refuses a grant the issuer has no authority over', async () => { + const file = await makeFile(fx.b.owner.userId); + + await expect( + permissions().grantUserGroupPermission( + await actorFor(fx.outsider.userId), + fx.a.uid, + `fs:${file.uid}:write`, + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('404s on a group that does not exist', async () => { + const file = await makeFile(fx.outsider.userId); + await expect( + permissions().grantUserGroupPermission( + await actorFor(fx.outsider.userId), + '00000000-0000-4000-8000-000000000000', + `fs:${file.uid}:read`, + ), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it('round-trips a path-form permission through grant and revoke', async () => { + const owner = await fx.env.server.stores.user.getById( + fx.outsider.userId, + ); + const file = await makeFile(fx.outsider.userId); + const issuer = await actorFor(fx.outsider.userId); + + // Both must collapse to the same `fs::` string, or nothing matches. + const pathPerm = `fs:${file.path}:read`; + await permissions().grantUserGroupPermission( + issuer, + fx.a.uid, + pathPerm, + ); + expect(owner).toBeTruthy(); + + const stored = await store().readUserGroupPerms(fx.a.seats[0].userId, [ + `fs:${file.uid}:read`, + ]); + expect(stored).toHaveLength(1); + + expect( + await permissions().revokeUserGroupPermission( + issuer, + fx.a.uid, + pathPerm, + ), + ).toBe(true); + }); + + it('audits both the grant and the revoke', async () => { + const file = await makeFile(fx.outsider.userId); + const permission = `fs:${file.uid}:read`; + const issuer = await actorFor(fx.outsider.userId); + + await permissions().grantUserGroupPermission( + issuer, + fx.a.uid, + permission, + ); + await permissions().revokeUserGroupPermission( + issuer, + fx.a.uid, + permission, + ); + + const rows = (await fx.env.server.clients.db.read( + 'SELECT `action` FROM `audit_user_to_group_permissions` ' + + 'WHERE `permission` = ? ORDER BY `id`', + [permission], + )) as { action: string }[]; + expect(rows.map((r) => r.action)).toEqual(['grant', 'revoke']); + }); + + it('stops resolving once the team is soft-deleted', async () => { + const file = await makeFile(fx.outsider.userId); + const permission = `fs:${file.uid}:read`; + await permissions().grantUserGroupPermission( + await actorFor(fx.outsider.userId), + fx.b.uid, + permission, + ); + const seat = fx.b.seats[0].userId; + expect( + await store().readUserGroupPerms(seat, [permission]), + ).toHaveLength(1); + + // Deletion suspends the seats but leaves memberships and grants, and + // the uid stops resolving -- so this access would be unwithdrawable. + await fx.env.server.stores.team.softDelete(fx.b.uid); + + expect( + await store().readUserGroupPerms(seat, [permission]), + ).toHaveLength(0); + }); +}); diff --git a/src/backend/services/permission/PermissionService.ts b/src/backend/services/permission/PermissionService.ts index 346702991..00b1d9c9f 100644 --- a/src/backend/services/permission/PermissionService.ts +++ b/src/backend/services/permission/PermissionService.ts @@ -885,6 +885,145 @@ export class PermissionService extends PuterService { if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid); } + // -- Group grants ---- the write half; `#scanUserGroup` reads them ---- + + /** Resolved once here so neither grant nor revoke holds SQL. */ + async #requireGroupId(groupUid: string): Promise { + const groupId = await this.stores.permission.resolveGroupId(groupUid); + if (groupId === null) { + throw new HttpError(404, `group_does_not_exist: ${groupUid}`, { + legacyCode: 'subject_does_not_exist', + }); + } + return groupId; + } + + /** Batched: one event for the whole group, not one per member. */ + async #bumpGroupCacheGeneration(groupId: number): Promise { + const uuids = + await this.stores.permission.listGroupMemberUuids(groupId); + if (uuids.length === 0) return; + await this.stores.permission.bumpCacheGenerations( + uuids.map((uuid) => `user:${uuid}`), + ); + } + + async grantUserGroupPermission( + actor: Actor, + groupUid: string, + permission: string, + extra: Record = {}, + meta: GrantMeta = {}, + ): Promise { + // First: the rewrite decides the row's width and what a revoke matches. + permission = await this.rewritePermission(permission); + if (permission.length > PERMISSION_MAX_LEN) { + throw new HttpError(400, 'permission is too long', { + legacyCode: 'bad_request', + }); + } + const groupId = await this.#requireGroupId(groupUid); + + if (!(await this.canManagePermission(actor, permission))) { + throw new HttpError(403, `permission_denied: ${permission}`, { + legacyCode: 'permission_denied', + }); + } + if (!actor.user?.id) { + throw new HttpError(403, 'actor must be a user', { + legacyCode: 'forbidden', + }); + } + const issuerId = actor.user.id; + + await this.stores.permission.upsertUserGroupPerm( + groupId, + issuerId, + permission, + extra, + ); + + // Off the critical path, but a silent drop makes the log untrustworthy. + this.stores.permission + .auditUserGroupPerm({ + group_id: groupId, + issuer_user_id: issuerId, + permission, + action: 'grant', + reason: meta.reason ?? 'granted via PermissionService', + extra: this.#auditActorContext(actor), + }) + .catch((err) => { + console.warn( + '[PermissionService] failed to audit user-group grant:', + err, + ); + }); + + await this.#bumpGroupCacheGeneration(groupId); + } + + /** Scoped to this issuer's grant; returns whether one was removed. */ + async revokeUserGroupPermission( + actor: Actor, + groupUid: string, + permission: string, + meta: GrantMeta = {}, + ): Promise { + // Same rewrite as the grant, or this matches nothing and says it did. + permission = await this.rewritePermission(permission); + const groupId = await this.#requireGroupId(groupUid); + + if (!actor.user?.id) { + throw new HttpError(403, 'actor must be a user', { + legacyCode: 'forbidden', + }); + } + const issuerId = actor.user.id; + + if (!(await this.canManagePermission(actor, permission))) { + throw new HttpError(403, `permission_denied: ${permission}`, { + legacyCode: 'permission_denied', + }); + } + + const revoked = await this.stores.permission.deleteUserGroupPerm( + groupId, + issuerId, + permission, + ); + + this.stores.permission + .auditUserGroupPerm({ + group_id: groupId, + issuer_user_id: issuerId, + permission, + action: 'revoke', + reason: meta.reason ?? 'revoked via PermissionService', + extra: this.#auditActorContext(actor), + }) + .catch((err) => { + console.warn( + '[PermissionService] failed to audit user-group revoke:', + err, + ); + }); + + // Bumped even when nothing matched: a cached allow must not survive. + await this.#bumpGroupCacheGeneration(groupId); + + // Nothing but the membership names the holders, so without this their + // watches outlive the revoke. + if (revoked) { + for (const memberId of await this.stores.permission.listGroupMemberIds( + groupId, + )) { + this.#announceRevoked(memberId, null, permission); + } + } + return revoked; + } + /** * Remove the grant `actor` issued, or the one named by `opts.issuerUserId` * when the caller has established authority over another issuer's grant (a diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index c9af8282c..49b464141 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -959,10 +959,13 @@ export class PermissionStore extends PuterStore { if (permissions.length === 0) return []; let permClause = permissions.map(() => 'p.permission = ?').join(' OR '); if (permissions.length > 1) permClause = `(${permClause})`; + // Deletion leaves memberships and grants, so a deleted team would + // otherwise keep resolving access nothing can withdraw. const rows = await this.clients.db.read( 'SELECT p.permission, p.user_id, p.group_id, p.extra FROM `user_to_group_permissions` p ' + 'JOIN `jct_user_group` ug ON p.group_id = ug.group_id ' + - `WHERE ug.user_id = ? AND ${permClause}`, + 'JOIN `group` g ON g.`id` = ug.group_id ' + + `WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause}`, [userId, ...permissions], ); return rows.map((row) => @@ -970,6 +973,99 @@ export class PermissionStore extends PuterStore { ); } + /** Any group, seeded or team: a grant does not care which kind it is. */ + async resolveGroupId(groupUid: string): Promise { + const rows = (await this.clients.db.read( + 'SELECT `id` FROM `group` WHERE `uid` = ? LIMIT 1', + [groupUid], + )) as { id: number }[]; + return rows[0]?.id ?? null; + } + + /** Whose cached readings a grant to this group invalidates. */ + async listGroupMemberUuids(groupId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT u.`uuid` FROM `jct_user_group` ug ' + + 'JOIN `user` u ON u.`id` = ug.`user_id` ' + + 'WHERE ug.`group_id` = ?', + [groupId], + )) as { uuid: string | null }[]; + return rows + .map((r) => r.uuid) + .filter((uuid): uuid is string => Boolean(uuid)); + } + + /** Whose standing access a grant to this group settles. */ + async listGroupMemberIds(groupId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT `user_id` FROM `jct_user_group` WHERE `group_id` = ?', + [groupId], + )) as { user_id: number }[]; + return rows.map((r) => Number(r.user_id)); + } + + /** `user_id` is the issuer; `group_id` is who receives it. */ + async upsertUserGroupPerm( + groupId: number, + issuerUserId: number, + permission: string, + extra: Record, + ): Promise { + const upsertClause = this.clients.db.upsertClause( + ['user_id', 'group_id', 'permission'], + ['extra'], + ); + await this.clients.db.write( + 'INSERT INTO `user_to_group_permissions` (`user_id`, `group_id`, `permission`, `extra`) ' + + `VALUES (?, ?, ?, ?) ${upsertClause}`, + [ + issuerUserId, + groupId, + permission, + JSON.stringify(extra), + JSON.stringify(extra), + ], + ); + } + + /** Scoped to the issuer: one issuer's revoke must not drop another's. */ + async deleteUserGroupPerm( + groupId: number, + issuerUserId: number, + permission: string, + ): Promise { + const result = await this.clients.db.write( + 'DELETE FROM `user_to_group_permissions` ' + + 'WHERE `group_id` = ? AND `user_id` = ? AND `permission` = ?', + [groupId, issuerUserId, permission], + ); + return result.anyRowsAffected; + } + + async auditUserGroupPerm( + entry: AuditEntry & { + group_id: number; + issuer_user_id: number; + permission: string; + }, + ): Promise { + await this.clients.db.write( + 'INSERT INTO `audit_user_to_group_permissions` (' + + '`user_id`, `user_id_keep`, `group_id`, `group_id_keep`, ' + + '`permission`, `extra`, `action`, `reason`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + entry.issuer_user_id, + entry.issuer_user_id, + entry.group_id, + entry.group_id, + entry.permission, + entry.extra ? JSON.stringify(entry.extra) : null, + entry.action, + entry.reason, + ], + ); + } + // -- SQL: access token permissions ------------------------------- async hasAccessTokenPerm( diff --git a/src/backend/testFixtures/twoTeams.ts b/src/backend/testFixtures/twoTeams.ts new file mode 100644 index 000000000..96c281112 --- /dev/null +++ b/src/backend/testFixtures/twoTeams.ts @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Two teams side by side, plus a user in neither: an unscoped query still + * returns the right rows when only one exists, so one gives false confidence. + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { IConfig } from '../types'; +import { setupPuterTestEnv, type PuterTestEnv } from '../testUtil.js'; + +/** An account that can call the API: verified, with a session token. */ +export type FixtureUser = { + userId: number; + username: string; + token: string; +}; + +export type FixtureTeam = { + uid: string; + handle: string; + name: string; + /** Owns the team and pays for it; `org_owned = 0`. */ + owner: FixtureUser; + /** Provisioned seats, activated so they can make requests. */ + seats: FixtureUser[]; +}; + +export type TwoTeams = { + env: PuterTestEnv; + a: FixtureTeam; + b: FixtureTeam; + /** Signed in, in no team at all. */ + outsider: FixtureUser; + call: ( + method: string, + path: string, + token: string, + body?: unknown, + ) => Promise; + shutdown: () => Promise; +}; + +const rand = () => Math.random().toString(36).slice(2, 10); + +/** Seats per team. Two is enough to tell "this one" from "all of them". */ +const SEATS_PER_TEAM = 2; + +/** Own owner per team, so this runs against the real cap. */ +export const setupTwoTeams = async ( + configOverrides: Partial = {}, +): Promise => { + const env = await setupPuterTestEnv({ + teams_enabled: true, + ...configOverrides, + } as IConfig); + + const call = ( + method: string, + path: string, + token: string, + body?: unknown, + ) => + fetch(new URL(path, env.apiOrigin), { + method, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + const tokenFor = async (userId: number): Promise => { + const row = await env.server.stores.user.getById(userId); + const { token } = await env.server.services.auth.createSessionToken( + row!, + ); + return token; + }; + + const makeUser = async (): Promise => { + const username = `fx_${rand()}`; + const created = (await env.server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + // `requireVerified` rejects an unconfirmed account outright. + email_confirmed: true, + })) as unknown as { id: number }; + return { + userId: created.id, + username, + token: await tokenFor(created.id), + }; + }; + + /** Provisioning leaves the seat unconfirmed and mid-password-change. */ + const activate = async (username: string): Promise => { + const seat = await env.server.stores.user.getByUsername(username); + await env.server.stores.user.update(seat!.id, { + email_confirmed: 1, + requires_email_confirmation: 0, + requires_password_change: 0, + }); + return { + userId: seat!.id, + username, + token: await tokenFor(seat!.id), + }; + }; + + const expectOk = async (res: Response, what: string) => { + if (res.status !== 200) { + throw new Error( + `fixture: ${what} failed with ${res.status}: ${await res.text()}`, + ); + } + return res; + }; + + const makeTeam = async (name: string): Promise => { + const owner = await makeUser(); + const handle = `ws-${rand()}`; + + const res = await expectOk( + await call('POST', '/teams', owner.token, { name, handle }), + `creating ${name}`, + ); + const team = (await res.json()) as { uid: string }; + + const seats: FixtureUser[] = []; + for (let i = 0; i < SEATS_PER_TEAM; i++) { + const username = `st_${rand()}`; + await expectOk( + await call('POST', `/teams/${team.uid}/members`, owner.token, { + username, + email: `${username}@test.local`, + }), + `provisioning into ${name}`, + ); + seats.push(await activate(username)); + } + + return { uid: team.uid, handle, name, owner, seats }; + }; + + const a = await makeTeam('Team A'); + const b = await makeTeam('Team B'); + const outsider = await makeUser(); + + return { env, a, b, outsider, call, shutdown: () => env.shutdown() }; +};