mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-21 11:01:16 +00:00
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:
1 parent
c6685eef5f
commit
d5bb8ca0de
11 files changed
+296
-774
No files matched your search
+13
-24
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user