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