test: stub core's seat rules, rehome the rest with the feature

A core suite can no longer build a team, so the rules that refuse a seat get
`testFixtures/seatStub.ts` instead: it answers the contract, which is all
those rules read, and puts the registry back when the test ends.

Seventeen cases move that way. Four could not: group shares and a seat's 2FA
enrolment over HTTP need the real feature and the `/teams` route, so they go
with it, as does the whole of `GroupGrants.test.ts`.

Two whoami cases asserted a seat's team through `extension.import`, which
resolves the registry rather than the stores object. They now assert the
absent case, and the behaviour they covered is asserted against
`buildUserDetails`, which takes its stores as an argument.

Suite: 12252 passed, 38 skipped.
This commit is contained in:
Juan Castro committed 2026-10-18 12:26:10 -04:00
1 parent c6685eef5f
commit d5bb8ca0de
11 files changed
+296 -774

No files matched your search

+13 -24
View File
@@ -1,13 +1,7 @@
import type { Request, Response } from 'express';
import { installSeatStub } from '@heyputer/backend/testFixtures/seatStub';
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
import { runWithContext } from '../src/backend/core/context.ts';
import { configContainer } from '../src/backend/exports.ts';
@@ -83,21 +77,20 @@ describe('whoami extension — handleWhoami', () => {
};
const seatOf = async (teamName: string) => {
const owner = await seedUser();
const seat = await seedSeat();
const team = await server.stores.team.create({
ownerUserId: owner.id as number,
const team = {
uid: `wt-${Math.random().toString(36).slice(2, 9)}`,
name: teamName,
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
});
await server.stores.team.addMember(team.uid, seat.id as number, {
orgOwned: true,
};
installSeatStub(server as never, [seat.id as number], {
teamUid: team.uid,
teamName: team.name,
});
return { seat, team };
};
it('names the team for a seat', async () => {
const { seat, team } = await seatOf('Acme Corp');
it('names no team where teams is not installed', async () => {
const { seat } = await seatOf('Acme Corp');
const { res, captured } = makeRes();
await runWithContext(
@@ -105,10 +98,8 @@ describe('whoami extension — handleWhoami', () => {
() => handleWhoami(makeReq(), res),
);
expect((captured.body as { team?: unknown }).team).toEqual({
uid: team.uid,
name: 'Acme Corp',
});
// `extension.import` resolves the registry, which has no team.
expect((captured.body as { team?: unknown }).team).toBeUndefined();
});
it('says nothing for an account that is not a seat', async () => {
@@ -526,9 +517,7 @@ describe('whoami extension — handleWhoami', () => {
}),
]) {
const { res, captured } = makeRes();
await runWithContext({ actor }, () =>
handleWhoami(makeReq(), res),
);
await runWithContext({ actor }, () => handleWhoami(makeReq(), res));
const body = captured.body as Record<string, unknown>;
expect(body.phone).toBeUndefined();
@@ -561,56 +561,6 @@ describe('2FA enrollment over HTTP', () => {
expect((await otpState(account.userId)).enabled).toBe(true);
});
it('lets a seat its team holds for 2FA enroll through the same gate', async () => {
const owner = await makeAccount();
// The rule can only be turned on by an owner who has 2FA.
await env.server.stores.user.update(owner.userId, { otp_enabled: 1 });
await env.server.stores.user.invalidateById(owner.userId);
const team = await env.server.services.team.createTeam(owner.userId, {
name: 'Acme',
});
const seatName = `seat_${uniq()}`;
const { userId, temporaryPassword } =
await env.server.services.team.provisionAccount(
team.uid,
owner.userId,
{ username: seatName },
);
await env.server.services.team.updateTeam(team.uid, owner.userId, {
require2fa: true,
});
const { token } = await env.server.services.auth.createSessionToken(
(await env.server.stores.user.getById(userId))!,
);
// First sign-in replaces the temporary password.
const changed = await protectedPost(
'/user-protected/change-password',
token,
{ password: temporaryPassword, new_pass: 'seat-own-password' },
);
expect(changed.status).toBe(200);
const held = await fetch(new URL('/teams', env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
expect(held.status).toBe(403);
expect(await held.text()).toContain('two_factor_required');
const res = await setup(token, { password: 'seat-own-password' });
expect(res.status).toBe(200);
const { secret } = (await res.json()) as { secret: string };
const enabled = await configure(token, 'enable', {
code: liveTotp(seatName, secret),
});
expect(enabled.status).toBe(200);
expect((await otpState(userId)).enabled).toBe(true);
const released = await fetch(new URL('/teams', env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
expect(released.status).toBe(200);
});
});
/**
@@ -28,6 +28,7 @@
* play along. Aligns with AGENTS.md: "Prefer test server over mocking deps."
*/
import { installSeatStub } from '../../testFixtures/seatStub.js';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { TOTP } from 'otpauth';
@@ -6314,29 +6315,25 @@ describe('AuthController password recovery', () => {
it('send-pass-recovery-email: refuses a team seat, and writes no token', async () => {
// The seat's address is admin-supplied and unverified; recovery there
// would be a takeover channel. Its recovery is the admin's reset.
const { user: owner } = await makeUserAndActor();
const { user: seat } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
const res = makeRes();
await controller.handleSendPassRecoveryEmail(
makeReq({ username: seat.username }),
res,
);
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeFalsy();
const restore = installSeatStub(server as never, [seat.id]);
try {
const res = makeRes();
await controller.handleSendPassRecoveryEmail(
makeReq({ username: seat.username }),
res,
);
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeFalsy();
} finally {
restore();
}
});
// `system` is seeded by migration; `admin` only by the self-hosted boot.
@@ -6725,16 +6722,9 @@ describe('AuthController user-protected mutations (validation paths)', () => {
});
it('change-email: 403 for an account its team provisioned', async () => {
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
await expect(
controller.handleChangeEmail(
@@ -6750,17 +6740,9 @@ describe('AuthController user-protected mutations (validation paths)', () => {
it('change-username: 403 for an account its team provisioned', async () => {
// The console lists members by username and the audit log records them
// by it; a self-service rename would desync both.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
// addMember refuses to adopt an account that has one.
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
await expect(
controller.handleChangeUsername(
@@ -8154,15 +8136,10 @@ describe('AuthController 2FA flows', () => {
it('lets a team seat enable 2FA without a confirmed email', async () => {
// A seat has no address by design (PUT-1792) while its team can
// require 2FA, so this check would bar it forever.
const owner = await makeUserAndActor();
const team = await server.services.team.createTeam(owner.user.id, {
name: 'Acme',
});
const seatName = `s_${uniq()}`;
await server.services.team.provisionAccount(team.uid, owner.user.id, {
username: seatName,
});
const seat = await server.stores.user.getByUsername(seatName);
const { user: made } = await makeUserAndActor({ email_confirmed: 0 });
await server.stores.user.update(made.id, { password: null });
const seat = await server.stores.user.getById(made.id, { force: true });
installSeatStub(server as never, [seat!.id]);
const actor = {
user: {
id: seat!.id,
@@ -8183,15 +8160,10 @@ describe('AuthController 2FA flows', () => {
});
it('refuses to disable 2FA while the team requires it', async () => {
const owner = await makeUserAndActor({ otp_enabled: 1 });
const team = await server.services.team.createTeam(owner.user.id, {
name: 'Acme',
});
const seatName = `s_${uniq()}`;
await server.services.team.provisionAccount(team.uid, owner.user.id, {
username: seatName,
});
const seat = await server.stores.user.getByUsername(seatName);
const { user: made } = await makeUserAndActor();
await server.stores.user.update(made.id, { password: null });
const seat = await server.stores.user.getById(made.id, { force: true });
installSeatStub(server as never, [seat!.id], { require2fa: true });
const actor = {
user: {
id: seat!.id,
@@ -8202,9 +8174,6 @@ describe('AuthController 2FA flows', () => {
} as Actor;
const { secret } = await setup2fa(actor);
await enable2fa(actor, { code: liveTotp(seat!.username, secret) });
await server.services.team.updateTeam(team.uid, owner.user.id, {
require2fa: true,
});
await expect(
controller.handleDisable2fa(makeReq({}, { actor }), makeRes()),
@@ -8495,16 +8464,9 @@ describe('AuthController.handleDeleteOwnUser', () => {
it('refuses, and keeps the row, for an account its team provisioned', async () => {
// The team is billed for the seat and closing it is theirs to do, from
// the console that keeps the audit trail.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
await expect(
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
@@ -10334,17 +10296,9 @@ describe('AuthController — app-data grants', () => {
describe('AuthController — a seat address nobody confirmed', () => {
/** A seat whose address its team typed in; unconfirmed. */
const makeSeat = async () => {
const { user: owner } = await makeUserAndActor();
const { user: seat } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
// A seat is created password-less and given a temporary one after.
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
await server.stores.user.update(seat.id, {
password: await bcrypt.hash('temporary-password', 8),
});
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { installSeatStub } from '../../testFixtures/seatStub.js';
import type { Request, RequestHandler, Response } from 'express';
import {
afterAll,
@@ -233,20 +234,14 @@ const callRoute = async (
);
};
const oidc = (): OIDCService =>
server.services.oidc as unknown as OIDCService;
const oidc = (): OIDCService => server.services.oidc as unknown as OIDCService;
// ── GET /auth/oidc/providers ────────────────────────────────────────
describe('OIDCController GET /auth/oidc/providers', () => {
it('lists every provider whose config validates', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/providers',
makeReq({}),
res,
);
await callRoute('get', '/auth/oidc/providers', makeReq({}), res);
// We configured a single `custom` provider; the live service
// walks the config and returns its id.
expect(captured.body).toEqual({ providers: ['custom'] });
@@ -417,9 +412,9 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
}),
res,
);
const state = new URL(
captured.redirectUrl ?? '',
).searchParams.get('state');
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const decoded = oidc().verifyState(state!);
expect(String(decoded?.redirect_uri)).toBe(TEST_ORIGIN);
}
@@ -624,10 +619,7 @@ describe('OIDCController login callback', () => {
// The real OIDCService linked the new user — verify via the
// live store.
const linkedUser = await oidc().findUserByProviderSub(
'custom',
sub,
);
const linkedUser = await oidc().findUserByProviderSub('custom', sub);
expect(linkedUser).not.toBeNull();
expect(linkedUser?.email).toBe(email);
});
@@ -791,14 +783,12 @@ describe('OIDCController login callback', () => {
// Seed by running the linked-sub path first via createUserFromOIDC.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
const firstUserId = created.user!.id;
@@ -838,14 +828,12 @@ describe('OIDCController login callback', () => {
it('redirects with auth_error when the user is suspended', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
// Flip the suspended bit on the existing row.
@@ -980,14 +968,12 @@ describe('OIDCController login callback', () => {
it('keeps an /app/<name> landing in the error redirect (suspended user)', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-app-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
@@ -1075,14 +1061,12 @@ describe('OIDCController login callback', () => {
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `pop-sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
@@ -1232,13 +1216,7 @@ describe('OIDCController login callback', () => {
email,
free_storage: 100 * 1024 * 1024,
});
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
// Provisioning issues a temporary password once the seat exists.
await server.stores.user.update(seat.id, { password: 'hashed' });
return seat;
@@ -1334,14 +1312,12 @@ describe('OIDCController signup veto (abuse harness)', () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
vetoWithTrail(email, 'trail-svc-1');
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
email,
email_verified: true,
}),
);
expect(created.success).toBe(false);
expect(created.code).toBe('signup_blocked');
@@ -1676,14 +1652,12 @@ describe('OIDCController signup callback', () => {
// resolves to `linked-sub` rather than creating a new user.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `existing-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
@@ -1755,14 +1729,12 @@ describe('OIDCController signup callback', () => {
it('redirects suspended users to action=signup with message=account_suspended', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
@@ -1899,14 +1871,12 @@ describe('OIDCController revalidate callback', () => {
// The linked account exists, but state.user_uuid points at someone else.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
@@ -1938,14 +1908,12 @@ describe('OIDCController revalidate callback', () => {
it('happy path: sets the revalidation cookie and redirects to redirect-done', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
const userUuid = created.user!.uuid;
@@ -1992,14 +1960,12 @@ describe('OIDCController revalidate callback', () => {
it('falls back to /auth/revalidate-done when state.redirect_uri is cross-origin', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
const userUuid = created.user!.uuid;
@@ -2038,12 +2004,7 @@ describe('OIDCController revalidate callback', () => {
describe('OIDCController GET /auth/revalidate-done', () => {
it('renders the postMessage HTML body with the configured origin', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/revalidate-done',
makeReq({}),
res,
);
await callRoute('get', '/auth/revalidate-done', makeReq({}), res);
expect(captured.contentType).toContain('text/html');
const body = String(captured.body);
expect(body).toContain('puter-revalidate-done');
@@ -2057,9 +2018,9 @@ describe('OIDCController GET /auth/revalidate-done', () => {
/**
* The proof exists because the popup return leg states two things the popup
* cannot check — the opener's origin and that a login completed — and a URL
* built from a verified `state` is byte-identical to one anybody can type.
* The opener's origin picks the app a token is minted for, so it has to be
* attested rather than read.
* built from a verified `state` is byte-identical to one anybody can type. The
* opener's origin picks the app a token is minted for, so it has to be attested
* rather than read.
*/
describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
const NONCE = 'binding-nonce';
@@ -1,362 +0,0 @@
/**
* 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 <https://www.gnu.org/licenses/>.
*/
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<Actor> => {
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:<uuid>:` 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);
});
});
@@ -1867,57 +1867,6 @@ describe('ShareService', () => {
).toBe(delegate.user.username);
});
it('hides a team share once the delegate loses the team that made them one', async () => {
const owner = await makeUser();
const delegate = await makeUser();
const third = await makeUser();
const file = await makeFile(owner.user);
const viaTeam = await server.stores.team.create({
ownerUserId: owner.user.id,
name: `t-${uuidv4().slice(0, 8)}`,
});
const audience = await server.stores.team.create({
ownerUserId: delegate.user.id,
name: `t-${uuidv4().slice(0, 8)}`,
});
for (const [team, members] of [
[viaTeam, [owner, delegate]],
[audience, [delegate, third]],
] as const) {
for (const each of members) {
await server.stores.team.addMember(team.uid, each.user.id, {
orgOwned: false,
});
}
}
await share(owner.actor, {
uid: file.uuid,
recipient: { team: viaTeam.uid },
mode: 'manage',
});
await share(delegate.actor, {
uid: file.uuid,
recipient: { team: audience.uid },
mode: 'read',
});
const holderTeams = async (actor: Actor) =>
(await listSharedByMe(actor)).items.map(
(i) => i.holderTeam?.uid,
);
// Asked of the owner first: a delegate's own reading would sit in
// the permission cache past the team's deletion.
expect(await holderTeams(owner.actor)).toContain(audience.uid);
await server.services.team.deleteTeam(viaTeam.uid, owner.user.id);
expect(await holderTeams(delegate.actor)).not.toContain(
audience.uid,
);
expect(await holderTeams(owner.actor)).toContain(audience.uid);
});
it('reads the app off an invite recorded under the legacy key', async () => {
const owner = await makeUser();
const file = await makeFile(owner.user);
@@ -3131,52 +3080,6 @@ describe('ShareService', () => {
expect(await canRead(delegate.actor, file.path)).toBe(true);
});
it('leaves a member’s re-shares alone when their manage is not the team’s', async () => {
const owner = await makeUser();
const member = await makeUser();
const bystander = await makeUser();
const third = await makeUser();
const file = await makeFile(owner.user);
const team = await server.stores.team.create({
ownerUserId: owner.user.id,
name: `t-${uuidv4().slice(0, 8)}`,
});
for (const each of [owner, member, bystander]) {
await server.stores.team.addMember(team.uid, each.user.id, {
orgOwned: false,
});
}
// The member manages the file in their own right; the team grant is
// plain read, and is all `bystander` has.
await share(owner.actor, {
uid: file.uuid,
recipient: { username: member.user.username },
mode: 'manage',
});
await share(owner.actor, {
uid: file.uuid,
recipient: { team: team.uid },
mode: 'read',
});
await share(member.actor, {
uid: file.uuid,
recipient: { username: third.user.username },
mode: 'read',
});
expect(await canRead(bystander.actor, file.path)).toBe(true);
await unshare(owner.actor, {
uid: file.uuid,
recipient: { team: team.uid },
});
expect(await canRead(bystander.actor, file.path)).toBe(false);
expect(await canRead(member.actor, file.path)).toBe(true);
expect(await canRead(third.actor, file.path)).toBe(true);
});
it('lets the owner clear a grant a delegate issued', async () => {
const owner = await makeUser();
const delegate = await makeUser();
@@ -5144,44 +5047,6 @@ describe('ShareService', () => {
);
});
it('retires a team share on a descendant when the folder changes owner', async () => {
const attacker = await makeUser();
const victim = await makeUser();
const member = await makeUser();
const { dir, file } = await makeDirWithFile(attacker.user);
const team = await server.stores.team.create({
ownerUserId: attacker.user.id,
name: `t-${Math.random().toString(36).slice(2, 9)}`,
});
await server.stores.team.addMember(team.uid, attacker.user.id, {
orgOwned: false,
});
await server.stores.team.addMember(team.uid, member.user.id, {
orgOwned: false,
});
await share(attacker.actor, {
uid: file.uuid,
recipient: { team: team.uid },
mode: 'read',
});
expect(await canRead(member.actor, file.path)).toBe(true);
await server.stores.fsEntry.updateEntry(dir.uuid, {
userId: victim.user.id,
});
await server.stores.fsEntry.updateEntry(file.uuid, {
userId: victim.user.id,
});
const moved = await server.stores.fsEntry.getEntryByUuid(dir.uuid);
await server.services.share.onEntryOwnerChanged(moved!);
expect(
await server.stores.share.listAllByFsentrySubtree(dir.id),
).toEqual([]);
expect(await canRead(member.actor, file.path)).toBe(false);
});
it('retires a whole burst of deletions in one flush', async () => {
const owner = await makeUser();
const recipient = await makeUser();
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { installSeatStub } from '../../testFixtures/seatStub.js';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { setupTestServer } from '../../testUtil.ts';
@@ -304,8 +305,9 @@ describe('UserAccountService', () => {
it('finishes when dropping the seat caches fails', async () => {
const user = await seedUser();
installSeatStub(server as never, [user.id]);
const capture = vi
.spyOn(server.services.team, 'captureSeatForBilling')
.spyOn(server.services.team as never, 'captureSeatForBilling')
.mockResolvedValue({
team_uid: `team-${uuidv4()}`,
owner_user_id: 1,
@@ -314,7 +316,7 @@ describe('UserAccountService', () => {
username: user.username,
});
const lookup = vi
.spyOn(server.stores.team, 'getByUid')
.spyOn(server.stores.team as never, 'getByUid')
.mockRejectedValue(new Error('store down'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
+17 -10
View File
@@ -3,18 +3,19 @@
*
* 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.
* 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.
* 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 <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { v4 as uuidv4 } from 'uuid';
@@ -180,7 +181,13 @@ describe('GroupStore', () => {
'VALUES (?, ?, ?, ?, ?, ?)',
[uid, owner.id, 'team', 'Also Not Yours', '{}', '{}'],
);
await server.stores.team.addMember(uid, member.id, { orgOwned: true });
// Written directly: the seat row is all this rule reads, and teams
// ships separately.
await server.clients.db.write(
'INSERT INTO `jct_user_group` (`user_id`, `group_id`, `org_owned`) ' +
'SELECT ?, id, 1 FROM `group` WHERE uid = ?',
[member.id, uid],
);
await store.removeUsers(uid, [member.username]);
expect(await memberUsernames(uid)).toEqual([member.username]);
+12 -4
View File
@@ -1036,10 +1036,18 @@ describe('ShareStore', () => {
it('returns the rows it inserts before the replica has them', async () => {
const entry = await makeEntry(issuer);
const team = await server.stores.team.create({
ownerUserId: issuer.id,
name: 'Lagging replica',
});
// A group row written directly: the share only needs its id,
// and teams ships separately.
const groupUid = uuidv4();
await server.clients.db.write(
'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, ' +
'`name`, `extra`, `metadata`) VALUES (?, ?, ?, ?, ?, ?)',
[groupUid, issuer.id, 'team', 'Lagging replica', '{}', '{}'],
);
const [team] = await server.clients.db.read(
'SELECT id FROM `group` WHERE uid = ?',
[groupUid],
);
const restore = lagShares();
try {
+114
View File
@@ -0,0 +1,114 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import { onTestFinished } from 'vitest';
import type { OrgSeatRow } from '../core/teams.js';
/**
* Teams ships as an extension, so core cannot build a real seat to test the
* rules that refuse one. This answers the contract instead, which is all those
* rules read.
*/
export interface SeatStubOptions {
/** Whether the seat's team demands a second factor. */
require2fa?: boolean;
teamUid?: string;
teamName?: string | null;
/** What `releaseUnconfirmedSeatEmail` reports back. */
released?: boolean;
/** Called with the id the signup path asked to release. */
onRelease?: (userId: number) => void;
}
interface Registries {
stores: Record<string, unknown> & {
user?: { clearUnconfirmedEmail?: (id: number) => Promise<boolean> };
};
services: Record<string, unknown>;
}
/** Installs the stub for `seatUserIds`; call the result to put it back. */
export const installSeatStub = (
server: Registries,
seatUserIds: number[],
opts: SeatStubOptions = {},
): (() => void) => {
const seats = new Set(seatUserIds);
const previousStore = server.stores.team;
const previousService = server.services.team;
const seatFor = (userId: number): OrgSeatRow | null =>
seats.has(userId)
? ({
id: userId,
user_id: userId,
uuid: `seat-${userId}`,
username: `seat-${userId}`,
team_uid: opts.teamUid ?? 'team-stub',
team_name: opts.teamName ?? 'Acme',
owner_user_id: 0,
require_2fa: opts.require2fa ? 1 : 0,
} as OrgSeatRow)
: null;
server.stores.team = {
getOrgSeat: async (userId: number) => seatFor(userId),
isMember: async () => false,
getByUid: async () => null,
getByHandle: async () => null,
getByIdIncludingDeleted: async () => null,
getByIdsIncludingDeleted: async () => new Map(),
listGroupIdsForUser: async () => [],
listMemberIdsByGroupId: async () => [],
memberIdsAmong: async () => [],
countOwned: async () => 0,
countOwnedTeamsWithAccounts: async () => 0,
};
server.services.team = {
teamsAvailableTo: async () => false,
captureSeatForBilling: async () => null,
emitSeatDeleted: () => undefined,
forgetSeat: async () => undefined,
recordPasswordSelfChange: async () => undefined,
releaseUnconfirmedSeatEmail: async (userId: number) => {
opts.onRelease?.(userId);
if (!seats.has(userId)) return false;
if (opts.released === false) return false;
seats.delete(userId);
// The real one clears the address; callers observe that, not the
// boolean.
return (
(await server.stores.user?.clearUnconfirmedEmail?.(userId)) ??
true
);
},
};
// Deleted rather than set undefined: shutdown walks the registry values.
const restore = () => {
if (previousStore === undefined) delete server.stores.team;
else server.stores.team = previousStore;
if (previousService === undefined) delete server.services.team;
else server.services.team = previousService;
};
// Put back when the test ends, so a caller need not unwind it.
onTestFinished(restore);
return restore;
};
+34
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { installSeatStub } from '../testFixtures/seatStub.js';
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { configContainer } from '../exports.js';
@@ -233,6 +234,39 @@ describe('buildUserDetails lookups', () => {
});
});
describe('the team a seat belongs to', () => {
it('names it for the account itself', async () => {
const user = await makeUser();
installSeatStub(server as never, [user.id], {
teamUid: 'team-xyz',
teamName: 'Acme Corp',
});
(deps().config as { teams_enabled?: boolean }).teams_enabled = true;
const details = await buildUserDetails(user, deps(), { isUser: true });
expect(details.team).toEqual({ uid: 'team-xyz', name: 'Acme Corp' });
});
it('withholds it from an app actor', async () => {
// Same class as the phone number: a seat's employer is not an app's
// business.
const user = await makeUser();
installSeatStub(server as never, [user.id], { teamName: 'Acme Corp' });
(deps().config as { teams_enabled?: boolean }).teams_enabled = true;
const details = await buildUserDetails(user, deps(), { isUser: false });
expect(details).not.toHaveProperty('team');
});
it('names none where teams is not installed', async () => {
const user = await makeUser();
(deps().config as { teams_enabled?: boolean }).teams_enabled = true;
const details = await buildUserDetails(user, deps(), { isUser: true });
expect(details).not.toHaveProperty('team');
});
});
describe('scrubSensitive', () => {
it('removes credentials and identifiers at any depth', () => {
const payload = {