diff --git a/extensions/whoami.test.ts b/extensions/whoami.test.ts index 3a4eae1f3..c38292df8 100644 --- a/extensions/whoami.test.ts +++ b/extensions/whoami.test.ts @@ -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; expect(body.phone).toBeUndefined(); diff --git a/src/backend/controllers/auth/AuthController.http.test.ts b/src/backend/controllers/auth/AuthController.http.test.ts index 1374fc679..b8a73fd91 100644 --- a/src/backend/controllers/auth/AuthController.http.test.ts +++ b/src/backend/controllers/auth/AuthController.http.test.ts @@ -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); - }); }); /** diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 8c81c6b59..8089ef77b 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -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), }); diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts index 09b8839e1..db83ec4fa 100644 --- a/src/backend/controllers/oidc/OIDCController.test.ts +++ b/src/backend/controllers/oidc/OIDCController.test.ts @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +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/ 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'; diff --git a/src/backend/services/permission/GroupGrants.test.ts b/src/backend/services/permission/GroupGrants.test.ts deleted file mode 100644 index d5955696e..000000000 --- a/src/backend/services/permission/GroupGrants.test.ts +++ /dev/null @@ -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 . - */ - -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/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index 925104888..427935740 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -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(); diff --git a/src/backend/services/user/UserAccountService.test.ts b/src/backend/services/user/UserAccountService.test.ts index 6c5587b88..6896ce510 100644 --- a/src/backend/services/user/UserAccountService.test.ts +++ b/src/backend/services/user/UserAccountService.test.ts @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +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 { diff --git a/src/backend/stores/group/GroupStore.test.ts b/src/backend/stores/group/GroupStore.test.ts index ec57cb9b6..fb649a3ea 100644 --- a/src/backend/stores/group/GroupStore.test.ts +++ b/src/backend/stores/group/GroupStore.test.ts @@ -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 . + * 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]); diff --git a/src/backend/stores/share/ShareStore.test.js b/src/backend/stores/share/ShareStore.test.js index ea79b4e2e..9d876778e 100644 --- a/src/backend/stores/share/ShareStore.test.js +++ b/src/backend/stores/share/ShareStore.test.js @@ -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 { diff --git a/src/backend/testFixtures/seatStub.ts b/src/backend/testFixtures/seatStub.ts new file mode 100644 index 000000000..44cf83198 --- /dev/null +++ b/src/backend/testFixtures/seatStub.ts @@ -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 . + */ + +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 & { + user?: { clearUnconfirmedEmail?: (id: number) => Promise }; + }; + services: Record; +} + +/** 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; +}; diff --git a/src/backend/util/userDetails.test.ts b/src/backend/util/userDetails.test.ts index d9a81c7d3..2f5642ee4 100644 --- a/src/backend/util/userDetails.test.ts +++ b/src/backend/util/userDetails.test.ts @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +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 = {