Files
puter/src/backend/controllers/team/TeamController.http.test.ts
T
Juan Fernando Castro 00afea1285 feat: a seat is created, never adopted (#3720)
`addMember` refuses to turn an account that already has a password into a
team seat. No service path did this — `provisionAccount` always creates —
but the store permitted it, and the design rules out existing accounts joining
a team.

Provisioning passes the guard because it admits the account before setting its
temporary password.

There is no bypass parameter. Both HTTP suites now provision a real seat and
authenticate as it, using the same token-minting the harness uses for
`POST /login`. That surfaced something worth knowing: an unactivated seat
cannot call the API at all. Provisioning leaves `requires_email_confirmation`
set and `requireVerified` rejects it, so the suites activate the seat first —
which is the state a member is actually in when making requests.

`listMembers` and `getMembership` also return `u.uuid`, which the billing
events need in order to name the account without a second lookup.
2026-09-08 16:45:16 -04:00

312 lines
11 KiB
TypeScript

/**
* 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 { IConfig } from '../../types';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const randomHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
describe('team endpoints over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (
method: string,
path: string,
token: string,
body?: unknown,
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
/** A team owned by `user`, with one provisioned member. */
const makeTeam = async () => {
const res = await call('POST', '/teams', env.users.user.token, {
name: 'Acme',
handle: randomHandle(),
});
// Unasserted, a broken create means tests run on `/teams/undefined`.
expect(res.status).toBe(200);
const team = (await res.json()) as { uid: string };
expect(team.uid).toBeTruthy();
const username = `http_${Math.random().toString(36).slice(2, 9)}`;
const member = await call(
'POST',
`/teams/${team.uid}/members`,
env.users.user.token,
{ username, email: `${username}@test.local` },
);
expect(member.status).toBe(200);
return { team, memberUsername: username };
};
// -- the owner-account gate --------------------------------------
it('creates a team and reports the caller as its owner', async () => {
const res = await call('POST', '/teams', env.users.user.token, {
name: 'Acme Design',
handle: randomHandle(),
});
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).toMatchObject({ name: 'Acme Design', is_owner: true });
expect(body.uid).toMatch(/^[0-9a-f-]{36}$/u);
// `id` is internal and must not reach the wire.
expect(body).not.toHaveProperty('id');
});
it('returns 404, not 403, to a non-member', async () => {
const { team } = await makeTeam();
const res = await call(
'GET',
`/teams/${team.uid}`,
env.users.other.token,
);
// 403 would confirm the team exists.
expect(res.status).toBe(404);
});
it('refuses every administrative route to a member who is not the owner', async () => {
const { team, memberUsername } = await makeTeam();
// An activated seat: provisioning leaves the email unconfirmed, which
// `requireVerified` rejects, so an unactivated one cannot call at all.
const provisioned = await env.server.stores.user.getByUsername(
memberUsername,
);
await env.server.stores.user.update(provisioned!.id, {
email_confirmed: 1,
requires_email_confirmation: 0,
requires_password_change: 0,
});
const seat = await env.server.stores.user.getByUsername(memberUsername);
const { token } = await env.server.services.auth.createSessionToken(
seat!,
);
for (const [method, path] of [
['PUT', `/teams/${team.uid}`],
['DELETE', `/teams/${team.uid}`],
['POST', `/teams/${team.uid}/members`],
['GET', `/teams/${team.uid}/audit`],
] as const) {
const res = await call(
method,
path,
token,
method === 'GET' ? undefined : { name: 'nope' },
);
expect(res.status, `${method} ${path}`).toBe(403);
}
// Still a member, so reads it is entitled to still work.
const readable = await call('GET', `/teams/${team.uid}`, token);
expect(readable.status).toBe(200);
});
// -- the org_owned guard ------------------------------------------
it('refuses the team owner as the target of a member route', async () => {
const { team } = await makeTeam();
const res = await call(
'POST',
`/teams/${team.uid}/members/${env.users.user.username}/disable`,
env.users.user.token,
);
expect(res.status).toBe(404);
});
it('lists members with org_owned distinguishing the owner', async () => {
const { team, memberUsername } = await makeTeam();
const res = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.token,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { username: string; org_owned: boolean }[];
};
const owner = body.items.find(
(m) => m.username === env.users.user.username,
);
const member = body.items.find((m) => m.username === memberUsername);
expect(owner?.org_owned).toBe(false);
expect(member?.org_owned).toBe(true);
});
// -- provisioning over the wire -----------------------------------
it('never returns the activation link to the administrator', async () => {
const { team } = await makeTeam();
const username = `secret_${Math.random().toString(36).slice(2, 9)}`;
const res = await call(
'POST',
`/teams/${team.uid}/members`,
env.users.user.token,
{ username, email: `${username}@test.local` },
);
expect(res.status).toBe(200);
const raw = JSON.stringify(await res.json());
// Emailed to the member; returning it would let an admin use it.
expect(raw).not.toContain('set-new-password');
expect(raw).not.toContain('token');
});
it('refuses a taken username and offers alternatives', async () => {
const { team } = await makeTeam();
const res = await call(
'POST',
`/teams/${team.uid}/members`,
env.users.user.token,
{
username: env.users.other.username,
email: 'taken@test.local',
},
);
expect(res.status).toBe(409);
const body = (await res.json()) as {
suggestions?: string[];
fields?: { suggestions?: string[] };
};
const suggestions = body.suggestions ?? body.fields?.suggestions ?? [];
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions).not.toContain(env.users.other.username);
});
// -- audit --------------------------------------------------------
it('exposes the team audit to the team owner only', async () => {
const { team } = await makeTeam();
const mine = await call(
'GET',
`/teams/${team.uid}/audit`,
env.users.user.token,
);
expect(mine.status).toBe(200);
const body = (await mine.json()) as { items: { action: string }[] };
expect(body.items.map((e) => e.action)).toContain('provision');
const theirs = await call(
'GET',
`/teams/${team.uid}/audit`,
env.users.other.token,
);
expect(theirs.status).toBe(404);
});
});
describe('team endpoints with teams_enabled off', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({ teams_enabled: false } as IConfig);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it('404s every team route, so no team code is reachable', async () => {
// Real team: 200 with the flag on, so a 404 means no route.
const owner = await env.server.stores.user.getByUsername(
env.users.user.username,
);
const team = await env.server.stores.team.create({
ownerUserId: owner!.id,
name: 'Unreachable',
handle: `off-${Math.random().toString(36).slice(2, 8)}`,
});
await env.server.stores.team.addMember(team.uid, owner!.id, {
orgOwned: false,
});
const paths: [string, string][] = [
['POST', '/teams'],
['GET', '/teams'],
['GET', `/teams/${team.uid}`],
['PUT', `/teams/${team.uid}`],
['DELETE', `/teams/${team.uid}`],
['GET', `/teams/${team.uid}/members`],
['POST', `/teams/${team.uid}/members`],
['GET', `/teams/${team.uid}/audit`],
['GET', `/teams/${team.uid}/audit/me`],
];
for (const [method, path] of paths) {
const res = await fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.users.user.token}`,
},
...(method === 'POST' || method === 'PUT'
? { body: JSON.stringify({ name: 'x' }) }
: {}),
});
expect(res.status, `${method} ${path}`).toBe(404);
// Ours would carry a team legacyCode; the framework's does not.
const body = await res.text();
expect(body, `${method} ${path}`).not.toContain('team_not_found');
expect(body, `${method} ${path}`).not.toContain(
'not_the_team_owner',
);
}
});
it('registers no team routes at all with the flag off', async () => {
// The controller exists and is constructed; only its routes are absent.
const controller = env.server.controllers.team as unknown as {
isEnabled?: () => boolean;
};
expect(controller).toBeTruthy();
expect(controller.isEnabled?.()).toBe(false);
});
it('leaves the schema inert rather than absent', async () => {
// The flag gates reachability, not DDL.
await expect(
env.server.stores.team.getByHandle('no-such-team'),
).resolves.toBeNull();
});
});