mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8e938acda | ||
|
|
b08d3f926e | ||
|
|
1fdde02f6a | ||
|
|
1627650abc | ||
|
|
4968a69d45 | ||
|
|
aa47dcd3e5 | ||
|
|
c8424f6775 | ||
|
|
b3c85c86e9 | ||
|
|
d22d419c4d | ||
|
|
0760530d0c | ||
|
|
e18a5c2b54 | ||
|
|
75385501a7 | ||
|
|
5b0205f2bf | ||
|
|
3bce3b056e | ||
|
|
f70240784f | ||
|
|
356e7b2b94 | ||
|
|
f02b4a6b73 | ||
|
|
0768978111 | ||
|
|
66093e78f3 | ||
|
|
ed531e3992 | ||
|
|
53e27f3d41 | ||
|
|
deac121675 | ||
|
|
53e6e3179b | ||
|
|
73624bf1d0 |
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@
|
||||
# config.default.json, so partial overrides work. Absent file = defaults.
|
||||
|
||||
# ---- Build stage ----
|
||||
FROM node:24-slim AS build
|
||||
FROM node:26-bookworm-slim AS build
|
||||
|
||||
WORKDIR /opt/puter
|
||||
|
||||
@@ -59,7 +59,7 @@ RUN set -e; \
|
||||
wait $pjs_pid
|
||||
|
||||
# ---- Runtime stage (slim — no build tools) ----
|
||||
FROM node:24-slim
|
||||
FROM node:26-bookworm-slim
|
||||
|
||||
WORKDIR /opt/puter
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
- **A specific verified factor:** `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. Opt-in, and the factor must actually have been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan when `card` is listed (a paying account already has a card on file). Otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on); with none verifiable the gate is inert rather than locking the route on a self-hosted install. A denial carries the first verifiable factor's code plus `factors` (the verifiable ones, in the route's order), so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, e.g. during an SMS-provider outage.
|
||||
- **Metered spend:** an endpoint that spends metered resources on the caller's behalf (moving file content, object-store requests, anything else the account is billed for) declares `requireCredits: true`, which turns an account with no budget left away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to see what it has, clear it, and reach its billing pages. Drivers have no route options, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
|
||||
Provider sign-in requires the account's authenticator or a recovery code when 2FA is enabled, including signup callbacks that resolve an existing account. The five-minute challenge uses the same single-use `/login/otp` and `/login/recovery-code` verification as password sign-in. The GUI carries it in a URL fragment and removes it before initialization. A session and successful popup-return proof are issued only after verification; cancelling retains the popup's signed retry context without marking it authenticated.
|
||||
|
||||
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and the current six-digit authenticator `code`. Missing, malformed, or incorrect codes leave the secret and recovery codes intact. A team's requirement for 2FA still prevents disabling it.
|
||||
|
||||
Email changes through `/user-protected/change-email` require the password or OIDC revalidation cookie, plus a six-digit `code` from the authenticator when 2FA is enabled. Missing or malformed codes return `400 bad_request`; incorrect codes return `401 code_mismatch`. Both include `factor: 'otp'`. Confirmation links are bound to that verified request. A 2FA-enabled account must request a new link if its existing link was issued without second-factor verification.
|
||||
|
||||
+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();
|
||||
|
||||
Generated
+91
-79
@@ -1503,6 +1503,17 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dual-bundle/import-meta-resolve": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
|
||||
"integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/JounQin"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -6526,14 +6537,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
|
||||
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -6547,8 +6558,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
"@vitest/browser": "4.1.12",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -6557,16 +6568,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -6575,13 +6586,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -6602,9 +6613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6615,13 +6626,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -6629,14 +6640,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -6645,9 +6656,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -6655,13 +6666,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -7768,9 +7779,9 @@
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9125,14 +9136,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz",
|
||||
"integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==",
|
||||
"version": "5.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz",
|
||||
"integrity": "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.1",
|
||||
"synckit": "^0.11.13"
|
||||
"synckit": "^0.12.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -9881,9 +9892,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -13363,9 +13374,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.24.5",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz",
|
||||
"integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==",
|
||||
"version": "3.24.6",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz",
|
||||
"integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
@@ -15578,13 +15589,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smtp-server": {
|
||||
"version": "3.19.21",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.21.tgz",
|
||||
"integrity": "sha512-d0wWijB5dK4gImQidwWMlcGsycqcDCJV0VdQbfz61rgkDqnzAx0Epvi0o5JN2nnr/YwriVC+XV0T8YgcoB0bng==",
|
||||
"version": "3.19.23",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.23.tgz",
|
||||
"integrity": "sha512-t6AOBe7RR9Y/9Kox4e5SIm7wS2ZA8P0VuhVEZ5Yiox0WyevX+rMjATzIwEX3A+B6SGwnJ8QvoHtJAhbJYbjy3A==",
|
||||
"license": "MIT-0",
|
||||
"dependencies": {
|
||||
"ipv6-normalize": "1.0.1",
|
||||
"nodemailer": "10.1.0",
|
||||
"nodemailer": "10.1.2",
|
||||
"punycode.js": "2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15592,9 +15603,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smtp-server/node_modules/nodemailer": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.0.tgz",
|
||||
"integrity": "sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==",
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.2.tgz",
|
||||
"integrity": "sha512-rnQn9Rzh8RAwcp42MVsIvxbsv6YZ2a2/av+uyJ/LPU1+2QSlrRgFsWWZTKenyRUqkIl8urOI0c4+CEcmFdsvQw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -16003,12 +16014,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
|
||||
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.12.3.tgz",
|
||||
"integrity": "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dual-bundle/import-meta-resolve": "^4.2.1",
|
||||
"@pkgr/core": "^0.3.6"
|
||||
},
|
||||
"engines": {
|
||||
@@ -16607,9 +16619,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.30.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
|
||||
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
|
||||
"version": "7.30.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
|
||||
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -16853,19 +16865,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -16893,12 +16905,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -17704,7 +17716,7 @@
|
||||
"lorem-ipsum": "^2.0.8",
|
||||
"mime-types": "^2.1.35",
|
||||
"murmurhash": "^2.0.1",
|
||||
"mysql2": "^3.24.5",
|
||||
"mysql2": "^3.24.6",
|
||||
"nodemailer": "^9.1.1",
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.5.2",
|
||||
@@ -17712,12 +17724,12 @@
|
||||
"postal-mime": "^3.0.1",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.35.5",
|
||||
"smtp-server": "^3.19.21",
|
||||
"smtp-server": "^3.19.23",
|
||||
"socket.io": "^4.8.4",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"together-ai": "^0.33.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
"undici": "^7.29.1",
|
||||
"undici": "^7.30.1",
|
||||
"uuid": "^14.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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 { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PuterServer } from './server.ts';
|
||||
import { createTestUser, setupTestServer } from './testUtil.ts';
|
||||
|
||||
/**
|
||||
* Teams is optional, so core has to work when neither the service nor the
|
||||
* store is registered. Both are dropped after boot here, which is the shape
|
||||
* core sees wherever the feature is not installed.
|
||||
*/
|
||||
describe('core without a teams registration', () => {
|
||||
let server: PuterServer;
|
||||
|
||||
const makeUser = async (): Promise<{ id: number; username: string }> => {
|
||||
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
})) as unknown as { id: number };
|
||||
return { id: created.id, username };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
teams_enabled: true,
|
||||
// Without this the allowlist check returns before asking Teams.
|
||||
teams_allowed_email_domains: ['test.local'],
|
||||
} as never);
|
||||
// What the registry looks like where Teams is not installed.
|
||||
delete (server.services as { team?: unknown }).team;
|
||||
delete (server.stores as { team?: unknown }).team;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('has no teams in the registry', () => {
|
||||
expect(server.services.team).toBeUndefined();
|
||||
expect(server.stores.team).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes an account without asking what it was charged for', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
server.services.userAccount.cascadeDelete(user.id),
|
||||
).resolves.not.toThrow();
|
||||
expect(await server.stores.user.getById(user.id)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('answers the address-claim question signup asks', async () => {
|
||||
const user = await makeUser();
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
// Nothing is a seat, so the holder is returned as-is.
|
||||
await expect(
|
||||
server.services.signup.signupEmailHolder(row.email as string, {
|
||||
releaseSeat: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ id: user.id });
|
||||
});
|
||||
|
||||
it('lists what is shared with an account, folding in no team shares', async () => {
|
||||
const user = await makeUser();
|
||||
await expect(
|
||||
server.services.share.listSharedWithMe({
|
||||
user: { id: user.id },
|
||||
} as never),
|
||||
).resolves.toMatchObject({ items: [] });
|
||||
});
|
||||
|
||||
it('refuses a share aimed at a team, rather than failing', async () => {
|
||||
// A real home directory, so the refusal is the recipient's and not
|
||||
// the entry's.
|
||||
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const creds = await createTestUser(server, {
|
||||
username,
|
||||
password: 'Nt-Pw-12345!',
|
||||
});
|
||||
const row = (await server.stores.user.getByUsername(username))!;
|
||||
const user = { id: row.id, username };
|
||||
const actor = { user: row } as never;
|
||||
expect(creds).toBeTruthy();
|
||||
// The recipient cannot exist, so this is a 404 and not a crash.
|
||||
await expect(
|
||||
server.services.share.share(actor, {
|
||||
recipient: { teamHandle: 'nobody' },
|
||||
mode: 'read',
|
||||
path: `/${user.username}`,
|
||||
} as never),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('serves the homepage shell with the teams tab off', async () => {
|
||||
const user = await makeUser();
|
||||
const row = (await server.stores.user.getById(user.id))!;
|
||||
let html = '';
|
||||
const res = { send: (out: string) => (html = out) };
|
||||
await server.services.homepage.send(
|
||||
{
|
||||
req: { headers: {}, query: {}, get: () => undefined },
|
||||
res,
|
||||
actor: { user: row },
|
||||
} as never,
|
||||
{ title: 'Puter' } as never,
|
||||
{} as never,
|
||||
);
|
||||
const params = JSON.parse(
|
||||
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
|
||||
);
|
||||
expect(params.teams_ui).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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 { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
@@ -24,7 +25,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { S3Client } from './S3Client';
|
||||
import { S3Client, withPresignHeadroom } from './S3Client';
|
||||
|
||||
const LOCAL_BUCKET = 'puter-local';
|
||||
|
||||
@@ -487,3 +488,54 @@ describe('S3Client — short reads during multipart', () => {
|
||||
expect(parts).toEqual([10, 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withPresignHeadroom', () => {
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const creds = (msLeft?: number, id = 'a') => ({
|
||||
accessKeyId: id,
|
||||
secretAccessKey: 's',
|
||||
...(msLeft === undefined
|
||||
? {}
|
||||
: { expiration: new Date(Date.now() + msLeft) }),
|
||||
});
|
||||
|
||||
it('reports expiry an hour early so the SDK refreshes while an hour remains', async () => {
|
||||
const chain = vi.fn(async () => creds(5 * HOUR));
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
expect(chain).toHaveBeenCalledTimes(1);
|
||||
const left = identity.expiration!.getTime() - Date.now();
|
||||
expect(left).toBeGreaterThan(4 * HOUR - 1000);
|
||||
expect(left).toBeLessThanOrEqual(4 * HOUR);
|
||||
});
|
||||
|
||||
it('force-refreshes credentials that are inside the presign window', async () => {
|
||||
const chain = vi.fn(async (options?: { forceRefresh?: boolean }) =>
|
||||
options?.forceRefresh
|
||||
? creds(6 * HOUR, 'fresh')
|
||||
: creds(30 * 60 * 1000, 'stale'),
|
||||
);
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
expect(chain).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ forceRefresh: true }),
|
||||
);
|
||||
expect(identity.accessKeyId).toBe('fresh');
|
||||
});
|
||||
|
||||
it('leaves long-lived credentials alone', async () => {
|
||||
const chain = vi.fn(async () => creds(undefined));
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
expect(chain).toHaveBeenCalledTimes(1);
|
||||
expect(identity.expiration).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never reports an expiry inside the SDK refresh margin', async () => {
|
||||
const chain = vi.fn(async () => creds(20 * 60 * 1000));
|
||||
const identity = await withPresignHeadroom(chain)();
|
||||
|
||||
const left = identity.expiration!.getTime() - Date.now();
|
||||
expect(left).toBeGreaterThan(5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,43 @@ const DEFAULT_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
const FAUXQS_SAFE_PUT_OBJECT_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||
const LEGACY_STORAGE_BUCKET = 'puter-local';
|
||||
|
||||
// A presigned URL stops working when the credentials that signed it expire.
|
||||
// Presigns last up to an hour, but the SDK only refreshes temporary
|
||||
// credentials five minutes before expiry.
|
||||
const PRESIGN_MAX_LIFETIME_MS = 60 * 60 * 1000;
|
||||
const SDK_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
||||
|
||||
type CredentialChain = ReturnType<typeof fromNodeProviderChain>;
|
||||
|
||||
/**
|
||||
* Keeps temporary credentials at least an hour from expiry when they sign. The
|
||||
* SDK is handed each identity with its expiration moved an hour early, so its
|
||||
* own refresh comes back here while an hour remains, and a credential already
|
||||
* inside that window is force-refreshed from the chain.
|
||||
*/
|
||||
export function withPresignHeadroom(chain: CredentialChain): CredentialChain {
|
||||
return async (options) => {
|
||||
let identity = await chain(options);
|
||||
const remaining = (i: typeof identity) =>
|
||||
i.expiration ? i.expiration.getTime() - Date.now() : Infinity;
|
||||
if (
|
||||
remaining(identity) <
|
||||
PRESIGN_MAX_LIFETIME_MS + SDK_REFRESH_MARGIN_MS
|
||||
) {
|
||||
identity = await chain({ ...options, forceRefresh: true });
|
||||
}
|
||||
if (!identity.expiration) return identity;
|
||||
// If even fresh credentials are short-lived, report an expiry just past
|
||||
// the SDK's margin rather than one in the past, which would make it
|
||||
// re-resolve on every request.
|
||||
const reported = Math.max(
|
||||
identity.expiration.getTime() - PRESIGN_MAX_LIFETIME_MS,
|
||||
Date.now() + SDK_REFRESH_MARGIN_MS + 60 * 1000,
|
||||
);
|
||||
return { ...identity, expiration: new Date(reported) };
|
||||
};
|
||||
}
|
||||
|
||||
type S3CommandSender = Pick<AwsS3Client, 'send'>;
|
||||
|
||||
export class S3Client extends PuterClient {
|
||||
@@ -83,7 +120,9 @@ export class S3Client extends PuterClient {
|
||||
|
||||
if (useCredentialChain) {
|
||||
this.useProviderChain = true;
|
||||
this.awsConfig = { credentials: fromNodeProviderChain() };
|
||||
this.awsConfig = {
|
||||
credentials: withPresignHeadroom(fromNodeProviderChain()),
|
||||
};
|
||||
this.partSize = 64 * 1024 * 1024;
|
||||
this.maxSingleUploadSize = 128 * 1024 * 1024;
|
||||
} else {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -111,6 +111,7 @@ import { normalizeAbsolutePath } from '../../services/fs/resolveNode.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { startWebSession } from './webSession.js';
|
||||
import { completeOidcLoginReturn } from './oidcLoginReturn.js';
|
||||
|
||||
const FINGERPRINT_MAX_LENGTH = 128;
|
||||
// One consent prompt covers a handful of scopes at most. The cap keeps a
|
||||
@@ -595,6 +596,7 @@ export class AuthController extends PuterController {
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
oidcContinuation?: unknown;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
@@ -628,7 +630,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user);
|
||||
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
|
||||
}
|
||||
|
||||
// -- Login: recovery code ----------------------------------------
|
||||
@@ -664,6 +666,7 @@ export class AuthController extends PuterController {
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
oidcContinuation?: unknown;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
@@ -688,6 +691,11 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
await this.#assertSecondFactorAttemptsRemain(user.uuid);
|
||||
if (typeof code !== 'string') {
|
||||
throw new HttpError(400, 'code must be a string.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const hashed = hashRecoveryCode(code);
|
||||
const codes = ((user.otp_recovery_codes as string) || '')
|
||||
.split(',')
|
||||
@@ -700,15 +708,15 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
await this.#consumeOtpLoginToken(token);
|
||||
|
||||
// Consume the recovery code
|
||||
codes.splice(idx, 1);
|
||||
await this.stores.user.update(user.id, {
|
||||
otp_recovery_codes: codes.join(','),
|
||||
});
|
||||
if (!(await this.stores.user.consumeOtpRecoveryCode(user.id, hashed))) {
|
||||
await this.#recordSecondFactorFailure(user.uuid);
|
||||
res.json({ proceed: false });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user);
|
||||
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
|
||||
}
|
||||
|
||||
// -- Signup ------------------------------------------------------
|
||||
@@ -1932,7 +1940,7 @@ export class AuthController extends PuterController {
|
||||
// A seat's address is admin-supplied and never verified, so whoever
|
||||
// holds that inbox could take the seat over. Its recovery channel is
|
||||
// the team admin's password reset, not this one.
|
||||
if (await this.stores.team.getOrgSeat(user.id)) {
|
||||
if (await this.stores.team?.getOrgSeat(user.id)) {
|
||||
res.json({ message: genericMessage });
|
||||
return;
|
||||
}
|
||||
@@ -2166,8 +2174,8 @@ export class AuthController extends PuterController {
|
||||
// Best effort: the password is already committed, and an audit write
|
||||
// must not skip the eviction below.
|
||||
await this.services.team
|
||||
.recordPasswordSelfChange(user.id as number)
|
||||
.catch((err: unknown) => {
|
||||
?.recordPasswordSelfChange(user.id as number)
|
||||
?.catch((err: unknown) => {
|
||||
console.warn('[team] password self-change audit failed:', err);
|
||||
});
|
||||
|
||||
@@ -2222,8 +2230,8 @@ export class AuthController extends PuterController {
|
||||
temp_password_expires_at: null,
|
||||
});
|
||||
await this.services.team
|
||||
.recordPasswordSelfChange(user.id)
|
||||
.catch((err: unknown) => {
|
||||
?.recordPasswordSelfChange(user.id)
|
||||
?.catch((err: unknown) => {
|
||||
console.warn('[team] password self-change audit failed:', err);
|
||||
});
|
||||
|
||||
@@ -2257,7 +2265,7 @@ export class AuthController extends PuterController {
|
||||
// A provisioned account's name belongs to the team that made it: the
|
||||
// console lists its members by username and the audit log records them
|
||||
// by username, so a self-service rename would desync both.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this username. Ask a team admin to change it.',
|
||||
@@ -2391,7 +2399,7 @@ export class AuthController extends PuterController {
|
||||
async handleChangeEmail(req: Request, res: Response): Promise<void> {
|
||||
// The address is where admin-issued credentials and team notices go;
|
||||
// same reasoning as the username and deletion guards above.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this address. Ask a team admin to change it.',
|
||||
@@ -4122,7 +4130,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
if (action === 'enable') {
|
||||
// A seat has no email by design, so this would bar it forever.
|
||||
const seat = await this.stores.team.getOrgSeat(user.id);
|
||||
const seat = await this.stores.team?.getOrgSeat(user.id);
|
||||
if (!user.email_confirmed && !seat) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
@@ -4196,7 +4204,7 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
|
||||
const seat = await this.stores.team.getOrgSeat(user.id);
|
||||
const seat = await this.stores.team?.getOrgSeat(user.id);
|
||||
if (seat && Number(seat.require_2fa) === 1) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
@@ -4449,7 +4457,7 @@ export class AuthController extends PuterController {
|
||||
const userId = req.actor!.user.id!;
|
||||
// The team owns the account and is billed for it; only they may close
|
||||
// it, through the console that keeps the audit trail.
|
||||
if (await this.stores.team.getOrgSeat(userId)) {
|
||||
if (await this.stores.team?.getOrgSeat(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team owns this account. Ask a team admin to remove it.',
|
||||
@@ -4459,7 +4467,7 @@ export class AuthController extends PuterController {
|
||||
// The other direction: deleting an owner sets `owner_user_id` NULL and
|
||||
// cascades their membership, leaving a team no one can administer —
|
||||
// not even an admin, since every team route resolves by membership.
|
||||
if (await this.stores.team.countOwned(userId)) {
|
||||
if (await this.stores.team?.countOwned(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Delete the teams you own before closing this account.',
|
||||
@@ -4468,7 +4476,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
// A deleted team's seats are suspended, not gone, and only its owner
|
||||
// can retire them.
|
||||
if (await this.stores.team.countOwnedTeamsWithAccounts(userId)) {
|
||||
if (await this.stores.team?.countOwnedTeamsWithAccounts(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Close the accounts your teams created before closing this one.',
|
||||
@@ -4621,7 +4629,15 @@ export class AuthController extends PuterController {
|
||||
req: Request,
|
||||
res: Response,
|
||||
user: UserRow,
|
||||
oidcContinuation?: unknown,
|
||||
): Promise<void> {
|
||||
const redirectUrl =
|
||||
oidcContinuation === undefined
|
||||
? undefined
|
||||
: completeOidcLoginReturn(res, user, oidcContinuation, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
});
|
||||
const gui_token = await startWebSession(req, res, user, {
|
||||
config: this.config,
|
||||
auth: this.services.auth,
|
||||
@@ -4647,6 +4663,7 @@ export class AuthController extends PuterController {
|
||||
next_step: 'complete',
|
||||
token: gui_token,
|
||||
user: details,
|
||||
...(redirectUrl ? { redirectUrl } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
|
||||
describe('account change second-factor verification', () => {
|
||||
let env: PuterTestEnv;
|
||||
@@ -333,15 +334,8 @@ describe('account change second-factor verification', () => {
|
||||
});
|
||||
|
||||
it('does not let a valid code override a team requirement for 2FA', async () => {
|
||||
const owner = await account();
|
||||
const member = await account(true, true);
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Required 2FA',
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, member.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
await env.server.services.team.updateTeam(team.uid, owner.id, {
|
||||
installSeatStub(env.server as never, [member.id], {
|
||||
require2fa: true,
|
||||
});
|
||||
const proof = env.server.services.token.sign('oidc-state', {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Response } from 'express';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { OIDCService } from '../../services/auth/OIDCService.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
|
||||
export const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
|
||||
export const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
|
||||
|
||||
export interface OidcLoginContinuation {
|
||||
userUuid: string;
|
||||
redirectUri: string;
|
||||
embeddedInPopup: boolean;
|
||||
openerOrigin: string | null;
|
||||
messageId: string | null;
|
||||
switchedToLogin: boolean;
|
||||
}
|
||||
|
||||
interface OidcReturnDeps {
|
||||
config: IConfig;
|
||||
oidc: OIDCService;
|
||||
}
|
||||
|
||||
/** Bind an attested popup return to the browser that completed sign-in. */
|
||||
export function mintOidcPopupReturn(
|
||||
res: Response,
|
||||
payload: Record<string, unknown>,
|
||||
deps: OidcReturnDeps,
|
||||
): string {
|
||||
const nonce = crypto.randomBytes(32).toString('base64url');
|
||||
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
|
||||
...sessionCookieFlags(deps.config, { crossSite: false }),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
return deps.oidc.signPopupReturn({ action: 'sign-in', ...payload, nonce });
|
||||
}
|
||||
|
||||
/** Call only after both the provider and the account's second factor succeed. */
|
||||
export function completeOidcLoginReturn(
|
||||
res: Response,
|
||||
user: Pick<UserRow, 'uuid'>,
|
||||
continuation: unknown,
|
||||
deps: OidcReturnDeps,
|
||||
): string {
|
||||
if (!continuation || typeof continuation !== 'object') {
|
||||
throw new HttpError(400, 'Invalid login continuation.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const data = continuation as OidcLoginContinuation;
|
||||
if (
|
||||
data.userUuid !== user.uuid ||
|
||||
typeof data.redirectUri !== 'string' ||
|
||||
typeof data.embeddedInPopup !== 'boolean' ||
|
||||
typeof data.switchedToLogin !== 'boolean' ||
|
||||
(data.openerOrigin !== null && typeof data.openerOrigin !== 'string') ||
|
||||
(data.messageId !== null && typeof data.messageId !== 'string')
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid login continuation.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const origin = (deps.config.origin ?? '').replace(/\/$/, '');
|
||||
let target = data.redirectUri || origin || '/';
|
||||
try {
|
||||
if (origin && new URL(target).origin !== new URL(origin).origin)
|
||||
target = origin;
|
||||
} catch {
|
||||
target = origin || '/';
|
||||
}
|
||||
const append = (key: string, value: string) => {
|
||||
target += `${target.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
};
|
||||
if (data.embeddedInPopup) {
|
||||
append('oidc_login', 'true');
|
||||
append(
|
||||
'opener_state',
|
||||
mintOidcPopupReturn(
|
||||
res,
|
||||
{
|
||||
opener_origin: data.openerOrigin,
|
||||
msg_id: data.messageId,
|
||||
oidc_login: true,
|
||||
user_uuid: user.uuid,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.switchedToLogin) append('oidc_switched', 'login');
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
describe('recovery code consumption', () => {
|
||||
let env: PuterTestEnv;
|
||||
const password = 'recovery-code-password';
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Hold the same cached snapshot until every concurrent request has read it.
|
||||
const overlapReads = (userUuid: string, count: number) => {
|
||||
const redis = env.server.clients.redis;
|
||||
const get = redis.get.bind(redis);
|
||||
const ready = Promise.withResolvers<void>();
|
||||
let reads = 0;
|
||||
vi.spyOn(redis, 'get').mockImplementation(async (key: string) => {
|
||||
const value = await get(key);
|
||||
if (key.includes(userUuid) && reads < count) {
|
||||
if (++reads === count) ready.resolve();
|
||||
await ready.promise;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
const post = async (path: string, body: Record<string, unknown>) => {
|
||||
const response = await fetch(new URL(path, env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { response, body: await response.json() };
|
||||
};
|
||||
|
||||
const account = async (codes: string[]) => {
|
||||
const username = `recovery_${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, { username, password });
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_enabled: true,
|
||||
otp_secret: 'TESTSECRETBASE32',
|
||||
otp_recovery_codes: codes.map(hashRecoveryCode).join(','),
|
||||
});
|
||||
const challenge = async () => {
|
||||
const { response, body } = await post('/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
expect(response.status).toBe(202);
|
||||
expect(body.next_step).toBe('otp');
|
||||
return body.otp_jwt_token as string;
|
||||
};
|
||||
return { user, challenge };
|
||||
};
|
||||
|
||||
it('allows only one concurrent redemption of the same code', async () => {
|
||||
const { user, challenge } = await account(['RECOVER1', 'RECOVER2']);
|
||||
const tokens = await Promise.all([challenge(), challenge()]);
|
||||
overlapReads(user.uuid, tokens.length);
|
||||
const results = await Promise.all(
|
||||
tokens.map((token) =>
|
||||
post('/login/recovery-code', { token, code: 'RECOVER1' }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
results.filter((result) => result.body.next_step === 'complete'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
results.filter((result) => result.body.proceed === false),
|
||||
).toHaveLength(1);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_recovery_codes).toBe(hashRecoveryCode('RECOVER2'));
|
||||
});
|
||||
|
||||
it('consumes different concurrent codes without restoring either one', async () => {
|
||||
const codes = ['RECOVER1', 'RECOVER2', 'RECOVER3'];
|
||||
const { user, challenge } = await account(codes);
|
||||
const tokens = await Promise.all(codes.map(() => challenge()));
|
||||
overlapReads(user.uuid, tokens.length);
|
||||
const results = await Promise.all(
|
||||
codes.map((code, index) =>
|
||||
post('/login/recovery-code', { token: tokens[index], code }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
results.every((result) => result.body.next_step === 'complete'),
|
||||
).toBe(true);
|
||||
const after = await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.otp_recovery_codes).toBe('');
|
||||
const replay = await post('/login/recovery-code', {
|
||||
token: tokens[0],
|
||||
code: codes[0],
|
||||
});
|
||||
expect(replay.body).toMatchObject({ proceed: false });
|
||||
});
|
||||
|
||||
it.each([{ code: 123456 }, { code: {} }, { code: [] }])(
|
||||
'rejects a non-string recovery code $code without consuming a code',
|
||||
async ({ code }) => {
|
||||
const { user, challenge } = await account(['RECOVER1']);
|
||||
const result = await post('/login/recovery-code', {
|
||||
token: await challenge(),
|
||||
code,
|
||||
});
|
||||
expect(result.response.status).toBe(400);
|
||||
expect(
|
||||
(await env.server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
}))!.otp_recovery_codes,
|
||||
).toBe(hashRecoveryCode('RECOVER1'));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1948,6 +1948,27 @@ describe('FSController.readEntry (file streaming)', () => {
|
||||
expect(captured.statusCode).toBe(206);
|
||||
});
|
||||
|
||||
it('returns 206 with Range honored when offset and byte_count query params are supplied', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const body = Buffer.from('abcdefghij');
|
||||
const target = `/${username}/Documents/ranged-query.bin`;
|
||||
await writeFile(userId, target, body, 'application/octet-stream');
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readEntry(
|
||||
makeReq({
|
||||
query: { path: target, offset: '2', byte_count: '4' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(captured.statusCode).toBe(206);
|
||||
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('cdef');
|
||||
});
|
||||
|
||||
it('throws 404 when the path does not exist', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveOrNotFound,
|
||||
resolveReadRange,
|
||||
signEntryThumbnail,
|
||||
} from './legacyFsHelpers.js';
|
||||
import type {
|
||||
@@ -1495,10 +1496,7 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
|
||||
@@ -3732,6 +3732,63 @@ describe('LegacyFSController.read file streaming', () => {
|
||||
);
|
||||
expect(captured.headers['Content-Type']).toMatch(/text\/html/);
|
||||
});
|
||||
|
||||
it('honors offset and byte_count query params on /read', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const body = Buffer.from('0123456789abcdef');
|
||||
const target = `/${username}/Documents/range-test.txt`;
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: target,
|
||||
size: body.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.read(
|
||||
makeReq({
|
||||
query: { file: target, offset: '4', byte_count: '6' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(captured.statusCode).toBe(206);
|
||||
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('456789');
|
||||
});
|
||||
|
||||
it('rejects invalid offset query param with 400', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const body = Buffer.from('test');
|
||||
const target = `/${username}/Documents/invalid-offset.txt`;
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: target,
|
||||
size: body.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: body,
|
||||
});
|
||||
|
||||
const { res } = makeStreamingRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.read(
|
||||
makeReq({
|
||||
query: { file: target, offset: '-1' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /file directory listing ─────────────────────────────────────────
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Team2faLookup } from '../../core/http/middleware/gates.js';
|
||||
import Busboy from 'busboy';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
@@ -102,6 +103,7 @@ import {
|
||||
type GuiItemEventName,
|
||||
loadLegacyAssociatedApps,
|
||||
replacedByMove,
|
||||
resolveReadRange,
|
||||
resolveV1Selector,
|
||||
signEntry,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -1287,10 +1289,7 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
@@ -1348,7 +1347,7 @@ export class LegacyFSController extends PuterController {
|
||||
// that guard every other authenticated FS route have to run here.
|
||||
assertNotSuspended(actor!.user);
|
||||
assertVerifiedAccount(actor!.user);
|
||||
await assertTeam2fa(actor!.user, this.stores.team);
|
||||
await assertTeam2fa(actor!.user, this.stores.team as Team2faLookup);
|
||||
|
||||
req.actor = assertResolvedActor(actor!);
|
||||
Context.set('actor', actor);
|
||||
@@ -1803,10 +1802,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// File: stream bytes with Range support.
|
||||
const range =
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
const range = resolveReadRange(req.headers.range, query);
|
||||
const download = await this.services.fs.readContent(entry, {
|
||||
range,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getBoolean,
|
||||
getString,
|
||||
loadLegacyAssociatedApps,
|
||||
resolveReadRange,
|
||||
signEntry,
|
||||
signEntryThumbnail,
|
||||
signingConfigFromAppConfig,
|
||||
@@ -618,3 +619,89 @@ describe('toLegacyEntry is_empty against stored children', () => {
|
||||
expect((await shape()).is_empty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveReadRange', () => {
|
||||
it('returns the explicit headersRange when provided', () => {
|
||||
expect(
|
||||
resolveReadRange('bytes=10-20', { offset: 0, byte_count: 5 }),
|
||||
).toBe('bytes=10-20');
|
||||
});
|
||||
|
||||
it('returns undefined when neither headersRange nor range query params are present', () => {
|
||||
expect(resolveReadRange(undefined, {})).toBeUndefined();
|
||||
expect(resolveReadRange('', {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives a byte range from offset and byte_count', () => {
|
||||
expect(resolveReadRange(undefined, { offset: 0, byte_count: 5 })).toBe(
|
||||
'bytes=0-4',
|
||||
);
|
||||
expect(
|
||||
resolveReadRange(undefined, { offset: 10, byte_count: 20 }),
|
||||
).toBe('bytes=10-29');
|
||||
expect(
|
||||
resolveReadRange(undefined, { offset: '4', byte_count: '6' }),
|
||||
).toBe('bytes=4-9');
|
||||
});
|
||||
|
||||
it('derives an open range when only offset is provided', () => {
|
||||
expect(resolveReadRange(undefined, { offset: 10 })).toBe('bytes=10-');
|
||||
expect(resolveReadRange(undefined, { offset: '0' })).toBe('bytes=0-');
|
||||
});
|
||||
|
||||
it('derives a prefix range when only byte_count is provided', () => {
|
||||
expect(resolveReadRange(undefined, { byte_count: 15 })).toBe(
|
||||
'bytes=0-14',
|
||||
);
|
||||
expect(resolveReadRange(undefined, { byte_count: '1' })).toBe(
|
||||
'bytes=0-0',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws 400 on invalid or negative offset', () => {
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: -1, byte_count: 10 }),
|
||||
).toThrow('Invalid offset');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 1.5, byte_count: 10 }),
|
||||
).toThrow('Invalid offset');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 'abc', byte_count: 10 }),
|
||||
).toThrow('Invalid offset');
|
||||
});
|
||||
|
||||
it('throws 400 on non-decimal or unsafe numeric strings', () => {
|
||||
for (const offset of [
|
||||
'1e3',
|
||||
'0x10',
|
||||
' 4',
|
||||
'1e21',
|
||||
'9007199254740993',
|
||||
]) {
|
||||
expect(() => resolveReadRange(undefined, { offset })).toThrow(
|
||||
'Invalid offset',
|
||||
);
|
||||
}
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, {
|
||||
offset: String(Number.MAX_SAFE_INTEGER),
|
||||
byte_count: '3',
|
||||
}),
|
||||
).toThrow('Invalid byte_count');
|
||||
});
|
||||
|
||||
it('throws 400 on non-positive or invalid byte_count', () => {
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: 0 }),
|
||||
).toThrow('Invalid byte_count');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: -5 }),
|
||||
).toThrow('Invalid byte_count');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: 2.5 }),
|
||||
).toThrow('Invalid byte_count');
|
||||
expect(() =>
|
||||
resolveReadRange(undefined, { offset: 0, byte_count: 'xyz' }),
|
||||
).toThrow('Invalid byte_count');
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,57 @@ export function getBoolean(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Non-negative safe integer from a number or a plain digit string; anything
|
||||
// else (exponents, hex, whitespace) is rejected.
|
||||
function parseByteParam(raw: unknown, name: string, min: number): number {
|
||||
const value =
|
||||
typeof raw === 'number'
|
||||
? raw
|
||||
: typeof raw === 'string' && /^\d+$/.test(raw)
|
||||
? Number(raw)
|
||||
: NaN;
|
||||
if (!Number.isSafeInteger(value) || value < min) {
|
||||
throw new HttpError(400, `Invalid ${name}`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an HTTP Range header string from explicit `headersRange` or fallback
|
||||
* `offset` / `byte_count` query parameters.
|
||||
*/
|
||||
export function resolveReadRange(
|
||||
headersRange: unknown,
|
||||
query: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
if (typeof headersRange === 'string' && headersRange.length > 0) {
|
||||
return headersRange;
|
||||
}
|
||||
const isSet = (v: unknown) => v !== undefined && v !== null && v !== '';
|
||||
const offset = isSet(query.offset)
|
||||
? parseByteParam(query.offset, 'offset', 0)
|
||||
: undefined;
|
||||
const byteCount = isSet(query.byte_count)
|
||||
? parseByteParam(query.byte_count, 'byte_count', 1)
|
||||
: undefined;
|
||||
|
||||
if (byteCount !== undefined) {
|
||||
const last = (offset ?? 0) + byteCount - 1;
|
||||
if (!Number.isSafeInteger(last)) {
|
||||
throw new HttpError(400, 'Invalid byte_count', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return `bytes=${offset ?? 0}-${last}`;
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
return `bytes=${offset}-`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Accepts either `{ path }` or `{ uid }` or `{ id }` or `{ parent, name }`
|
||||
// from a legacy body field. Returns a resolved entry (throwing 404 if not
|
||||
// found or 400 if no usable ref is present).
|
||||
|
||||
@@ -32,7 +32,6 @@ import { NotificationController } from './notification/NotificationController.js
|
||||
import { OIDCController } from './oidc/OIDCController.js';
|
||||
import { PuterAIController } from './puterai/PuterAIController.js';
|
||||
import { ShareController } from './share/ShareController.js';
|
||||
import { TeamController } from './team/TeamController.js';
|
||||
import { StaticAssetsController } from './static/StaticAssetsController.js';
|
||||
import { StaticPagesController } from './static/StaticPagesController.js';
|
||||
import { SystemController } from './system/SystemController.js';
|
||||
@@ -60,7 +59,6 @@ export const puterControllers = {
|
||||
notification: NotificationController,
|
||||
events: EventsController,
|
||||
share: ShareController,
|
||||
team: TeamController,
|
||||
webdav: WebDAVController,
|
||||
oidc: OIDCController,
|
||||
wisp: WispController,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -24,6 +24,12 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { startWebSession } from '../auth/webSession.js';
|
||||
import {
|
||||
completeOidcLoginReturn,
|
||||
mintOidcPopupReturn,
|
||||
OIDC_POPUP_RETURN_COOKIE_NAME,
|
||||
type OidcLoginContinuation,
|
||||
} from '../auth/oidcLoginReturn.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { normalizeBonusCode } from '../../util/signupBonus.js';
|
||||
@@ -43,11 +49,6 @@ const REVALIDATION_EXPIRY_SEC = 300;
|
||||
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
|
||||
const OIDC_NONCE_EXPIRY_SEC = 600;
|
||||
|
||||
// Single-use, and binds a popup-return proof to the browser that earned it.
|
||||
// Expiry mirrors POPUP_RETURN_EXPIRY_SEC in OIDCService.
|
||||
const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
|
||||
const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
|
||||
|
||||
// The popup branch below hard-codes this return target.
|
||||
const POPUP_RETURN_ACTION = 'sign-in';
|
||||
|
||||
@@ -284,11 +285,6 @@ function buildErrorRedirectUrl(
|
||||
return `${base}${pagePath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function appendQueryParam(url: string, key: string, value: string): string {
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
/** Length-safe constant-time string compare (never throws on mismatch). */
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
@@ -803,18 +799,9 @@ if (window.opener) {
|
||||
|
||||
/** Sign a popup-return proof, bound to this browser and this popup. */
|
||||
#mintPopupReturn(res: Response, payload: Record<string, unknown>): string {
|
||||
const nonce = crypto.randomBytes(32).toString('base64url');
|
||||
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
|
||||
// Redeemed same-origin.
|
||||
...sessionCookieFlags(this.config, { crossSite: false }),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
return this.services.oidc.signPopupReturn({
|
||||
action: POPUP_RETURN_ACTION,
|
||||
...payload,
|
||||
nonce,
|
||||
return mintOidcPopupReturn(res, payload, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -872,7 +859,7 @@ if (window.opener) {
|
||||
byEmail !== null &&
|
||||
!byEmail.email_confirmed &&
|
||||
userinfo.email_verified === true &&
|
||||
(await this.services.team.releaseUnconfirmedSeatEmail(
|
||||
(await this.services.team?.releaseUnconfirmedSeatEmail(
|
||||
byEmail.id,
|
||||
));
|
||||
if (byEmail && !releasedFromSeat) {
|
||||
@@ -1012,49 +999,71 @@ if (window.opener) {
|
||||
stateDecoded: Record<string, unknown>,
|
||||
extraQueryParams?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
const continuation: OidcLoginContinuation = {
|
||||
userUuid: user.uuid,
|
||||
redirectUri:
|
||||
typeof stateDecoded.redirect_uri === 'string'
|
||||
? stateDecoded.redirect_uri
|
||||
: origin || '/',
|
||||
embeddedInPopup: stateDecoded.embedded_in_popup === true,
|
||||
openerOrigin:
|
||||
typeof stateDecoded.opener_origin === 'string'
|
||||
? stateDecoded.opener_origin
|
||||
: null,
|
||||
messageId:
|
||||
stateDecoded.msg_id == null
|
||||
? null
|
||||
: String(stateDecoded.msg_id),
|
||||
switchedToLogin: extraQueryParams?.oidc_switched === 'login',
|
||||
};
|
||||
if (user.otp_enabled) {
|
||||
const challenge = this.services.token.sign(
|
||||
'otp',
|
||||
{
|
||||
purpose: 'otp-login',
|
||||
user_uid: user.uuid,
|
||||
jti: crypto.randomUUID(),
|
||||
oidcContinuation: continuation,
|
||||
},
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Referrer-Policy', 'no-referrer');
|
||||
let retryUrl = `${origin}/action/login`;
|
||||
if (continuation.embeddedInPopup) {
|
||||
const retry = new URL(
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
'unauthorized',
|
||||
stateDecoded,
|
||||
undefined,
|
||||
(payload) => this.#mintPopupReturn(res, payload),
|
||||
),
|
||||
);
|
||||
retry.searchParams.delete('auth_error');
|
||||
retry.searchParams.delete('message');
|
||||
retryUrl = retry.href;
|
||||
}
|
||||
const fragment = new URLSearchParams({
|
||||
token: challenge,
|
||||
retryUrl,
|
||||
});
|
||||
res.redirect(302, `${origin}/action/oidc-2fa#${fragment}`);
|
||||
return;
|
||||
}
|
||||
await startWebSession(req, res, user, {
|
||||
config: this.config,
|
||||
auth: this.services.auth,
|
||||
});
|
||||
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
let target = (stateDecoded.redirect_uri as string) || origin || '/';
|
||||
if (!isSameOrigin(target, origin)) {
|
||||
target = origin || '/';
|
||||
}
|
||||
|
||||
if (stateDecoded.embedded_in_popup) {
|
||||
target = appendQueryParam(target, 'oidc_login', 'true');
|
||||
// `opener_origin` and `oidc_login` reach the popup as bare query
|
||||
// parameters, which say nothing about where they came from: the
|
||||
// URL a verified state produces is byte-identical to one anybody
|
||||
// can type. The popup treats the opener's origin as the app
|
||||
// identity to mint a token for, so it needs the integrity this
|
||||
// state already carries — re-signed here, at the one point where
|
||||
// the round trip is known to have actually happened.
|
||||
//
|
||||
// `user_uuid` binds the proof to the account that just completed
|
||||
// OIDC. Without it a proof from one login could be replayed in
|
||||
// another signed-in browser to skip its account picker; the popup
|
||||
// only honors `oidc_login` when this matches its current user.
|
||||
target = appendQueryParam(
|
||||
target,
|
||||
'opener_state',
|
||||
this.#mintPopupReturn(res, {
|
||||
opener_origin: stateDecoded.opener_origin ?? null,
|
||||
msg_id: stateDecoded.msg_id ?? null,
|
||||
oidc_login: true,
|
||||
user_uuid: user.uuid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (extraQueryParams) {
|
||||
for (const [k, v] of Object.entries(extraQueryParams)) {
|
||||
if (v != null) target = appendQueryParam(target, k, v);
|
||||
}
|
||||
}
|
||||
|
||||
res.redirect(302, target);
|
||||
res.redirect(
|
||||
302,
|
||||
completeOidcLoginReturn(res, user, continuation, {
|
||||
config: this.config,
|
||||
oidc: this.services.oidc,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { TOTP } from 'otpauth';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
createTestUser,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
|
||||
|
||||
describe('OIDC account second-factor login', () => {
|
||||
let env: PuterTestEnv;
|
||||
let claims: { sub: string; email: string; email_verified: boolean };
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
cookie_name: 'puter_auth_token',
|
||||
oidc: {
|
||||
providers: {
|
||||
custom: {
|
||||
client_id: 'test-client',
|
||||
client_secret: 'test-secret',
|
||||
authorization_endpoint: 'https://idp.example/authorize',
|
||||
token_endpoint: 'https://idp.example/token',
|
||||
userinfo_endpoint: 'https://idp.example/userinfo',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as IConfig);
|
||||
vi.spyOn(
|
||||
env.server.services.oidc,
|
||||
'exchangeCodeForTokens',
|
||||
).mockResolvedValue({ access_token: 'test-access' } as never);
|
||||
vi.spyOn(env.server.services.oidc, 'getUserInfo').mockImplementation(
|
||||
async () => claims,
|
||||
);
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const account = async (enabled = true, linked = true) => {
|
||||
const username = `oidc2fa-${uuidv4().slice(0, 8)}`;
|
||||
await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'oidc-test-password',
|
||||
});
|
||||
const user = (await env.server.stores.user.getByUsername(username))!;
|
||||
const otp = new TOTP({
|
||||
issuer: 'puter.com',
|
||||
label: username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
});
|
||||
claims = {
|
||||
sub: uuidv4(),
|
||||
email: `${username}@example.com`,
|
||||
email_verified: true,
|
||||
};
|
||||
await env.server.stores.user.update(user.id, {
|
||||
email: claims.email,
|
||||
email_confirmed: true,
|
||||
otp_enabled: enabled,
|
||||
otp_secret: otp.secret.base32,
|
||||
otp_recovery_codes: hashRecoveryCode('ABCDEFG2'),
|
||||
});
|
||||
if (linked) {
|
||||
await env.server.services.oidc.linkProviderToUser(
|
||||
user.id,
|
||||
'custom',
|
||||
claims,
|
||||
);
|
||||
}
|
||||
return { user, otp };
|
||||
};
|
||||
const callback = async (
|
||||
flow = 'login',
|
||||
extra: Record<string, string> = {},
|
||||
) => {
|
||||
const start = new URL(
|
||||
`/auth/oidc/custom/start?flow=${flow}`,
|
||||
env.origin,
|
||||
);
|
||||
Object.entries(extra).forEach(([key, value]) =>
|
||||
start.searchParams.set(key, value),
|
||||
);
|
||||
const begun = await fetch(start, { redirect: 'manual' });
|
||||
expect(begun.status).toBe(302);
|
||||
const state = new URL(begun.headers.get('location')!).searchParams.get(
|
||||
'state',
|
||||
)!;
|
||||
const nonce = begun.headers
|
||||
.getSetCookie()
|
||||
.find((value) => value.startsWith('puter_oidc_nonce='))!
|
||||
.split(';')[0]!;
|
||||
return fetch(
|
||||
new URL(
|
||||
`/auth/oidc/callback/${flow}?code=test-code&state=${encodeURIComponent(state)}`,
|
||||
env.origin,
|
||||
),
|
||||
{ redirect: 'manual', headers: { cookie: nonce } },
|
||||
);
|
||||
};
|
||||
const verify = (token: string, code: string, recovery = false) =>
|
||||
fetch(
|
||||
new URL(
|
||||
recovery ? '/login/recovery-code' : '/login/otp',
|
||||
env.origin,
|
||||
),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token, code }),
|
||||
},
|
||||
);
|
||||
const challenge = (response: Response) => {
|
||||
expect(response.status).toBe(302);
|
||||
expect(
|
||||
response.headers
|
||||
.getSetCookie()
|
||||
.some((value) => value.startsWith('puter_auth_token=')),
|
||||
).toBe(false);
|
||||
const location = new URL(response.headers.get('location')!);
|
||||
expect(location.pathname).toBe('/action/oidc-2fa');
|
||||
const token = new URLSearchParams(location.hash.slice(1)).get('token');
|
||||
expect(token).toBeTruthy();
|
||||
return token!;
|
||||
};
|
||||
|
||||
it.each(['login', 'signup'])(
|
||||
'requires the authenticator before completing existing-account %s',
|
||||
async (flow) => {
|
||||
const { otp } = await account();
|
||||
const token = challenge(
|
||||
await callback(flow, { return_to: '/dashboard' }),
|
||||
);
|
||||
const response = await verify(token, otp.generate());
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
redirectUrl: `${env.origin}/dashboard${flow === 'signup' ? '?oidc_switched=login' : ''}`,
|
||||
});
|
||||
expect(
|
||||
response.headers
|
||||
.getSetCookie()
|
||||
.some((value) => value.startsWith('puter_auth_token=')),
|
||||
).toBe(true);
|
||||
expect((await verify(token, otp.generate())).status).toBe(400);
|
||||
},
|
||||
);
|
||||
|
||||
it('incorrect codes do not start a session and allow a valid recovery-code retry', async () => {
|
||||
const { otp } = await account();
|
||||
const token = challenge(await callback());
|
||||
const wrong = ['000000', '000001', '000002'].find(
|
||||
(code) => otp.validate({ token: code }) === null,
|
||||
)!;
|
||||
const rejected = await verify(token, wrong);
|
||||
expect(await rejected.json()).toMatchObject({ proceed: false });
|
||||
expect(rejected.headers.getSetCookie()).toHaveLength(0);
|
||||
const accepted = await verify(token, 'ABCDEFG2', true);
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(await accepted.json()).toMatchObject({
|
||||
proceed: true,
|
||||
redirectUrl: env.origin,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an unauthenticated popup retry and attests login only after verification', async () => {
|
||||
const { otp, user } = await account();
|
||||
const response = await callback('login', {
|
||||
embedded_in_popup: 'true',
|
||||
msg_id: 'message-123',
|
||||
opener_origin: 'https://app.example',
|
||||
});
|
||||
const token = challenge(response);
|
||||
const fragment = new URLSearchParams(
|
||||
new URL(response.headers.get('location')!).hash.slice(1),
|
||||
);
|
||||
const retry = new URL(fragment.get('retryUrl')!);
|
||||
expect(retry.origin).toBe(env.origin);
|
||||
expect(retry.searchParams.get('action')).toBe('login');
|
||||
expect(retry.searchParams.get('embedded_in_popup')).toBe('true');
|
||||
expect(retry.searchParams.get('msg_id')).toBe('message-123');
|
||||
expect(retry.searchParams.has('auth_error')).toBe(false);
|
||||
const retryNonce = response.headers
|
||||
.get('set-cookie')!
|
||||
.match(/puter_oidc_popup_return=([^;, ]+)/)![1];
|
||||
const retried = await fetch(
|
||||
new URL('/auth/oidc/verify-popup-return', env.origin),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
cookie: `puter_oidc_popup_return=${retryNonce}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
opener_state: retry.searchParams.get('opener_state'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(retried.status).toBe(200);
|
||||
expect(await retried.json()).toMatchObject({
|
||||
action: 'login',
|
||||
oidc_login: false,
|
||||
user_uuid: null,
|
||||
msg_id: 'message-123',
|
||||
opener_origin: 'https://app.example',
|
||||
});
|
||||
const accepted = await verify(token, otp.generate());
|
||||
const data = await accepted.json();
|
||||
const target = new URL(data.redirectUrl);
|
||||
expect(target.pathname).toBe('/action/sign-in');
|
||||
expect(target.searchParams.get('msg_id')).toBe('message-123');
|
||||
expect(target.searchParams.get('oidc_login')).toBe('true');
|
||||
const proof = env.server.services.oidc.verifyPopupReturn(
|
||||
target.searchParams.get('opener_state')!,
|
||||
);
|
||||
expect(proof).toMatchObject({
|
||||
user_uuid: user.uuid,
|
||||
opener_origin: 'https://app.example',
|
||||
msg_id: 'message-123',
|
||||
oidc_login: true,
|
||||
});
|
||||
expect(
|
||||
accepted.headers
|
||||
.getSetCookie()
|
||||
.some((value) => value.startsWith('puter_oidc_popup_return=')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the authenticator when verified email first links an existing account', async () => {
|
||||
const { otp } = await account(true, false);
|
||||
const token = challenge(await callback());
|
||||
expect((await verify(token, otp.generate())).status).toBe(200);
|
||||
});
|
||||
|
||||
it('requires the authenticator for an OIDC-only account', async () => {
|
||||
const { user, otp } = await account();
|
||||
await env.server.stores.user.update(user.id, { password: null });
|
||||
const token = challenge(await callback());
|
||||
const response = await verify(token, otp.generate());
|
||||
expect(await response.json()).toMatchObject({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses distinct short-lived challenges and refuses a modified token', async () => {
|
||||
const { otp } = await account();
|
||||
const first = challenge(await callback());
|
||||
const second = challenge(await callback());
|
||||
expect(first).not.toBe(second);
|
||||
const decoded = env.server.services.token.verify<{
|
||||
purpose: string;
|
||||
jti: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}>('otp', first);
|
||||
expect(decoded.purpose).toBe('otp-login');
|
||||
expect(decoded.jti).toBeTruthy();
|
||||
expect(decoded.exp - decoded.iat).toBe(300);
|
||||
const modified = `${first.slice(0, -4)}abcd`;
|
||||
const rejected = await verify(modified, otp.generate());
|
||||
expect(rejected.status).toBe(400);
|
||||
expect(rejected.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('validates continuation account binding before creating any session', async () => {
|
||||
const { otp } = await account();
|
||||
const issued = challenge(await callback());
|
||||
const decoded = env.server.services.token.verify<
|
||||
Record<string, unknown>
|
||||
>('otp', issued);
|
||||
const continuation = decoded.oidcContinuation as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const mismatched = env.server.services.token.sign('otp', {
|
||||
...decoded,
|
||||
jti: uuidv4(),
|
||||
oidcContinuation: { ...continuation, userUuid: uuidv4() },
|
||||
});
|
||||
const rejected = await verify(mismatched, otp.generate());
|
||||
expect(rejected.status).toBe(400);
|
||||
expect(rejected.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps a signed continuation redirect to the configured origin', async () => {
|
||||
const { otp } = await account();
|
||||
const issued = challenge(await callback());
|
||||
const decoded = env.server.services.token.verify<
|
||||
Record<string, unknown>
|
||||
>('otp', issued);
|
||||
const continuation = decoded.oidcContinuation as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const redirected = env.server.services.token.sign('otp', {
|
||||
...decoded,
|
||||
jti: uuidv4(),
|
||||
oidcContinuation: {
|
||||
...continuation,
|
||||
redirectUri: 'https://elsewhere.example/',
|
||||
},
|
||||
});
|
||||
const accepted = await verify(redirected, otp.generate());
|
||||
expect(await accepted.json()).toMatchObject({
|
||||
proceed: true,
|
||||
redirectUrl: env.origin,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves ordinary OIDC login for accounts without 2FA', async () => {
|
||||
await account(false);
|
||||
const response = await callback('login', { return_to: '/desktop' });
|
||||
expect(response.headers.get('location')).toBe(`${env.origin}/desktop`);
|
||||
expect(
|
||||
response.headers.get('set-cookie')?.includes('puter_auth_token='),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,978 +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 { makeActor } from '../../core/actor.js';
|
||||
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 () => {
|
||||
// The cap has its own suite; these tests need many teams.
|
||||
env = await setupPuterTestEnv({
|
||||
teams_enabled: true,
|
||||
max_teams_per_user: 100,
|
||||
} 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) }),
|
||||
});
|
||||
|
||||
/** An app token acting for `owner`, minted the way the dashboard does. */
|
||||
const makeAppToken = async (owner: { username: string }) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `team-http-app-${crypto.randomUUID()}`,
|
||||
title: 'Team app',
|
||||
index_url: `https://team-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id! },
|
||||
);
|
||||
return env.server.services.auth.getUserAppToken(actor, app.uid);
|
||||
};
|
||||
|
||||
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
|
||||
const makeScopedToken = async (owner: {
|
||||
username: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const anchor = `/${owner.username}/${crypto.randomUUID()}`;
|
||||
await env.server.services.fs.mkdir(user!.id, {
|
||||
path: anchor,
|
||||
createMissingParents: true,
|
||||
});
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
owner.token,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(
|
||||
actor!,
|
||||
[[`fs:${entry!.uid}:list`]],
|
||||
{ label: 'teams-scope' },
|
||||
);
|
||||
};
|
||||
|
||||
/** 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('deletes a seat over HTTP only once it is disabled', async () => {
|
||||
const { team, memberUsername } = await makeTeam();
|
||||
const path = `/teams/${team.uid}/members/${memberUsername}`;
|
||||
|
||||
const live = await call('DELETE', path, env.users.user.token);
|
||||
expect(live.status).toBe(409);
|
||||
expect(await live.json()).toMatchObject({
|
||||
code: 'account_must_be_disabled_first',
|
||||
});
|
||||
|
||||
await call('POST', `${path}/disable`, env.users.user.token);
|
||||
const gone = await call('DELETE', path, env.users.user.token);
|
||||
|
||||
expect(gone.status).toBe(200);
|
||||
expect(
|
||||
await env.server.stores.user.getByUsername(memberUsername),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
// -- the directory ------------------------------------------------
|
||||
|
||||
it('keeps the directory shut until the team opts in', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const before = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
env.users.user.token,
|
||||
);
|
||||
// 404, not 403: whether this is on is not an app's to probe for.
|
||||
expect(before.status).toBe(404);
|
||||
|
||||
const on = await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
|
||||
directory_enabled: true,
|
||||
});
|
||||
expect(on.status).toBe(200);
|
||||
expect(await on.json()).toMatchObject({ directory_enabled: true });
|
||||
|
||||
const after = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as { items: { username: string }[] };
|
||||
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
|
||||
|
||||
// A scoped token was minted for one thing; a roster is not it.
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
const narrow = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
typeof scoped === 'string' ? scoped : scoped.token,
|
||||
);
|
||||
expect(narrow.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses the directory to someone outside the team', async () => {
|
||||
const { team } = await makeTeam();
|
||||
await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
|
||||
directory_enabled: true,
|
||||
});
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- app actors, gated by the same opt-in as the directory --------
|
||||
//
|
||||
// Built through the service, not the wire: these routes share a
|
||||
// rate-limit budget with the mutation tests above, and every HTTP
|
||||
// create/update here would eat into that same window.
|
||||
|
||||
/** A team owned by `user`, created and toggled through the service. */
|
||||
const makeServiceTeam = async () => {
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
return { team, ownerId: owner.id };
|
||||
};
|
||||
|
||||
it('omits a team from an app token until the directory opts in', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call('GET', '/teams', appToken);
|
||||
expect(before.status).toBe(200);
|
||||
const beforeBody = (await before.json()) as { items: { uid: string }[] };
|
||||
expect(beforeBody.items.some((t) => t.uid === team.uid)).toBe(false);
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call('GET', '/teams', appToken);
|
||||
expect(after.status).toBe(200);
|
||||
const afterBody = (await after.json()) as { items: { uid: string }[] };
|
||||
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not count a team it hides from an app token', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const res = await call('GET', '/teams?includeTotal=true', appToken);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { uid: string }[];
|
||||
total: number;
|
||||
};
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(false);
|
||||
// A count over rows the page withholds reports the hidden team.
|
||||
expect(body.total).toBe(body.items.length);
|
||||
});
|
||||
|
||||
it('still lists a team to its owner session with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.token);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('lists a team to a full-access API token with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.apiToken);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('404s /members to an app token until the directory opts in, then gives usernames only', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const username = `svc_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username,
|
||||
});
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(before.status).toBe(404);
|
||||
expect(await before.json()).toMatchObject({ code: 'team_not_found' });
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(body.items.length).toBeGreaterThan(0);
|
||||
// An app gets username only — no org_owned, created_at, or uuid.
|
||||
for (const m of body.items) expect(Object.keys(m)).toEqual(['username']);
|
||||
});
|
||||
|
||||
it('still refuses an app token on GET /teams/:uid', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const res = await call('GET', `/teams/${team.uid}`, appToken);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('excludes a never-activated or suspended seat from an app`s view of /members', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const neverActivated = `svc_na_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username: neverActivated,
|
||||
});
|
||||
const suspendedUsername = `svc_su_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const suspended = await env.server.services.team.provisionAccount(
|
||||
team.uid,
|
||||
ownerId,
|
||||
{ username: suspendedUsername },
|
||||
);
|
||||
await env.server.services.team.disableMember(
|
||||
team.uid,
|
||||
ownerId,
|
||||
suspended.userId,
|
||||
);
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
const appView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(appView.status).toBe(200);
|
||||
const appItems = (
|
||||
(await appView.json()) as { items: Record<string, unknown>[] }
|
||||
).items;
|
||||
for (const m of appItems) expect(Object.keys(m)).toEqual(['username']);
|
||||
const appUsernames = appItems.map((m) => m.username);
|
||||
expect(appUsernames).not.toContain(neverActivated);
|
||||
expect(appUsernames).not.toContain(suspendedUsername);
|
||||
// The owner's own seat is active, so it still shows.
|
||||
expect(appUsernames).toContain(env.users.user.username);
|
||||
|
||||
// The owner's own session sees every seat regardless of status.
|
||||
const ownerView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(ownerView.status).toBe(200);
|
||||
const ownerUsernames = (
|
||||
(await ownerView.json()) as { items: { username: string }[] }
|
||||
).items.map((m) => m.username);
|
||||
expect(ownerUsernames).toContain(neverActivated);
|
||||
expect(ownerUsernames).toContain(suspendedUsername);
|
||||
});
|
||||
|
||||
it('gives a full-access API token the owner`s uuid on /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.apiToken,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { username: string; uuid?: string }[];
|
||||
};
|
||||
const owner = body.items.find(
|
||||
(m) => m.username === env.users.user.username,
|
||||
);
|
||||
expect(owner?.uuid).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('refuses a scoped access token on GET /teams and /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
|
||||
const teamsRes = await call('GET', '/teams', scoped);
|
||||
expect(teamsRes.status).toBe(403);
|
||||
expect(await teamsRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
|
||||
const membersRes = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
scoped,
|
||||
);
|
||||
expect(membersRes.status).toBe(403);
|
||||
expect(await membersRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('gives seat uuids to the owner and to nobody else', async () => {
|
||||
// Built through the service, not the wire: provisioning shares a
|
||||
// rate-limit budget with the seat tests below, and one more HTTP
|
||||
// provision here 429s them.
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const slug0 = Math.random().toString(36).slice(2, 9);
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: `uuidt-${slug0}`,
|
||||
});
|
||||
const memberUsername = `seat_${slug0}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, owner.id, {
|
||||
username: memberUsername,
|
||||
});
|
||||
|
||||
const owned = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, env.users.user.token)
|
||||
).json()) as { items: { username: string; uuid?: string }[] };
|
||||
const seat = owned.items.find((m) => m.username === memberUsername);
|
||||
// Billing keys a seat's plan on this, so the owner cannot act without it.
|
||||
expect(seat?.uuid).toEqual(expect.any(String));
|
||||
|
||||
// A throwaway member, not the shared fixture: joining a team is
|
||||
// permanent and would follow `other` into every later test.
|
||||
const slug = Math.random().toString(36).slice(2, 9);
|
||||
const joiner = await env.server.stores.user.create({
|
||||
username: `joiner_${slug}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: 'hashed',
|
||||
email: `joiner_${slug}@test.local`,
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, joiner.id, {
|
||||
orgOwned: false,
|
||||
});
|
||||
const { token } =
|
||||
await env.server.services.auth.createSessionToken(joiner);
|
||||
|
||||
const seen = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, token)
|
||||
).json()) as { items: { uuid?: string }[] };
|
||||
expect(seen.items.length).toBeGreaterThan(0);
|
||||
for (const m of seen.items) expect(m.uuid).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- 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: '[email protected]',
|
||||
},
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
// -- sharing with a team -------------------------------------
|
||||
|
||||
it('names the team it shared with instead of an empty recipient', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const file = `/${env.users.user.username}/Documents/label-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.txt`;
|
||||
const written = await call('POST', '/fs/write', env.users.user.token, {
|
||||
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
|
||||
fileContent: 'abc',
|
||||
});
|
||||
expect(written.status).toBe(200);
|
||||
|
||||
const shared = await call('POST', '/share', env.users.user.token, {
|
||||
recipients: [{ team: team.uid }],
|
||||
items: [file],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(shared.status).toBe(200);
|
||||
const body = (await shared.json()) as {
|
||||
results: { recipient: string; status: string }[];
|
||||
};
|
||||
|
||||
expect(body.results[0].status).toBe('success');
|
||||
// Echoes the identifier the caller named; '' would leave a client with
|
||||
// nothing to render or to match its request against.
|
||||
expect(body.results[0].recipient).toBe(team.uid);
|
||||
});
|
||||
|
||||
it('shares with two teams rather than collapsing them into one', async () => {
|
||||
const second = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Second',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
expect(second.status).toBe(200);
|
||||
const teamB = (await second.json()) as { uid: string };
|
||||
const { team: teamA } = await makeTeam();
|
||||
|
||||
const file = `/${env.users.user.username}/Documents/two-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.txt`;
|
||||
const written = await call('POST', '/fs/write', env.users.user.token, {
|
||||
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
|
||||
fileContent: 'abc',
|
||||
});
|
||||
const fileUid = ((await written.json()) as { fsEntry: { uid: string } })
|
||||
.fsEntry.uid;
|
||||
|
||||
const shared = await call('POST', '/share', env.users.user.token, {
|
||||
recipients: [{ team: teamA.uid }, { team: teamB.uid }],
|
||||
items: [file],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(shared.status).toBe(200);
|
||||
const body = (await shared.json()) as {
|
||||
results: { recipient: string; status: string }[];
|
||||
};
|
||||
|
||||
expect(body.results).toHaveLength(2);
|
||||
expect(body.results.every((r) => r.status === 'success')).toBe(true);
|
||||
|
||||
// The response shape alone proves nothing: `results` is built from the
|
||||
// request's pairs, so a collapsed pair still reports two successes with
|
||||
// the right labels. Only the shares that exist afterwards show it.
|
||||
const mine = await call(
|
||||
'GET',
|
||||
'/share/shared-by-me?limit=100',
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(mine.status).toBe(200);
|
||||
const listing = (await mine.json()) as {
|
||||
items: { uid_entry?: string; holder_team?: { uid: string } }[];
|
||||
};
|
||||
const holders = listing.items
|
||||
.filter((i) => i.uid_entry === fileUid)
|
||||
.map((i) => i.holder_team?.uid)
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
expect(holders).toEqual([teamA.uid, teamB.uid].sort());
|
||||
});
|
||||
|
||||
it('names the team by handle when the caller shared by handle', async () => {
|
||||
const handle = randomHandle();
|
||||
const res = await call('POST', '/teams', env.users.user.token, {
|
||||
name: 'Byhandle',
|
||||
handle,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const file = `/${env.users.user.username}/Documents/handle-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.txt`;
|
||||
await call('POST', '/fs/write', env.users.user.token, {
|
||||
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
|
||||
fileContent: 'abc',
|
||||
});
|
||||
|
||||
const shared = await call('POST', '/share', env.users.user.token, {
|
||||
recipients: [{ teamHandle: handle }],
|
||||
items: [file],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(shared.status).toBe(200);
|
||||
const body = (await shared.json()) as {
|
||||
results: { recipient: string }[];
|
||||
};
|
||||
expect(body.results[0].recipient).toBe(handle);
|
||||
});
|
||||
|
||||
it('gives a non-member 404 on the member view, not an empty page', async () => {
|
||||
const { team } = await makeTeam();
|
||||
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit/me`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- the forced-change gate ---------------------------------------
|
||||
|
||||
/**
|
||||
* A seat that has signed in on its temporary password. Email confirmation
|
||||
* is cleared first so the gate under test is the one that answers.
|
||||
*/
|
||||
const signedInSeat = async (teamUid: string) => {
|
||||
const username = `seat_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${teamUid}/members`,
|
||||
env.users.user.token,
|
||||
{ username, email: `${username}@test.local` },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const { temporary_password: password } = (await res.json()) as {
|
||||
temporary_password: string;
|
||||
};
|
||||
|
||||
const row = (await env.server.stores.user.getByUsername(username))!;
|
||||
await env.server.stores.user.update(row.id, {
|
||||
requires_email_confirmation: false,
|
||||
email_confirmed: true,
|
||||
});
|
||||
await env.server.stores.user.invalidateById(row.id);
|
||||
|
||||
const fresh = (await env.server.stores.user.getById(row.id))!;
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
fresh,
|
||||
{ user_agent: 'puter-test-seat' },
|
||||
);
|
||||
return { username, password, userId: row.id, token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Cookie-credentialed on the GUI origin, as the user-protected gate insists.
|
||||
* Sent with the seat's own user agent: the session touch otherwise rewrites
|
||||
* it to Node's once a second boundary passes.
|
||||
*/
|
||||
const changePassword = (token: string, password: string, next: string) =>
|
||||
fetch(new URL('/user-protected/change-password', env.origin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'puter-test-seat',
|
||||
authorization: `Bearer ${token}`,
|
||||
cookie: `puter_auth_token=${token}`,
|
||||
},
|
||||
body: JSON.stringify({ password, new_pass: next }),
|
||||
});
|
||||
|
||||
it('signs the seat in but refuses every authenticated route after that', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
|
||||
const res = await call('GET', '/teams', seat.token);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.text()).toContain('password_change_required');
|
||||
});
|
||||
|
||||
it('leaves change-password reachable, since it is what clears the gate', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
|
||||
const changed = await changePassword(
|
||||
seat.token,
|
||||
seat.password,
|
||||
'chosen-by-the-member',
|
||||
);
|
||||
expect(changed.status).toBe(200);
|
||||
|
||||
const row = await env.server.stores.user.getByProperty(
|
||||
'id',
|
||||
seat.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(Number(row?.requires_password_change)).toBe(0);
|
||||
expect(row?.temp_password_expires_at ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('admits the seat to the product once it has chosen a password', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
expect(
|
||||
(await changePassword(seat.token, seat.password, 'my-own-password'))
|
||||
.status,
|
||||
).toBe(200);
|
||||
|
||||
// The session that changed the password is the one that survives.
|
||||
const res = await call('GET', '/teams', seat.token);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('closes re-issue behind a seat that has activated', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await changePassword(seat.token, seat.password, 'a-password-of-mine');
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members/${seat.username}/activation`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('shows the member the reset and their own sign-in, and nothing else', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await changePassword(seat.token, seat.password, 'chosen-once-already');
|
||||
|
||||
const reset = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members/${seat.username}/password-reset`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(reset.status).toBe(200);
|
||||
const { temporary_password: issued } = (await reset.json()) as {
|
||||
temporary_password: string;
|
||||
};
|
||||
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getById(seat.userId))!,
|
||||
{ user_agent: 'puter-test-seat' },
|
||||
);
|
||||
const mine = await call('GET', `/teams/${team.uid}/audit/me`, token);
|
||||
// The seat owes a password change again, so its own view is all it reaches.
|
||||
expect(mine.status).toBe(403);
|
||||
|
||||
const admin = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/audit`,
|
||||
env.users.user.token,
|
||||
);
|
||||
const body = (await admin.json()) as { items: { action: string }[] };
|
||||
expect(body.items.map((e) => e.action)).toContain(
|
||||
'reset_member_password',
|
||||
);
|
||||
expect(JSON.stringify(body)).not.toContain(issued);
|
||||
});
|
||||
|
||||
it('shows an activated seat its own reset and sign-in over the wire', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await changePassword(seat.token, seat.password, 'the-one-i-picked');
|
||||
|
||||
const res = await call('GET', `/teams/${team.uid}/audit/me`, seat.token);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: {
|
||||
action: string;
|
||||
username: string | null;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
}[];
|
||||
};
|
||||
expect(body.items.map((e) => e.action)).toContain('activate');
|
||||
|
||||
const tell = body.items.find((e) => e.action === 'sign_in');
|
||||
expect(tell?.username).toBe(seat.username);
|
||||
expect(tell?.user_agent).toBe('puter-test-seat');
|
||||
// Only their own; the team owner's entries are not theirs to read.
|
||||
expect(
|
||||
body.items.every((e) => e.username === seat.username),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a temporary password that was never used in time', async () => {
|
||||
const { team } = await makeTeam();
|
||||
const seat = await signedInSeat(team.uid);
|
||||
await env.server.stores.user.update(seat.userId, {
|
||||
temp_password_expires_at: Math.floor(Date.now() / 1000) - 1,
|
||||
});
|
||||
await env.server.stores.user.invalidateById(seat.userId);
|
||||
|
||||
const res = await fetch(new URL('/login', env.origin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: seat.username,
|
||||
password: seat.password,
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.text()).toContain('temporary_password_expired');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,565 +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 type { Request, Response } from 'express';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
} from '../../core/http/decorators.js';
|
||||
import { isAccountContext } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { TeamRow } from '../../stores/team/TeamStore.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
/** Mirrors ShareController's dual-window shape. */
|
||||
const TEAM_LIMIT = [
|
||||
{ scope: 'team:mutate', limit: 60, window: 60_000, key: 'user' as const },
|
||||
{
|
||||
scope: 'team:mutate-daily',
|
||||
limit: 500,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/** Tighter than TEAM_LIMIT: a reset hands the admin a working credential. */
|
||||
const TEAM_RESET_LIMIT = [
|
||||
...TEAM_LIMIT,
|
||||
{
|
||||
scope: 'team:reset-password',
|
||||
limit: 20,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const TEAM_READ_LIMIT = {
|
||||
scope: 'team:read',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'user' as const,
|
||||
};
|
||||
|
||||
/** What a team looks like on the wire. `id` stays internal. */
|
||||
const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
|
||||
uid: team.uid,
|
||||
name: team.name,
|
||||
handle: team.handle,
|
||||
is_owner: isOwner,
|
||||
created_at: team.created_at,
|
||||
directory_enabled: Number(team.directory_enabled) === 1,
|
||||
require_2fa: Number(team.require_2fa) === 1,
|
||||
});
|
||||
|
||||
@Controller('/teams')
|
||||
export class TeamController extends PuterController {
|
||||
// Most routes gate with `requireUserActor`; `listTeams` and `listMembers`
|
||||
// use `requireAuth` so an app can read a team whose owner opted into the
|
||||
// directory (see `directory_enabled`).
|
||||
|
||||
/** Off means `/teams` 404s and no team route is registered at all. */
|
||||
isEnabled(): boolean {
|
||||
return this.config.teams_enabled === true;
|
||||
}
|
||||
|
||||
@Post('', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async createTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
const body = this.#body(req);
|
||||
|
||||
const team = await this.services.team.createTeam(userId, {
|
||||
name: this.#requireString(body.name, 'name'),
|
||||
handle:
|
||||
body.handle === undefined || body.handle === null
|
||||
? null
|
||||
: this.#requireString(body.handle, 'handle'),
|
||||
});
|
||||
res.json(toClientTeam(team, true));
|
||||
}
|
||||
|
||||
@Get('', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listTeams(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
// In SQL, or cursor and total describe teams the app cannot see.
|
||||
const page = await this.stores.team.listTeamsForUser(userId, {
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
includeTotal: req.query.includeTotal === 'true',
|
||||
openOnly: !isAccountContext(req.actor),
|
||||
});
|
||||
res.json({
|
||||
items: page.items.map((t) =>
|
||||
toClientTeam(t, t.owner_user_id === userId),
|
||||
),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total === undefined ? {} : { total: page.total }),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async getTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const team = await this.services.team.requireMembership(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
);
|
||||
res.json(toClientTeam(team, team.owner_user_id === userId));
|
||||
}
|
||||
|
||||
@Put('/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async updateTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.services.team.requireOwner(this.#param(req, 'uid'), userId);
|
||||
const body = this.#body(req);
|
||||
|
||||
const changes: {
|
||||
name?: string;
|
||||
handle?: string | null;
|
||||
directoryEnabled?: boolean;
|
||||
require2fa?: boolean;
|
||||
} = {};
|
||||
if (body.name !== undefined)
|
||||
changes.name = this.#requireString(body.name, 'name');
|
||||
if (body.handle !== undefined)
|
||||
changes.handle = body.handle === null ? null : String(body.handle);
|
||||
if (body.directory_enabled !== undefined)
|
||||
changes.directoryEnabled = body.directory_enabled === true;
|
||||
if (body.require_2fa !== undefined)
|
||||
changes.require2fa = body.require_2fa === true;
|
||||
|
||||
// Through the service: turning the directory on writes an audit row.
|
||||
const team = await this.services.team.updateTeam(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
changes,
|
||||
);
|
||||
res.json(toClientTeam(team, true));
|
||||
}
|
||||
|
||||
@Delete('/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async deleteTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.services.team.deleteTeam(this.#param(req, 'uid'), userId);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// -- Members ------------------------------------------------------
|
||||
|
||||
@Get('/:uid/members', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listMembers(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const team = await this.services.team.requireMembership(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
);
|
||||
const accountContext = isAccountContext(req.actor);
|
||||
// Same consent as the directory: an app only reads a team that opted in.
|
||||
if (!accountContext) this.services.team.assertDirectoryOpen(team);
|
||||
|
||||
// uuid only when the team owner calls with their own session or API
|
||||
// token; an app needing a stable id uses `listDirectory()` instead.
|
||||
const showUuid = accountContext && team.owner_user_id === userId;
|
||||
|
||||
const page = await this.stores.team.listMembers(
|
||||
this.#param(req, 'uid'),
|
||||
{
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
// An app's view matches the directory's: active seats only.
|
||||
activeOnly: !accountContext,
|
||||
},
|
||||
);
|
||||
// An app only ever gets a username; org_owned/created_at are account-only.
|
||||
res.json({
|
||||
items: page.items.map((m) =>
|
||||
accountContext
|
||||
? {
|
||||
username: m.username,
|
||||
org_owned: Number(m.org_owned) === 1,
|
||||
created_at: m.created_at,
|
||||
...(showUuid ? { uuid: m.uuid } : {}),
|
||||
}
|
||||
: { username: m.username },
|
||||
),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** App actors too, once opted in; carries the `uuid` `/members` hides. */
|
||||
@Get('/:uid/directory', {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listDirectory(req: Request, res: Response): Promise<void> {
|
||||
// The membership is the person's, never the app's.
|
||||
const userId = this.#requireUserId(req);
|
||||
// A scoped token holds only what it was minted for; a roster is not that.
|
||||
const token = req.actor?.accessToken;
|
||||
if (token && token.fullAccess !== true) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This endpoint is not available to scoped access tokens',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
const page = await this.services.team.listDirectory(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
{
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
res.json(page);
|
||||
}
|
||||
|
||||
@Post('/:uid/members', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async createMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority before shape, or a stranger learns if their body parsed.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
|
||||
const body = this.#body(req);
|
||||
const result = await this.services.team.provisionAccount(
|
||||
uid,
|
||||
userId,
|
||||
{
|
||||
username: this.#requireString(body.username, 'username'),
|
||||
email: this.#optionalString(body.email, 'email'),
|
||||
},
|
||||
// The owner's request is what the signup gate gets to judge.
|
||||
{ req, fingerprint: req.deviceFingerprint ?? null },
|
||||
);
|
||||
// Shown once; the admin delivers it out of band.
|
||||
res.json({
|
||||
username: result.username,
|
||||
temporary_password: result.temporaryPassword,
|
||||
});
|
||||
}
|
||||
|
||||
// Same budget as a reset: both hand the administrator a working credential,
|
||||
// and a reset re-arms the flag this route needs.
|
||||
@Post('/:uid/members/:username/activation', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_RESET_LIMIT,
|
||||
})
|
||||
async reissueCredential(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
const { temporaryPassword } =
|
||||
await this.services.team.reissueCredential(uid, userId, target);
|
||||
// Shown once for the admin to deliver out of band.
|
||||
res.json({ temporary_password: temporaryPassword });
|
||||
}
|
||||
|
||||
@Post('/:uid/members/:username/password-reset', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_RESET_LIMIT,
|
||||
})
|
||||
async resetMemberPassword(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
const { temporaryPassword } =
|
||||
await this.services.team.resetMemberPassword(uid, userId, target);
|
||||
// Shown once; it is not retrievable afterwards.
|
||||
res.json({ temporary_password: temporaryPassword });
|
||||
}
|
||||
|
||||
// The gate closes the seat's own route to fix it, so the owner must.
|
||||
@Post('/:uid/members/:username/email', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async updateSeatEmail(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.updateSeatEmail(
|
||||
uid,
|
||||
userId,
|
||||
target,
|
||||
(req.body as { email?: unknown } | undefined)?.email ?? null,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// Same budget as a password reset: both are a step toward the account.
|
||||
@Post('/:uid/members/:username/2fa-reset', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_RESET_LIMIT,
|
||||
})
|
||||
async resetMemberTwoFactor(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.resetMemberTwoFactor(uid, userId, target);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Post('/:uid/members/:username/disable', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async disableMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.disableMember(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
target,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Post('/:uid/members/:username/enable', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async enableMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.enableMember(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
target,
|
||||
);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Delete('/:uid/members/:username', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_LIMIT,
|
||||
})
|
||||
async deleteMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first (anti-oracle); deleted team included, see the service.
|
||||
await this.services.team.requireOwnedTeam(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.deleteMember(uid, userId, target);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// -- Audit --------------------------------------------------------
|
||||
|
||||
@Get('/:uid/audit', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listAudit(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
res.json(
|
||||
await this.services.team.listAudit(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
this.#pageOpts(req),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('/:uid/audit/me', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listOwnAudit(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
res.json(
|
||||
await this.services.team.listOwnAudit(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
this.#pageOpts(req),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------
|
||||
|
||||
/** `limit` and `cursor` as doc/pagination.md defines them. */
|
||||
#pageOpts(req: Request): { limit?: unknown; cursor?: string } {
|
||||
return {
|
||||
limit: req.query.limit,
|
||||
cursor:
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain-allowlist gate, on the two routes that enter the feature. Same
|
||||
* 404 as a teams-off deployment; other routes bound by membership.
|
||||
*/
|
||||
async #requireTeamsAvailable(req: Request, userId: number): Promise<void> {
|
||||
const email = (
|
||||
req.actor as { user?: { email?: string | null } } | undefined
|
||||
)?.user?.email;
|
||||
if (await this.services.team.teamsAvailableTo(userId, email)) return;
|
||||
throw new HttpError(404, 'Not found', { legacyCode: 'not_found' });
|
||||
}
|
||||
|
||||
#requireUserId(req: Request): number {
|
||||
const id = (req.actor as { user?: { id?: number } } | undefined)?.user
|
||||
?.id;
|
||||
if (!id)
|
||||
throw new HttpError(401, 'User required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Express types a param as `string | string[]`; routes here take one. */
|
||||
#param(req: Request, name: string): string {
|
||||
const value = req.params[name];
|
||||
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '');
|
||||
}
|
||||
|
||||
#body(req: Request): Record<string, unknown> {
|
||||
return (req.body ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Absent or empty means "not given"; a wrong type is still a 400. */
|
||||
#optionalString(value: unknown, field: string): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, `${field} must be a string`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value.trim() === '' ? null : value;
|
||||
}
|
||||
|
||||
#requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new HttpError(400, `${field} is required`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
#notFound(): HttpError {
|
||||
return new HttpError(404, 'Team not found', {
|
||||
legacyCode: 'team_not_found',
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves `:username` to an id; the service decides whether it may act. */
|
||||
async #requireTargetUserId(req: Request): Promise<number> {
|
||||
const user = await this.stores.user.getByUsername(
|
||||
this.#param(req, 'username'),
|
||||
);
|
||||
if (!user)
|
||||
throw new HttpError(404, 'Not an account of this team', {
|
||||
legacyCode: 'not_an_org_account',
|
||||
});
|
||||
return user.id;
|
||||
}
|
||||
}
|
||||
@@ -1,220 +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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A team manages accounts and cannot read them. Nobody writes that grant
|
||||
* deliberately, but a new implicator or a widened actor would create it and no
|
||||
* other test would fail. Asserts outcomes, never that an implicator is absent.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
describe('a team cannot read its members data', () => {
|
||||
let env: PuterTestEnv;
|
||||
let teamUid: string;
|
||||
let memberUsername: string;
|
||||
let memberFile: string;
|
||||
let ownerFile: string;
|
||||
let memberToken: string;
|
||||
|
||||
/** Written directly: these tests are about reading, not about writing. */
|
||||
const makeFile = async (username: string, label: string) => {
|
||||
const uid = crypto.randomUUID();
|
||||
const name = `${label}-${uid.slice(0, 8)}.txt`;
|
||||
const path = `/${username}/${name}`;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uid,
|
||||
name,
|
||||
path,
|
||||
user!.id,
|
||||
// `is_dir` is a real boolean on postgres.
|
||||
env.server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
return path;
|
||||
};
|
||||
|
||||
const stat = (path: string, token: string) =>
|
||||
fetch(new URL('/stat', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
|
||||
const readdir = (path: string, token: string) =>
|
||||
fetch(new URL('/readdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// The cap has its own suite; these tests need many teams.
|
||||
env = await setupPuterTestEnv({
|
||||
teams_enabled: true,
|
||||
max_teams_per_user: 100,
|
||||
} as IConfig);
|
||||
|
||||
// A provisioned account cannot authenticate until it activates.
|
||||
const res = await fetch(new URL('/teams', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.users.user.token}`,
|
||||
},
|
||||
body: JSON.stringify({ name: 'Isolation', handle: `iso-${Date.now()}` }),
|
||||
});
|
||||
teamUid = ((await res.json()) as { uid: string }).uid;
|
||||
|
||||
// A real provisioned seat, activated so it can authenticate.
|
||||
memberUsername = `iso_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(
|
||||
teamUid,
|
||||
(await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!.id,
|
||||
{ username: memberUsername, email: `${memberUsername}@test.local` },
|
||||
);
|
||||
const member = await env.server.stores.user.getByUsername(memberUsername);
|
||||
await env.server.stores.user.update(member!.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
requires_password_change: 0,
|
||||
});
|
||||
memberToken = (
|
||||
await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getByUsername(memberUsername))!,
|
||||
)
|
||||
).token;
|
||||
|
||||
memberFile = await makeFile(memberUsername, 'member');
|
||||
ownerFile = await makeFile(env.users.user.username, 'owner');
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
// -- refused ------------------------------------------------------
|
||||
|
||||
it('refuses the team owner a members file', async () => {
|
||||
const res = await stat(memberFile, env.users.user.token);
|
||||
// Administering, paying for, and reading an account are three things.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('refuses the team owner a members home directory', async () => {
|
||||
const res = await readdir(`/${memberUsername}`, env.users.user.token);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('refuses a full-access token no less than a session', async () => {
|
||||
// `#scanAccessToken` re-scans the issuer as a plain user actor.
|
||||
const res = await stat(memberFile, env.users.user.apiToken);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('grants no team-wide reach through the team routes', async () => {
|
||||
// There is no route to a member's files or KV, and none should appear.
|
||||
for (const path of [
|
||||
`/teams/${teamUid}/files`,
|
||||
`/teams/${teamUid}/members/${memberUsername}/files`,
|
||||
`/teams/${teamUid}/kv`,
|
||||
]) {
|
||||
const res = await fetch(new URL(path, env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${env.users.user.token}` },
|
||||
});
|
||||
expect(res.status, path).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
// -- allowed ------------------------------------------------------
|
||||
|
||||
it('lets the team owner read its own files', async () => {
|
||||
const res = await stat(ownerFile, env.users.user.token);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a member read their own files', async () => {
|
||||
const res = await stat(memberFile, memberToken);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('lets an explicit grant through, as an ordinary share', async () => {
|
||||
const member = await env.server.stores.user.getByUsername(
|
||||
memberUsername,
|
||||
);
|
||||
const [entry] = (await env.server.clients.db.read(
|
||||
'SELECT `uuid` FROM `fsentries` WHERE `path` = ?',
|
||||
[memberFile],
|
||||
)) as { uuid: string }[];
|
||||
|
||||
// Only the member's own grant changes, never team authority.
|
||||
const before = await stat(memberFile, env.users.user.token);
|
||||
expect(before.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
await env.server.services.permission.grantUserUserPermission(
|
||||
makeActor({ user: member!, app: null, accessToken: null } as never),
|
||||
env.users.user.username,
|
||||
`fs:${entry.uuid}:read`,
|
||||
);
|
||||
|
||||
const after = await stat(memberFile, env.users.user.token);
|
||||
expect(after.status).toBe(200);
|
||||
});
|
||||
|
||||
it('disabling a member neither grants nor transfers their files', async () => {
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const member = await env.server.stores.user.getByUsername(
|
||||
memberUsername,
|
||||
);
|
||||
// A fresh file: the earlier test granted the owner read on `memberFile`.
|
||||
const untouched = await makeFile(memberUsername, 'untouched');
|
||||
|
||||
await env.server.services.team.disableMember(
|
||||
teamUid,
|
||||
owner!.id,
|
||||
member!.id,
|
||||
);
|
||||
|
||||
// The member loses access to their own files...
|
||||
const asMember = await stat(untouched, memberToken);
|
||||
expect(asMember.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// ...and the team still does not gain it.
|
||||
const asOwner = await stat(untouched, env.users.user.token);
|
||||
expect(asOwner.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus
|
||||
// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter
|
||||
import { installSeatStub } from '../../testFixtures/seatStub.js';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
@@ -736,9 +737,9 @@ describe('WebDAVController', () => {
|
||||
otp_enabled: 1,
|
||||
otp_secret: secret,
|
||||
});
|
||||
const seat = vi
|
||||
.spyOn(server.stores.team, 'getOrgSeat')
|
||||
.mockResolvedValue({ require_2fa: 1 } as never);
|
||||
// Teams is optional, so the gate's input is stood up directly.
|
||||
installSeatStub(server as never, [id], { require2fa: true });
|
||||
const seat = vi.spyOn(server.stores.team as never, 'getOrgSeat');
|
||||
try {
|
||||
const code = new TOTP({
|
||||
issuer: 'puter.com',
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Team2faLookup } from '../../core/http/middleware/gates.js';
|
||||
import { compare as bcryptCompare } from 'bcrypt';
|
||||
import type { Request, Response } from 'express';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
@@ -288,7 +289,7 @@ export class WebDAVController extends PuterController {
|
||||
// filesystem over the `dav` subdomain, bypassing the gates.
|
||||
assertNotSuspended(actor.user);
|
||||
assertVerifiedAccount(actor.user);
|
||||
await assertTeam2fa(actor.user, this.stores.team);
|
||||
await assertTeam2fa(actor.user, this.stores.team as Team2faLookup);
|
||||
|
||||
// The actor was absent when the auth probe ran, so anything that reads
|
||||
// it off the request or the context — shared-path masking, egress
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
createNativeAppStatic,
|
||||
createUserSubdomainNotFound,
|
||||
createWwwRedirect,
|
||||
subdomainLabelOf,
|
||||
type ResolvedClaim,
|
||||
type SubdomainClaim,
|
||||
} from './hostRedirects';
|
||||
@@ -676,6 +677,32 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('subdomainLabelOf', () => {
|
||||
it('returns the single label under the domain, ignoring case and port', () => {
|
||||
expect(subdomainLabelOf('shop.puter.com', 'puter.com')).toBe('shop');
|
||||
expect(subdomainLabelOf('SHOP.Puter.com:4100', 'puter.com')).toBe(
|
||||
'shop',
|
||||
);
|
||||
expect(subdomainLabelOf('shop.puter.com', 'Puter.com:443')).toBe(
|
||||
'shop',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for anything but exactly one label under the domain', () => {
|
||||
for (const host of [
|
||||
'puter.com',
|
||||
'.puter.com',
|
||||
'a.shop.puter.com',
|
||||
'shop.puter.com.example.test',
|
||||
'shopputer.com',
|
||||
'shop.other.com',
|
||||
]) {
|
||||
expect(subdomainLabelOf(host, 'puter.com')).toBeUndefined();
|
||||
}
|
||||
expect(subdomainLabelOf('shop.puter.com', '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addClaimedSubdomain', () => {
|
||||
const claim: SubdomainClaim = { authorize: () => true };
|
||||
|
||||
|
||||
@@ -99,6 +99,18 @@ export type ClaimLookup = (name: string) => ResolvedClaim | undefined;
|
||||
|
||||
const SUBDOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
/** The label `x` when `host` is exactly `x.<domain>`, ignoring case and port. */
|
||||
export const subdomainLabelOf = (
|
||||
host: string,
|
||||
domain: string,
|
||||
): string | undefined => {
|
||||
const hostName = host.toLowerCase().split(':')[0];
|
||||
const base = domain.toLowerCase().split(':')[0];
|
||||
if (!base || !hostName.endsWith(`.${base}`)) return undefined;
|
||||
const label = hostName.slice(0, -(base.length + 1));
|
||||
return label && !label.includes('.') ? label : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Record a claim on `<name>.<domain>`. Throws on a malformed or uppercase
|
||||
* label, a subdomain core already handles, or a name that is already claimed.
|
||||
@@ -262,7 +274,8 @@ export const createUserSubdomainNotFound = (
|
||||
// responder, body parsing, auth and every route, `subdomain: '*'` ones
|
||||
// included. One label deep only: `<name>.x.<domain>` is not the claim.
|
||||
const claimed =
|
||||
req.subdomains?.length === 1 && hostName === `${active}.${domain}`
|
||||
req.subdomains?.length === 1 &&
|
||||
subdomainLabelOf(hostName, domain) === active
|
||||
? claim
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What core asks of Teams. Nothing here is an implementation: the pair is
|
||||
* registered by whatever provides the feature, and a deployment that does not
|
||||
* run it registers neither, so every caller treats both as absent.
|
||||
*/
|
||||
|
||||
/** A team, as much of one as core reads back. */
|
||||
export interface TeamRow {
|
||||
id: number;
|
||||
uid: string;
|
||||
owner_user_id: number;
|
||||
kind: string;
|
||||
name: string | null;
|
||||
handle: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
/** Whether an app acting for a member may read the member list. */
|
||||
directory_enabled: number;
|
||||
/** Whether the accounts this team provisioned must hold 2FA. */
|
||||
require_2fa: number;
|
||||
}
|
||||
|
||||
/** The seat a user holds, for the gates that ask whether they hold one. */
|
||||
export interface OrgSeatRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
uuid: string;
|
||||
username: string;
|
||||
team_uid: string;
|
||||
team_name: string | null;
|
||||
owner_user_id: number;
|
||||
/** Carried here so the per-request 2FA gate costs no extra read. */
|
||||
require_2fa: number;
|
||||
}
|
||||
|
||||
/** Most members one notification fans out to. */
|
||||
export const NOTIFY_FANOUT_CAP = 500;
|
||||
|
||||
/** The reads core makes of teams it did not create. */
|
||||
export interface TeamStoreContract {
|
||||
getOrgSeat(userId: number): Promise<OrgSeatRow | null>;
|
||||
isMember(teamUid: string, userId: number): Promise<boolean>;
|
||||
getByUid(uid: string): Promise<TeamRow | null>;
|
||||
getByHandle(handle: string): Promise<TeamRow | null>;
|
||||
getByIdIncludingDeleted(id: number): Promise<TeamRow | null>;
|
||||
getByIdsIncludingDeleted(ids: number[]): Promise<Map<number, TeamRow>>;
|
||||
listGroupIdsForUser(userId: number): Promise<number[]>;
|
||||
listMemberIdsByGroupId(groupId: number): Promise<number[]>;
|
||||
memberIdsAmong(teamUid: string, userIds: number[]): Promise<number[]>;
|
||||
countOwned(ownerUserId: number): Promise<number>;
|
||||
countOwnedTeamsWithAccounts(ownerUserId: number): Promise<number>;
|
||||
}
|
||||
|
||||
/** The calls core makes when an account it owns changes. */
|
||||
export interface TeamServiceContract {
|
||||
teamsAvailableTo(userId: number, email: string | null): Promise<boolean>;
|
||||
captureSeatForBilling(userId: number): Promise<unknown | null>;
|
||||
emitSeatDeleted(seat: unknown): void;
|
||||
forgetSeat(seat: unknown): Promise<void>;
|
||||
releaseUnconfirmedSeatEmail(userId: number): Promise<boolean>;
|
||||
recordPasswordSelfChange(userId: number): Promise<void>;
|
||||
}
|
||||
@@ -633,6 +633,40 @@ describe('ChatCompletionDriver exhausted-chain classification', () => {
|
||||
expect(err.message).toBe('unsupported parameter: top_k');
|
||||
});
|
||||
|
||||
it('maps a gateway 4xx with no upstream error body to 400 upstream_provider_unavailable', async () => {
|
||||
const err = await errorFor({
|
||||
status: 400,
|
||||
message: '400 bad response status code 400',
|
||||
error: {
|
||||
type: 'upstream_error',
|
||||
message: 'bad response status code 400',
|
||||
code: 400,
|
||||
},
|
||||
});
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_provider_unavailable',
|
||||
});
|
||||
expect(err.message).toBe('AI provider unavailable');
|
||||
});
|
||||
|
||||
it('keeps a gateway 4xx that carries the vendor message as the caller’s upstream_bad_request', async () => {
|
||||
const err = await errorFor({
|
||||
status: 400,
|
||||
message:
|
||||
'400 bad response status code 400, message: max_tokens too large, body: {}',
|
||||
error: {
|
||||
type: 'upstream_error',
|
||||
message:
|
||||
'bad response status code 400, message: max_tokens too large, body: {}',
|
||||
},
|
||||
});
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a completion with no choices to a 400, not an internal 500', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockImplementation(
|
||||
() =>
|
||||
@@ -809,6 +843,26 @@ describe('ChatCompletionDriver cross-provider fallback', () => {
|
||||
expect(line).toContain('first 422');
|
||||
});
|
||||
|
||||
it('falls back past a gateway 4xx with no upstream error body and marks the route unhealthy', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue(
|
||||
Object.assign(new Error('400 bad response status code 400'), {
|
||||
status: 400,
|
||||
error: { type: 'upstream_error' },
|
||||
}),
|
||||
);
|
||||
vi.spyOn(OpenAiChatProvider.prototype, 'complete').mockResolvedValue({
|
||||
message: { role: 'assistant', content: 'from the fallback' },
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
} as never);
|
||||
|
||||
const result = (await completeShared()) as {
|
||||
message: { content: string };
|
||||
};
|
||||
expect(result.message.content).toBe('from the fallback');
|
||||
expect(isRouteUnhealthy('azure-openai', SHARED_MODEL)).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces the primary route-level 4xx after a non-strict fallback also fails', async () => {
|
||||
vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue(
|
||||
Object.assign(new Error('unsupported parameter: frobnicate'), {
|
||||
|
||||
@@ -323,8 +323,17 @@ const isAuthFailure = (a: ProviderAttempt) =>
|
||||
a.status === 403 ||
|
||||
/unauthorized|forbidden|invalid api key/i.test(a.error));
|
||||
|
||||
/**
|
||||
* A new-api gateway's (Infron's) wrapper for an upstream that answered without
|
||||
* a JSON error body. It mirrors the upstream's status, so the 4xx says nothing
|
||||
* about the caller's request; a real vendor rejection carries its own message.
|
||||
*/
|
||||
const isOpaqueGatewayFailure = (a: ProviderAttempt) =>
|
||||
/bad response status code \d+(?:,\s*body:|$)/i.test(a.error);
|
||||
|
||||
const isUpstream5xx = (a: ProviderAttempt) =>
|
||||
(a.status !== undefined && a.status >= 500) ||
|
||||
isOpaqueGatewayFailure(a) ||
|
||||
/provider returned error|internal server error|service unavailable|bad gateway/i.test(
|
||||
a.error,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import { actorOwnsRow, requireContextActor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { subdomainLabelOf } from '../../core/http/middleware/hostRedirects.js';
|
||||
import { extensionStore } from '../../extensions.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
@@ -719,9 +721,10 @@ export class AppDriver extends PuterDriver {
|
||||
* loading from the GUI host would execute third-party code same-origin with
|
||||
* the desktop — a full sandbox escape. The API host is reserved for the
|
||||
* same reason, and the builtin sentinel host is rewritten by the GUI to
|
||||
* `<gui origin>/builtin/…` (see BUILTIN_APPS_HOST above). Host comparison
|
||||
* (rather than full origin) deliberately also catches scheme/port variants
|
||||
* of these hosts.
|
||||
* `<gui origin>/builtin/…` (see BUILTIN_APPS_HOST above). A subdomain an
|
||||
* extension has claimed is a system host too. Host comparison (rather than
|
||||
* full origin) deliberately also catches scheme/port variants of these
|
||||
* hosts.
|
||||
*/
|
||||
#assertIndexUrlHostAllowed(indexUrl) {
|
||||
let hostname;
|
||||
@@ -753,7 +756,13 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
if (reserved.has(hostname)) {
|
||||
const label = config.domain
|
||||
? subdomainLabelOf(hostname, config.domain)
|
||||
: undefined;
|
||||
if (
|
||||
reserved.has(hostname) ||
|
||||
(label !== undefined && extensionStore.claimedSubdomains.has(label))
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`index_url` cannot point at a Puter system host',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { extensionStore } from '../../extensions.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { decodeCursor } from '../../util/pagination.js';
|
||||
@@ -195,6 +196,105 @@ describe('AppDriver.create', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a claimed subdomain', () => {
|
||||
const claimed = 'claimed-app-host';
|
||||
|
||||
beforeAll(() => {
|
||||
extensionStore.claimedSubdomains.set(claimed, {
|
||||
authorize: () => true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
extensionStore.claimedSubdomains.delete(claimed);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['exactly', `https://${claimed}.puter.localhost/`],
|
||||
[
|
||||
'in uppercase',
|
||||
`https://${claimed.toUpperCase()}.PUTER.localhost/`,
|
||||
],
|
||||
['with a port', `http://${claimed}.puter.localhost:4100/app.html`],
|
||||
])(
|
||||
'rejects an index_url on it %s with 400',
|
||||
async (_label, index_url) => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('claimed-host'),
|
||||
title: 't',
|
||||
index_url,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: /system host/,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['exactly', `https://${claimed}.puter.localhost/`],
|
||||
[
|
||||
'in uppercase',
|
||||
`https://${claimed.toUpperCase()}.PUTER.localhost/`,
|
||||
],
|
||||
['with a port', `http://${claimed}.puter.localhost:4100/app.html`],
|
||||
])(
|
||||
'rejects updating an index_url onto it %s with 400',
|
||||
async (_label, index_url) => {
|
||||
const { actor } = await makeUser();
|
||||
const created = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('claimed-host-upd'),
|
||||
title: 't',
|
||||
index_url: uniqueIndexUrl(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.update({
|
||||
uid: created.uid,
|
||||
object: { index_url },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: /system host/,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an unclaimed sibling subdomain',
|
||||
'https://not-claimed-app-host.puter.localhost/',
|
||||
],
|
||||
[
|
||||
'a host nested under the claim',
|
||||
`https://x.${claimed}.puter.localhost/`,
|
||||
],
|
||||
])('allows an index_url on %s', async (_label, index_url) => {
|
||||
const { actor } = await makeUser();
|
||||
const created = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('claimed-sibling'),
|
||||
title: 't',
|
||||
index_url,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(created.index_url).toBe(index_url);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a duplicate app name with 400', async () => {
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
|
||||
@@ -99,7 +99,8 @@ describe('image downloads', () => {
|
||||
statusCode: 400,
|
||||
code: 'input_too_large',
|
||||
});
|
||||
expect(pulled).toBeLessThanOrEqual(4);
|
||||
// Readable.fromWeb reads ahead by a few chunks before the cap trips.
|
||||
expect(pulled).toBeLessThanOrEqual(6);
|
||||
});
|
||||
it('gives up on a slow host with a 400 rather than waiting it out', async () => {
|
||||
secureFetchMock.mockImplementationOnce(
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* WorkerDriver with a deploy backend configured.
|
||||
*
|
||||
@@ -47,7 +48,9 @@ import { PuterServer } from '../../server.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { resolveCallableMethods } from '../meta.js';
|
||||
import {
|
||||
INTERNAL_ADMIN_DESTROY,
|
||||
INTERNAL_ADMISSION_BYPASS,
|
||||
type WorkerDriver,
|
||||
} from './WorkerDriver.js';
|
||||
@@ -60,8 +63,12 @@ let server: PuterServer;
|
||||
let target: WorkerDriver;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
|
||||
const edgeResponse = (body: unknown) =>
|
||||
({ json: async () => body }) as unknown as Response;
|
||||
const edgeResponse = (body: unknown, status = 200) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as unknown as Response;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
@@ -159,6 +166,30 @@ const deleteCalls = () =>
|
||||
(call) => (call[1] as RequestInit | undefined)?.method === 'DELETE',
|
||||
);
|
||||
|
||||
const deployedToken = () => {
|
||||
const [, init] = putCalls().at(-1)!;
|
||||
const form = (init as RequestInit).body as FormData;
|
||||
const metadata = JSON.parse(form.get('metadata') as string);
|
||||
return metadata.bindings.find(
|
||||
(b: { name: string }) => b.name === 'puter_auth',
|
||||
).text as string;
|
||||
};
|
||||
|
||||
/** A fresh user with one deployed worker and the token it was deployed with. */
|
||||
const deployWorker = async (label: string) => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/${label}.js`;
|
||||
const entry = await writeSource(actor, user.id, path, 'src');
|
||||
const name = `${label}-${user.username}`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({ appId: '', workerName: name, filePath: path }),
|
||||
);
|
||||
return { user, actor, entry, name, token: deployedToken() };
|
||||
};
|
||||
|
||||
const workerSession = (userId: number, workerName: string) =>
|
||||
server.stores.session.getWorker(userId, { appUid: null, workerName });
|
||||
|
||||
// -- create ----------------------------------------------------------
|
||||
|
||||
describe('WorkerDriver.create with a configured deploy backend', () => {
|
||||
@@ -596,17 +627,220 @@ describe('WorkerDriver.destroy with a configured deploy backend', () => {
|
||||
target.destroy({ workerName: name }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
errors: [],
|
||||
messages: [],
|
||||
result: { id: name },
|
||||
});
|
||||
const [url, init] = deleteCalls()[0]!;
|
||||
expect(url).toBe(`${SCRIPTS_BASE}/${name}/`);
|
||||
expect((init as RequestInit).headers).toEqual({
|
||||
Authorization: `Bearer ${AUTH_KEY}`,
|
||||
});
|
||||
expect((init as RequestInit).signal).toBeInstanceOf(AbortSignal);
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reports a refused edge delete as 502 and keeps the row and the worker session', async () => {
|
||||
const { user, actor, name, token } = await deployWorker('refused');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [{ code: 10000, message: 'Internal error' }],
|
||||
},
|
||||
500,
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'worker_takedown_failed',
|
||||
message:
|
||||
'Cloudflare refused to remove the script; nothing was deleted.',
|
||||
fields: { status: 500 },
|
||||
});
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(await workerSession(user.id, name)).toBeTruthy();
|
||||
expect(
|
||||
(await server.services.auth.authenticate(token)).actor,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the row when the edge answers 200 with success:false', async () => {
|
||||
const { actor, name } = await deployWorker('softfail');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse({ success: false, errors: [{ code: 10001 }] }),
|
||||
);
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toMatchObject({ statusCode: 502 });
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reports an edge delete that times out as 504 and keeps the row', async () => {
|
||||
const { actor, name } = await deployWorker('slow');
|
||||
|
||||
fetchSpy.mockRejectedValueOnce(
|
||||
new DOMException('The operation was aborted', 'TimeoutError'),
|
||||
);
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 504,
|
||||
legacyCode: 'worker_takedown_timeout',
|
||||
});
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reports a transport failure as 504 and keeps the row', async () => {
|
||||
const { actor, name } = await deployWorker('unreachable');
|
||||
|
||||
fetchSpy.mockRejectedValueOnce(new TypeError('fetch failed'));
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 504,
|
||||
legacyCode: 'worker_takedown_timeout',
|
||||
});
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lets any other failure before the edge answers surface as itself', async () => {
|
||||
const { actor, name } = await deployWorker('ownerr');
|
||||
|
||||
fetchSpy.mockRejectedValueOnce(new Error('config unavailable'));
|
||||
const err = (await inCtx(actor, () =>
|
||||
target.destroy({ workerName: name }),
|
||||
).catch((e: unknown) => e)) as Error & { statusCode?: number };
|
||||
expect(err.message).toBe('config unavailable');
|
||||
expect(err.statusCode).toBeUndefined();
|
||||
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('tears down in order: script, then sessions, then row', async () => {
|
||||
const { user, actor, name } = await deployWorker('order');
|
||||
const store = server.stores.subdomain;
|
||||
let seenAtRowDelete: { edgeDeletes: number; sessionGone: boolean } = {
|
||||
edgeDeletes: -1,
|
||||
sessionGone: false,
|
||||
};
|
||||
const deleteSpy = vi
|
||||
.spyOn(store, 'deleteByUuid')
|
||||
.mockImplementation(async (...args) => {
|
||||
seenAtRowDelete = {
|
||||
edgeDeletes: deleteCalls().length,
|
||||
sessionGone: !(await workerSession(user.id, name)),
|
||||
};
|
||||
deleteSpy.mockRestore();
|
||||
return store.deleteByUuid(...args);
|
||||
});
|
||||
|
||||
await inCtx(actor, () => target.destroy({ workerName: name }));
|
||||
|
||||
expect(seenAtRowDelete).toEqual({ edgeDeletes: 1, sessionGone: true });
|
||||
});
|
||||
|
||||
it('keeps the row when the sessions cannot be dropped, and a retry finishes the job', async () => {
|
||||
const { actor, name } = await deployWorker('retry');
|
||||
vi.spyOn(
|
||||
server.services.auth,
|
||||
'deleteWorkerSessionsByName',
|
||||
).mockRejectedValueOnce(new Error('session store unavailable'));
|
||||
|
||||
await expect(
|
||||
inCtx(actor, () => target.destroy({ workerName: name })),
|
||||
).rejects.toThrow('session store unavailable');
|
||||
expect(deleteCalls()).toHaveLength(1);
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
const retried = await inCtx(actor, () =>
|
||||
target.destroy({ workerName: name }),
|
||||
);
|
||||
expect(retried).toEqual({
|
||||
success: true,
|
||||
errors: [],
|
||||
messages: [],
|
||||
result: { id: name },
|
||||
});
|
||||
expect(deleteCalls()).toHaveLength(2);
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('treats a script the edge no longer has (code 10007) as taken down', async () => {
|
||||
const { user, actor, name } = await deployWorker('gone10007');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [
|
||||
{
|
||||
code: 10007,
|
||||
message: 'workers.api.error.script_not_found',
|
||||
},
|
||||
],
|
||||
},
|
||||
404,
|
||||
),
|
||||
);
|
||||
const result = await inCtx(actor, () =>
|
||||
target.destroy({ workerName: name }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
errors: [],
|
||||
messages: [],
|
||||
result: { id: name },
|
||||
});
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(await workerSession(user.id, name)).toBeNull();
|
||||
});
|
||||
|
||||
it('lowercases the requested name before looking the worker up', async () => {
|
||||
@@ -630,6 +864,319 @@ describe('WorkerDriver.destroy with a configured deploy backend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- operator teardown ------------------------------------------------
|
||||
|
||||
describe('WorkerDriver operator teardown (INTERNAL_ADMIN_DESTROY)', () => {
|
||||
const rowOf = (name: string) =>
|
||||
server.stores.subdomain.getBySubdomain(`workers.puter.${name}`, {
|
||||
primary: true,
|
||||
});
|
||||
|
||||
/** Collects every `subdomain.delete` announced while `fn` runs. */
|
||||
const announced = async <T>(fn: () => Promise<T>) => {
|
||||
const seen: unknown[] = [];
|
||||
const handler = (_key: string, data: unknown) => {
|
||||
seen.push(data);
|
||||
};
|
||||
server.clients.event.on('subdomain.delete', handler as never);
|
||||
try {
|
||||
return { result: await fn(), seen };
|
||||
} finally {
|
||||
server.clients.event.off('subdomain.delete', handler as never);
|
||||
}
|
||||
};
|
||||
|
||||
it('takes the script down before the row, drops the session, and announces the delete once', async () => {
|
||||
const { user, name, token } = await deployWorker('op');
|
||||
const row = (await rowOf(name))!;
|
||||
const store = server.stores.subdomain;
|
||||
let seenAtRowDelete = { edgeDeletes: -1, sessionGone: false };
|
||||
const deleteSpy = vi
|
||||
.spyOn(store, 'deleteByUuid')
|
||||
.mockImplementation(async (...args) => {
|
||||
seenAtRowDelete = {
|
||||
edgeDeletes: deleteCalls().length,
|
||||
sessionGone: !(await workerSession(user.id, name)),
|
||||
};
|
||||
deleteSpy.mockRestore();
|
||||
return store.deleteByUuid(...args);
|
||||
});
|
||||
|
||||
const { result, seen } = await announced(() =>
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
subdomain: `workers.puter.${name}`,
|
||||
userId: user.id,
|
||||
workerName: name,
|
||||
});
|
||||
const [url] = deleteCalls()[0]!;
|
||||
expect(url).toBe(`${SCRIPTS_BASE}/${name}/`);
|
||||
expect(seenAtRowDelete).toEqual({ edgeDeletes: 1, sessionGone: true });
|
||||
expect(await rowOf(name)).toBeNull();
|
||||
expect(await workerSession(user.id, name)).toBeNull();
|
||||
expect(await server.services.auth.authenticate(token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
expect(seen).toEqual([
|
||||
{ subdomain: `workers.puter.${name}`, uid: row.uuid },
|
||||
]);
|
||||
|
||||
// The driver's own listener hears this and leaves the script alone.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(deleteCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports a refused edge delete as 502 and keeps the row and session', async () => {
|
||||
const { user, name, token } = await deployWorker('oprefused');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [{ code: 10000, message: 'edge-only detail' }],
|
||||
},
|
||||
500,
|
||||
),
|
||||
);
|
||||
const { seen } = await announced(async () => {
|
||||
const err = (await target[INTERNAL_ADMIN_DESTROY]({
|
||||
uuid: row.uuid,
|
||||
}).catch((e: unknown) => e)) as {
|
||||
statusCode: number;
|
||||
legacyCode: string;
|
||||
message: string;
|
||||
fields: unknown;
|
||||
};
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'worker_takedown_failed',
|
||||
message:
|
||||
'Cloudflare refused to remove the script; nothing was deleted.',
|
||||
fields: { status: 500 },
|
||||
});
|
||||
// Cloudflare's own text stays out of what the caller sees.
|
||||
expect(JSON.stringify([err.message, err.fields])).not.toContain(
|
||||
'edge-only detail',
|
||||
);
|
||||
});
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
expect(await workerSession(user.id, name)).toBeTruthy();
|
||||
expect(
|
||||
(await server.services.auth.authenticate(token)).actor,
|
||||
).toBeTruthy();
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the row when the edge answers 200 with success:false', async () => {
|
||||
const { name } = await deployWorker('opsoft');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse({ success: false, errors: [] }),
|
||||
);
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).rejects.toMatchObject({ statusCode: 502 });
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
});
|
||||
|
||||
it('treats script-not-found (code 10007) as taken down', async () => {
|
||||
const { name } = await deployWorker('op10007');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [
|
||||
{
|
||||
code: 10007,
|
||||
message: 'workers.api.error.script_not_found',
|
||||
},
|
||||
],
|
||||
},
|
||||
404,
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).resolves.toMatchObject({ workerName: name });
|
||||
|
||||
expect(await rowOf(name)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a bare 404 with no usable body as a refusal and keeps the row', async () => {
|
||||
const { name } = await deployWorker('op404');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => {
|
||||
throw new SyntaxError('not json');
|
||||
},
|
||||
} as unknown as Response);
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).rejects.toMatchObject({ statusCode: 502, fields: { status: 404 } });
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
});
|
||||
|
||||
it('treats a 404 that could not be routed (code 7003) as a refusal and keeps the row', async () => {
|
||||
const { name } = await deployWorker('op7003');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
edgeResponse(
|
||||
{
|
||||
success: false,
|
||||
errors: [
|
||||
{
|
||||
code: 7003,
|
||||
message: 'Could not route to /accounts/x/workers',
|
||||
},
|
||||
],
|
||||
},
|
||||
404,
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'worker_takedown_failed',
|
||||
});
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
});
|
||||
|
||||
it('reports a timed-out edge delete as 504 with no edge text, and keeps the row', async () => {
|
||||
const { name } = await deployWorker('opslow');
|
||||
const row = (await rowOf(name))!;
|
||||
|
||||
fetchSpy.mockRejectedValueOnce(
|
||||
new DOMException('edge-only timeout detail', 'TimeoutError'),
|
||||
);
|
||||
const err = (await target[INTERNAL_ADMIN_DESTROY]({
|
||||
uuid: row.uuid,
|
||||
}).catch((e: unknown) => e)) as {
|
||||
message: string;
|
||||
fields: unknown;
|
||||
};
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 504,
|
||||
legacyCode: 'worker_takedown_timeout',
|
||||
});
|
||||
expect(JSON.stringify([err.message, err.fields ?? null])).not.toContain(
|
||||
'edge-only',
|
||||
);
|
||||
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
});
|
||||
|
||||
it('keeps the row when the sessions cannot be dropped, and a retry finishes the job', async () => {
|
||||
const { name } = await deployWorker('opretry');
|
||||
const row = (await rowOf(name))!;
|
||||
vi.spyOn(
|
||||
server.services.auth,
|
||||
'deleteWorkerSessionsByName',
|
||||
).mockRejectedValueOnce(new Error('session store unavailable'));
|
||||
|
||||
const { seen } = await announced(async () =>
|
||||
expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid }),
|
||||
).rejects.toThrow('session store unavailable'),
|
||||
);
|
||||
expect(deleteCalls()).toHaveLength(1);
|
||||
expect(await rowOf(name)).toMatchObject({ uuid: row.uuid });
|
||||
expect(seen).toEqual([]);
|
||||
|
||||
await target[INTERNAL_ADMIN_DESTROY]({ uuid: row.uuid });
|
||||
expect(deleteCalls()).toHaveLength(2);
|
||||
expect(await rowOf(name)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips only its own announcement: a re-created worker of the same name is still taken down', async () => {
|
||||
const { user, actor, name } = await deployWorker('opsame');
|
||||
const first = (await rowOf(name))!;
|
||||
await target[INTERNAL_ADMIN_DESTROY]({ uuid: first.uuid });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(deleteCalls()).toHaveLength(1);
|
||||
|
||||
// Same name, new row: a delete announced for it is not the one above.
|
||||
await inCtx(actor, () =>
|
||||
target.create({
|
||||
appId: '',
|
||||
workerName: name,
|
||||
filePath: `/${user.username}/opsame.js`,
|
||||
}),
|
||||
);
|
||||
const second = (await rowOf(name))!;
|
||||
expect(second.uuid).not.toBe(first.uuid);
|
||||
await server.stores.subdomain.deleteByUuid(second.uuid);
|
||||
server.clients.event.emit(
|
||||
'subdomain.delete',
|
||||
{ subdomain: `workers.puter.${name}`, uid: second.uuid },
|
||||
{},
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => deleteCalls().length === 2,
|
||||
'edge delete for the re-created worker',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 for a row that is not a worker, and leaves it alone', async () => {
|
||||
const { user } = await makeUser();
|
||||
const site = await server.stores.subdomain.create({
|
||||
userId: user.id,
|
||||
subdomain: `site-${user.username}`,
|
||||
rootDirId: null,
|
||||
associatedAppId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: site.uuid }),
|
||||
).rejects.toMatchObject({ statusCode: 404, legacyCode: 'not_found' });
|
||||
|
||||
expect(deleteCalls()).toEqual([]);
|
||||
expect(
|
||||
await server.stores.subdomain.getByUuid(site.uuid, {
|
||||
primary: true,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown or missing uuid', async () => {
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({ uuid: uuidv4() }),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
await expect(
|
||||
target[INTERNAL_ADMIN_DESTROY]({} as { uuid: string }),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
expect(deleteCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('is not on the /drivers/call surface', () => {
|
||||
const callable = resolveCallableMethods(target);
|
||||
expect([...callable].sort()).toEqual([
|
||||
'create',
|
||||
'destroy',
|
||||
'getFilePaths',
|
||||
'getLoggingUrl',
|
||||
]);
|
||||
expect(callable.has(String(INTERNAL_ADMIN_DESTROY.description))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -- worker rows deleted elsewhere ------------------------------------
|
||||
|
||||
describe('WorkerDriver on subdomain.delete', () => {
|
||||
@@ -1027,29 +1574,6 @@ describe('WorkerDriver hot reload', () => {
|
||||
// -- worker sessions --------------------------------------------------
|
||||
|
||||
describe('WorkerDriver keeps a worker`s token tied to the worker', () => {
|
||||
const deployedToken = () => {
|
||||
const [, init] = putCalls().at(-1)!;
|
||||
const form = (init as RequestInit).body as FormData;
|
||||
const metadata = JSON.parse(form.get('metadata') as string);
|
||||
return metadata.bindings.find(
|
||||
(b: { name: string }) => b.name === 'puter_auth',
|
||||
).text as string;
|
||||
};
|
||||
|
||||
const deployWorker = async (label: string) => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/${label}.js`;
|
||||
const entry = await writeSource(actor, user.id, path, 'src');
|
||||
const name = `${label}-${user.username}`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({ appId: '', workerName: name, filePath: path }),
|
||||
);
|
||||
return { user, actor, entry, name, token: deployedToken() };
|
||||
};
|
||||
|
||||
const workerSession = (userId: number, workerName: string) =>
|
||||
server.stores.session.getWorker(userId, { appUid: null, workerName });
|
||||
|
||||
it('binds the deployed token to the worker row, and a redeploy keeps it', async () => {
|
||||
const { user, actor, name, token } = await deployWorker('bind');
|
||||
const row = await server.stores.subdomain.getBySubdomain(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// This test checks everything in WorkerDriver up to the cloudflare level.
|
||||
// This test checks everything in WorkerDriver up to the cloudflare level.
|
||||
// Full deployments are not tested as this would require Cloudflare Workerd,
|
||||
// however the interactions with the Puter API are
|
||||
import {
|
||||
@@ -13,10 +13,18 @@ import {
|
||||
import { Actor, makeActor as resolveActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
setupTestServer,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import { decodeCursor } from '../../util/pagination.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { INTERNAL_ADMISSION_BYPASS } from './WorkerDriver.js';
|
||||
import { resolveCallableMethods } from '../meta.js';
|
||||
import {
|
||||
INTERNAL_ADMIN_DESTROY,
|
||||
INTERNAL_ADMISSION_BYPASS,
|
||||
} from './WorkerDriver.js';
|
||||
import type { WorkerDriver } from './WorkerDriver.js';
|
||||
|
||||
describe('WorkerDriver', () => {
|
||||
@@ -252,7 +260,10 @@ describe('WorkerDriver', () => {
|
||||
it('lets an app bind a worker to an app it created', async () => {
|
||||
const { generated, builderActor } = await seedApps();
|
||||
await expect(
|
||||
inCtx(() => deploy(generated.uid, 'wk-sandboxed'), builderActor),
|
||||
inCtx(
|
||||
() => deploy(generated.uid, 'wk-sandboxed'),
|
||||
builderActor,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 503 });
|
||||
});
|
||||
|
||||
@@ -266,14 +277,20 @@ describe('WorkerDriver', () => {
|
||||
it('rejects an app binding a worker to an app it did not create', async () => {
|
||||
const { unrelated, builderActor } = await seedApps();
|
||||
await expect(
|
||||
inCtx(() => deploy(unrelated.uid, 'wk-unrelated'), builderActor),
|
||||
inCtx(
|
||||
() => deploy(unrelated.uid, 'wk-unrelated'),
|
||||
builderActor,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('rejects an app binding a worker to an unknown app', async () => {
|
||||
const { builderActor } = await seedApps();
|
||||
await expect(
|
||||
inCtx(() => deploy('app-does-not-exist', 'wk-ghost'), builderActor),
|
||||
inCtx(
|
||||
() => deploy('app-does-not-exist', 'wk-ghost'),
|
||||
builderActor,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
@@ -906,7 +923,9 @@ describe('WorkerDriver', () => {
|
||||
try {
|
||||
for (const body of forgeries) {
|
||||
const forged = JSON.parse(body) as Record<string, unknown>;
|
||||
expect(Object.getOwnPropertySymbols(forged)).toHaveLength(0);
|
||||
expect(Object.getOwnPropertySymbols(forged)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor: unverified() }, () =>
|
||||
strictDriver.create({
|
||||
@@ -927,3 +946,139 @@ describe('WorkerDriver', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Boots local workerd mode, which sticks for the rest of this file: runs last.
|
||||
describe('WorkerDriver operator teardown on local workerd', () => {
|
||||
let env: PuterTestEnv;
|
||||
let driver: WorkerDriver;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
workers: { localServer: true },
|
||||
} as never);
|
||||
driver = env.server.drivers.workers as unknown as WorkerDriver;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const seedWorker = async (label: string) => {
|
||||
const owner = await env.server.stores.user.create({
|
||||
username: `wklocal-${label}`,
|
||||
uuid: `wklocal-uuid-${label}`,
|
||||
password: null,
|
||||
email: null,
|
||||
});
|
||||
const row = await env.server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: `workers.puter.${label}`,
|
||||
rootDirId: null,
|
||||
associatedAppId: null,
|
||||
});
|
||||
return { owner, row };
|
||||
};
|
||||
|
||||
/** A worker deployed for real through the driver, so Miniflare holds it. */
|
||||
const deployLocal = async (label: string) => {
|
||||
const owner = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const ownerActor = resolveActor({
|
||||
user: {
|
||||
uuid: owner!.uuid,
|
||||
id: owner!.id,
|
||||
username: owner!.username,
|
||||
email: `${owner!.username}@test.com`,
|
||||
email_confirmed: true,
|
||||
},
|
||||
});
|
||||
const path = `/${owner!.username}/${label}.js`;
|
||||
const source = `router.get('/', () => new Response('${label}'));`;
|
||||
await runWithContext({ actor: ownerActor }, () =>
|
||||
env.server.services.fs.write(owner!.id, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: Buffer.byteLength(source),
|
||||
contentType: 'application/javascript',
|
||||
overwrite: true,
|
||||
},
|
||||
fileContent: Buffer.from(source),
|
||||
}),
|
||||
);
|
||||
const deployed = (await runWithContext({ actor: ownerActor }, () =>
|
||||
driver.create({ appId: '', workerName: label, filePath: path }),
|
||||
)) as { success?: boolean };
|
||||
expect(deployed.success).toBe(true);
|
||||
const row = await env.server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${label}`,
|
||||
{ primary: true },
|
||||
);
|
||||
return { owner: owner!, row: row! };
|
||||
};
|
||||
|
||||
it('takes the local script down, drops the row, and reports the owner', async () => {
|
||||
const { owner, row } = await deployLocal('torn');
|
||||
const local = env.server.services.localworkerservice;
|
||||
const deleteLocal = vi.spyOn(local, 'cfDeleteLocal');
|
||||
const served = await local.cfCallLocal(
|
||||
'torn',
|
||||
new Request('http://worker.localhost/'),
|
||||
);
|
||||
expect(await served.text()).toBe('torn');
|
||||
|
||||
const result = await driver[INTERNAL_ADMIN_DESTROY]({
|
||||
uuid: row.uuid,
|
||||
});
|
||||
|
||||
expect(deleteLocal).toHaveBeenCalledWith('torn');
|
||||
expect(result).toEqual({
|
||||
subdomain: 'workers.puter.torn',
|
||||
userId: owner.id,
|
||||
workerName: 'torn',
|
||||
});
|
||||
expect(
|
||||
await env.server.stores.subdomain.getByUuid(row.uuid, {
|
||||
primary: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
// The Miniflare instance is disposed with the row: nothing answers now.
|
||||
const after = await local.cfCallLocal(
|
||||
'torn',
|
||||
new Request('http://worker.localhost/'),
|
||||
);
|
||||
expect(after.status).toBe(404);
|
||||
}, 60_000);
|
||||
|
||||
it('is absent from the callable surface and unreachable over /drivers/call', async () => {
|
||||
const { row } = await seedWorker('kept');
|
||||
expect(resolveCallableMethods(driver).has('destroy')).toBe(true);
|
||||
|
||||
for (const method of [
|
||||
String(INTERNAL_ADMIN_DESTROY.description),
|
||||
'INTERNAL_ADMIN_DESTROY',
|
||||
`Symbol(${INTERNAL_ADMIN_DESTROY.description})`,
|
||||
]) {
|
||||
expect(resolveCallableMethods(driver).has(method)).toBe(false);
|
||||
const res = await fetch(new URL('/drivers/call', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${env.users.user.token}`,
|
||||
Origin: env.apiOrigin,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
interface: 'workers',
|
||||
method,
|
||||
args: { uuid: row.uuid },
|
||||
}),
|
||||
});
|
||||
expect(res.status, method).toBe(404);
|
||||
}
|
||||
expect(
|
||||
await env.server.stores.subdomain.getByUuid(row.uuid, {
|
||||
primary: true,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '../../services/metering/consts.js';
|
||||
import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
import { loadFileInput } from '../util/fileInput.js';
|
||||
@@ -92,6 +93,58 @@ export const INTERNAL_ADMISSION_BYPASS = Symbol(
|
||||
*/
|
||||
export const INTERNAL_DEPLOY_TARGET = Symbol('workers.internalDeployTarget');
|
||||
|
||||
/** Operator teardown key; a symbol keeps it off the `/drivers/call` surface. */
|
||||
export const INTERNAL_ADMIN_DESTROY = Symbol('workers.internalAdminDestroy');
|
||||
|
||||
/** What the operator teardown reports back, for the caller's audit trail. */
|
||||
export interface AdminDestroyResult {
|
||||
subdomain: string;
|
||||
userId: number;
|
||||
workerName: string;
|
||||
}
|
||||
|
||||
/** The edge refused a script delete: neither success nor "no such script". */
|
||||
export class WorkerTakedownRefusedError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly detail: string | undefined,
|
||||
) {
|
||||
super(
|
||||
`Cloudflare refused the script delete (status ${status})${
|
||||
detail ? `: ${detail}` : ''
|
||||
}`,
|
||||
);
|
||||
this.name = 'WorkerTakedownRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
const CF_DELETE_TIMEOUT_MS = 15_000;
|
||||
// Cloudflare Workers API code 10007: Worker or workers.dev subdomain not found.
|
||||
const CF_SCRIPT_NOT_FOUND_CODE = 10007;
|
||||
|
||||
// What `fetch` rejects with when the edge never answered: timeout, transport.
|
||||
const isEdgeTransportError = (err: unknown): boolean =>
|
||||
err instanceof TypeError ||
|
||||
(err instanceof Error &&
|
||||
(err.name === 'TimeoutError' || err.name === 'AbortError'));
|
||||
|
||||
const withDeadline = <T>(promise: Promise<T>, ms: number): Promise<T> => {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const deadline = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new DOMException(
|
||||
'The operation was aborted due to timeout',
|
||||
'TimeoutError',
|
||||
),
|
||||
),
|
||||
ms,
|
||||
);
|
||||
});
|
||||
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
|
||||
};
|
||||
|
||||
export interface InternalDeployTarget {
|
||||
/** Script name to deploy under, in place of `workerName`. */
|
||||
scriptName: string;
|
||||
@@ -289,6 +342,11 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
#cfBaseUrl = '';
|
||||
#hotReloadSubscribed = false;
|
||||
// Row uids this process tore down itself; its own announcement skips them.
|
||||
#tornDown = new BoundedTtlMap<string, true>({
|
||||
maxEntries: 1000,
|
||||
ttlMs: 30_000,
|
||||
});
|
||||
|
||||
static currentPreambleVersion(): string | null {
|
||||
return preambleVersion;
|
||||
@@ -568,6 +626,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
return cfResult;
|
||||
}
|
||||
|
||||
/** Tear down the caller's worker; a refused edge delete is a 502. */
|
||||
async destroy(args: Record<string, unknown>): Promise<unknown> {
|
||||
const actor = requireContextUserActor();
|
||||
assertActorEmailVerified(actor, this.config);
|
||||
@@ -595,12 +654,60 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
this.#requireCfConfig();
|
||||
|
||||
const cfResult = await this.#cfDelete(workerName);
|
||||
await this.stores.subdomain.deleteByUuid(row.uuid, {
|
||||
userId: actor.user.id,
|
||||
});
|
||||
await this.#teardown(row, { userId: actor.user.id });
|
||||
// Stable regardless of the edge body: puter.js needs `result` truthy.
|
||||
return {
|
||||
success: true,
|
||||
errors: [],
|
||||
messages: [],
|
||||
result: { id: workerName },
|
||||
};
|
||||
}
|
||||
|
||||
/** Operator teardown by row uuid: no actor, no ownership check. */
|
||||
async [INTERNAL_ADMIN_DESTROY](input: {
|
||||
uuid: string;
|
||||
}): Promise<AdminDestroyResult> {
|
||||
const uuid = String(input?.uuid ?? '');
|
||||
const row = uuid
|
||||
? await this.stores.subdomain.getByUuid(uuid, { primary: true })
|
||||
: null;
|
||||
const subdomain = String(row?.subdomain ?? '');
|
||||
if (!row || !subdomain.startsWith(WORKER_SUBDOMAIN_PREFIX))
|
||||
throw new HttpError(404, 'Worker not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
|
||||
this.#requireCfConfig();
|
||||
|
||||
await this.#teardown(row);
|
||||
return {
|
||||
subdomain,
|
||||
userId: Number(row.user_id),
|
||||
workerName: subdomain.slice(WORKER_SUBDOMAIN_PREFIX.length),
|
||||
};
|
||||
}
|
||||
|
||||
/** Script, sessions, then row: a failed step leaves the row for a retry. */
|
||||
async #teardown(
|
||||
row: SubdomainRow,
|
||||
{ userId }: { userId?: number } = {},
|
||||
): Promise<void> {
|
||||
const subdomain = String(row.subdomain);
|
||||
const workerName = subdomain.slice(WORKER_SUBDOMAIN_PREFIX.length);
|
||||
await this.#takeDownScript(workerName);
|
||||
await this.services.auth.deleteWorkerSessionsByName(workerName);
|
||||
return cfResult;
|
||||
await this.stores.subdomain.deleteByUuid(row.uuid, { userId });
|
||||
this.#tornDown.set(row.uuid, true);
|
||||
try {
|
||||
this.clients.event.emit(
|
||||
'subdomain.delete',
|
||||
{ subdomain, uid: row.uuid },
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// Non-critical.
|
||||
}
|
||||
}
|
||||
|
||||
async getFilePaths(args: Record<string, unknown>): Promise<unknown> {
|
||||
@@ -858,16 +965,70 @@ export class WorkerDriver extends PuterDriver {
|
||||
return { success: false, errors, url: null };
|
||||
}
|
||||
|
||||
/** Edge delete: success or code 10007 resolves, anything else refuses. */
|
||||
async #cfDelete(workerName: string): Promise<Record<string, unknown>> {
|
||||
if (USE_LOCAL_WORKERD) {
|
||||
return this.services.localworkerservice.cfDeleteLocal(workerName);
|
||||
return withDeadline(
|
||||
this.services.localworkerservice.cfDeleteLocal(workerName),
|
||||
CF_DELETE_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
const cfg = this.#workerConfig();
|
||||
const res = await fetch(`${this.#cfBaseUrl}/scripts/${workerName}/`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${cfg.XAUTHKEY}` },
|
||||
signal: AbortSignal.timeout(CF_DELETE_TIMEOUT_MS),
|
||||
});
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
let json: {
|
||||
success?: unknown;
|
||||
errors?: Array<{ code?: unknown; message?: unknown }>;
|
||||
} = {};
|
||||
try {
|
||||
json = (await res.json()) ?? {};
|
||||
} catch {
|
||||
// No usable body; the status decides.
|
||||
}
|
||||
if (res.ok && json.success !== false) return json;
|
||||
const errors = Array.isArray(json.errors) ? json.errors : [];
|
||||
if (errors.some((e) => Number(e?.code) === CF_SCRIPT_NOT_FOUND_CODE))
|
||||
return { success: true, errors: [], result: null };
|
||||
const first = errors[0]?.message;
|
||||
throw new WorkerTakedownRefusedError(
|
||||
res.status,
|
||||
typeof first === 'string' ? first : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Refusal is 502, timeout or transport 504; edge text is only logged. */
|
||||
async #takeDownScript(workerName: string): Promise<void> {
|
||||
try {
|
||||
await this.#cfDelete(workerName);
|
||||
} catch (err) {
|
||||
if (err instanceof WorkerTakedownRefusedError) {
|
||||
console.warn(
|
||||
`[workers] edge refused delete of ${workerName}: ${err.message}`,
|
||||
);
|
||||
throw new HttpError(
|
||||
502,
|
||||
'Cloudflare refused to remove the script; nothing was deleted.',
|
||||
{
|
||||
legacyCode: 'worker_takedown_failed',
|
||||
cause: err,
|
||||
fields: { status: err.status },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!isEdgeTransportError(err)) throw err;
|
||||
console.warn(
|
||||
`[workers] edge delete of ${workerName} did not complete`,
|
||||
err,
|
||||
);
|
||||
throw new HttpError(
|
||||
504,
|
||||
'The script delete did not complete; the worker is kept and a retry finishes it.',
|
||||
{ legacyCode: 'worker_takedown_timeout', cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------
|
||||
@@ -1093,9 +1254,15 @@ export class WorkerDriver extends PuterDriver {
|
||||
// owner's account) still has a deployed script to take down.
|
||||
this.clients.event.on(
|
||||
'subdomain.delete',
|
||||
(_key: string, data: { subdomain?: unknown }) => {
|
||||
(_key: string, data: { subdomain?: unknown; uid?: unknown }) => {
|
||||
const subdomain = String(data?.subdomain ?? '');
|
||||
if (!subdomain.startsWith(WORKER_SUBDOMAIN_PREFIX)) return;
|
||||
// Its own announcement: that row is already fully torn down.
|
||||
const uid = String(data?.uid ?? '');
|
||||
if (uid && this.#tornDown.has(uid)) {
|
||||
this.#tornDown.delete(uid);
|
||||
return;
|
||||
}
|
||||
const workerName = subdomain.slice(
|
||||
WORKER_SUBDOMAIN_PREFIX.length,
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"lorem-ipsum": "^2.0.8",
|
||||
"mime-types": "^2.1.35",
|
||||
"murmurhash": "^2.0.1",
|
||||
"mysql2": "^3.24.5",
|
||||
"mysql2": "^3.24.6",
|
||||
"nodemailer": "^9.1.1",
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.5.2",
|
||||
@@ -60,12 +60,12 @@
|
||||
"postal-mime": "^3.0.1",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.35.5",
|
||||
"smtp-server": "^3.19.21",
|
||||
"smtp-server": "^3.19.23",
|
||||
"socket.io": "^4.8.4",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"together-ai": "^0.33.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
"undici": "^7.29.1",
|
||||
"undici": "^7.30.1",
|
||||
"uuid": "^14.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
requireReputationGate,
|
||||
requireSubscriptionGate,
|
||||
requireTeam2fa,
|
||||
type Team2faLookup,
|
||||
requireVerifiedAccount,
|
||||
requireNonAccessTokenGate,
|
||||
requireUserActorGate,
|
||||
@@ -1038,7 +1039,9 @@ export class PuterServer {
|
||||
if (needsAuth && !opts.allowUnconfirmed) {
|
||||
mwChain.push(requireVerifiedAccount());
|
||||
// Same opt-out: a member owing 2FA must still reach those routes.
|
||||
mwChain.push(requireTeam2fa(this.stores?.team));
|
||||
mwChain.push(
|
||||
requireTeam2fa((this.stores as { team?: Team2faLookup })?.team),
|
||||
);
|
||||
}
|
||||
|
||||
// block access tokens by default
|
||||
|
||||
@@ -3316,6 +3316,29 @@ describe('FSService reads', () => {
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('reports 416 when the range starts past the end of the object', async () => {
|
||||
const entry = await writeFile(
|
||||
user,
|
||||
`${user.home}/Documents/short.txt`,
|
||||
'abc',
|
||||
);
|
||||
const getObjectStream = vi
|
||||
.spyOn(server.stores.s3Object, 'getObjectStream')
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('range not satisfiable'), {
|
||||
name: 'InvalidRange',
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await caught(() =>
|
||||
fs.readContent(entry, { range: 'bytes=10-' }),
|
||||
);
|
||||
expect(error.statusCode).toBe(416);
|
||||
expect(await entryAt(user, '/Documents/short.txt')).not.toBeNull();
|
||||
|
||||
getObjectStream.mockRestore();
|
||||
});
|
||||
|
||||
it('propagates a non-NoSuchKey storage failure unchanged', async () => {
|
||||
const entry = await writeFile(
|
||||
user,
|
||||
|
||||
@@ -175,6 +175,12 @@ const isNoSuchKeyError = (err: unknown): boolean => {
|
||||
return e.name === 'NoSuchKey' || e.Code === 'NoSuchKey';
|
||||
};
|
||||
|
||||
const isInvalidRangeError = (err: unknown): boolean => {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const e = err as { name?: unknown; Code?: unknown };
|
||||
return e.name === 'InvalidRange' || e.Code === 'InvalidRange';
|
||||
};
|
||||
|
||||
/** Also recorded as the failure reason on a session refused for it. */
|
||||
const UPLOAD_NOT_RECEIVED = 'Upload content was not received';
|
||||
const uploadNotReceivedError = (): HttpError =>
|
||||
@@ -3886,6 +3892,13 @@ export class FSService extends PuterService {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Range starts at or past the end of the object.
|
||||
if (isInvalidRangeError(err)) {
|
||||
throw new HttpError(416, 'Requested range not satisfiable', {
|
||||
legacyCode: 'bad_request',
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,9 +445,11 @@ export class PuterHomepageService extends PuterService {
|
||||
const user = actor?.user;
|
||||
if (typeof user?.id !== 'number') return false;
|
||||
try {
|
||||
return await this.services.team.teamsAvailableTo(
|
||||
user.id,
|
||||
user.email ?? null,
|
||||
return (
|
||||
(await this.services.team?.teamsAvailableTo(
|
||||
user.id,
|
||||
user.email ?? null,
|
||||
)) ?? false
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -43,7 +43,6 @@ import { ProfileService } from './profile/ProfileService';
|
||||
import { DefaultUserService } from './selfhosted/DefaultUserService';
|
||||
import { ShareNotificationService } from './share/ShareNotificationService';
|
||||
import { ShareService } from './share/ShareService';
|
||||
import { TeamService } from './team/TeamService';
|
||||
import { SocketService } from './socket/SocketService';
|
||||
import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService';
|
||||
import type { IPuterServiceRegistry } from './types';
|
||||
@@ -87,7 +86,6 @@ declare module './types' {
|
||||
homepage: PuterHomepageService;
|
||||
health: ServerHealthService;
|
||||
userAccount: UserAccountService;
|
||||
team: TeamService;
|
||||
signup: SignupService;
|
||||
}
|
||||
}
|
||||
@@ -124,7 +122,6 @@ export const puterServices = {
|
||||
// first.
|
||||
userAccount: UserAccountService,
|
||||
// Leaf: team + user stores only.
|
||||
team: TeamService,
|
||||
// Reaches `team` and `socket` at call time only.
|
||||
signup: SignupService,
|
||||
// AppPermissionService + SubdomainPermissionService register permission
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
POLICY_PREFIX,
|
||||
USAGE_DETAIL_SHARD_COUNT,
|
||||
V1_CLAIM_THROUGH_MONTH,
|
||||
isFreeSubscription,
|
||||
subscriptionOverride,
|
||||
} from './consts.ts';
|
||||
import { subscriptionSatisfies } from './enforcement.ts';
|
||||
import type { MeteringService } from './MeteringService.ts';
|
||||
import type { UsageInput } from './types.ts';
|
||||
import { detailShardOf } from './usageDetail.ts';
|
||||
@@ -299,6 +302,67 @@ describe('MeteringService', () => {
|
||||
expect(await target.actorHasPaidSubscription(seat)).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a plan registered as free as free wherever free-ness is asked', async () => {
|
||||
target.registerPolicy({
|
||||
id: 'registered-free',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 1024,
|
||||
free: true,
|
||||
});
|
||||
target.registerSubscriptionResolver(async () => 'registered-free');
|
||||
|
||||
const holder = makeActor();
|
||||
expect((await target.getActorSubscription(holder)).id).toBe(
|
||||
'registered-free',
|
||||
);
|
||||
expect(await target.actorHasPaidSubscription(holder)).toBe(false);
|
||||
expect(isFreeSubscription('registered-free')).toBe(true);
|
||||
expect(subscriptionSatisfies('registered-free', true)).toBe(false);
|
||||
expect(
|
||||
subscriptionOverride(
|
||||
{ [DEFAULT_FREE_SUBSCRIPTION]: 7 },
|
||||
'registered-free',
|
||||
),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('lets a provisional answer win, but only for the fallback window', async () => {
|
||||
target.registerPolicy({
|
||||
id: 'provisional-plan',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
target.registerPolicy({
|
||||
id: 'later-default',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
const first = vi.fn(async () => ({
|
||||
id: 'provisional-plan',
|
||||
provisional: true,
|
||||
}));
|
||||
target.registerDefaultSubscriptionResolver(first, { priority: 10 });
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
async () => 'later-default',
|
||||
);
|
||||
|
||||
const status = await target.getActorSubscriptionStatus(actor);
|
||||
expect(status.policy.id).toBe('provisional-plan');
|
||||
expect(status.provisional).toBe(true);
|
||||
|
||||
const { SUBSCRIPTION_FALLBACK_CACHE_MS: fallbackMs } =
|
||||
target.constructor as typeof MeteringService;
|
||||
const now = Date.now();
|
||||
const dateNow = vi.spyOn(Date, 'now');
|
||||
try {
|
||||
dateNow.mockReturnValue(now + fallbackMs + 1);
|
||||
await target.getActorSubscription(actor);
|
||||
expect(first).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the first non-empty subscription resolver', async () => {
|
||||
const customPolicy = {
|
||||
id: 'custom-paid',
|
||||
@@ -745,6 +809,129 @@ describe('MeteringService', () => {
|
||||
|
||||
// ── Addons ───────────────────────────────────────────────────────
|
||||
|
||||
describe('getActorSubscriptionStatus', () => {
|
||||
it('answers a resolved plan as not provisional', async () => {
|
||||
target.registerPolicy({
|
||||
id: 'status-paid',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
target.registerSubscriptionResolver(async () => 'status-paid');
|
||||
|
||||
const status = await target.getActorSubscriptionStatus(actor);
|
||||
expect(status).toMatchObject({
|
||||
policy: { id: 'status-paid' },
|
||||
paid: true,
|
||||
provisional: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the fallback a failed resolver causes as provisional, cached or not', async () => {
|
||||
target.registerSubscriptionResolver(async () => {
|
||||
throw new Error('store down');
|
||||
});
|
||||
|
||||
const first = await target.getActorSubscriptionStatus(actor);
|
||||
expect(first).toMatchObject({
|
||||
policy: { id: DEFAULT_FREE_SUBSCRIPTION },
|
||||
paid: false,
|
||||
provisional: true,
|
||||
});
|
||||
const again = await target.getActorSubscriptionStatus(actor);
|
||||
expect(again.provisional).toBe(true);
|
||||
// The plain read still answers with the fallback.
|
||||
expect((await target.getActorSubscription(actor)).id).toBe(
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
);
|
||||
});
|
||||
|
||||
const pastFallbackWindow = async (run: () => Promise<void>) => {
|
||||
const { SUBSCRIPTION_FALLBACK_CACHE_MS: fallbackMs } =
|
||||
target.constructor as typeof MeteringService;
|
||||
const now = Date.now();
|
||||
const dateNow = vi.spyOn(Date, 'now');
|
||||
try {
|
||||
dateNow.mockReturnValue(now + fallbackMs + 1);
|
||||
await run();
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
for (const [label, defaultAnswer] of [
|
||||
['throws', () => Promise.reject(new Error('store down'))],
|
||||
[
|
||||
'answers provisionally',
|
||||
async () => ({
|
||||
id: 'status-default',
|
||||
provisional: true,
|
||||
}),
|
||||
],
|
||||
] as const) {
|
||||
it(`keeps an explicit plan resolved when a default ${label}`, async () => {
|
||||
target.registerPolicy({
|
||||
id: 'status-default',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
target.registerPolicy({
|
||||
id: 'status-explicit',
|
||||
monthUsageAllowance: toMicroCents(5),
|
||||
monthlyStorageAllowance: 1024,
|
||||
});
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
defaultAnswer as never,
|
||||
{ priority: 10 },
|
||||
);
|
||||
const explicit = vi.fn(async () => 'status-explicit');
|
||||
target.registerSubscriptionResolver(explicit);
|
||||
|
||||
expect(
|
||||
await target.getActorSubscriptionStatus(actor),
|
||||
).toMatchObject({
|
||||
policy: { id: 'status-explicit' },
|
||||
paid: true,
|
||||
provisional: false,
|
||||
});
|
||||
await pastFallbackWindow(async () => {
|
||||
await target.getActorSubscription(actor);
|
||||
});
|
||||
expect(explicit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
it('marks a default that wins after another default threw as provisional', async () => {
|
||||
target.registerDefaultSubscriptionResolver(
|
||||
async () => {
|
||||
throw new Error('store down');
|
||||
},
|
||||
{ priority: 10 },
|
||||
);
|
||||
const status = await target.getActorSubscriptionStatus(actor);
|
||||
expect(status.provisional).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a provisional answer with no plan as provisional', async () => {
|
||||
target.registerSubscriptionResolver(async () => ({
|
||||
id: null,
|
||||
provisional: true,
|
||||
}));
|
||||
const status = await target.getActorSubscriptionStatus(actor);
|
||||
expect(status).toMatchObject({
|
||||
policy: { id: DEFAULT_FREE_SUBSCRIPTION },
|
||||
provisional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks an answer for an actor missing a keyed field as provisional', async () => {
|
||||
const { id: _id, ...partialUser } = actor.user;
|
||||
const status = await target.getActorSubscriptionStatus({
|
||||
user: partialUser,
|
||||
});
|
||||
expect(status.provisional).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActorAddons / updateAddonCredit', () => {
|
||||
it('returns an empty addon map for a fresh user', async () => {
|
||||
const addons = await target.getActorAddons(actor);
|
||||
@@ -2414,11 +2601,11 @@ describe('MeteringService', () => {
|
||||
});
|
||||
|
||||
it('leaves free policies alone without reading for an override', async () => {
|
||||
await target.setMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
1,
|
||||
);
|
||||
// Written directly: `setMonthAllowance` refuses a free plan.
|
||||
await server.stores.kv.set({
|
||||
key: `${POLICY_PREFIX}:actor:${actor.user.uuid}:allowance:${DEFAULT_FREE_SUBSCRIPTION}:${currentMonthString()}`,
|
||||
value: 1,
|
||||
});
|
||||
const getSpy = vi.spyOn(server.stores.kv, 'get');
|
||||
|
||||
const policy = await target.getActorSubscription(actor);
|
||||
@@ -2508,6 +2695,31 @@ describe('MeteringService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an allowance for a plan that never takes one', async () => {
|
||||
target.registerPolicy({
|
||||
id: 'month-free',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 0,
|
||||
free: true,
|
||||
});
|
||||
target.registerPolicy({
|
||||
id: 'month-none',
|
||||
monthUsageAllowance: 0,
|
||||
monthlyStorageAllowance: 0,
|
||||
noAllowance: true,
|
||||
});
|
||||
for (const id of [
|
||||
'month-free',
|
||||
'month-none',
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
UNLIMITED_SUBSCRIPTION,
|
||||
]) {
|
||||
await expect(
|
||||
target.setMonthAllowance(actor.user.uuid!, id, 1_000),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('has no month allowance for a policy nobody registered', async () => {
|
||||
expect(
|
||||
await target.getMonthAllowance(actor.user.uuid!, 'ghost-plan'),
|
||||
@@ -2925,6 +3137,132 @@ describe('MeteringService', () => {
|
||||
addonsSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('a plan that grants no allowance', () => {
|
||||
const onNoAllowancePlan = () => {
|
||||
target.registerPolicy({
|
||||
id: 'test-no-allowance',
|
||||
monthUsageAllowance: toMicroCents(1),
|
||||
monthlyStorageAllowance: 0,
|
||||
noAllowance: true,
|
||||
});
|
||||
target.registerSubscriptionResolver(() => 'test-no-allowance');
|
||||
};
|
||||
|
||||
it('has no allowance, whatever the policy states', async () => {
|
||||
onNoAllowancePlan();
|
||||
const sub = await target.getActorSubscription(actor);
|
||||
expect(sub.monthUsageAllowance).toBe(0);
|
||||
});
|
||||
|
||||
it('is out of budget without purchased credit', async () => {
|
||||
onNoAllowancePlan();
|
||||
expect(await target.hasAnyUsageCached(actor)).toBe(false);
|
||||
expect(await target.hasEnoughCredits(actor, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('spends purchased credit, and only that', async () => {
|
||||
onNoAllowancePlan();
|
||||
expect(await target.hasAnyUsageCached(actor)).toBe(false);
|
||||
await target.updateAddonCredit(actor.user.uuid!, 5_000);
|
||||
expect(await target.hasAnyUsageCached(actor)).toBe(true);
|
||||
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 2_000);
|
||||
const addons = await target.getActorAddons(actor);
|
||||
expect(addons.consumedPurchaseCredits).toBe(2_000);
|
||||
});
|
||||
|
||||
const creditStates = () => {
|
||||
const states: string[] = [];
|
||||
const seen = (_key: string, event: { state: string }) => {
|
||||
states.push(event.state);
|
||||
};
|
||||
server.clients.event.on('metering.credit-state', seen as never);
|
||||
return {
|
||||
states,
|
||||
stop: () =>
|
||||
server.clients.event.off(
|
||||
'metering.credit-state',
|
||||
seen as never,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
it('announces nothing for an actor that never had budget', async () => {
|
||||
onNoAllowancePlan();
|
||||
const { states, stop } = creditStates();
|
||||
try {
|
||||
expect(await target.hasAnyUsageCached(actor)).toBe(false);
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1_000);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(states).toEqual([]);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('announces exhaustion when an actor moves onto it from a plan with budget', async () => {
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1);
|
||||
const { states, stop } = creditStates();
|
||||
try {
|
||||
onNoAllowancePlan();
|
||||
target.invalidateActorSubscription(actor.user.uuid!);
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1);
|
||||
await waitFor(() => expect(states).toEqual(['exhausted']));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('announces exhaustion once when purchased credit runs out', async () => {
|
||||
onNoAllowancePlan();
|
||||
await target.updateAddonCredit(actor.user.uuid!, 1_000);
|
||||
const { states, stop } = creditStates();
|
||||
try {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1_000);
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(states.filter((s) => s === 'exhausted')).toEqual(
|
||||
['exhausted'],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the whole allowance to a plan switched to later in the month', async () => {
|
||||
onNoAllowancePlan();
|
||||
await target.updateAddonCredit(actor.user.uuid!, 5_000);
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 3_000);
|
||||
|
||||
const later = {
|
||||
id: 'test-later-allowance',
|
||||
monthUsageAllowance: 10_000,
|
||||
monthlyStorageAllowance: 0,
|
||||
};
|
||||
target.registerPolicy(later);
|
||||
internals.subscriptionResolvers.length = 0;
|
||||
target.registerSubscriptionResolver(
|
||||
() => 'test-later-allowance',
|
||||
);
|
||||
target.invalidateActorSubscription(actor.user.uuid!);
|
||||
|
||||
// Credit already paid for the earlier spend.
|
||||
expect((await target.getAllowedUsage(actor)).remaining).toBe(
|
||||
10_000 + 2_000,
|
||||
);
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 4_000);
|
||||
expect((await target.getAllowedUsage(actor)).remaining).toBe(
|
||||
6_000 + 2_000,
|
||||
);
|
||||
expect(
|
||||
(await target.getActorAddons(actor))
|
||||
.consumedPurchaseCredits,
|
||||
).toBe(3_000);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not block when the balance cannot be read', async () => {
|
||||
const failing = vi
|
||||
.spyOn(target, 'getActorAddons')
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
USAGE_DETAIL_SHARD_COUNT,
|
||||
V1_CLAIM_THROUGH_MONTH,
|
||||
isFreeSubscription,
|
||||
registerFreeSubscription,
|
||||
} from './consts';
|
||||
import { EGRESS_COSTS } from './costs';
|
||||
import type {
|
||||
@@ -69,13 +70,32 @@ import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
type SubscriptionPolicy = (typeof SUB_POLICIES)[number] & {
|
||||
/** Seats a team owned by an account on this plan may provision. */
|
||||
teamSeatCap?: number;
|
||||
/** Counts as a free plan wherever `isFreeSubscription` is asked. */
|
||||
free?: boolean;
|
||||
/** Grants no allowance; without it, an allowance of 0 means unmetered. */
|
||||
noAllowance?: boolean;
|
||||
};
|
||||
|
||||
/** What `getActorSubscriptionStatus` answers. */
|
||||
export interface ActorSubscriptionStatus {
|
||||
policy: SubscriptionPolicy;
|
||||
/** Somebody subscribed the actor to it; a resolver did not infer it. */
|
||||
paid: boolean;
|
||||
/** May not be this actor's plan; cached only for the fallback window. */
|
||||
provisional: boolean;
|
||||
}
|
||||
|
||||
/** A `provisional` answer wins but is cached only for the fallback window. */
|
||||
export type SubscriptionAnswer =
|
||||
string | { id: string | null; provisional: boolean } | null | undefined;
|
||||
|
||||
export type SubscriptionResolver = (
|
||||
actor: Actor,
|
||||
) => Promise<string | null | undefined> | string | null | undefined;
|
||||
) => Promise<SubscriptionAnswer> | SubscriptionAnswer;
|
||||
|
||||
type ResolverOutcome = { id: string | null | undefined } | { error: unknown };
|
||||
type ResolverOutcome =
|
||||
| { id: string | null | undefined; provisional?: boolean }
|
||||
| { error: unknown };
|
||||
|
||||
/** One usage type's amount, pre-aggregated across one call. */
|
||||
type TypeAmounts = { units: number; cost: number; count: number };
|
||||
@@ -292,10 +312,10 @@ export class MeteringService extends PuterService {
|
||||
}
|
||||
>();
|
||||
|
||||
/** Uuid → the last budget state announced, so a retry loop emits once. */
|
||||
/** Uuid → last budget state sent; `rearmed` makes the next one news. */
|
||||
private creditAlertState = new Map<
|
||||
string,
|
||||
'ok' | 'near-limit' | 'exhausted'
|
||||
'ok' | 'near-limit' | 'exhausted' | 'rearmed'
|
||||
>();
|
||||
|
||||
/** Uuid → whether the actor had budget left. See CREDIT_CACHE_MS. */
|
||||
@@ -482,9 +502,12 @@ export class MeteringService extends PuterService {
|
||||
|
||||
// -- Extension hooks ----------------------------------------------
|
||||
|
||||
/** Register a policy that should be available to actors. */
|
||||
/** Register a policy for actors; `free` applies process-wide, by id. */
|
||||
registerPolicy(policy: SubscriptionPolicy): void {
|
||||
this.extraPolicies.push(policy);
|
||||
if (policy.free) registerFreeSubscription(policy.id);
|
||||
this.extraPolicies.push(
|
||||
policy.noAllowance ? { ...policy, monthUsageAllowance: 0 } : policy,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -671,7 +694,7 @@ export class MeteringService extends PuterService {
|
||||
userId,
|
||||
actorUsageKey,
|
||||
actorUsages,
|
||||
actorSubscription.monthUsageAllowance,
|
||||
actorSubscription,
|
||||
actorAddons,
|
||||
totalBatchCost,
|
||||
);
|
||||
@@ -689,7 +712,7 @@ export class MeteringService extends PuterService {
|
||||
this.rememberRemainingCredits(
|
||||
userId,
|
||||
settledAllowanceUsed,
|
||||
actorSubscription.monthUsageAllowance,
|
||||
actorSubscription,
|
||||
actorAddons,
|
||||
provisionalUntil,
|
||||
);
|
||||
@@ -1646,6 +1669,11 @@ export class MeteringService extends PuterService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Nothing to run out of, unless the plan says it grants nothing. */
|
||||
private static isUnmetered(policy: SubscriptionPolicy): boolean {
|
||||
return !policy.noAllowance && !(policy.monthUsageAllowance > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What's left of an actor's budget: what remains of the monthly allowance
|
||||
* plus what remains of the lifetime credit pool.
|
||||
@@ -1753,7 +1781,8 @@ export class MeteringService extends PuterService {
|
||||
this.#spentAddons.delete(userUuid);
|
||||
this.#spentAddonsDrops++;
|
||||
// Added capacity re-arms the alert: the next exhaustion is news again.
|
||||
this.creditAlertState.delete(userUuid);
|
||||
if (this.creditAlertState.has(userUuid))
|
||||
this.creditAlertState.set(userUuid, 'rearmed');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1786,10 +1815,8 @@ export class MeteringService extends PuterService {
|
||||
try {
|
||||
const { policy: subscription, provisionalUntil } =
|
||||
await this.#actorSubscriptionWithStatus(actor);
|
||||
// A non-positive allowance is how a policy says it isn't metered
|
||||
// (the overuse alarm reads it the same way) — no budget to run out
|
||||
// of, and no reason to pay for the reads below.
|
||||
if (!(subscription.monthUsageAllowance > 0)) {
|
||||
// Nothing to run out of, so no reason to pay for the reads below.
|
||||
if (MeteringService.isUnmetered(subscription)) {
|
||||
this.rememberHasCredits(uuid, true, provisionalUntil);
|
||||
return;
|
||||
}
|
||||
@@ -1803,7 +1830,7 @@ export class MeteringService extends PuterService {
|
||||
totals,
|
||||
subscription.monthUsageAllowance,
|
||||
),
|
||||
subscription.monthUsageAllowance,
|
||||
subscription,
|
||||
addons,
|
||||
provisionalUntil,
|
||||
);
|
||||
@@ -1838,14 +1865,15 @@ export class MeteringService extends PuterService {
|
||||
private rememberRemainingCredits(
|
||||
userId: string,
|
||||
allowanceUsed: number,
|
||||
monthUsageAllowance: number,
|
||||
subscription: SubscriptionPolicy,
|
||||
addons: UsageAddons | null | undefined,
|
||||
provisionalUntil: number | undefined,
|
||||
): void {
|
||||
if (!(monthUsageAllowance > 0)) {
|
||||
if (MeteringService.isUnmetered(subscription)) {
|
||||
this.rememberHasCredits(userId, true, provisionalUntil);
|
||||
return;
|
||||
}
|
||||
const { monthUsageAllowance } = subscription;
|
||||
const remaining = MeteringService.remainingFrom(
|
||||
allowanceUsed,
|
||||
monthUsageAllowance,
|
||||
@@ -1874,6 +1902,8 @@ export class MeteringService extends PuterService {
|
||||
// Purchased credits are spendable, so the allowance alone warns early.
|
||||
const capacity =
|
||||
(monthUsageAllowance || 0) + (addons?.purchasedCredits || 0);
|
||||
// No budget, and none seen before: there is no crossing to announce.
|
||||
if (!(capacity > 0) && !this.creditAlertState.has(userUuid)) return;
|
||||
const state =
|
||||
remaining <= 0
|
||||
? 'exhausted'
|
||||
@@ -1971,11 +2001,20 @@ export class MeteringService extends PuterService {
|
||||
|
||||
/** On a plan somebody subscribed them to, not one a resolver inferred. */
|
||||
async actorHasPaidSubscription(actor: Actor): Promise<boolean> {
|
||||
const { policy, fromDefault } =
|
||||
return (await this.getActorSubscriptionStatus(actor)).paid;
|
||||
}
|
||||
|
||||
/** The plan, whether it is paid, and whether the answer is provisional. */
|
||||
async getActorSubscriptionStatus(
|
||||
actor: Actor,
|
||||
): Promise<ActorSubscriptionStatus> {
|
||||
const { policy, fromDefault, provisionalUntil } =
|
||||
await this.#actorSubscriptionWithStatus(actor);
|
||||
// A deployment that turned unlimited on means it.
|
||||
if (policy.id === UNLIMITED_SUBSCRIPTION) return true;
|
||||
return !fromDefault && !isFreeSubscription(policy.id);
|
||||
const paid =
|
||||
policy.id === UNLIMITED_SUBSCRIPTION ||
|
||||
(!fromDefault && !isFreeSubscription(policy.id));
|
||||
return { policy, paid, provisional: provisionalUntil !== undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2062,7 +2101,7 @@ export class MeteringService extends PuterService {
|
||||
};
|
||||
}
|
||||
|
||||
/** `lookupFailed`: a resolver or the allowance read threw, so it fell back. */
|
||||
/** `lookupFailed`: the applied side failed, or the allowance read did. */
|
||||
async #resolveActorSubscription(actor: Actor): Promise<{
|
||||
policy: SubscriptionPolicy;
|
||||
lookupFailed: boolean;
|
||||
@@ -2087,7 +2126,6 @@ export class MeteringService extends PuterService {
|
||||
const resolvedDefault = defaults.id || fallbackDefault;
|
||||
const user = await this.firstResolver(pendingUser);
|
||||
const resolvedUser = user.id || resolvedDefault;
|
||||
const resolverFailed = defaults.failed || user.failed;
|
||||
|
||||
const availablePolicies: SubscriptionPolicy[] = [
|
||||
...this.extraPolicies,
|
||||
@@ -2109,6 +2147,8 @@ export class MeteringService extends PuterService {
|
||||
availablePolicies.find((p) => p.id === fallbackDefault);
|
||||
// Of the policy that applied, not the id that was asked for.
|
||||
const fromDefault = !user.id || !explicit;
|
||||
// A default's failure says nothing about an explicit plan that won.
|
||||
const resolverFailed = user.failed || (fromDefault && defaults.failed);
|
||||
if (policy) {
|
||||
const withAllowance = await this.#withMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
@@ -2142,11 +2182,7 @@ export class MeteringService extends PuterService {
|
||||
policy: SubscriptionPolicy,
|
||||
month: string = this.monthYearString(),
|
||||
): Promise<{ policy: SubscriptionPolicy; failed: boolean }> {
|
||||
if (
|
||||
isFreeSubscription(policy.id) ||
|
||||
policy.id === UNLIMITED_SUBSCRIPTION ||
|
||||
!(policy.monthUsageAllowance > 0)
|
||||
) {
|
||||
if (!MeteringService.takesMonthAllowance(policy)) {
|
||||
return { policy, failed: false };
|
||||
}
|
||||
try {
|
||||
@@ -2174,6 +2210,17 @@ export class MeteringService extends PuterService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Free, unlimited and allowance-less plans never take a month override. */
|
||||
private static takesMonthAllowance(
|
||||
policy: Pick<SubscriptionPolicy, 'id' | 'monthUsageAllowance'>,
|
||||
): boolean {
|
||||
return (
|
||||
!isFreeSubscription(policy.id) &&
|
||||
policy.id !== UNLIMITED_SUBSCRIPTION &&
|
||||
policy.monthUsageAllowance > 0
|
||||
);
|
||||
}
|
||||
|
||||
#monthAllowanceKey(
|
||||
userUuid: string,
|
||||
policyId: string,
|
||||
@@ -2357,6 +2404,14 @@ export class MeteringService extends PuterService {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error('Month allowance must be a positive number');
|
||||
}
|
||||
// An unregistered plan is judged by its id alone.
|
||||
const policy = this.getRegisteredPolicy(policyId) ?? {
|
||||
id: policyId,
|
||||
monthUsageAllowance: value,
|
||||
};
|
||||
if (!MeteringService.takesMonthAllowance(policy)) {
|
||||
throw new Error(`Plan ${policyId} never takes a month allowance`);
|
||||
}
|
||||
const month = opts.month ?? this.monthYearString();
|
||||
const parsed = /^(\d{4})-(\d{2})$/.exec(month);
|
||||
if (!parsed) throw new Error('Month must be YYYY-MM');
|
||||
@@ -2494,24 +2549,24 @@ export class MeteringService extends PuterService {
|
||||
): Promise<ResolverOutcome>[] {
|
||||
return resolvers.map(async (resolver): Promise<ResolverOutcome> => {
|
||||
try {
|
||||
return { id: await resolver(actor) };
|
||||
const answer = await resolver(actor);
|
||||
if (typeof answer === 'string') return { id: answer };
|
||||
if (!answer) return { id: null };
|
||||
return { id: answer.id, provisional: answer.provisional };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The first non-empty answer in registration order, and whether a resolver
|
||||
* ahead of it threw. Outcomes after the winner are ignored, as if never
|
||||
* asked.
|
||||
*/
|
||||
/** First answer; `failed` if one up to it threw or was provisional. */
|
||||
private async firstResolver(
|
||||
pending: Promise<ResolverOutcome>[],
|
||||
): Promise<{ id: string | null; failed: boolean }> {
|
||||
let failed = false;
|
||||
for (const next of pending) {
|
||||
const outcome = await next;
|
||||
if ('provisional' in outcome && outcome.provisional) failed = true;
|
||||
if ('error' in outcome) {
|
||||
failed = true;
|
||||
console.warn(
|
||||
@@ -2728,10 +2783,11 @@ export class MeteringService extends PuterService {
|
||||
userId: string,
|
||||
actorUsageKey: string,
|
||||
usageRecord: UsageByType,
|
||||
monthUsageAllowance: number,
|
||||
subscription: SubscriptionPolicy,
|
||||
addons: UsageAddons,
|
||||
incrementCost: number,
|
||||
): Promise<number> {
|
||||
const { monthUsageAllowance } = subscription;
|
||||
if (incrementCost <= 0) {
|
||||
return MeteringService.allowanceUsedFrom(
|
||||
usageRecord,
|
||||
@@ -2763,6 +2819,18 @@ export class MeteringService extends PuterService {
|
||||
const overage = incrementCost - allowanceCharge;
|
||||
|
||||
const writes: Promise<unknown>[] = [];
|
||||
// Recorded, so a later plan with an allowance doesn't count this spend.
|
||||
if (
|
||||
subscription.noAllowance &&
|
||||
usageRecord.allowanceUsed === undefined
|
||||
) {
|
||||
writes.push(
|
||||
this.stores.meteringBuffer.incr({
|
||||
key: actorUsageKey,
|
||||
pathAndAmountMap: { allowanceUsed: 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (baseline + allowanceCharge > 0) {
|
||||
writes.push(
|
||||
this.stores.meteringBuffer.incr({
|
||||
|
||||
@@ -78,9 +78,16 @@ export const FREE_SUBSCRIPTION_IDS: ReadonlySet<string> = new Set([
|
||||
ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
]);
|
||||
|
||||
/** Plans registered as `free`; see `MeteringService.registerPolicy`. */
|
||||
const registeredFreeIds = new Set<string>();
|
||||
|
||||
export const registerFreeSubscription = (id: string): void => {
|
||||
registeredFreeIds.add(id);
|
||||
};
|
||||
|
||||
/** Whether a policy id is a free plan. Check free-ness here, not on the set. */
|
||||
export const isFreeSubscription = (id: string): boolean =>
|
||||
FREE_SUBSCRIPTION_IDS.has(id);
|
||||
FREE_SUBSCRIPTION_IDS.has(id) || registeredFreeIds.has(id);
|
||||
|
||||
/** Where a default-subscription resolver sits; lower wins. */
|
||||
export const ORG_SEAT_RESOLVER_PRIORITY = 10;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
shareDeepLink,
|
||||
sharedViewLink,
|
||||
} from './shareDeepLink';
|
||||
import { NOTIFY_FANOUT_CAP } from '../../stores/team/TeamStore';
|
||||
import { NOTIFY_FANOUT_CAP } from '../../core/teams.js';
|
||||
import { abuseKey, cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { blocksAllShares, type ResolvedShare } from './ShareService';
|
||||
import { withRedisLock } from '../../util/redisLock.js';
|
||||
@@ -370,9 +370,10 @@ export class ShareNotificationService extends PuterService {
|
||||
let members = byGroup.get(share.holderGroupId);
|
||||
if (!members) {
|
||||
try {
|
||||
members = await this.stores.team.listMemberIdsByGroupId(
|
||||
share.holderGroupId,
|
||||
);
|
||||
members =
|
||||
(await this.stores.team?.listMemberIdsByGroupId(
|
||||
share.holderGroupId,
|
||||
)) ?? [];
|
||||
} catch (err) {
|
||||
// Silence is this path's failure mode, so say so out loud.
|
||||
console.warn(
|
||||
|
||||
@@ -1,151 +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 { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { setupTwoTeams, type TwoTeams } from '../../testFixtures/twoTeams.js';
|
||||
|
||||
describe('sharing: delegate withdrawing grants through an app', () => {
|
||||
let fx: TwoTeams;
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await setupTwoTeams();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await fx?.shutdown();
|
||||
});
|
||||
|
||||
const server = () => fx.env.server;
|
||||
|
||||
const actorOf = async (userId: number): Promise<Actor> => {
|
||||
const user = await server().stores.user.getById(userId);
|
||||
return makeActor({ user: user! });
|
||||
};
|
||||
|
||||
const makeFile = async (ownerId: number) => {
|
||||
const owner = await server().stores.user.getById(ownerId);
|
||||
const uid = crypto.randomUUID();
|
||||
const name = `secscan05-${uid.slice(0, 8)}.txt`;
|
||||
const path = `/${owner!.username}/${name}`;
|
||||
await server().clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uid,
|
||||
name,
|
||||
path,
|
||||
ownerId,
|
||||
server().clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
return { uid, path, name };
|
||||
};
|
||||
|
||||
const makeApp = async (ownerId: number) =>
|
||||
server().stores.app.create(
|
||||
{
|
||||
name: `secscan05-app-${crypto.randomUUID().slice(0, 8)}`,
|
||||
title: 'Shared tool',
|
||||
index_url: `https://secscan05-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: ownerId },
|
||||
);
|
||||
|
||||
/** The user hands the app `permission` and gets the app's token back. */
|
||||
const appTokenFor = async (
|
||||
userId: number,
|
||||
appUid: string,
|
||||
permission: string,
|
||||
) => {
|
||||
const actor = await actorOf(userId);
|
||||
await runWithContext({ actor }, () =>
|
||||
server().services.permission.grantUserAppPermission(
|
||||
actor,
|
||||
appUid,
|
||||
permission,
|
||||
),
|
||||
);
|
||||
return server().services.auth.getUserAppToken(actor, appUid);
|
||||
};
|
||||
|
||||
const canRead = async (userId: number, path: string) =>
|
||||
server().services.acl.check(
|
||||
await actorOf(userId),
|
||||
server().services.acl.fsDescriptor(path),
|
||||
'read',
|
||||
);
|
||||
|
||||
it("preserves the owner's team share when a delegate revokes through an app", async () => {
|
||||
const owner = fx.a.owner;
|
||||
const seat = fx.a.seats[0];
|
||||
const delegate = fx.outsider;
|
||||
const file = await makeFile(owner.userId);
|
||||
const app = await makeApp(owner.userId);
|
||||
|
||||
const ownerAppToken = await appTokenFor(
|
||||
owner.userId,
|
||||
app.uid,
|
||||
`fs:${file.uid}:read`,
|
||||
);
|
||||
const teamShare = await fx.call('POST', '/share', ownerAppToken, {
|
||||
recipients: [{ team: fx.a.uid }],
|
||||
items: [{ uid: file.uid }],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(teamShare.status).toBe(200);
|
||||
expect((await teamShare.json()).status).toBe('success');
|
||||
|
||||
const delegation = await fx.call('POST', '/share', owner.token, {
|
||||
recipients: [delegate.username],
|
||||
items: [{ uid: file.uid }],
|
||||
mode: 'manage',
|
||||
});
|
||||
expect((await delegation.json()).status).toBe('success');
|
||||
expect(await canRead(seat.userId, file.path)).toBe(true);
|
||||
|
||||
const revokeTeam = (token: string) =>
|
||||
fx.call('POST', '/share/revoke', token, {
|
||||
recipients: [{ team: fx.a.uid }],
|
||||
items: [{ uid: file.uid }],
|
||||
});
|
||||
|
||||
const direct = await revokeTeam(delegate.token);
|
||||
expect(direct.status).toBe(200);
|
||||
expect((await direct.json()).revoked).toBe(0);
|
||||
expect(await canRead(seat.userId, file.path)).toBe(true);
|
||||
|
||||
const delegateAppToken = await appTokenFor(
|
||||
delegate.userId,
|
||||
app.uid,
|
||||
`fs:${file.uid}:read`,
|
||||
);
|
||||
const viaApp = await revokeTeam(delegateAppToken);
|
||||
expect(viaApp.status).toBe(200);
|
||||
expect((await viaApp.json()).revoked).toBe(0);
|
||||
|
||||
expect(await canRead(seat.userId, file.path)).toBe(true);
|
||||
const listed = await server().services.share.listSharesOf(
|
||||
await actorOf(owner.userId),
|
||||
{ uid: file.uid },
|
||||
);
|
||||
expect(listed.some((share) => share.holderTeam)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,17 @@
|
||||
* 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 {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
@@ -1104,103 +1113,6 @@ describe('ShareService', () => {
|
||||
expect(await canWrite(stranger.actor, file.path)).toBe(false);
|
||||
});
|
||||
|
||||
it('goes with a file that changes owner, with its other grants, and nothing else is read', async () => {
|
||||
const owner = await makeUser();
|
||||
paid.add(owner.user.uuid);
|
||||
const newOwner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const member = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.user.id,
|
||||
name: `t-${Math.random().toString(36).slice(2, 9)}`,
|
||||
});
|
||||
for (const user of [owner.user, member.user]) {
|
||||
await server.stores.team.addMember(team.uid, user.id, {
|
||||
orgOwned: false,
|
||||
});
|
||||
}
|
||||
for (const recipientSpec of [
|
||||
anyone,
|
||||
{ email: recipient.email },
|
||||
{ team: team.uid },
|
||||
]) {
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: recipientSpec,
|
||||
mode: 'read',
|
||||
});
|
||||
}
|
||||
for (const reader of [stranger, recipient, member]) {
|
||||
expect(await canRead(reader.actor, file.path)).toBe(true);
|
||||
}
|
||||
|
||||
await server.stores.fsEntry.updateEntry(file.uuid, {
|
||||
userId: newOwner.user.id,
|
||||
});
|
||||
const moved = await server.stores.fsEntry.getEntryByUuid(file.uuid);
|
||||
|
||||
const listByFsentry = vi.spyOn(
|
||||
server.stores.share,
|
||||
'listByFsentry',
|
||||
);
|
||||
const getEntriesByIds = vi.spyOn(
|
||||
server.stores.fsEntry,
|
||||
'getEntriesByIds',
|
||||
);
|
||||
const revokeUserGrants = vi.spyOn(
|
||||
server.stores.permission,
|
||||
'deleteUserUserPermsByPermissionPrefixes',
|
||||
);
|
||||
const revokeGroupGrants = vi.spyOn(
|
||||
server.stores.permission,
|
||||
'deleteUserGroupPermsByPermissionPrefixes',
|
||||
);
|
||||
const deleteRows = vi.spyOn(
|
||||
server.stores.share,
|
||||
'deleteByFsentryIds',
|
||||
);
|
||||
try {
|
||||
await server.services.share.onEntryOwnerChanged(moved!);
|
||||
|
||||
expect(listByFsentry).not.toHaveBeenCalled();
|
||||
expect(getEntriesByIds).not.toHaveBeenCalled();
|
||||
const prefixes = [`fs:${file.uuid}`, `manage:fs:${file.uuid}`];
|
||||
expect(revokeUserGrants).toHaveBeenCalledWith(prefixes);
|
||||
expect(revokeGroupGrants).toHaveBeenCalledWith(prefixes);
|
||||
expect(deleteRows).toHaveBeenCalledWith([file.id]);
|
||||
// Grants go before the rows that record them.
|
||||
expect(
|
||||
revokeUserGrants.mock.invocationCallOrder[0]!,
|
||||
).toBeLessThan(revokeGroupGrants.mock.invocationCallOrder[0]!);
|
||||
expect(
|
||||
revokeGroupGrants.mock.invocationCallOrder[0]!,
|
||||
).toBeLessThan(deleteRows.mock.invocationCallOrder[0]!);
|
||||
} finally {
|
||||
for (const spy of [
|
||||
listByFsentry,
|
||||
getEntriesByIds,
|
||||
revokeUserGrants,
|
||||
revokeGroupGrants,
|
||||
deleteRows,
|
||||
]) {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
await server.clients.db.read(
|
||||
'SELECT * FROM `share` WHERE `fsentry_id` = ?',
|
||||
[file.id],
|
||||
),
|
||||
).toEqual([]);
|
||||
for (const reader of [stranger, recipient, member]) {
|
||||
expect(await canRead(reader.actor, file.path)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// A just-moved row can be missing from a lagging replica; a file's
|
||||
// grants go by the uuid in hand regardless.
|
||||
it('goes with a file that changes owner even when its row reads back missing', async () => {
|
||||
@@ -1281,6 +1193,115 @@ describe('ShareService', () => {
|
||||
expect.objectContaining({ anyone: true, entryUid: file.uuid }),
|
||||
);
|
||||
});
|
||||
|
||||
it('goes with a file that changes owner, with its other grants, and nothing else is read', async () => {
|
||||
const owner = await makeUser();
|
||||
paid.add(owner.user.uuid);
|
||||
const newOwner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const member = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
// A group and its share written directly: what this exercises is
|
||||
// the owner change, and teams is optional.
|
||||
const groupUid = uuidv4();
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, ' +
|
||||
'`name`, `extra`, `metadata`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[groupUid, owner.user.id, 'team', 'Owner change', '{}', '{}'],
|
||||
);
|
||||
const [group] = (await server.clients.db.read(
|
||||
'SELECT id FROM `group` WHERE uid = ?',
|
||||
[groupUid],
|
||||
)) as { id: number }[];
|
||||
for (const user of [owner.user, member.user]) {
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) ' +
|
||||
'VALUES (?, ?)',
|
||||
[user.id, group.id],
|
||||
);
|
||||
}
|
||||
for (const recipientSpec of [anyone, { email: recipient.email }]) {
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: recipientSpec,
|
||||
mode: 'read',
|
||||
});
|
||||
}
|
||||
await server.stores.share.upsertActiveGroup({
|
||||
issuerUserId: owner.user.id,
|
||||
holderGroupId: group.id,
|
||||
fsentryId: file.id,
|
||||
mode: 'read',
|
||||
});
|
||||
for (const reader of [stranger, recipient, member]) {
|
||||
expect(await canRead(reader.actor, file.path)).toBe(true);
|
||||
}
|
||||
|
||||
await server.stores.fsEntry.updateEntry(file.uuid, {
|
||||
userId: newOwner.user.id,
|
||||
});
|
||||
const moved = await server.stores.fsEntry.getEntryByUuid(file.uuid);
|
||||
|
||||
const listByFsentry = vi.spyOn(
|
||||
server.stores.share,
|
||||
'listByFsentry',
|
||||
);
|
||||
const getEntriesByIds = vi.spyOn(
|
||||
server.stores.fsEntry,
|
||||
'getEntriesByIds',
|
||||
);
|
||||
const revokeUserGrants = vi.spyOn(
|
||||
server.stores.permission,
|
||||
'deleteUserUserPermsByPermissionPrefixes',
|
||||
);
|
||||
const revokeGroupGrants = vi.spyOn(
|
||||
server.stores.permission,
|
||||
'deleteUserGroupPermsByPermissionPrefixes',
|
||||
);
|
||||
const deleteRows = vi.spyOn(
|
||||
server.stores.share,
|
||||
'deleteByFsentryIds',
|
||||
);
|
||||
try {
|
||||
await server.services.share.onEntryOwnerChanged(moved!);
|
||||
|
||||
expect(listByFsentry).not.toHaveBeenCalled();
|
||||
expect(getEntriesByIds).not.toHaveBeenCalled();
|
||||
const prefixes = [`fs:${file.uuid}`, `manage:fs:${file.uuid}`];
|
||||
expect(revokeUserGrants).toHaveBeenCalledWith(prefixes);
|
||||
expect(revokeGroupGrants).toHaveBeenCalledWith(prefixes);
|
||||
expect(deleteRows).toHaveBeenCalledWith([file.id]);
|
||||
// Grants go before the rows that record them.
|
||||
expect(
|
||||
revokeUserGrants.mock.invocationCallOrder[0]!,
|
||||
).toBeLessThan(revokeGroupGrants.mock.invocationCallOrder[0]!);
|
||||
expect(
|
||||
revokeGroupGrants.mock.invocationCallOrder[0]!,
|
||||
).toBeLessThan(deleteRows.mock.invocationCallOrder[0]!);
|
||||
} finally {
|
||||
for (const spy of [
|
||||
listByFsentry,
|
||||
getEntriesByIds,
|
||||
revokeUserGrants,
|
||||
revokeGroupGrants,
|
||||
deleteRows,
|
||||
]) {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
await server.clients.db.read(
|
||||
'SELECT * FROM `share` WHERE `fsentry_id` = ?',
|
||||
[file.id],
|
||||
),
|
||||
).toEqual([]);
|
||||
for (const reader of [stranger, recipient, member]) {
|
||||
expect(await canRead(reader.actor, file.path)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the listing flag', () => {
|
||||
@@ -1867,57 +1888,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 +3101,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 +5068,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();
|
||||
@@ -6302,6 +6188,11 @@ describe('ShareService', () => {
|
||||
describe('listings read independent parts together', () => {
|
||||
const failure = (what: string) => new Error(`${what} failed`);
|
||||
|
||||
// These spy on the team store; Teams is optional, so stand one up.
|
||||
beforeEach(() => {
|
||||
installSeatStub(server as never, []);
|
||||
});
|
||||
|
||||
/** Unhandled rejections raised while `fn` runs, and shortly after. */
|
||||
const unhandledDuring = async (fn: () => Promise<void>) => {
|
||||
const seen: unknown[] = [];
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from '../../util/email.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry';
|
||||
import type { UserUserAuditFilter } from '../../stores/permission/PermissionStore';
|
||||
import { type TeamRow } from '../../stores/team/TeamStore';
|
||||
import { type TeamRow } from '../../core/teams.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore';
|
||||
import type { AclMode } from '../acl/ACLService';
|
||||
import {
|
||||
@@ -998,7 +998,7 @@ export class ShareService extends PuterService {
|
||||
// Handles are public, so without this anyone could push files into
|
||||
// every member's inbox by naming one.
|
||||
if (
|
||||
!(await this.stores.team.isMember(resolved.team.uid, issuerId))
|
||||
!(await this.stores.team?.isMember(resolved.team.uid, issuerId))
|
||||
) {
|
||||
throw new HttpError(404, 'Team does not exist', {
|
||||
legacyCode: 'team_not_found',
|
||||
@@ -1718,7 +1718,7 @@ export class ShareService extends PuterService {
|
||||
),
|
||||
),
|
||||
// Deleted teams included: their grants are still revocable.
|
||||
this.stores.team.getByIdsIncludingDeleted(
|
||||
this.#teamsByIds(
|
||||
rows.map((row: { holder_group_id: number }) =>
|
||||
Number(row.holder_group_id),
|
||||
),
|
||||
@@ -1923,7 +1923,8 @@ export class ShareService extends PuterService {
|
||||
}> {
|
||||
const holderId = this.#requireUserId(actor);
|
||||
// Resolved once; team shares ride the caller's own page.
|
||||
const groupIds = await this.stores.team.listGroupIdsForUser(holderId);
|
||||
const groupIds =
|
||||
(await this.stores.team?.listGroupIdsForUser(holderId)) ?? [];
|
||||
// Account-wide, so only for a caller acting as the account.
|
||||
const totalLookup =
|
||||
opts.includeTotal && isAccountContext(actor)
|
||||
@@ -2034,7 +2035,7 @@ export class ShareService extends PuterService {
|
||||
|
||||
// Named so the listing can say which team, not just that it is one.
|
||||
const teamsLookup = awaitLater(
|
||||
this.stores.team.getByIdsIncludingDeleted(
|
||||
this.#teamsByIds(
|
||||
rows
|
||||
.filter((row) => row.holder_group_id)
|
||||
.map((row) => Number(row.holder_group_id)),
|
||||
@@ -2255,9 +2256,10 @@ export class ShareService extends PuterService {
|
||||
|
||||
// Not an invite: deleting the row alone leaves every member's access.
|
||||
if (row.holder_group_id) {
|
||||
const team = await this.stores.team.getByIdIncludingDeleted(
|
||||
Number(row.holder_group_id),
|
||||
);
|
||||
const team =
|
||||
(await this.stores.team?.getByIdIncludingDeleted(
|
||||
Number(row.holder_group_id),
|
||||
)) ?? null;
|
||||
if (!team) throw notFound();
|
||||
await this.#assertCanManage(actor, entry);
|
||||
return this.#unshareTeam(
|
||||
@@ -2531,7 +2533,7 @@ export class ShareService extends PuterService {
|
||||
// The team itself, so whoever manages the node can see it is shared
|
||||
// with one and take it back from here.
|
||||
const liveGroup = await this.#liveGroupGrants(groupRows, nodeById);
|
||||
const teamsById = await this.stores.team.getByIdsIncludingDeleted(
|
||||
const teamsById = await this.#teamsByIds(
|
||||
groupRows.map((row) => Number(row.holder_group_id)),
|
||||
);
|
||||
const groups: ResolvedShare[] = groupRows
|
||||
@@ -3161,10 +3163,10 @@ export class ShareService extends PuterService {
|
||||
);
|
||||
// One walk: two members can have re-shared to each other.
|
||||
const seen = new Set<number>();
|
||||
for (const memberId of await this.stores.team.memberIdsAmong(
|
||||
for (const memberId of (await this.stores.team?.memberIdsAmong(
|
||||
team.uid,
|
||||
candidates,
|
||||
)) {
|
||||
)) ?? []) {
|
||||
// Nor does what a member re-shared on `manage` held elsewhere —
|
||||
// another person's grant, another team's, or a folder above.
|
||||
if (
|
||||
@@ -3388,6 +3390,13 @@ export class ShareService extends PuterService {
|
||||
* Group rows on this page whose grant is still there. The index row and the
|
||||
* grant are separate writes, so a row can outlive what it records.
|
||||
*/
|
||||
/** Empty where the deployment has no teams, so a group grant finds none. */
|
||||
async #teamsByIds(ids: number[]): Promise<Map<number, TeamRow>> {
|
||||
return (
|
||||
(await this.stores.team?.getByIdsIncludingDeleted(ids)) ?? new Map()
|
||||
);
|
||||
}
|
||||
|
||||
async #liveGroupGrants(
|
||||
rows: OutboundShareRow[],
|
||||
nodeById: Map<number, FSEntry>,
|
||||
@@ -3566,9 +3575,9 @@ export class ShareService extends PuterService {
|
||||
}
|
||||
|
||||
const team = uid
|
||||
? await this.stores.team.getByUid(uid)
|
||||
? await this.stores.team?.getByUid(uid)
|
||||
: // Filters soft-deleted, so a released handle 404s until reclaimed.
|
||||
await this.stores.team.getByHandle(handle!);
|
||||
await this.stores.team?.getByHandle(handle!);
|
||||
if (!team) {
|
||||
throw new HttpError(404, 'Team does not exist', {
|
||||
legacyCode: 'team_not_found',
|
||||
|
||||
@@ -1,903 +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, vi } from 'vitest';
|
||||
import type { Actor } from '../../core/actor';
|
||||
import {
|
||||
setupTwoTeams,
|
||||
type FixtureUser,
|
||||
type TwoTeams,
|
||||
} from '../../testFixtures/twoTeams.js';
|
||||
|
||||
describe('sharing with a team', () => {
|
||||
let fx: TwoTeams;
|
||||
|
||||
const actorFor = async (userId: number): Promise<Actor> => {
|
||||
const user = await fx.env.server.stores.user.getById(userId);
|
||||
// A session acts as the account; omitting this reads as an app.
|
||||
return { user, effectiveApp: null } as unknown as Actor;
|
||||
};
|
||||
|
||||
const shares = () => fx.env.server.services.share;
|
||||
|
||||
/** A file the owner of team A owns, ready to be shared. */
|
||||
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,
|
||||
fx.env.server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
return { path, uid };
|
||||
};
|
||||
|
||||
/** A directory and a file inside it, parent-linked as real rows are. */
|
||||
const makeNestedFile = async (ownerId: number) => {
|
||||
const owner = await fx.env.server.stores.user.getById(ownerId);
|
||||
const dirUid = crypto.randomUUID();
|
||||
const dirName = `d_${dirUid.slice(0, 8)}`;
|
||||
const dirPath = `/${owner!.username}/${dirName}`;
|
||||
const insert = (
|
||||
uid: string,
|
||||
name: string,
|
||||
path: string,
|
||||
isDir: boolean,
|
||||
parentId: number | null = null,
|
||||
) =>
|
||||
fx.env.server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uid,
|
||||
name,
|
||||
path,
|
||||
ownerId,
|
||||
fx.env.server.clients.db.booleanValue(isDir),
|
||||
Math.floor(Date.now() / 1000),
|
||||
parentId,
|
||||
],
|
||||
);
|
||||
await insert(dirUid, dirName, dirPath, true);
|
||||
const dirEntry =
|
||||
await fx.env.server.stores.fsEntry.getEntryByUuid(dirUid);
|
||||
const fileUid = crypto.randomUUID();
|
||||
const fileName = `f_${fileUid.slice(0, 8)}.txt`;
|
||||
await insert(
|
||||
fileUid,
|
||||
fileName,
|
||||
`${dirPath}/${fileName}`,
|
||||
false,
|
||||
dirEntry!.id,
|
||||
);
|
||||
return { dirPath, dirUid, fileUid };
|
||||
};
|
||||
|
||||
const shareWithTeam = async (
|
||||
ownerId: number,
|
||||
path: string,
|
||||
recipient: Record<string, string>,
|
||||
mode = 'read',
|
||||
) =>
|
||||
shares().share(await actorFor(ownerId), {
|
||||
path,
|
||||
recipient,
|
||||
mode,
|
||||
} as never);
|
||||
|
||||
const inbox = async (userId: number) =>
|
||||
shares().listSharedWithMe(await actorFor(userId), { limit: 100 });
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await setupTwoTeams();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await fx?.shutdown();
|
||||
});
|
||||
|
||||
// -- the recipient -------------------------------------------------
|
||||
|
||||
it('shares with a team addressed by uid', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
|
||||
const res = await shareWithTeam(fx.a.owner.userId, file.path, {
|
||||
team: fx.a.uid,
|
||||
});
|
||||
|
||||
expect(res.holderTeam?.uid).toBe(fx.a.uid);
|
||||
expect(res.isNew).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the handle under its own field', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
|
||||
const res = await shareWithTeam(fx.a.owner.userId, file.path, {
|
||||
teamHandle: fx.a.handle,
|
||||
});
|
||||
|
||||
expect(res.holderTeam?.uid).toBe(fx.a.uid);
|
||||
});
|
||||
|
||||
it('refuses both fields at once rather than picking one', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
|
||||
await expect(
|
||||
shareWithTeam(fx.a.owner.userId, file.path, {
|
||||
team: fx.a.uid,
|
||||
teamHandle: fx.a.handle,
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('404s on a team that does not exist', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
|
||||
await expect(
|
||||
shareWithTeam(fx.a.owner.userId, file.path, {
|
||||
team: '00000000-0000-4000-8000-000000000000',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('does not read a team handle out of a bare string recipient', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
|
||||
// A bare string is an email or a username, never a team — the
|
||||
// SDK contract that keeps this additive.
|
||||
await expect(
|
||||
shareWithTeam(fx.a.owner.userId, file.path, {
|
||||
username: fx.a.handle,
|
||||
} as never),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// -- who it reaches ------------------------------------------------
|
||||
|
||||
it('reaches every member of the team', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
for (const seat of fx.a.seats) {
|
||||
const items = (await inbox(seat.userId)).items;
|
||||
expect(
|
||||
items.map((i) => i.entryUid),
|
||||
`seat ${seat.username}`,
|
||||
).toContain(file.uid);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not reach the other team', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
for (const seat of fx.b.seats) {
|
||||
const items = (await inbox(seat.userId)).items;
|
||||
expect(items.map((i) => i.entryUid)).not.toContain(file.uid);
|
||||
}
|
||||
});
|
||||
|
||||
it('reaches someone who joins the team afterwards', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
// The grant is one row against the team; the scan resolves it for
|
||||
// whoever is a member at the time.
|
||||
const username = `late_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created = await fx.env.server.services.team.provisionAccount(
|
||||
fx.a.uid,
|
||||
fx.a.owner.userId,
|
||||
{ username, email: `${username}@test.local` },
|
||||
);
|
||||
|
||||
const items = (await inbox(created.userId)).items;
|
||||
expect(items.map((i) => i.entryUid)).toContain(file.uid);
|
||||
});
|
||||
|
||||
// -- listing: the silent failure this phase guards ----------------
|
||||
|
||||
it('appears in a member inbox, which is what `#liveGrants` decides', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
const items = (await inbox(fx.a.seats[0].userId)).items;
|
||||
const row = items.find((i) => i.entryUid === file.uid);
|
||||
// Without group evidence the share resolves correctly and is filtered
|
||||
// out of the listing as dead — nothing errors, it is simply absent.
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.mode).toBe('read');
|
||||
});
|
||||
|
||||
it('counts team shares in the total, not just the page', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
const seat = fx.a.seats[0];
|
||||
const res = await shares().listSharedWithMe(
|
||||
await actorFor(seat.userId),
|
||||
{ limit: 100, includeTotal: true },
|
||||
);
|
||||
expect(res.total).toBeGreaterThanOrEqual(res.items.length);
|
||||
});
|
||||
|
||||
// -- revoke --------------------------------------------------------
|
||||
|
||||
it('unsharing removes it from every member inbox', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
await shares().unshare(await actorFor(fx.a.owner.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.a.uid },
|
||||
} as never);
|
||||
|
||||
for (const seat of fx.a.seats) {
|
||||
const items = (await inbox(seat.userId)).items;
|
||||
expect(items.map((i) => i.entryUid)).not.toContain(file.uid);
|
||||
}
|
||||
});
|
||||
|
||||
it('unsharing revokes the grant, not just the index row', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
await shares().unshare(await actorFor(fx.a.owner.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.a.uid },
|
||||
} as never);
|
||||
|
||||
// Deleting the row alone would hide the share while leaving every
|
||||
// member holding real access — worse than a listing bug.
|
||||
const perms = await fx.env.server.stores.permission.readUserGroupPerms(
|
||||
fx.a.seats[0].userId,
|
||||
[`fs:${file.uid}:read`],
|
||||
);
|
||||
expect(perms).toHaveLength(0);
|
||||
});
|
||||
|
||||
// -- coexistence with user shares ---------------------------------
|
||||
|
||||
it('leaves an external share working', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
const outsider = await fx.env.server.stores.user.getById(
|
||||
fx.outsider.userId,
|
||||
);
|
||||
|
||||
// A member sharing outside the team is permitted; if that ever
|
||||
// stops working it should fail here rather than for a customer.
|
||||
const res = await shares().share(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
{
|
||||
path: file.path,
|
||||
recipient: { username: outsider!.username },
|
||||
mode: 'read',
|
||||
} as never,
|
||||
);
|
||||
expect(res.holderId).toBe(fx.outsider.userId);
|
||||
|
||||
const items = (await inbox(fx.outsider.userId)).items;
|
||||
expect(items.map((i) => i.entryUid)).toContain(file.uid);
|
||||
});
|
||||
|
||||
it('keeps a user share when the team share is revoked', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
const outsider = await fx.env.server.stores.user.getById(
|
||||
fx.outsider.userId,
|
||||
);
|
||||
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
await shares().share(await actorFor(fx.a.owner.userId), {
|
||||
path: file.path,
|
||||
recipient: { username: outsider!.username },
|
||||
mode: 'read',
|
||||
} as never);
|
||||
|
||||
await shares().unshare(await actorFor(fx.a.owner.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.a.uid },
|
||||
} as never);
|
||||
|
||||
// The two holders are independent rows and independent grants.
|
||||
const stillThere = (await inbox(fx.outsider.userId)).items;
|
||||
expect(stillThere.map((i) => i.entryUid)).toContain(file.uid);
|
||||
const gone = (await inbox(fx.a.seats[0].userId)).items;
|
||||
expect(gone.map((i) => i.entryUid)).not.toContain(file.uid);
|
||||
});
|
||||
|
||||
// -- the index row -------------------------------------------------
|
||||
|
||||
it('writes a group-held index row, not a user-held one', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
const rows = (await fx.env.server.clients.db.read(
|
||||
'SELECT `holder_user_id`, `holder_group_id` FROM `share` ' +
|
||||
'WHERE `fsentry_id` = (SELECT `id` FROM `fsentries` WHERE `uuid` = ?)',
|
||||
[file.uid],
|
||||
)) as { holder_user_id: number | null; holder_group_id: number | null }[];
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].holder_user_id).toBeFalsy();
|
||||
expect(rows[0].holder_group_id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('re-sharing the same team does not spend quota twice', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
|
||||
const first = await shareWithTeam(fx.a.owner.userId, file.path, {
|
||||
team: fx.a.uid,
|
||||
});
|
||||
const second = await shareWithTeam(fx.a.owner.userId, file.path, {
|
||||
team: fx.a.uid,
|
||||
});
|
||||
|
||||
expect(first.isNew).toBe(true);
|
||||
expect(second.isNew).toBe(false);
|
||||
});
|
||||
|
||||
// -- a group row is not a pending invite ---------------------------
|
||||
//
|
||||
// `holder_user_id IS NULL` meant "unclaimed invite" everywhere until a
|
||||
// team share gave that column a second reason to be null.
|
||||
|
||||
it('lists a team share as a real share, not a pending invite', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
const page = await shares().listSharedByMe(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
{ limit: 100 },
|
||||
);
|
||||
const row = page.items.find((i) => i.entryUid === file.uid);
|
||||
|
||||
expect(row).toBeTruthy();
|
||||
expect(row?.pending).toBeFalsy();
|
||||
expect(row?.holderTeam?.uid).toBe(fx.a.uid);
|
||||
});
|
||||
|
||||
it('does not show a phantom invite on the item it was shared with', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
const rows = await shares().listSharesOf(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
{ uid: file.uid } as never,
|
||||
);
|
||||
|
||||
// A blank-addressed pending row here is the group row misread.
|
||||
expect(
|
||||
rows.some((r) => r.pending && !r.recipientEmail),
|
||||
).toBe(false);
|
||||
|
||||
// And at the source: the group row must not be in the invite feed at
|
||||
// all. Asserted separately because `#resolvedShareRow` would mask it.
|
||||
const entry = await fx.env.server.stores.fsEntry.getEntryByUuid(
|
||||
file.uid,
|
||||
);
|
||||
const invites =
|
||||
await fx.env.server.stores.share.listPendingOnFsentry(entry!.id);
|
||||
expect(invites.filter((r) => r.holder_group_id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('revoking from the outbound listing withdraws the grant, not just the row', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
const page = await shares().listSharedByMe(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
{ limit: 100 },
|
||||
);
|
||||
const row = page.items.find((i) => i.entryUid === file.uid);
|
||||
expect(row?.uid).toBeTruthy();
|
||||
|
||||
await shares().revokeSharedByMe(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
row!.uid,
|
||||
);
|
||||
|
||||
// Probing the inbox would pass either way: deleting the index row alone
|
||||
// also empties the listing. The grant is what members actually resolve.
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
for (const seat of fx.a.seats) {
|
||||
const rows = await fx.env.server.stores.permission.readUserGroupPerms(
|
||||
seat.userId,
|
||||
[permission],
|
||||
);
|
||||
expect(rows, `seat ${seat.username}`).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the caller own grant even when another issuer lost authority', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
const owner = await actorFor(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
// A second index row attributed to a seat, who cannot manage this node.
|
||||
// Impersonating them to revoke throws 403 and aborts the whole unshare.
|
||||
const team = await fx.env.server.stores.team.getByUid(fx.a.uid);
|
||||
const entry = await fx.env.server.stores.fsEntry.getEntryByUuid(
|
||||
file.uid,
|
||||
);
|
||||
await fx.env.server.stores.share.upsertActiveGroup({
|
||||
issuerUserId: fx.a.seats[0].userId,
|
||||
holderGroupId: team.id,
|
||||
fsentryId: entry.id,
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
await shares().unshare(owner, {
|
||||
path: file.path,
|
||||
recipient: { team: fx.a.uid },
|
||||
});
|
||||
|
||||
const rows = await fx.env.server.stores.permission.readUserGroupPerms(
|
||||
fx.a.seats[1].userId,
|
||||
[`fs:${file.uid}:read`],
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('counts members as reached, so they get live events on a shared folder', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
const entry = await fx.env.server.stores.fsEntry.getEntryByUuid(
|
||||
file.uid,
|
||||
);
|
||||
|
||||
// The feed behind `outer.gui.item.*`: without group rows a member sees
|
||||
// the folder but never a change inside it, so it goes stale silently.
|
||||
const rows =
|
||||
await fx.env.server.stores.share.listGroupReachingMembers([
|
||||
entry.id,
|
||||
]);
|
||||
const reached = rows.map((r) => Number(r.holder_user_id));
|
||||
for (const seat of fx.a.seats) {
|
||||
expect(reached, `seat ${seat.username}`).toContain(seat.userId);
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the group grant when the entry itself is deleted', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
const permission = `fs:${file.uid}:read`;
|
||||
const seat = fx.a.seats[0].userId;
|
||||
expect(
|
||||
await fx.env.server.stores.permission.readUserGroupPerms(seat, [
|
||||
permission,
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
|
||||
await shares().onEntryDeleted([file.uid]);
|
||||
|
||||
// Otherwise the grant outlives the file and keeps answering "allowed".
|
||||
expect(
|
||||
await fx.env.server.stores.permission.readUserGroupPerms(seat, [
|
||||
permission,
|
||||
]),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not let a team grant keep a revoked direct share listed', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
const seat = fx.a.seats[0];
|
||||
const owner = await actorFor(fx.a.owner.userId);
|
||||
|
||||
// Same issuer reaches the same holder two ways: directly, and through
|
||||
// the team they share.
|
||||
await shares().share(owner, {
|
||||
path: file.path,
|
||||
recipient: { username: seat.username },
|
||||
mode: 'read',
|
||||
});
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
// The direct grant is withdrawn outside `unshare`, leaving its index
|
||||
// row behind. The group grant must not stand in for it.
|
||||
await fx.env.server.stores.permission.deleteUserUserPermsByPermissionPrefixes(
|
||||
[`fs:${file.uid}`, `manage:fs:${file.uid}`],
|
||||
);
|
||||
|
||||
const page = await shares().listSharedByMe(owner, { limit: 100 });
|
||||
const direct = page.items.filter(
|
||||
(i) => i.entryUid === file.uid && !i.holderTeam,
|
||||
);
|
||||
expect(direct).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows the team in the share dialog for that item', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
const rows = await shares().listSharesOf(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
{ uid: file.uid },
|
||||
);
|
||||
|
||||
// Neither the holder feed nor the invite feed matches a group row, so
|
||||
// without its own read the dialog shows nothing to revoke.
|
||||
const team = rows.filter((r) => r.holderTeam?.uid === fx.a.uid);
|
||||
expect(team).toHaveLength(1);
|
||||
expect(team[0].pending).toBeFalsy();
|
||||
});
|
||||
// -- review findings ------------------------------------------------
|
||||
|
||||
it('refuses to share into a team the sharer does not belong to', async () => {
|
||||
const file = await makeFile(fx.outsider.userId);
|
||||
|
||||
await expect(
|
||||
shares().share(await actorFor(fx.outsider.userId), {
|
||||
path: file.path,
|
||||
recipient: { teamHandle: fx.a.handle },
|
||||
mode: 'read',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('revoking one row leaves another issuer grant on the same team', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
const owner = await actorFor(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
const team = await fx.env.server.stores.team.getByUid(fx.a.uid);
|
||||
const entry = await fx.env.server.stores.fsEntry.getEntryByUuid(file.uid);
|
||||
await fx.env.server.stores.share.upsertActiveGroup({
|
||||
issuerUserId: fx.a.seats[0].userId,
|
||||
holderGroupId: team.id,
|
||||
fsentryId: entry.id,
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const rows = await fx.env.server.stores.share.listGroupOnFsentries([
|
||||
entry.id,
|
||||
]);
|
||||
const delegateRow = rows.find(
|
||||
(r) => Number(r.issuer_user_id) === fx.a.seats[0].userId,
|
||||
);
|
||||
await shares().revokeSharedByMe(owner, String(delegateRow.uid));
|
||||
|
||||
// Row-addressed revocation takes that row's issuer only.
|
||||
const left = await fx.env.server.stores.permission.readUserGroupPerms(
|
||||
fx.a.seats[1].userId,
|
||||
[`fs:${file.uid}:read`],
|
||||
);
|
||||
expect(left.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('names the issuer of a team-only share in the dialog', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
|
||||
const rows = await shares().listSharesOf(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
{ uid: file.uid },
|
||||
);
|
||||
const team = rows.find((r) => r.holderTeam?.uid === fx.a.uid);
|
||||
|
||||
// Without the issuer resolved the dialog reads "shared by nobody".
|
||||
expect(team?.issuer?.username).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lists a team reaching a file through a shared ancestor', async () => {
|
||||
const { dirPath, fileUid } = await makeNestedFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, dirPath, { team: fx.a.uid });
|
||||
|
||||
const rows = await shares().listSharesOf(
|
||||
await actorFor(fx.a.owner.userId),
|
||||
{ uid: fileUid },
|
||||
);
|
||||
expect(rows.some((r) => r.holderTeam?.uid === fx.a.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the teams on a file and its folders in one pass', async () => {
|
||||
const { dirPath, fileUid } = await makeNestedFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, dirPath, { team: fx.a.uid });
|
||||
const owner = await actorFor(fx.a.owner.userId);
|
||||
await shares().share(owner, {
|
||||
uid: fileUid,
|
||||
recipient: { team: fx.a.uid },
|
||||
mode: 'write',
|
||||
} as never);
|
||||
|
||||
const { share, team, permission } = fx.env.server.stores;
|
||||
const spies = [
|
||||
vi.spyOn(share, 'listGroupOnFsentries'),
|
||||
vi.spyOn(team, 'getByIdsIncludingDeleted'),
|
||||
vi.spyOn(permission, 'queryIssuerGroupPermsByPrefixes'),
|
||||
];
|
||||
try {
|
||||
const rows = await shares().listSharesOf(owner, { uid: fileUid });
|
||||
// The file's own row, then the folder's, both live.
|
||||
expect(
|
||||
rows.filter((r) => r.holderTeam?.uid === fx.a.uid),
|
||||
).toHaveLength(2);
|
||||
for (const spy of spies) expect(spy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
for (const spy of spies) spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// -- the recipient block list ---------------------------------------
|
||||
// A team share is the team's, so a per-sender block does not withhold it
|
||||
// from a colleague; only the notification is suppressed. Leaving the team
|
||||
// is what ends the access.
|
||||
|
||||
const block = (blocker: FixtureUser, blocked: FixtureUser) =>
|
||||
fx.env.server.stores.userBlock.create(blocker.userId, blocked.userId);
|
||||
const unblock = (blocker: FixtureUser, blocked: FixtureUser) =>
|
||||
fx.env.server.stores.userBlock.deleteByPair(
|
||||
blocker.userId,
|
||||
blocked.userId,
|
||||
);
|
||||
|
||||
it('still delivers a team share to a member who blocked the sender', async () => {
|
||||
const [blockingSeat, otherSeat] = fx.a.seats;
|
||||
await block(blockingSeat, fx.a.owner);
|
||||
try {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, {
|
||||
team: fx.a.uid,
|
||||
});
|
||||
|
||||
// The block is about one person's contact, not the team's files.
|
||||
const mine = await shares().listSharedWithMe(
|
||||
await actorFor(blockingSeat.userId),
|
||||
{ limit: 100, includeTotal: true },
|
||||
);
|
||||
expect(mine.items.map((i) => i.entryUid)).toContain(file.uid);
|
||||
|
||||
// And the grant is there to back it, not just the listing row.
|
||||
expect(
|
||||
await fx.env.server.stores.permission.readUserGroupPerms(
|
||||
blockingSeat.userId,
|
||||
[`fs:${file.uid}:read`],
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
const others = (await inbox(otherSeat.userId)).items;
|
||||
expect(others.map((i) => i.entryUid)).toContain(file.uid);
|
||||
} finally {
|
||||
await unblock(blockingSeat, fx.a.owner);
|
||||
}
|
||||
});
|
||||
|
||||
it('counts a team share for a blocking member, so page and total agree', async () => {
|
||||
const seat = fx.a.seats[0];
|
||||
await block(seat, fx.a.owner);
|
||||
try {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, {
|
||||
team: fx.a.uid,
|
||||
});
|
||||
const res = await shares().listSharedWithMe(
|
||||
await actorFor(seat.userId),
|
||||
{ limit: 100, includeTotal: true },
|
||||
);
|
||||
expect(res.total).toBeGreaterThanOrEqual(res.items.length);
|
||||
} finally {
|
||||
await unblock(seat, fx.a.owner);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a blocking member in the live-event fan-out', async () => {
|
||||
const [blockingSeat] = fx.a.seats;
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid });
|
||||
const entry = await fx.env.server.stores.fsEntry.getEntryByUuid(
|
||||
file.uid,
|
||||
);
|
||||
|
||||
await block(blockingSeat, fx.a.owner);
|
||||
try {
|
||||
// They can open the file, so they have to be told it changed.
|
||||
const rows =
|
||||
await fx.env.server.stores.share.listGroupReachingMembers([
|
||||
entry.id,
|
||||
]);
|
||||
expect(rows.map((r) => Number(r.holder_user_id))).toContain(
|
||||
blockingSeat.userId,
|
||||
);
|
||||
} finally {
|
||||
await unblock(blockingSeat, fx.a.owner);
|
||||
}
|
||||
});
|
||||
|
||||
// -- unshare sweeps by rows, not by a member page --------------------
|
||||
|
||||
it('sweeps a member re-share on team unshare', async () => {
|
||||
const seat = fx.a.seats[0];
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
// `manage` is what lets a member re-share in the first place.
|
||||
await shareWithTeam(
|
||||
fx.a.owner.userId,
|
||||
file.path,
|
||||
{ team: fx.a.uid },
|
||||
'manage',
|
||||
);
|
||||
await shares().share(await actorFor(seat.userId), {
|
||||
path: file.path,
|
||||
recipient: { username: fx.outsider.username },
|
||||
mode: 'read',
|
||||
} as never);
|
||||
expect(
|
||||
(await inbox(fx.outsider.userId)).items.map((i) => i.entryUid),
|
||||
).toContain(file.uid);
|
||||
|
||||
await shares().unshare(await actorFor(fx.a.owner.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.a.uid },
|
||||
} as never);
|
||||
|
||||
// The re-share rested on the group grant and goes with it.
|
||||
expect(
|
||||
(await inbox(fx.outsider.userId)).items.map((i) => i.entryUid),
|
||||
).not.toContain(file.uid);
|
||||
});
|
||||
|
||||
it('sweeps a member\'s unclaimed invite on team unshare', async () => {
|
||||
const seat = fx.a.seats[0];
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(
|
||||
fx.a.owner.userId,
|
||||
file.path,
|
||||
{ team: fx.a.uid },
|
||||
'manage',
|
||||
);
|
||||
const invited = `invitee_${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
await shares().share(await actorFor(seat.userId), {
|
||||
path: file.path,
|
||||
recipient: { email: invited },
|
||||
mode: 'read',
|
||||
} as never);
|
||||
expect(
|
||||
await fx.env.server.stores.share.listPendingByEmail(invited),
|
||||
).toHaveLength(1);
|
||||
|
||||
await shares().unshare(await actorFor(fx.a.owner.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.a.uid },
|
||||
} as never);
|
||||
|
||||
// The invite rests on the same authority the unshare withdrew.
|
||||
expect(
|
||||
await fx.env.server.stores.share.listPendingByEmail(invited),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('finds every issuer in the subtree, and filters them to members', async () => {
|
||||
const { dirPath, dirUid, fileUid } = await makeNestedFile(
|
||||
fx.a.owner.userId,
|
||||
);
|
||||
const seat = fx.a.seats[0];
|
||||
await shareWithTeam(
|
||||
fx.a.owner.userId,
|
||||
dirPath,
|
||||
{ team: fx.a.uid },
|
||||
'manage',
|
||||
);
|
||||
// A re-share on the nested file, visible only if the walk descends.
|
||||
const fileEntry = await fx.env.server.stores.fsEntry.getEntryByUuid(
|
||||
fileUid,
|
||||
);
|
||||
await fx.env.server.stores.share.upsertActive({
|
||||
issuerUserId: seat.userId,
|
||||
holderUserId: fx.outsider.userId,
|
||||
fsentryId: fileEntry!.id,
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const dirEntry = await fx.env.server.stores.fsEntry.getEntryByUuid(
|
||||
dirUid,
|
||||
);
|
||||
const issuers = await fx.env.server.stores.share.listIssuerIdsBySubtree(
|
||||
dirEntry!.id,
|
||||
);
|
||||
expect(issuers).toContain(fx.a.owner.userId);
|
||||
expect(issuers).toContain(seat.userId);
|
||||
|
||||
// The outsider issued nothing and is no member; both fall out here.
|
||||
const members = await fx.env.server.stores.team.memberIdsAmong(
|
||||
fx.a.uid,
|
||||
[...issuers, fx.outsider.userId],
|
||||
);
|
||||
expect(members).toContain(seat.userId);
|
||||
expect(members).not.toContain(fx.outsider.userId);
|
||||
});
|
||||
|
||||
it('sweeps a member re-share made to another team', async () => {
|
||||
const seat = fx.a.seats[0];
|
||||
// The seat belongs to both teams, so it may re-share A's file into B.
|
||||
await fx.env.server.stores.team.addMember(fx.b.uid, seat.userId, {
|
||||
orgOwned: false,
|
||||
});
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(
|
||||
fx.a.owner.userId,
|
||||
file.path,
|
||||
{ team: fx.a.uid },
|
||||
'manage',
|
||||
);
|
||||
await shares().share(await actorFor(seat.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.b.uid },
|
||||
mode: 'read',
|
||||
} as never);
|
||||
expect(
|
||||
(await inbox(fx.b.seats[0].userId)).items.map((i) => i.entryUid),
|
||||
).toContain(file.uid);
|
||||
|
||||
await shares().unshare(await actorFor(fx.a.owner.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.a.uid },
|
||||
} as never);
|
||||
|
||||
// Left standing, team B's grant is dormant and springs back whenever
|
||||
// the seat requalifies — the row and the grant both have to go.
|
||||
expect(
|
||||
await fx.env.server.stores.permission.readUserGroupPerms(
|
||||
fx.b.seats[0].userId,
|
||||
[`fs:${file.uid}:read`],
|
||||
),
|
||||
).toHaveLength(0);
|
||||
const entry = await fx.env.server.stores.fsEntry.getEntryByUuid(
|
||||
file.uid,
|
||||
);
|
||||
expect(
|
||||
await fx.env.server.stores.share.listGroupSharesByFsentry(
|
||||
entry!.id,
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps member re-shares while another issuer still grants the team', async () => {
|
||||
const [m1, m2] = fx.a.seats;
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(
|
||||
fx.a.owner.userId,
|
||||
file.path,
|
||||
{ team: fx.a.uid },
|
||||
'manage',
|
||||
);
|
||||
// Two grants back the team; m2's re-share rests on either of them.
|
||||
await shareWithTeam(m1.userId, file.path, { team: fx.a.uid });
|
||||
await shares().share(await actorFor(m2.userId), {
|
||||
path: file.path,
|
||||
recipient: { username: fx.outsider.username },
|
||||
mode: 'read',
|
||||
} as never);
|
||||
|
||||
// m1 withdraws only their own grant; the owner's still delivers.
|
||||
await shares().unshare(await actorFor(m1.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.a.uid },
|
||||
} as never);
|
||||
|
||||
expect(
|
||||
(await inbox(fx.outsider.userId)).items.map((i) => i.entryUid),
|
||||
).toContain(file.uid);
|
||||
});
|
||||
});
|
||||
@@ -1,146 +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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Team sharing, asked the question the product actually asks: *can this member
|
||||
* open the file*. `TeamShare.test.ts` covers the rows and the listings, both of
|
||||
* which resolve a team share without ever entering the permission scan — so a
|
||||
* scan that never terminates leaves that whole suite green.
|
||||
*
|
||||
* The scan is only reachable when the issuer does not own the entry: the
|
||||
* `is-owner` implicator (`FSService`) is a shortcut, so an owner's sub-scan
|
||||
* returns before the group scanner runs. A member re-sharing into their own
|
||||
* team is the case that gets past it.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { Actor } from '../../core/actor';
|
||||
import { setupTwoTeams, type TwoTeams } from '../../testFixtures/twoTeams.js';
|
||||
|
||||
describe('resolving access to a team-shared item', () => {
|
||||
let fx: TwoTeams;
|
||||
|
||||
const actorFor = async (userId: number): Promise<Actor> => {
|
||||
const user = await fx.env.server.stores.user.getById(userId);
|
||||
return { user } as unknown as Actor;
|
||||
};
|
||||
|
||||
const shares = () => fx.env.server.services.share;
|
||||
|
||||
/** Read access as ACLService answers it — the real authorization path. */
|
||||
const canRead = async (userId: number, path: string): Promise<boolean> =>
|
||||
fx.env.server.services.acl.check(
|
||||
await actorFor(userId),
|
||||
{
|
||||
path,
|
||||
resolveAncestors: () =>
|
||||
fx.env.server.services.fs.getAncestorChain(path),
|
||||
},
|
||||
'read',
|
||||
);
|
||||
|
||||
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,
|
||||
fx.env.server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
return { path, uid };
|
||||
};
|
||||
|
||||
const shareWithTeam = async (
|
||||
actingUserId: number,
|
||||
path: string,
|
||||
teamUid: string,
|
||||
mode: string,
|
||||
) =>
|
||||
shares().share(await actorFor(actingUserId), {
|
||||
path,
|
||||
recipient: { team: teamUid },
|
||||
mode,
|
||||
} as never);
|
||||
|
||||
/**
|
||||
* Owner hands the team `manage`, then a seat re-shares into that same
|
||||
* team. The second grant is issued by someone who is himself a member, so
|
||||
* the issuer's own scan reaches the row he wrote.
|
||||
*/
|
||||
const reshareIntoOwnTeam = async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
// `manage` is what lets a member re-share at all.
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, fx.a.uid, 'manage');
|
||||
await shareWithTeam(fx.a.seats[0].userId, file.path, fx.a.uid, 'read');
|
||||
return file;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await setupTwoTeams();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await fx?.shutdown();
|
||||
});
|
||||
|
||||
it('answers for a plain team share', async () => {
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(fx.a.owner.userId, file.path, fx.a.uid, 'read');
|
||||
|
||||
// Baseline: the owner issues it, so `is-owner` ends the sub-scan and
|
||||
// the group scanner never runs. This passes with or without the fix.
|
||||
expect(await canRead(fx.a.seats[0].userId, file.path)).toBe(true);
|
||||
expect(await canRead(fx.b.seats[0].userId, file.path)).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
it('terminates when a member re-shared into the team they belong to', async () => {
|
||||
const file = await reshareIntoOwnTeam();
|
||||
|
||||
// Without the fix this never returns: the scan of the re-sharer finds
|
||||
// his own row again, and the scan cache is only written on completion
|
||||
// so the in-flight re-entry always misses.
|
||||
expect(await canRead(fx.a.seats[1].userId, file.path)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it('still resolves for the re-sharer after someone else took that path', async () => {
|
||||
const file = await reshareIntoOwnTeam();
|
||||
|
||||
// Order matters: the other member's check is what runs the pruned
|
||||
// sub-scan of the re-sharer. If that pruned reading is kept, the
|
||||
// re-sharer is then denied access he holds through the owner's grant.
|
||||
expect(await canRead(fx.a.seats[1].userId, file.path)).toBe(true);
|
||||
expect(await canRead(fx.a.seats[0].userId, file.path)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it('does not leak the re-shared item to the other team', async () => {
|
||||
const file = await reshareIntoOwnTeam();
|
||||
|
||||
expect(await canRead(fx.b.seats[0].userId, file.path)).toBe(false);
|
||||
expect(await canRead(fx.outsider.userId, file.path)).toBe(false);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,319 +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,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { Actor } from '../../core/actor';
|
||||
import {
|
||||
setupTwoTeams,
|
||||
type TwoTeams,
|
||||
} from '../../testFixtures/twoTeams.js';
|
||||
|
||||
/**
|
||||
* A team share names no holder, so every map in `notifyShared` used to skip
|
||||
* it and the call succeeded having told nobody. The failure mode is silence:
|
||||
* the share is created, resolves and lists correctly, and nothing errors. Only
|
||||
* a test that counts notifications catches it.
|
||||
*/
|
||||
describe('announcing a team share', () => {
|
||||
let fx: TwoTeams;
|
||||
|
||||
const actorFor = async (userId: number): Promise<Actor> => {
|
||||
const user = await fx.env.server.stores.user.getById(userId);
|
||||
return { user } as unknown as Actor;
|
||||
};
|
||||
|
||||
const makeFile = async (ownerId: number) => {
|
||||
const uid = crypto.randomUUID();
|
||||
const name = `n_${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,
|
||||
fx.env.server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
return path;
|
||||
};
|
||||
|
||||
/** Share and announce, the way `ShareController` does on the response path. */
|
||||
const shareAndNotify = async (
|
||||
issuerId: number,
|
||||
path: string,
|
||||
recipient: Record<string, string>,
|
||||
) => {
|
||||
const actor = await actorFor(issuerId);
|
||||
const share = await fx.env.server.services.share.share(actor, {
|
||||
path,
|
||||
recipient,
|
||||
mode: 'read',
|
||||
} as never);
|
||||
await fx.env.server.services.shareNotification.notifyShared(actor, [
|
||||
share,
|
||||
]);
|
||||
return share;
|
||||
};
|
||||
|
||||
const notificationsFor = async (userId: number) => {
|
||||
const rows = await fx.env.server.stores.notification.listByUserId(
|
||||
userId,
|
||||
{ filter: 'unacknowledged' },
|
||||
);
|
||||
return rows as unknown as Array<{ value?: unknown }>;
|
||||
};
|
||||
|
||||
const titlesFor = async (userId: number): Promise<string[]> =>
|
||||
(await notificationsFor(userId)).map((row) => {
|
||||
const value = (
|
||||
typeof row.value === 'string'
|
||||
? JSON.parse(row.value)
|
||||
: (row.value ?? {})
|
||||
) as { title?: string };
|
||||
return value.title ?? '';
|
||||
});
|
||||
|
||||
let sent: Array<{ to: string; subject: string }>;
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await setupTwoTeams({
|
||||
// A transport must exist for the service to try at all; `sendRaw`
|
||||
// is spied, so nothing leaves the process.
|
||||
email: {
|
||||
from: '"Puter (test)" <[email protected]>',
|
||||
host: '127.0.0.1',
|
||||
port: 1,
|
||||
},
|
||||
// Digests flush almost at once rather than after a real minute.
|
||||
share_notify_limits: { emailBatchSeconds: 0.05 },
|
||||
} as never);
|
||||
}, 180_000);
|
||||
|
||||
beforeEach(() => {
|
||||
sent = [];
|
||||
vi.spyOn(fx.env.server.clients.email, 'sendRaw').mockImplementation(
|
||||
async (options: { to: string; subject: string }) => {
|
||||
sent.push({ to: options.to, subject: options.subject });
|
||||
return null;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fx?.shutdown();
|
||||
});
|
||||
|
||||
it('tells every member of the team', async () => {
|
||||
const path = await makeFile(fx.a.owner.userId);
|
||||
await shareAndNotify(fx.a.owner.userId, path, { team: fx.a.uid });
|
||||
|
||||
for (const seat of fx.a.seats) {
|
||||
const titles = await titlesFor(seat.userId);
|
||||
expect(titles.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not tell the issuer about their own share', async () => {
|
||||
const before = (await notificationsFor(fx.a.owner.userId)).length;
|
||||
const path = await makeFile(fx.a.owner.userId);
|
||||
await shareAndNotify(fx.a.owner.userId, path, { team: fx.a.uid });
|
||||
|
||||
expect((await notificationsFor(fx.a.owner.userId)).length).toBe(before);
|
||||
});
|
||||
|
||||
it('does not tell members of a team it was not shared with', async () => {
|
||||
const before = await Promise.all(
|
||||
fx.b.seats.map(async (s) => (await notificationsFor(s.userId)).length),
|
||||
);
|
||||
const path = await makeFile(fx.a.owner.userId);
|
||||
await shareAndNotify(fx.a.owner.userId, path, { team: fx.a.uid });
|
||||
|
||||
const after = await Promise.all(
|
||||
fx.b.seats.map(async (s) => (await notificationsFor(s.userId)).length),
|
||||
);
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it('does not retroactively tell a member who joins afterwards', async () => {
|
||||
const path = await makeFile(fx.a.owner.userId);
|
||||
await shareAndNotify(fx.a.owner.userId, path, { team: fx.a.uid });
|
||||
|
||||
// The outsider joins after the announcement; the scan will resolve the
|
||||
// grant for them, but announcements describe a moment, not a state.
|
||||
const team = await fx.env.server.stores.team.getByUid(fx.a.uid);
|
||||
await fx.env.server.stores.team.addMember(
|
||||
fx.a.uid,
|
||||
fx.outsider.userId,
|
||||
{ orgOwned: false },
|
||||
);
|
||||
expect(team).toBeTruthy();
|
||||
|
||||
expect(await titlesFor(fx.outsider.userId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emails every member too, not just the in-app notification', async () => {
|
||||
const path = await makeFile(fx.a.owner.userId);
|
||||
await shareAndNotify(fx.a.owner.userId, path, { team: fx.a.uid });
|
||||
|
||||
// The digest is held briefly, so wait for the flush rather than the
|
||||
// queueing -- asserting too early passes for the wrong reason.
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
for (const seat of fx.a.seats) {
|
||||
expect(
|
||||
sent.some((mail) => mail.to === `${seat.username}@test.local`),
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
{ timeout: 10_000, interval: 100 },
|
||||
);
|
||||
|
||||
// And nobody outside the team was mailed about it. Checked against
|
||||
// team B's owner, who never joins A -- the `outsider` is admitted
|
||||
// to A by the test above, so by here they are a member.
|
||||
expect(
|
||||
sent.some((mail) => mail.to.includes(fx.b.owner.username)),
|
||||
).toBe(false);
|
||||
// Longer than the wait above, which the 5s default cuts short.
|
||||
}, 30_000);
|
||||
|
||||
it('does not announce to a member who blocked the sharer', async () => {
|
||||
// A member nobody has notified yet, so a new row is detectable. Reusing
|
||||
// a seat would not be: a second share *folds* into its open
|
||||
// notification, leaving the row count unchanged either way.
|
||||
const username = `blk_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const fresh = await fx.env.server.stores.user.create({
|
||||
username,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
await fx.env.server.stores.team.addMember(fx.a.uid, fresh.id, {
|
||||
orgOwned: false,
|
||||
});
|
||||
const blocker = { userId: fresh.id, username };
|
||||
const other = fx.a.seats[1];
|
||||
await fx.env.server.stores.userBlock.create(
|
||||
blocker.userId,
|
||||
fx.a.owner.userId,
|
||||
);
|
||||
|
||||
const before = (await notificationsFor(blocker.userId)).length;
|
||||
expect(before).toBe(0);
|
||||
const path = await makeFile(fx.a.owner.userId);
|
||||
await shareAndNotify(fx.a.owner.userId, path, { team: fx.a.uid });
|
||||
|
||||
// The grant is one row against the group so it still reaches them; the
|
||||
// contact the block refuses is the telling.
|
||||
//
|
||||
// Asserted on notifications rather than mail: by this point in the file
|
||||
// the per-pair interruption budget is spent, so no digest opens and an
|
||||
// email assertion would pass for the wrong reason. Notifications are
|
||||
// still written when the budget is spent -- that is the documented
|
||||
// split between what it says and whether it may interrupt.
|
||||
expect((await notificationsFor(blocker.userId)).length).toBe(before);
|
||||
expect(
|
||||
(await notificationsFor(other.userId)).length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await fx.env.server.stores.userBlock.deleteByPair(
|
||||
blocker.userId,
|
||||
fx.a.owner.userId,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the member list once however many items are shared', async () => {
|
||||
const spy = vi.spyOn(
|
||||
fx.env.server.stores.team,
|
||||
'listMemberIdsByGroupId',
|
||||
);
|
||||
const actor = await actorFor(fx.a.owner.userId);
|
||||
const paths = [
|
||||
await makeFile(fx.a.owner.userId),
|
||||
await makeFile(fx.a.owner.userId),
|
||||
await makeFile(fx.a.owner.userId),
|
||||
];
|
||||
const created = [];
|
||||
for (const path of paths) {
|
||||
created.push(
|
||||
await fx.env.server.services.share.share(actor, {
|
||||
path,
|
||||
recipient: { team: fx.a.uid },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
}
|
||||
spy.mockClear();
|
||||
|
||||
await fx.env.server.services.shareNotification.notifyShared(
|
||||
actor,
|
||||
created,
|
||||
);
|
||||
|
||||
// One team, so one read -- not one per item.
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('checks block state in one read per team, not per item or member', async () => {
|
||||
const spy = vi.spyOn(fx.env.server.stores.userBlock, 'blockersAmong');
|
||||
const single = vi.spyOn(fx.env.server.stores.userBlock, 'isBlocked');
|
||||
const actor = await actorFor(fx.a.owner.userId);
|
||||
const created = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
created.push(
|
||||
await fx.env.server.services.share.share(actor, {
|
||||
path: await makeFile(fx.a.owner.userId),
|
||||
recipient: { team: fx.a.uid },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
}
|
||||
spy.mockClear();
|
||||
single.mockClear();
|
||||
|
||||
await fx.env.server.services.shareNotification.notifyShared(
|
||||
actor,
|
||||
created,
|
||||
);
|
||||
|
||||
// Block state is per (member, issuer) pair -- constant across the items
|
||||
// in one call, and every member's is one query.
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(single).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
single.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,180 +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 { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { Actor } from '../../core/actor';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
|
||||
describe('credit-state transitions', () => {
|
||||
let server: PuterServer;
|
||||
let service: PuterServer['services']['team'];
|
||||
let owner: { id: number };
|
||||
|
||||
/** Resolved per seat: a seat is on the common tier like any account. */
|
||||
const allowanceOf = async (actor: Actor) =>
|
||||
(await server.services.metering.getActorSubscription(actor))
|
||||
.monthUsageAllowance;
|
||||
|
||||
/** Credit-state events seen since the last reset. */
|
||||
const states: Array<{ state: string; user_uuid: string }> = [];
|
||||
|
||||
const makeUser = async () => {
|
||||
const username = `cr_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
})) as unknown as { id: number };
|
||||
return { id: created.id, username };
|
||||
};
|
||||
|
||||
const freeHandle = () => `cw-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
/**
|
||||
* A team with its own owner and one real provisioned seat. The owner
|
||||
* is per-test so mail is attributable: these handlers run detached, and a
|
||||
* shared recipient would let a late send land in the next test's tally.
|
||||
*/
|
||||
const makeSeat = async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await service.createTeam(owner.id, {
|
||||
name: 'Credit Co',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const username = `seat_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
const user = await server.stores.user.getById(created.userId);
|
||||
const ownerRow = await server.stores.user.getById(owner.id);
|
||||
return {
|
||||
team,
|
||||
user: user!,
|
||||
owner: ownerRow!,
|
||||
actor: { user } as unknown as Actor,
|
||||
};
|
||||
};
|
||||
|
||||
/** Spend `fraction` of the seat's own monthly allowance. */
|
||||
const spend = async (actor: Actor, fraction: number) =>
|
||||
server.services.metering.incrementUsage(
|
||||
actor,
|
||||
'kv:read',
|
||||
1,
|
||||
Math.floor((await allowanceOf(actor)) * fraction),
|
||||
);
|
||||
|
||||
/** Forget what this process has announced, as a second node would not know. */
|
||||
const asAnotherNode = (uuid: string) => {
|
||||
(
|
||||
server.services.metering as unknown as {
|
||||
creditAlertState: Map<string, string>;
|
||||
}
|
||||
).creditAlertState.delete(uuid);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// The cap has its own suite; these tests need many teams.
|
||||
server = await setupTestServer({
|
||||
teams_enabled: true,
|
||||
max_teams_per_user: 100,
|
||||
} as never);
|
||||
service = server.services.team;
|
||||
owner = await makeUser();
|
||||
|
||||
server.clients.event.on('metering.credit-state', ((
|
||||
_k: string,
|
||||
d: { state: string; user_uuid: string },
|
||||
) => {
|
||||
states.push(d);
|
||||
}) as never);
|
||||
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
states.length = 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
// -- the transition signal ----------------------------------------
|
||||
|
||||
it('announces near-limit when the member passes 90%', async () => {
|
||||
const { actor, user } = await makeSeat();
|
||||
states.length = 0;
|
||||
|
||||
await spend(actor, 0.95);
|
||||
|
||||
const mine = states.filter((s) => s.user_uuid === user.uuid);
|
||||
expect(mine.map((s) => s.state)).toEqual(['near-limit']);
|
||||
});
|
||||
|
||||
it('does not warn a member whose purchased credits carry them past the allowance', async () => {
|
||||
const { actor, user } = await makeSeat();
|
||||
const allowance = await allowanceOf(actor);
|
||||
await server.services.metering.updateAddonCredit(user.uuid, allowance);
|
||||
states.length = 0;
|
||||
|
||||
await spend(actor, 0.95);
|
||||
|
||||
expect(states.filter((s) => s.user_uuid === user.uuid)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('says nothing while the member is comfortably inside the allowance', async () => {
|
||||
const { actor, user } = await makeSeat();
|
||||
states.length = 0;
|
||||
|
||||
await spend(actor, 0.5);
|
||||
|
||||
expect(states.filter((s) => s.user_uuid === user.uuid)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('announces exhausted once, however many times the member retries', async () => {
|
||||
const { actor, user } = await makeSeat();
|
||||
await spend(actor, 1);
|
||||
states.length = 0;
|
||||
|
||||
// Increments, not cached reads: a cache hit never recomputes the
|
||||
// state, so a read loop would pass this test without exercising it.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await spend(actor, 0.01);
|
||||
}
|
||||
|
||||
expect(states.filter((s) => s.user_uuid === user.uuid)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('crosses both lines in order, one announcement each', async () => {
|
||||
const { actor, user } = await makeSeat();
|
||||
states.length = 0;
|
||||
|
||||
await spend(actor, 0.95);
|
||||
await spend(actor, 0.1);
|
||||
|
||||
const mine = states.filter((s) => s.user_uuid === user.uuid);
|
||||
expect(mine.map((s) => s.state)).toEqual(['near-limit', 'exhausted']);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,474 +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 { readFile } from 'node:fs/promises';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import { isFreeSubscription } from '../metering/consts.ts';
|
||||
|
||||
describe('team and seat caps', () => {
|
||||
let server: PuterServer;
|
||||
let service: PuterServer['services']['team'];
|
||||
|
||||
/** Small enough to reach; the shipped default is 50. */
|
||||
const SEAT_CAP = 3;
|
||||
|
||||
const makeUser = async () => {
|
||||
const username = `cap_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
})) as unknown as { id: number };
|
||||
return { id: created.id, username };
|
||||
};
|
||||
|
||||
const freeHandle = () => `cp-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const makeTeam = (ownerId: number) =>
|
||||
service.createTeam(ownerId, {
|
||||
name: 'Capped Co',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
|
||||
const provision = (teamUid: string, ownerId: number) => {
|
||||
const username = `st_${Math.random().toString(36).slice(2, 10)}`;
|
||||
return service.provisionAccount(teamUid, ownerId, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// The shipped team default, and a reachable seat limit.
|
||||
server = await setupTestServer({
|
||||
teams_enabled: true,
|
||||
max_seats_per_team: SEAT_CAP,
|
||||
} as never);
|
||||
service = server.services.team;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
// -- teams per user ------------------------------------------
|
||||
|
||||
it('allows one team and refuses the second', async () => {
|
||||
const owner = await makeUser();
|
||||
await makeTeam(owner.id);
|
||||
|
||||
await expect(makeTeam(owner.id)).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
legacyCode: 'team_limit_reached',
|
||||
});
|
||||
});
|
||||
|
||||
it('holds the team cap against concurrent creates', async () => {
|
||||
const owner = await makeUser();
|
||||
|
||||
// Counting then inserting without serializing lets every one of these
|
||||
// read a count below the cap and all succeed.
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 5 }, () => makeTeam(owner.id)),
|
||||
);
|
||||
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses on the cap before complaining about the handle', async () => {
|
||||
const owner = await makeUser();
|
||||
await makeTeam(owner.id);
|
||||
|
||||
// A capped user hears about the cap, not that their name was taken.
|
||||
await expect(
|
||||
service.createTeam(owner.id, {
|
||||
name: 'Bad',
|
||||
handle: 'NOT A VALID HANDLE',
|
||||
}),
|
||||
).rejects.toMatchObject({ legacyCode: 'team_limit_reached' });
|
||||
});
|
||||
|
||||
it('frees the slot when the team is deleted', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
await service.deleteTeam(team.uid, owner.id);
|
||||
|
||||
// Soft-deleted teams do not count against the cap.
|
||||
await expect(makeTeam(owner.id)).resolves.toMatchObject({
|
||||
owner_user_id: owner.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts per user, not globally', async () => {
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
await makeTeam(a.id);
|
||||
|
||||
await expect(makeTeam(b.id)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('falls back to one team when the config omits the key', async () => {
|
||||
// `config.default.json` is the merge base and sets this, so the code
|
||||
// fallback only ever runs for a config that dropped the key.
|
||||
const owner = await makeUser();
|
||||
const cfg = service.config as { max_teams_per_user?: number };
|
||||
const had = cfg.max_teams_per_user;
|
||||
delete cfg.max_teams_per_user;
|
||||
try {
|
||||
await makeTeam(owner.id);
|
||||
await expect(makeTeam(owner.id)).rejects.toMatchObject({
|
||||
legacyCode: 'team_limit_reached',
|
||||
});
|
||||
} finally {
|
||||
cfg.max_teams_per_user = had;
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a nonsensical cap rather than locking everyone out', async () => {
|
||||
const owner = await makeUser();
|
||||
const cfg = service.config as { max_teams_per_user?: number };
|
||||
const had = cfg.max_teams_per_user;
|
||||
// A zero or negative cap would otherwise refuse every team.
|
||||
cfg.max_teams_per_user = 0;
|
||||
try {
|
||||
await expect(makeTeam(owner.id)).resolves.toBeTruthy();
|
||||
} finally {
|
||||
cfg.max_teams_per_user = had;
|
||||
}
|
||||
});
|
||||
|
||||
// -- seats per team ------------------------------------------
|
||||
|
||||
it('provisions up to the seat cap and refuses the next', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
|
||||
for (let i = 0; i < SEAT_CAP; i++) {
|
||||
await provision(team.uid, owner.id);
|
||||
}
|
||||
|
||||
await expect(provision(team.uid, owner.id)).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
legacyCode: 'seat_limit_reached',
|
||||
});
|
||||
});
|
||||
|
||||
it('holds the seat cap against concurrent provisions', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: SEAT_CAP + 3 }, () =>
|
||||
provision(team.uid, owner.id),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(
|
||||
SEAT_CAP,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses rather than overshooting when the cap lock is contended', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
// Never claimed: the lock must refuse rather than run unserialized.
|
||||
const set = vi
|
||||
.spyOn(server.clients.redis, 'set')
|
||||
.mockResolvedValue(null as never);
|
||||
try {
|
||||
await expect(provision(team.uid, owner.id)).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
});
|
||||
} finally {
|
||||
set.mockRestore();
|
||||
}
|
||||
const after = await server.stores.team.countSeats(team.id);
|
||||
expect(after).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses when the cap lock backend is unreachable', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
const set = vi
|
||||
.spyOn(server.clients.redis, 'set')
|
||||
.mockRejectedValue(new Error('redis down'));
|
||||
try {
|
||||
await expect(provision(team.uid, owner.id)).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
});
|
||||
} finally {
|
||||
set.mockRestore();
|
||||
}
|
||||
expect(await server.stores.team.countSeats(team.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('does not count the team owner against the seat cap', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
|
||||
// The owner is `org_owned = 0`; it pays, it does not occupy a seat.
|
||||
for (let i = 0; i < SEAT_CAP; i++) {
|
||||
await expect(provision(team.uid, owner.id)).resolves.toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses on the cap before creating any account state', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
for (let i = 0; i < SEAT_CAP; i++) await provision(team.uid, owner.id);
|
||||
|
||||
const username = `st_${Math.random().toString(36).slice(2, 10)}`;
|
||||
await expect(
|
||||
service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
}),
|
||||
).rejects.toMatchObject({ legacyCode: 'seat_limit_reached' });
|
||||
|
||||
// The refused name must still be free, or a capped team would
|
||||
// burn global usernames on every rejected attempt.
|
||||
expect(await server.stores.user.getByUsername(username)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('lowering the cap blocks provisioning without disabling anyone', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
const seats = [];
|
||||
for (let i = 0; i < SEAT_CAP; i++) {
|
||||
seats.push(await provision(team.uid, owner.id));
|
||||
}
|
||||
|
||||
const cfg = service.config as { max_seats_per_team?: number };
|
||||
cfg.max_seats_per_team = 1;
|
||||
try {
|
||||
await expect(provision(team.uid, owner.id)).rejects.toMatchObject({
|
||||
legacyCode: 'seat_limit_reached',
|
||||
});
|
||||
|
||||
// Over the limit is not a reason to suspend people.
|
||||
for (const seat of seats) {
|
||||
const user = await server.stores.user.getByProperty(
|
||||
'id',
|
||||
seat.userId,
|
||||
{ force: true },
|
||||
);
|
||||
expect(user?.suspended).toBeFalsy();
|
||||
}
|
||||
} finally {
|
||||
cfg.max_seats_per_team = SEAT_CAP;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a deleted seat be replaced beyond the cap', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
const seats = [];
|
||||
for (let i = 0; i < SEAT_CAP; i++) {
|
||||
seats.push(await provision(team.uid, owner.id));
|
||||
}
|
||||
|
||||
// Deleting the account removes the membership row, so the seat frees.
|
||||
await server.services.userAccount.cascadeDelete(seats[0].userId);
|
||||
await expect(provision(team.uid, owner.id)).resolves.toBeTruthy();
|
||||
await expect(provision(team.uid, owner.id)).rejects.toMatchObject({
|
||||
legacyCode: 'seat_limit_reached',
|
||||
});
|
||||
});
|
||||
|
||||
// Only reachable with `max_seats_per_team` unset; the override wins.
|
||||
describe('with no configured override', () => {
|
||||
const FREE = 3;
|
||||
|
||||
it('is not overridden by the shipped defaults', async () => {
|
||||
// A flat cap in config.default.json makes the plan branch below
|
||||
// dead code on every deployment that does not delete the key.
|
||||
const defaults = JSON.parse(
|
||||
await readFile(
|
||||
new URL('../../../../config.default.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
expect(defaults.max_seats_per_team).toBeUndefined();
|
||||
expect(defaults.max_seats_per_team_free).toBe(FREE);
|
||||
});
|
||||
|
||||
/** Runs `fn` with the override off, then puts it back. */
|
||||
const withoutOverride = async (fn: () => Promise<void>) => {
|
||||
const cfg = service.config as Record<string, unknown>;
|
||||
const saved = cfg.max_seats_per_team;
|
||||
delete cfg.max_seats_per_team;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
cfg.max_seats_per_team = saved;
|
||||
}
|
||||
};
|
||||
|
||||
// The cap asks whether the owner is on a plan somebody bought.
|
||||
const onPlan = (id: string) =>
|
||||
vi
|
||||
.spyOn(server.services.metering, 'actorHasPaidSubscription')
|
||||
.mockResolvedValue(!isFreeSubscription(id));
|
||||
|
||||
const fill = async (teamUid: string, ownerId: number, n: number) => {
|
||||
for (let i = 0; i < n; i++) await provision(teamUid, ownerId);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stops a free team at three seats', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
await withoutOverride(async () => {
|
||||
onPlan('user_free');
|
||||
await fill(team.uid, owner.id, FREE);
|
||||
await expect(
|
||||
provision(team.uid, owner.id),
|
||||
).rejects.toMatchObject({
|
||||
legacyCode: 'seat_limit_reached',
|
||||
fields: { limit: FREE },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a paying owner past the free cap', async () => {
|
||||
// The upgrade path: same team, same seats, a plan that is not free.
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
await withoutOverride(async () => {
|
||||
const plan = onPlan('user_free');
|
||||
await fill(team.uid, owner.id, FREE);
|
||||
await expect(
|
||||
provision(team.uid, owner.id),
|
||||
).rejects.toMatchObject({ legacyCode: 'seat_limit_reached' });
|
||||
|
||||
plan.mockResolvedValue(true);
|
||||
await expect(
|
||||
provision(team.uid, owner.id),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the plan's own seat cap over the paid one", async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
await withoutOverride(async () => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getActorSubscription',
|
||||
).mockResolvedValue({ id: 'basic', teamSeatCap: 2 } as never);
|
||||
await fill(team.uid, owner.id, 2);
|
||||
await expect(
|
||||
provision(team.uid, owner.id),
|
||||
).rejects.toMatchObject({
|
||||
legacyCode: 'seat_limit_reached',
|
||||
fields: { limit: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('counts a seat on the free org policy as free, not as paying', async () => {
|
||||
// A seat's own plan is free by construction; it must not read as
|
||||
// the owner having bought something.
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
await withoutOverride(async () => {
|
||||
onPlan('org_seat_free');
|
||||
await fill(team.uid, owner.id, FREE);
|
||||
await expect(
|
||||
provision(team.uid, owner.id),
|
||||
).rejects.toMatchObject({ fields: { limit: FREE } });
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the per-plan config keys over the built-in numbers', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
await withoutOverride(async () => {
|
||||
const cfg = service.config as Record<string, unknown>;
|
||||
cfg.max_seats_per_team_free = 1;
|
||||
try {
|
||||
onPlan('user_free');
|
||||
await expect(
|
||||
provision(team.uid, owner.id),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
provision(team.uid, owner.id),
|
||||
).rejects.toMatchObject({ fields: { limit: 1 } });
|
||||
} finally {
|
||||
delete cfg.max_seats_per_team_free;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('asks about the owner with a whole user, not an id/uuid stub', async () => {
|
||||
// A resolver keyed on any other field would miss, and the cap would
|
||||
// then depend on whether something else cached this plan first.
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
await withoutOverride(async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'actorHasPaidSubscription',
|
||||
).mockImplementation(async (actor: never) => {
|
||||
seen.push(
|
||||
(actor as { user: Record<string, unknown> }).user,
|
||||
);
|
||||
return false;
|
||||
});
|
||||
await provision(team.uid, owner.id);
|
||||
expect(seen[0]).toMatchObject({
|
||||
id: owner.id,
|
||||
username: owner.username,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the free cap when the plan cannot be read', async () => {
|
||||
// Over-provisioning on an unreadable plan is the worse failure.
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
await withoutOverride(async () => {
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getActorSubscription',
|
||||
).mockRejectedValue(new Error('metering down'));
|
||||
await fill(team.uid, owner.id, FREE);
|
||||
await expect(
|
||||
provision(team.uid, owner.id),
|
||||
).rejects.toMatchObject({ fields: { limit: FREE } });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +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 { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.ts';
|
||||
|
||||
describe('teams email-domain allowlist', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
const makeUser = async (email: string) => {
|
||||
const username = `dg_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await env.server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email,
|
||||
email_confirmed: true,
|
||||
})) as unknown as { id: number };
|
||||
const row = await env.server.stores.user.getById(created.id);
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
row!,
|
||||
);
|
||||
return { id: created.id, username, token };
|
||||
};
|
||||
|
||||
const call = (method: string, path: string, token: string, body?: unknown) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const create = (token: string) =>
|
||||
call('POST', '/teams', token, {
|
||||
name: 'Gate',
|
||||
handle: `gt-${Math.random().toString(36).slice(2, 10)}`,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
teams_enabled: true,
|
||||
teams_allowed_email_domains: ['puter.com'],
|
||||
max_teams_per_user: 10,
|
||||
} as IConfig);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('lets an allowed domain create and list', async () => {
|
||||
const staff = await makeUser(`${uuidv4().slice(0, 8)}@puter.com`);
|
||||
expect((await create(staff.token)).status).toBe(200);
|
||||
expect((await call('GET', '/teams', staff.token)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('matches the domain case-insensitively', async () => {
|
||||
const staff = await makeUser(`${uuidv4().slice(0, 8)}@PUTER.COM`);
|
||||
expect((await create(staff.token)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('answers other domains with the same 404 as teams-off', async () => {
|
||||
const outsider = await makeUser(`${uuidv4().slice(0, 8)}@gmail.com`);
|
||||
expect((await create(outsider.token)).status).toBe(404);
|
||||
const list = await call('GET', '/teams', outsider.token);
|
||||
expect(list.status).toBe(404);
|
||||
// `not_found` is what the GUI reads as "feature not here".
|
||||
expect(((await list.json()) as { code?: string }).code).toBe(
|
||||
'not_found',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a provisioned seat through on membership alone', async () => {
|
||||
const staff = await makeUser(`${uuidv4().slice(0, 8)}@puter.com`);
|
||||
const created = await create(staff.token);
|
||||
const team = (await created.json()) as { uid: string };
|
||||
|
||||
const seatName = `sg_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const provisioned = await call(
|
||||
'POST',
|
||||
`/teams/${team.uid}/members`,
|
||||
staff.token,
|
||||
{ username: seatName, email: `${seatName}@gmail.com` },
|
||||
);
|
||||
expect(provisioned.status).toBe(200);
|
||||
|
||||
// Off-domain, but someone allowed brought them in.
|
||||
const seatRow = await env.server.stores.user.getByUsername(seatName);
|
||||
await env.server.stores.user.update(seatRow!.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
requires_password_change: 0,
|
||||
});
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
(await env.server.stores.user.getById(seatRow!.id))!,
|
||||
);
|
||||
const list = await call('GET', '/teams', token);
|
||||
expect(list.status).toBe(200);
|
||||
const body = (await list.json()) as { items: Array<{ uid: string }> };
|
||||
expect(body.items.map((t) => t.uid)).toContain(team.uid);
|
||||
});
|
||||
});
|
||||
@@ -1,454 +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 { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
|
||||
describe('team billing events', () => {
|
||||
let server: PuterServer;
|
||||
let service: PuterServer['services']['team'];
|
||||
let owner: { id: number };
|
||||
|
||||
/** Every billing event emitted since the last reset, in order. */
|
||||
const seen: Array<{ key: string; data: Record<string, unknown> }> = [];
|
||||
const of = (key: string) => seen.filter((e) => e.key === key);
|
||||
|
||||
const makeUser = async (): Promise<{ id: number; username: string }> => {
|
||||
const username = `bil_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
})) as unknown as { id: number };
|
||||
return { id: created.id, username };
|
||||
};
|
||||
|
||||
const freeHandle = () => `bw-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const makeTeam = async () =>
|
||||
service.createTeam(owner.id, {
|
||||
name: 'Billing Co',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
|
||||
/** A real provisioned seat, as every chargeable account is. */
|
||||
const provision = async (team: { uid: string }) => {
|
||||
const username = `seat_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
return created;
|
||||
};
|
||||
|
||||
/** Same, for a team whose owner is not the shared one. */
|
||||
const provision2 = async (team: { uid: string }, ownerId: number) => {
|
||||
const username = `seat_${Math.random().toString(36).slice(2, 10)}`;
|
||||
return service.provisionAccount(team.uid, ownerId, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
});
|
||||
};
|
||||
|
||||
/** Bytes the report has to find; `size` is what `SUM(size)` reads. */
|
||||
const giveFile = async (userId: number, size: number) => {
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO fsentries (uuid, parent_uid, user_id, name, path, is_dir, size, created, accessed, modified) ' +
|
||||
'VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uuidv4(),
|
||||
userId,
|
||||
`f_${Math.random().toString(36).slice(2, 8)}`,
|
||||
`/f_${Math.random().toString(36).slice(2, 8)}`,
|
||||
server.clients.db.booleanValue(false),
|
||||
size,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// The cap has its own suite; these tests need many teams.
|
||||
server = await setupTestServer({
|
||||
teams_enabled: true,
|
||||
max_teams_per_user: 100,
|
||||
} as never);
|
||||
service = server.services.team;
|
||||
owner = await makeUser();
|
||||
|
||||
for (const key of [
|
||||
'team.account.created',
|
||||
'team.account.disabled',
|
||||
'team.account.enabled',
|
||||
'team.account.deleted',
|
||||
'team.deleted',
|
||||
'team.held-bytes.report',
|
||||
]) {
|
||||
server.clients.event.on(
|
||||
key as never,
|
||||
((k: string, data: Record<string, unknown>) => {
|
||||
seen.push({ key: k, data });
|
||||
}) as never,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
seen.length = 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
// -- on-demand lifecycle events -----------------------------------
|
||||
|
||||
it('emits account-created once, naming the team and the owner', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
|
||||
const events = of('team.account.created');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].data).toMatchObject({
|
||||
team_uid: team.uid,
|
||||
owner_user_id: owner.id,
|
||||
user_id: seat.userId,
|
||||
username: seat.username,
|
||||
});
|
||||
// The payment side keys the charge off this, so it must be present.
|
||||
expect(typeof events[0].data.user_uuid).toBe('string');
|
||||
});
|
||||
|
||||
it('carries the held bytes on disable, so billing need not query back', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
await giveFile(seat.userId, 4096);
|
||||
seen.length = 0;
|
||||
|
||||
await service.disableMember(team.uid, owner.id, seat.userId);
|
||||
|
||||
const events = of('team.account.disabled');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].data.held_bytes).toBe(4096);
|
||||
expect(events[0].data.user_id).toBe(seat.userId);
|
||||
});
|
||||
|
||||
it('emits account-enabled with the bytes that stop being charged', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
await giveFile(seat.userId, 1024);
|
||||
await service.disableMember(team.uid, owner.id, seat.userId);
|
||||
seen.length = 0;
|
||||
|
||||
await service.enableMember(team.uid, owner.id, seat.userId);
|
||||
|
||||
const events = of('team.account.enabled');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].data.held_bytes).toBe(1024);
|
||||
});
|
||||
|
||||
it('emits one disabled event per seat plus a team event on delete', async () => {
|
||||
const team = await makeTeam();
|
||||
const a = await provision(team);
|
||||
const b = await provision(team);
|
||||
seen.length = 0;
|
||||
|
||||
await service.deleteTeam(team.uid, owner.id);
|
||||
|
||||
// Per seat, because the byte charge that follows is per account.
|
||||
const disabled = of('team.account.disabled');
|
||||
expect(disabled).toHaveLength(2);
|
||||
expect(disabled.map((e) => e.data.user_id).sort()).toEqual(
|
||||
[a.userId, b.userId].sort(),
|
||||
);
|
||||
|
||||
const deleted = of('team.deleted');
|
||||
expect(deleted).toHaveLength(1);
|
||||
expect(deleted[0].data).toMatchObject({
|
||||
team_uid: team.uid,
|
||||
account_count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-charge a seat that was already disabled before the delete', async () => {
|
||||
const team = await makeTeam();
|
||||
const a = await provision(team);
|
||||
const b = await provision(team);
|
||||
await service.disableMember(team.uid, owner.id, a.userId);
|
||||
seen.length = 0;
|
||||
|
||||
await service.deleteTeam(team.uid, owner.id);
|
||||
|
||||
// Only one `enabled` will ever close a charge, so only one may open it.
|
||||
const disabled = of('team.account.disabled');
|
||||
expect(disabled).toHaveLength(1);
|
||||
expect(disabled[0].data.user_id).toBe(b.userId);
|
||||
|
||||
// Still both seats: the team is losing two accounts either way.
|
||||
expect(of('team.deleted')[0].data).toMatchObject({ account_count: 2 });
|
||||
});
|
||||
|
||||
it('emits account-deleted, which a post-delete listener could not', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
seen.length = 0;
|
||||
|
||||
await server.services.userAccount.cascadeDelete(seat.userId);
|
||||
|
||||
const events = of('team.account.deleted');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].data).toMatchObject({
|
||||
team_uid: team.uid,
|
||||
user_id: seat.userId,
|
||||
username: seat.username,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the membership unreadable after the delete it was captured for', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
await server.services.userAccount.cascadeDelete(seat.userId);
|
||||
|
||||
// `jct_user_group.user_id` is ON DELETE CASCADE -- this is why the
|
||||
// identity has to be captured before the row goes.
|
||||
expect(await server.stores.team.getOrgSeat(seat.userId)).toBeNull();
|
||||
});
|
||||
|
||||
// -- the authorization cache ------------------------------------------
|
||||
// `isMember` and `getByUid` gate every team route, so a stale entry is
|
||||
// access, not a stray notification. Each bust gets its own case.
|
||||
|
||||
it('stops answering member once the account is removed', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
expect(await server.stores.team.isMember(team.uid, seat.userId)).toBe(true);
|
||||
|
||||
await server.stores.team.removeMember(team.uid, seat.userId);
|
||||
|
||||
expect(await server.stores.team.isMember(team.uid, seat.userId)).toBe(false);
|
||||
});
|
||||
|
||||
it('stops answering member once the account is deleted out from under it', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
expect(await server.stores.team.isMember(team.uid, seat.userId)).toBe(true);
|
||||
|
||||
// Cascades the membership row without passing through TeamStore.
|
||||
await server.services.userAccount.cascadeDelete(seat.userId);
|
||||
|
||||
expect(await server.stores.team.isMember(team.uid, seat.userId)).toBe(false);
|
||||
});
|
||||
|
||||
it('stops resolving the team, and its memberships, once it is deleted', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
expect(await server.stores.team.getByUid(team.uid)).toBeTruthy();
|
||||
expect(await server.stores.team.isMember(team.uid, seat.userId)).toBe(true);
|
||||
|
||||
await server.stores.team.softDelete(team.uid);
|
||||
|
||||
expect(await server.stores.team.getByUid(team.uid)).toBeNull();
|
||||
expect(await server.stores.team.isMember(team.uid, seat.userId)).toBe(false);
|
||||
});
|
||||
|
||||
it('serves the renamed team, not the name it was read at', async () => {
|
||||
const team = await makeTeam();
|
||||
expect((await server.stores.team.getByUid(team.uid))?.name).toBe('Billing Co');
|
||||
|
||||
await server.stores.team.update(team.uid, { name: 'Renamed Co' });
|
||||
|
||||
expect((await server.stores.team.getByUid(team.uid))?.name).toBe('Renamed Co');
|
||||
});
|
||||
|
||||
it('does not serve a stale member list after the roster changes', async () => {
|
||||
const team = await makeTeam();
|
||||
const before = await server.stores.team.listMemberIdsByGroupId(team.id);
|
||||
|
||||
const seat = await provision(team);
|
||||
const after = await server.stores.team.listMemberIdsByGroupId(team.id);
|
||||
expect(after).toContain(seat.userId);
|
||||
expect(after.length).toBe(before.length + 1);
|
||||
|
||||
// The membership row cascades away with the account, never through
|
||||
// TeamStore -- the case a TTL alone would get wrong.
|
||||
await server.services.userAccount.cascadeDelete(seat.userId);
|
||||
expect(
|
||||
await server.stores.team.listMemberIdsByGroupId(team.id),
|
||||
).not.toContain(seat.userId);
|
||||
});
|
||||
|
||||
it('refuses to delete a live account', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
|
||||
await expect(
|
||||
service.deleteMember(team.uid, owner.id, seat.userId),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
legacyCode: 'account_must_be_disabled_first',
|
||||
});
|
||||
expect(await server.stores.user.getById(seat.userId)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deletes a disabled account and closes the storage charge once', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
await service.disableMember(team.uid, owner.id, seat.userId);
|
||||
seen.length = 0;
|
||||
|
||||
await service.deleteMember(team.uid, owner.id, seat.userId);
|
||||
|
||||
expect(await server.stores.user.getById(seat.userId)).toBeFalsy();
|
||||
expect(of('team.account.deleted')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the audit trail attributable after the account is gone', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
await service.disableMember(team.uid, owner.id, seat.userId);
|
||||
await service.deleteMember(team.uid, owner.id, seat.userId);
|
||||
|
||||
const rows = await server.stores.team.listAudit(team.id);
|
||||
const mine = rows.items.filter(
|
||||
(r) => Number(r.user_id_keep) === seat.userId,
|
||||
);
|
||||
// The FKs are ON DELETE SET NULL, so `_keep` is the only thing left.
|
||||
expect(mine.map((r) => r.action)).toContain('delete_account');
|
||||
});
|
||||
|
||||
it('deletes a suspended seat after its team is deleted', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
await service.deleteTeam(team.uid, owner.id);
|
||||
seen.length = 0;
|
||||
|
||||
// The deletion suspended the seat; this is the only way to retire it.
|
||||
await service.deleteMember(team.uid, owner.id, seat.userId);
|
||||
|
||||
expect(await server.stores.user.getById(seat.userId)).toBeFalsy();
|
||||
// The billing stop still fires: `getOrgSeat` includes deleted teams.
|
||||
expect(of('team.account.deleted')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses to delete a seat Puter suspended', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
await server.stores.user.update(seat.userId, {
|
||||
suspended: 1,
|
||||
suspended_at: Math.floor(Date.now() / 1000),
|
||||
suspended_reason: 'abuse_review',
|
||||
});
|
||||
await server.stores.user.invalidateById(seat.userId);
|
||||
|
||||
// As `enableMember` holds: a platform suspension is not the team's.
|
||||
await expect(
|
||||
service.deleteMember(team.uid, owner.id, seat.userId),
|
||||
).rejects.toMatchObject({ statusCode: 409, legacyCode: 'conflict' });
|
||||
expect(await server.stores.user.getById(seat.userId)).toBeTruthy();
|
||||
});
|
||||
|
||||
// -- the directory --------------------------------------------------
|
||||
|
||||
it('offers only members who have actually taken up their account', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
await service.updateTeam(team.uid, owner.id, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const unactivated = await service.listDirectory(team.uid, owner.id);
|
||||
// Provisioned holds the temporary password from birth, so this is the
|
||||
// forced-change flag, not the password, deciding.
|
||||
expect(unactivated.items.map((m) => m.username)).not.toContain(seat.username);
|
||||
|
||||
await server.stores.user.update(seat.userId, {
|
||||
requires_password_change: 0,
|
||||
});
|
||||
await server.stores.user.invalidateById(seat.userId);
|
||||
const activated = await service.listDirectory(team.uid, owner.id);
|
||||
expect(activated.items.map((m) => m.username)).toContain(seat.username);
|
||||
|
||||
await service.disableMember(team.uid, owner.id, seat.userId);
|
||||
const suspended = await service.listDirectory(team.uid, owner.id);
|
||||
expect(suspended.items.map((m) => m.username)).not.toContain(seat.username);
|
||||
});
|
||||
|
||||
it('records the directory being opened, but not a repeat of the same value', async () => {
|
||||
const team = await makeTeam();
|
||||
const directoryRows = async () => {
|
||||
const page = await server.stores.team.listAudit(team.id);
|
||||
return page.items
|
||||
.map((r) => String(r.action))
|
||||
.filter((a) => a.startsWith('directory_'));
|
||||
};
|
||||
|
||||
await service.updateTeam(team.uid, owner.id, { directoryEnabled: true });
|
||||
expect(await directoryRows()).toEqual(['directory_enabled']);
|
||||
|
||||
await service.updateTeam(team.uid, owner.id, { directoryEnabled: true });
|
||||
expect(await directoryRows()).toEqual(['directory_enabled']);
|
||||
|
||||
await service.updateTeam(team.uid, owner.id, { directoryEnabled: false });
|
||||
expect(await directoryRows()).toEqual(['directory_disabled', 'directory_enabled']);
|
||||
});
|
||||
|
||||
it('refuses the directory while the team has not opted in', async () => {
|
||||
const team = await makeTeam();
|
||||
await expect(
|
||||
service.listDirectory(team.uid, owner.id),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('says nothing about an account no team pays for', async () => {
|
||||
const outsider = await makeUser();
|
||||
seen.length = 0;
|
||||
|
||||
await server.services.userAccount.cascadeDelete(outsider.id);
|
||||
|
||||
expect(of('team.account.deleted')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not open a second byte charge when a seat is disabled twice', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
await service.disableMember(team.uid, owner.id, seat.userId);
|
||||
seen.length = 0;
|
||||
|
||||
await service.disableMember(team.uid, owner.id, seat.userId);
|
||||
|
||||
// `held_bytes` opens a charge one `enabled` closes; two opens and one
|
||||
// close leaves the payer billed for storage nobody holds.
|
||||
expect(of('team.account.disabled')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not close a charge that was never opened', async () => {
|
||||
const team = await makeTeam();
|
||||
const seat = await provision(team);
|
||||
seen.length = 0;
|
||||
|
||||
await service.enableMember(team.uid, owner.id, seat.userId);
|
||||
|
||||
expect(of('team.account.enabled')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,213 +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 { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { configContainer } from '../../exports.ts';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.ts';
|
||||
|
||||
describe('team account provisioning: home path conflicts', () => {
|
||||
let server: PuterServer;
|
||||
let service: PuterServer['services']['team'];
|
||||
|
||||
const makeUser = async () => {
|
||||
const username = `tp_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
})) as unknown as { id: number; username: string };
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
created as never,
|
||||
);
|
||||
return { id: created.id, username };
|
||||
};
|
||||
|
||||
const freeHandle = () => `tp-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const makeTeam = (ownerId: number) =>
|
||||
service.createTeam(ownerId, {
|
||||
name: 'Provisioned Co',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({ teams_enabled: true } as never);
|
||||
service = server.services.team;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('refuses a name that is free in the users table but whose home path is occupied', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
|
||||
// Nothing sits at `/parked` itself — only a leftover child row does,
|
||||
// the shape a partial cascade or a user-scoped cleanup leaves behind.
|
||||
// (An exact occupant would hit the provisioning backstop instead,
|
||||
// after the user row was already inserted — this is the gap that
|
||||
// backstop can't cover.)
|
||||
const occupant = await makeUser();
|
||||
const parked = `tp_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const occupantDocs = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${occupant.username}/Documents`,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}/Documents`, occupantDocs.id],
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.provisionAccount(team.uid, owner.id, {
|
||||
username: parked,
|
||||
email: `${parked}@test.local`,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
legacyCode: 'username_already_in_use',
|
||||
});
|
||||
|
||||
// Refused whole: no orphaned user row holding the name.
|
||||
await expect(
|
||||
server.stores.user.getByUsername(parked, { force: true }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a seat email that could render as markup', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
const username = `tp_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
// A quoted local part passes `validator.isEmail`; it must not store.
|
||||
await expect(
|
||||
service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
email: '"<img/src=x/onerror=alert(1)>"@example.com',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
// Refused before any write, so the name is still free.
|
||||
await expect(
|
||||
server.stores.user.getByUsername(username, { force: true }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('puts a provisioned seat in the default user group, as signup does', async () => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
const username = `tp_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const group = configContainer.default_user_group as string;
|
||||
expect(group).toBeTruthy();
|
||||
|
||||
const { userId } = await service.provisionAccount(team.uid, owner.id, {
|
||||
username,
|
||||
});
|
||||
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT 1 FROM jct_user_group j JOIN `group` g ON g.id = j.group_id ' +
|
||||
'WHERE j.user_id = ? AND g.uid = ?',
|
||||
[userId, group],
|
||||
)) as unknown[];
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['inserting them', 'write', 'INSERT INTO fsentries'],
|
||||
['reading them back', 'pread', 'SELECT id, uuid FROM fsentries'],
|
||||
] as const)(
|
||||
'creates no seat when its home folders fail while %s',
|
||||
async (_step, method, statement) => {
|
||||
const owner = await makeUser();
|
||||
const team = await makeTeam(owner.id);
|
||||
const username = `tp_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const billing: string[] = [];
|
||||
for (const key of [
|
||||
'team.account.created',
|
||||
'team.account.deleted',
|
||||
]) {
|
||||
server.clients.event.on(
|
||||
key as never,
|
||||
((k: string, data: { username?: string }) => {
|
||||
if (data.username === username) billing.push(k);
|
||||
}) as never,
|
||||
);
|
||||
}
|
||||
|
||||
const db = server.clients.db;
|
||||
const real = db[method].bind(db);
|
||||
const refuse = async (sql: string, params?: unknown[]) => {
|
||||
if (sql.includes(statement)) {
|
||||
throw new Error('database unavailable');
|
||||
}
|
||||
return real(sql, params);
|
||||
};
|
||||
const failing = vi
|
||||
.spyOn(db, method)
|
||||
.mockImplementation(refuse as never);
|
||||
try {
|
||||
await expect(
|
||||
service.provisionAccount(team.uid, owner.id, { username }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 500,
|
||||
legacyCode: 'internal_error',
|
||||
});
|
||||
} finally {
|
||||
failing.mockRestore();
|
||||
}
|
||||
|
||||
await expect(
|
||||
server.stores.user.getByUsername(username, { force: true }),
|
||||
).resolves.toBeNull();
|
||||
expect(await server.stores.team.countSeats(team.id)).toBe(0);
|
||||
expect(billing).toEqual([]);
|
||||
|
||||
// Nothing written for the failed attempt still holds the name.
|
||||
await service.provisionAccount(team.uid, owner.id, { username });
|
||||
expect(await server.stores.team.countSeats(team.id)).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not suggest a name whose home path is occupied', async () => {
|
||||
const owner = await makeUser();
|
||||
await makeTeam(owner.id);
|
||||
|
||||
const base = `tps_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const occupant = await makeUser();
|
||||
const occupantDocs = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${occupant.username}/Documents`,
|
||||
))!;
|
||||
// The first suggestion `suggestUsernames` would try is `${base}1`.
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${base}1/Documents`, occupantDocs.id],
|
||||
);
|
||||
|
||||
const suggestions = await service.suggestUsernames(base);
|
||||
|
||||
expect(suggestions).not.toContain(`${base}1`);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { TeamServiceContract } from '../core/teams.js';
|
||||
|
||||
import type { puterClients } from '../clients';
|
||||
import type { IExtensionClientInstances } from '../clients/types';
|
||||
import type {
|
||||
@@ -34,6 +36,8 @@ export interface IPuterServiceInstances {}
|
||||
|
||||
/** Extension-augmentable service registry; see `IExtensionClientInstances`. */
|
||||
export interface IExtensionServiceInstances {
|
||||
/** Teams ships separately; absent where it is not installed. */
|
||||
team?: TeamServiceContract;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -698,10 +698,10 @@ export class SignupService extends PuterService {
|
||||
force,
|
||||
});
|
||||
if (!holder || holder.email_confirmed) return holder;
|
||||
if (!(await this.stores.team.getOrgSeat(holder.id))) return holder;
|
||||
if (!(await this.stores.team?.getOrgSeat(holder.id))) return holder;
|
||||
if (!opts.releaseSeat) return null;
|
||||
if (
|
||||
!(await this.services.team.releaseUnconfirmedSeatEmail(
|
||||
!(await this.services.team?.releaseUnconfirmedSeatEmail(
|
||||
holder.id,
|
||||
))
|
||||
)
|
||||
@@ -723,7 +723,8 @@ export class SignupService extends PuterService {
|
||||
if (!existing) return null;
|
||||
// A provisioned account looks exactly like a claimable placeholder,
|
||||
// but claiming it hands a stranger that team's membership.
|
||||
const orgSeat = await this.stores.team.getOrgSeat(existing.id);
|
||||
const orgSeat =
|
||||
(await this.stores.team?.getOrgSeat(existing.id)) ?? null;
|
||||
if (
|
||||
existing.email_confirmed ||
|
||||
existing.password !== null ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -69,7 +69,8 @@ export class UserAccountService extends PuterService {
|
||||
}
|
||||
|
||||
// Same reason, one table over: the membership row cascades on delete.
|
||||
const seat = await this.services.team.captureSeatForBilling(userId);
|
||||
const seat =
|
||||
(await this.services.team?.captureSeatForBilling(userId)) ?? null;
|
||||
|
||||
// The SQL rows cascade with the user; the flat entries are keyed on the
|
||||
// holder, so without this the access outlives the issuer.
|
||||
@@ -149,11 +150,11 @@ export class UserAccountService extends PuterService {
|
||||
// ignore — event emission shouldn't block deletion
|
||||
}
|
||||
// Tells prod to stop charging the owner for this seat.
|
||||
this.services.team.emitSeatDeleted(seat);
|
||||
this.services.team?.emitSeatDeleted(seat);
|
||||
// The membership row cascaded away, so anything keyed on it is stale.
|
||||
if (seat) {
|
||||
try {
|
||||
await this.services.team.forgetSeat(seat);
|
||||
await this.services.team?.forgetSeat(seat);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[cascade-delete-user] seat cache cleanup failed:',
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -37,7 +37,6 @@ import { ShareStore } from './share/ShareStore.js';
|
||||
import { SubdomainStore } from './subdomain/SubdomainStore.js';
|
||||
import { PresenceStore } from './events/PresenceStore.js';
|
||||
import { SystemKVStore } from './systemKv/SystemKVStore.js';
|
||||
import { TeamStore } from './team/TeamStore.js';
|
||||
import { UploadReservationStore } from './fs/UploadReservationStore.js';
|
||||
import { UserBlockStore } from './userBlock/UserBlockStore.js';
|
||||
import { UserStore } from './user/UserStore.js';
|
||||
@@ -65,7 +64,6 @@ declare module './types.js' {
|
||||
notification: NotificationStore;
|
||||
share: ShareStore;
|
||||
group: GroupStore;
|
||||
team: TeamStore;
|
||||
permission: PermissionStore;
|
||||
session: SessionStore;
|
||||
oidc: OIDCStore;
|
||||
@@ -105,7 +103,6 @@ export const puterStores = {
|
||||
notification: NotificationStore,
|
||||
share: ShareStore,
|
||||
group: GroupStore,
|
||||
team: TeamStore,
|
||||
permission: PermissionStore,
|
||||
session: SessionStore,
|
||||
oidc: OIDCStore,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,835 +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 { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import { decodeCursor } from '../../util/pagination.ts';
|
||||
import { checkHandle } from './TeamStore.ts';
|
||||
|
||||
describe('TeamStore', () => {
|
||||
let server: PuterServer;
|
||||
let store: PuterServer['stores']['team'];
|
||||
let owner: { id: number };
|
||||
|
||||
const makeUser = async (): Promise<{ id: number }> => {
|
||||
const username = `team-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = (await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
})) as unknown as { id: number };
|
||||
return { id: created.id };
|
||||
};
|
||||
|
||||
// Random so parallel cases never collide on the unique index.
|
||||
const freeHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
store = server.stores.team;
|
||||
owner = await makeUser();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
// -- handle validation --------------------------------------------
|
||||
|
||||
it('accepts lowercase handles with single inner hyphens', () => {
|
||||
for (const handle of ['acme', 'acme-design', 'a1-b2-c3', 'x'.repeat(64)]) {
|
||||
expect(checkHandle(handle)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses handles that are malformed, mis-sized or reserved', () => {
|
||||
expect(checkHandle('ab')).toBe('too_short');
|
||||
expect(checkHandle('x'.repeat(65))).toBe('too_long');
|
||||
expect(checkHandle('Acme')).toBe('malformed');
|
||||
expect(checkHandle('acme_design')).toBe('malformed');
|
||||
expect(checkHandle('-acme')).toBe('malformed');
|
||||
expect(checkHandle('acme-')).toBe('malformed');
|
||||
expect(checkHandle('acme--design')).toBe('malformed');
|
||||
expect(checkHandle('café')).toBe('malformed');
|
||||
// The point of the list: these read like Puter itself.
|
||||
expect(checkHandle('puter-support')).toBe('reserved');
|
||||
expect(checkHandle('security')).toBe('reserved');
|
||||
expect(checkHandle('admin')).toBe('reserved');
|
||||
});
|
||||
|
||||
it('refuses a reserved handle at the store boundary, not just in the checker', async () => {
|
||||
await expect(
|
||||
store.create({ ownerUserId: owner.id, name: 'Support', handle: 'support' }),
|
||||
).rejects.toThrow(/reserved/u);
|
||||
});
|
||||
|
||||
// -- create and read ----------------------------------------------
|
||||
|
||||
it('creates a team and reads it back by uid', async () => {
|
||||
const handle = freeHandle();
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme Design',
|
||||
handle,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
owner_user_id: owner.id,
|
||||
kind: 'team',
|
||||
name: 'Acme Design',
|
||||
handle,
|
||||
deleted_at: null,
|
||||
});
|
||||
expect(created.uid).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
await expect(store.getByUid(created.uid)).resolves.toMatchObject({
|
||||
uid: created.uid,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a team even when replica reads cannot see it yet', async () => {
|
||||
const db = server.clients.db as unknown as {
|
||||
read: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
||||
pread: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
||||
};
|
||||
const [realRead, realPread] = [db.read.bind(db), db.pread.bind(db)];
|
||||
// sqlite's pread delegates to read, so the primary is pinned separately.
|
||||
db.pread = realRead;
|
||||
db.read = async (q: string, p?: unknown[]) =>
|
||||
/FROM `group`/u.test(q) ? [] : realRead(q, p);
|
||||
|
||||
try {
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Lagging Replica',
|
||||
});
|
||||
expect(created).toMatchObject({ name: 'Lagging Replica' });
|
||||
} finally {
|
||||
[db.read, db.pread] = [realRead, realPread];
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no team behind when the row cannot be read back at all', async () => {
|
||||
const db = server.clients.db as unknown as {
|
||||
read: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
||||
pread: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
||||
};
|
||||
const [realRead, realPread] = [db.read.bind(db), db.pread.bind(db)];
|
||||
const blind = async (q: string, p?: unknown[]) =>
|
||||
/FROM `group`/u.test(q) ? [] : realRead(q, p);
|
||||
[db.read, db.pread] = [blind, blind];
|
||||
|
||||
const loner = await makeUser();
|
||||
try {
|
||||
await expect(
|
||||
store.create({ ownerUserId: loner.id, name: 'Doomed' }),
|
||||
).rejects.toThrow(/disappeared/u);
|
||||
} finally {
|
||||
[db.read, db.pread] = [realRead, realPread];
|
||||
}
|
||||
|
||||
expect(await store.countOwned(loner.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('reads many teams by id at once, soft-deleted included', async () => {
|
||||
const kept = await store.create({ ownerUserId: owner.id, name: 'Kept' });
|
||||
const gone = await store.create({ ownerUserId: owner.id, name: 'Gone' });
|
||||
await store.softDelete(gone.uid);
|
||||
|
||||
const teams = await store.getByIdsIncludingDeleted([
|
||||
kept.id,
|
||||
gone.id,
|
||||
kept.id,
|
||||
-1,
|
||||
]);
|
||||
expect([...teams.keys()].sort()).toEqual([kept.id, gone.id].sort());
|
||||
expect(teams.get(gone.id)?.deleted_at).not.toBeNull();
|
||||
await expect(store.getByIdIncludingDeleted(gone.id)).resolves.toMatchObject(
|
||||
{ uid: gone.uid },
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a team without a handle', async () => {
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Unnamed',
|
||||
});
|
||||
expect(created.handle).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a handle case-insensitively', async () => {
|
||||
const handle = freeHandle();
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Case',
|
||||
handle,
|
||||
});
|
||||
// NOCASE on sqlite, utf8mb4_unicode_ci on mysql, lower() on postgres.
|
||||
await expect(
|
||||
store.getByHandle(handle.toUpperCase()),
|
||||
).resolves.toMatchObject({ uid: created.uid });
|
||||
});
|
||||
|
||||
it('refuses a duplicate handle, including one differing only in case', async () => {
|
||||
const handle = freeHandle();
|
||||
await store.create({ ownerUserId: owner.id, name: 'First', handle });
|
||||
|
||||
await expect(
|
||||
store.create({ ownerUserId: owner.id, name: 'Second', handle }),
|
||||
).rejects.toThrow();
|
||||
// `create` rejects uppercase before any SQL, so insert directly.
|
||||
await expect(
|
||||
server.clients.db.write(
|
||||
'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `handle`, `extra`, `metadata`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[uuidv4(), owner.id, 'team', 'Third', handle.toUpperCase(), '{}', '{}'],
|
||||
),
|
||||
).rejects.toThrow(/unique|duplicate/iu);
|
||||
});
|
||||
|
||||
it('returns null for a uid that is not a team', async () => {
|
||||
await expect(store.getByUid(uuidv4())).resolves.toBeNull();
|
||||
await expect(store.getByHandle(freeHandle())).resolves.toBeNull();
|
||||
});
|
||||
|
||||
// -- the seeded system groups -------------------------------------
|
||||
|
||||
it('never returns a seeded system group from any method', async () => {
|
||||
const seeded = (await server.clients.db.read(
|
||||
'SELECT `uid`, `owner_user_id` FROM `group` WHERE `kind` IS NULL',
|
||||
)) as { uid: string; owner_user_id: number }[];
|
||||
expect(seeded.length).toBeGreaterThan(1);
|
||||
|
||||
// Unreachable by predicate: a team admin is not a platform admin.
|
||||
for (const group of seeded) {
|
||||
await expect(store.getByUid(group.uid)).resolves.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
// -- update -------------------------------------------------------
|
||||
|
||||
it('renames a team without changing its uid', async () => {
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Before',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
|
||||
const updated = await store.update(created.uid, { name: 'After' });
|
||||
expect(updated).toMatchObject({ uid: created.uid, name: 'After' });
|
||||
});
|
||||
|
||||
it('keeps the uid valid after the handle changes', async () => {
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Movable',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const next = freeHandle();
|
||||
|
||||
await store.update(created.uid, { handle: next });
|
||||
|
||||
// The whole point of addressing by uid: the reference survives a rename.
|
||||
await expect(store.getByUid(created.uid)).resolves.toMatchObject({
|
||||
handle: next,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to update into a reserved handle', async () => {
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Fine',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
await expect(
|
||||
store.update(created.uid, { handle: 'admin' }),
|
||||
).rejects.toThrow(/reserved/u);
|
||||
});
|
||||
|
||||
it('returns null when updating a uid that does not exist', async () => {
|
||||
await expect(
|
||||
store.update(uuidv4(), { name: 'Ghost' }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
// -- soft delete --------------------------------------------------
|
||||
|
||||
it('excludes a soft-deleted team from reads', async () => {
|
||||
const handle = freeHandle();
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Doomed',
|
||||
handle,
|
||||
});
|
||||
|
||||
await expect(store.softDelete(created.uid)).resolves.toBe(true);
|
||||
|
||||
await expect(store.getByUid(created.uid)).resolves.toBeNull();
|
||||
await expect(store.getByHandle(handle)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('releases the handle on soft delete so it can be claimed again', async () => {
|
||||
const handle = freeHandle();
|
||||
const first = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'First',
|
||||
handle,
|
||||
});
|
||||
await store.softDelete(first.uid);
|
||||
|
||||
// Nothing addresses by handle, so the name returns to the pool.
|
||||
const second = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Second',
|
||||
handle,
|
||||
});
|
||||
expect(second.uid).not.toBe(first.uid);
|
||||
await expect(store.getByHandle(handle)).resolves.toMatchObject({
|
||||
uid: second.uid,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps `name` on a soft-deleted team so history still reads', async () => {
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Remembered',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
await store.softDelete(created.uid);
|
||||
|
||||
const [row] = (await server.clients.db.read(
|
||||
'SELECT `name`, `handle`, `deleted_at` FROM `group` WHERE `uid` = ?',
|
||||
[created.uid],
|
||||
)) as { name: string; handle: string | null; deleted_at: string }[];
|
||||
expect(row.name).toBe('Remembered');
|
||||
expect(row.handle).toBeNull();
|
||||
expect(row.deleted_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reports no rows affected when soft-deleting twice', async () => {
|
||||
const created = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Once',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
await expect(store.softDelete(created.uid)).resolves.toBe(true);
|
||||
await expect(store.softDelete(created.uid)).resolves.toBe(false);
|
||||
});
|
||||
it('refuses a name that is empty or longer than mysql allows', async () => {
|
||||
await expect(
|
||||
store.create({ ownerUserId: owner.id, name: ' ' }),
|
||||
).rejects.toThrow(/required/iu);
|
||||
await expect(
|
||||
store.create({ ownerUserId: owner.id, name: 'x'.repeat(256) }),
|
||||
).rejects.toThrow(/too long/iu);
|
||||
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: ' Padded ',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
expect(team.name).toBe('Padded');
|
||||
});
|
||||
|
||||
// -- membership ---------------------------------------------------
|
||||
|
||||
it('adds a member and reads the membership back', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Members',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const member = await makeUser();
|
||||
|
||||
await expect(
|
||||
store.addMember(team.uid, member.id, { orgOwned: true }),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const row = await store.getMembership(team.uid, member.id);
|
||||
expect(row).toMatchObject({ user_id: member.id, group_id: team.id });
|
||||
expect(Boolean(row?.org_owned)).toBe(true);
|
||||
await expect(store.isMember(team.uid, member.id)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('distinguishes the team owner by org_owned', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Payer',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
await store.addMember(team.uid, owner.id, { orgOwned: false });
|
||||
|
||||
const row = await store.getMembership(team.uid, owner.id);
|
||||
// Decides who pays, not who may read.
|
||||
expect(Boolean(row?.org_owned)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to make an account that already has a password a seat', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Adopt',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
// Provisioning creates the row password-less and sets one afterwards,
|
||||
// so a password is what distinguishes an account that predates us.
|
||||
const existing = await makeUser();
|
||||
await server.stores.user.update(existing.id, { password: 'hashed' });
|
||||
|
||||
await expect(
|
||||
store.addMember(team.uid, existing.id, { orgOwned: true }),
|
||||
).rejects.toThrow(/cannot make an existing account a team seat/);
|
||||
await expect(store.isMember(team.uid, existing.id)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('still admits an account that has a password as the owner', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Payer2',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
// The guard is about seats: the owner joins their own team with a
|
||||
// password already set, and must not be caught by it.
|
||||
const payer = await makeUser();
|
||||
await server.stores.user.update(payer.id, { password: 'hashed' });
|
||||
|
||||
await expect(
|
||||
store.addMember(team.uid, payer.id, { orgOwned: false }),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('treats a repeated add as a no-op rather than a duplicate', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Once',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const member = await makeUser();
|
||||
|
||||
await store.addMember(team.uid, member.id, { orgOwned: true });
|
||||
await store.addMember(team.uid, member.id, { orgOwned: true });
|
||||
|
||||
const page = await store.listMembers(team.uid);
|
||||
expect(page.items.filter((m) => m.user_id === member.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes a member and reports whether there was one', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Leaving',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const member = await makeUser();
|
||||
await store.addMember(team.uid, member.id, { orgOwned: true });
|
||||
|
||||
await expect(store.removeMember(team.uid, member.id)).resolves.toBe(true);
|
||||
await expect(store.removeMember(team.uid, member.id)).resolves.toBe(false);
|
||||
await expect(store.isMember(team.uid, member.id)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
describe('peer regions', () => {
|
||||
type CacheUpdate = { cacheKey: string[] };
|
||||
|
||||
const captureCacheUpdates = async (
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<CacheUpdate[]> => {
|
||||
const seen: CacheUpdate[] = [];
|
||||
const listener = (_key: string, data: CacheUpdate) => {
|
||||
seen.push(data);
|
||||
};
|
||||
server.clients.event.on('outer.cacheUpdate', listener);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
server.clients.event.off('outer.cacheUpdate', listener);
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
it('stop seeing a removed member once they apply the broadcast', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Regions',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const member = await makeUser();
|
||||
await store.addMember(team.uid, member.id, { orgOwned: false });
|
||||
const cached = await store.getMembership(team.uid, member.id);
|
||||
expect(cached).not.toBeNull();
|
||||
const key = `team:member:${team.uid}:${member.id}`;
|
||||
|
||||
const updates = await captureCacheUpdates(() =>
|
||||
store.removeMember(team.uid, member.id),
|
||||
);
|
||||
expect(updates.flatMap((u) => u.cacheKey)).toContain(key);
|
||||
|
||||
// Stand in for the peer: it still holds the membership it cached
|
||||
// before the removal.
|
||||
await server.clients.redis.set(
|
||||
key,
|
||||
JSON.stringify(cached),
|
||||
'EX',
|
||||
60,
|
||||
);
|
||||
await expect(store.isMember(team.uid, member.id)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
for (const update of updates) {
|
||||
await server.clients.event.emitAndWait(
|
||||
'outer.cacheUpdate',
|
||||
update,
|
||||
{ from_outside: true },
|
||||
);
|
||||
}
|
||||
|
||||
await expect(store.isMember(team.uid, member.id)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('drop every membership of a deleted team from one update', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Closing',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const members = [await makeUser(), await makeUser()];
|
||||
for (const member of members) {
|
||||
await store.addMember(team.uid, member.id, { orgOwned: false });
|
||||
}
|
||||
|
||||
const updates = await captureCacheUpdates(() =>
|
||||
store.softDelete(team.uid),
|
||||
);
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]!.cacheKey).toEqual(
|
||||
expect.arrayContaining([
|
||||
`team:row:${team.uid}`,
|
||||
`team:members:${team.id}`,
|
||||
...members.map((m) => `team:member:${team.uid}:${m.id}`),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes membership to the team asked for', async () => {
|
||||
const a = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'A',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const b = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'B',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const member = await makeUser();
|
||||
await store.addMember(a.uid, member.id, { orgOwned: true });
|
||||
|
||||
await expect(store.isMember(b.uid, member.id)).resolves.toBe(false);
|
||||
expect((await store.listMembers(b.uid)).items).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists the teams a user belongs to', async () => {
|
||||
const member = await makeUser();
|
||||
const a = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'One',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const b = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Two',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
await store.addMember(a.uid, member.id, { orgOwned: true });
|
||||
await store.addMember(b.uid, member.id, { orgOwned: true });
|
||||
|
||||
const { items } = await store.listTeamsForUser(member.id);
|
||||
expect(items.map((t) => t.uid).sort()).toEqual([a.uid, b.uid].sort());
|
||||
});
|
||||
|
||||
it('drops a soft-deleted team from the user\'s list', async () => {
|
||||
const member = await makeUser();
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Gone',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
await store.addMember(team.uid, member.id, { orgOwned: true });
|
||||
await store.softDelete(team.uid);
|
||||
|
||||
expect((await store.listTeamsForUser(member.id)).items).toEqual([]);
|
||||
await expect(store.isMember(team.uid, member.id)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('pages the user team list and counts on request', async () => {
|
||||
const member = await makeUser();
|
||||
const made = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: `Paged ${i}`,
|
||||
handle: freeHandle(),
|
||||
});
|
||||
await store.addMember(team.uid, member.id, { orgOwned: true });
|
||||
made.push(team.uid);
|
||||
}
|
||||
|
||||
const first = await store.listTeamsForUser(member.id, {
|
||||
limit: 2,
|
||||
includeTotal: true,
|
||||
});
|
||||
expect(first.items).toHaveLength(2);
|
||||
expect(first.total).toBe(3);
|
||||
expect(first.cursor).toBeTruthy();
|
||||
// Sealed: it reads as nothing but a cursor.
|
||||
expect(() => decodeCursor(first.cursor)).toThrow();
|
||||
|
||||
const second = await store.listTeamsForUser(member.id, {
|
||||
cursor: first.cursor,
|
||||
});
|
||||
expect(second.items).toHaveLength(1);
|
||||
expect(second.cursor).toBeUndefined();
|
||||
expect(
|
||||
[...first.items, ...second.items].map((t) => t.uid).sort(),
|
||||
).toEqual([...made].sort());
|
||||
});
|
||||
|
||||
it('refuses to add a member to a group that is not a team', async () => {
|
||||
const [seeded] = (await server.clients.db.read(
|
||||
'SELECT `uid` FROM `group` WHERE `kind` IS NULL LIMIT 1',
|
||||
)) as { uid: string }[];
|
||||
const member = await makeUser();
|
||||
|
||||
await expect(
|
||||
store.addMember(seeded.uid, member.id, { orgOwned: true }),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
// -- seat counts --------------------------------------------------
|
||||
|
||||
// `countSeats` bounds the cap and `countActiveSeats` is the billable
|
||||
// count, so the two must disagree exactly when a seat is suspended.
|
||||
describe('counting seats', () => {
|
||||
const suspend = async (userId: number) => {
|
||||
await server.clients.db.write(
|
||||
'UPDATE `user` SET `suspended` = 1 WHERE `id` = ?',
|
||||
[userId],
|
||||
);
|
||||
};
|
||||
|
||||
const seatedTeam = async (seats = 0) => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Counted',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const members: { id: number }[] = [];
|
||||
for (let i = 0; i < seats; i++) {
|
||||
const m = await makeUser();
|
||||
await store.addMember(team.uid, m.id, { orgOwned: true });
|
||||
members.push(m);
|
||||
}
|
||||
return { team, members };
|
||||
};
|
||||
|
||||
it('counts owned teams still holding accounts, deleted ones included', async () => {
|
||||
// What gates closing an owner's account: a deleted team's seats
|
||||
// are suspended, not gone, and only its owner can retire them.
|
||||
const solo = await makeUser();
|
||||
expect(await store.countOwnedTeamsWithAccounts(solo.id)).toBe(0);
|
||||
|
||||
const team = await store.create({
|
||||
ownerUserId: solo.id,
|
||||
name: 'Solo',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const seat = await makeUser();
|
||||
await store.addMember(team.uid, seat.id, { orgOwned: true });
|
||||
expect(await store.countOwnedTeamsWithAccounts(solo.id)).toBe(1);
|
||||
|
||||
await store.softDelete(team.uid);
|
||||
expect(await store.countOwnedTeamsWithAccounts(solo.id)).toBe(1);
|
||||
// `countOwned` sees only live teams, which is why it is not enough.
|
||||
expect(await store.countOwned(solo.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts provisioned seats and active seats alike when none is suspended', async () => {
|
||||
const { team } = await seatedTeam(3);
|
||||
expect(await store.countSeats(team.id)).toBe(3);
|
||||
expect(await store.countActiveSeats(team.id)).toBe(3);
|
||||
});
|
||||
|
||||
it('drops a suspended seat from the active count but not from the cap', async () => {
|
||||
// The console promises a suspended account costs nothing per
|
||||
// account, while the seat it holds is still taken.
|
||||
const { team, members } = await seatedTeam(3);
|
||||
await suspend(members[0].id);
|
||||
|
||||
expect(await store.countActiveSeats(team.id)).toBe(2);
|
||||
expect(await store.countSeats(team.id)).toBe(3);
|
||||
});
|
||||
|
||||
it('still counts a provisioned seat that has never been activated', async () => {
|
||||
// It holds a temporary password, not a suspension — it is paid for.
|
||||
const { team, members } = await seatedTeam(1);
|
||||
await server.clients.db.write(
|
||||
'UPDATE `user` SET `requires_password_change` = 1 WHERE `id` = ?',
|
||||
[members[0].id],
|
||||
);
|
||||
|
||||
expect(await store.countActiveSeats(team.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('excludes the payer, who is not a seat', async () => {
|
||||
const { team } = await seatedTeam(2);
|
||||
const payer = await makeUser();
|
||||
await server.stores.user.update(payer.id, { password: 'hashed' });
|
||||
await store.addMember(team.uid, payer.id, { orgOwned: false });
|
||||
|
||||
expect(await store.countActiveSeats(team.id)).toBe(2);
|
||||
});
|
||||
|
||||
it('is zero for a team with no seats, not an error', async () => {
|
||||
const { team } = await seatedTeam(0);
|
||||
expect(await store.countActiveSeats(team.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -- pagination ---------------------------------------------------
|
||||
|
||||
it('pages members on `id` and stops when the set is exhausted', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Paged',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const m = await makeUser();
|
||||
ids.push(m.id);
|
||||
await store.addMember(team.uid, m.id, { orgOwned: true });
|
||||
}
|
||||
|
||||
const seen: number[] = [];
|
||||
let cursor: string | undefined;
|
||||
let pages = 0;
|
||||
do {
|
||||
const page = await store.listMembers(team.uid, { limit: 2, cursor });
|
||||
seen.push(...page.items.map((m) => m.user_id));
|
||||
cursor = page.cursor;
|
||||
if (cursor) expect(() => decodeCursor(cursor)).toThrow();
|
||||
pages++;
|
||||
} while (cursor && pages < 10);
|
||||
|
||||
expect(seen.sort()).toEqual([...ids].sort());
|
||||
expect(pages).toBe(3);
|
||||
});
|
||||
|
||||
it('pages the directory on a sealed cursor', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Directory',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
for (let i = 0; i < 3; i++)
|
||||
await store.addMember(team.uid, (await makeUser()).id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
let pages = 0;
|
||||
do {
|
||||
const page = await store.listDirectory(team.uid, {
|
||||
limit: 2,
|
||||
cursor,
|
||||
});
|
||||
seen.push(...page.items.map((m) => m.uuid));
|
||||
cursor = page.cursor;
|
||||
if (cursor) expect(() => decodeCursor(cursor)).toThrow();
|
||||
pages++;
|
||||
} while (cursor && pages < 10);
|
||||
|
||||
expect(new Set(seen).size).toBe(seen.length);
|
||||
expect(pages).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('caps the page size rather than trusting the caller', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Capped',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
// An empty team satisfies any cap, so seed enough to page.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const m = await makeUser();
|
||||
await store.addMember(team.uid, m.id, { orgOwned: true });
|
||||
}
|
||||
|
||||
const capped = await store.listMembers(team.uid, { limit: 100_000 });
|
||||
expect(capped.items).toHaveLength(3);
|
||||
expect(capped.cursor).toBeUndefined();
|
||||
|
||||
const paged = await store.listMembers(team.uid, { limit: 2 });
|
||||
expect(paged.items).toHaveLength(2);
|
||||
expect(paged.cursor).toBeTruthy();
|
||||
});
|
||||
it('returns audit timestamps as unix seconds, not a dialect datetime', async () => {
|
||||
const team = await store.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Stamps',
|
||||
handle: freeHandle(),
|
||||
});
|
||||
const member = await makeUser();
|
||||
await store.addMember(team.uid, member.id, { orgOwned: true });
|
||||
await store.appendAudit({
|
||||
teamId: team.id,
|
||||
userId: member.id,
|
||||
actorUserId: owner.id,
|
||||
action: 'provision',
|
||||
});
|
||||
|
||||
const page = await store.listAuditForUser(team.id, member.id);
|
||||
const row = page.items[0];
|
||||
|
||||
// Merged with session rows, which are unix seconds; a dialect
|
||||
// datetime sorts the two streams apart.
|
||||
expect(typeof row.created_at).toBe('number');
|
||||
|
||||
// Against the database's own clock, not the host's: the postgres test
|
||||
// engine is an emulator whose VM clock sits years off wall time.
|
||||
const [{ db_now: dbNow }] = (await server.clients.db.read(
|
||||
server.clients.db.case({
|
||||
postgres:
|
||||
'SELECT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP::timestamp)::bigint AS db_now',
|
||||
mysql: 'SELECT UNIX_TIMESTAMP() AS db_now',
|
||||
otherwise: "SELECT CAST(strftime('%s','now') AS INTEGER) AS db_now",
|
||||
}),
|
||||
[],
|
||||
)) as Array<{ db_now: number }>;
|
||||
expect(Math.abs(row.created_at - Number(dbNow))).toBeLessThan(300);
|
||||
});
|
||||
});
|
||||
@@ -1,907 +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 { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
keysetPage,
|
||||
normalizeLimit,
|
||||
openIdCursor,
|
||||
type PageResult,
|
||||
} from '../../util/pagination.js';
|
||||
import { PuterStore } from '../types';
|
||||
|
||||
/** A team: a `group` row with `kind = 'team'`. */
|
||||
export interface TeamRow {
|
||||
id: number;
|
||||
uid: string;
|
||||
owner_user_id: number;
|
||||
kind: string;
|
||||
name: string | null;
|
||||
handle: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
/** Whether an app acting for a member may read the member list. */
|
||||
directory_enabled: number;
|
||||
/** Whether the accounts this team provisioned must hold 2FA. */
|
||||
require_2fa: number;
|
||||
}
|
||||
|
||||
export const TEAM_KIND = 'team';
|
||||
|
||||
/** Short: a stale read here costs a notification, not access. */
|
||||
const TEAM_CACHE_TTL_SECONDS = 60;
|
||||
|
||||
/** A membership row, joined to the member's username. */
|
||||
/** A seat, joined to the team that pays for it. */
|
||||
export interface OrgSeatRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
uuid: string;
|
||||
username: string;
|
||||
team_uid: string;
|
||||
team_name: string | null;
|
||||
owner_user_id: number;
|
||||
/** Carried here so the per-request 2FA gate costs no extra read. */
|
||||
require_2fa: number;
|
||||
}
|
||||
|
||||
export interface TeamMemberRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
group_id: number;
|
||||
username: string;
|
||||
uuid: string;
|
||||
org_owned: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** One entry in the insert-only record of what a team did to an account. */
|
||||
export interface TeamAuditRow {
|
||||
id: number;
|
||||
user_id_keep: number;
|
||||
actor_user_id: number | null;
|
||||
action: string;
|
||||
reason: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Default and ceiling for `listMembers`, matching the other paginated stores. */
|
||||
export const MEMBER_PAGE_SIZE = 50;
|
||||
export const MEMBER_PAGE_CAP = 200;
|
||||
export const AUDIT_PAGE_SIZE = 50;
|
||||
export const AUDIT_PAGE_CAP = 200;
|
||||
|
||||
/**
|
||||
* Ceiling on a share announcement's fan-out. Well above the default seat cap,
|
||||
* so it bounds a pathological team rather than a real one; the caller logs when
|
||||
* it bites, because a silently shortened fan-out reads as "everyone knows".
|
||||
*/
|
||||
export const NOTIFY_FANOUT_CAP = 500;
|
||||
|
||||
/** Longest handle mysql can store — `varchar(64)` in mysql_mig_28. */
|
||||
export const HANDLE_MAX_LENGTH = 64;
|
||||
export const HANDLE_MIN_LENGTH = 3;
|
||||
|
||||
/** Mysql declares `name varchar(255)`; sqlite and postgres use TEXT. */
|
||||
export const NAME_MAX_LENGTH = 255;
|
||||
|
||||
/** Narrower than the column, so the engines' collations cannot disagree. */
|
||||
const HANDLE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||
|
||||
/** A handle reaching users in email makes an impersonation more convincing. */
|
||||
const RESERVED_HANDLES = new Set([
|
||||
'about',
|
||||
'account',
|
||||
'admin',
|
||||
'administrator',
|
||||
'api',
|
||||
'app',
|
||||
'apps',
|
||||
'billing',
|
||||
'blog',
|
||||
'ceo',
|
||||
'contact',
|
||||
'dashboard',
|
||||
'dev',
|
||||
'developer',
|
||||
'docs',
|
||||
'help',
|
||||
'internal',
|
||||
'legal',
|
||||
'login',
|
||||
'mail',
|
||||
'moderator',
|
||||
'official',
|
||||
'owner',
|
||||
'payment',
|
||||
'payments',
|
||||
'puter',
|
||||
'puter-support',
|
||||
'puterteam',
|
||||
'root',
|
||||
'security',
|
||||
'settings',
|
||||
'signup',
|
||||
'staff',
|
||||
'status',
|
||||
'support',
|
||||
'system',
|
||||
'team',
|
||||
'teams',
|
||||
'trust',
|
||||
'trust-safety',
|
||||
'verify',
|
||||
'team',
|
||||
'teams',
|
||||
]);
|
||||
|
||||
export type HandleRejection =
|
||||
| 'too_short'
|
||||
| 'too_long'
|
||||
| 'malformed'
|
||||
| 'reserved';
|
||||
|
||||
/** Trimmed and capped, so the same name is accepted on every engine. */
|
||||
export const normalizeTeamName = (name: string): string => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === '') throw new Error('team name is required');
|
||||
if (trimmed.length > NAME_MAX_LENGTH)
|
||||
throw new Error('team name is too long');
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
/** Why `handle` is unusable, or null when it is fine. */
|
||||
export const checkHandle = (handle: string): HandleRejection | null => {
|
||||
if (handle.length < HANDLE_MIN_LENGTH) return 'too_short';
|
||||
if (handle.length > HANDLE_MAX_LENGTH) return 'too_long';
|
||||
if (!HANDLE_PATTERN.test(handle)) return 'malformed';
|
||||
if (RESERVED_HANDLES.has(handle)) return 'reserved';
|
||||
return null;
|
||||
};
|
||||
|
||||
export class TeamStore extends PuterStore {
|
||||
/** Postgres indexes lower(handle); the others are already insensitive. */
|
||||
#handleMatch(): string {
|
||||
return this.clients.db.case({
|
||||
postgres: 'lower(`handle`) = lower(?)',
|
||||
otherwise: '`handle` = ?',
|
||||
});
|
||||
}
|
||||
|
||||
/** Makes the seeded `kind IS NULL` groups unreachable, not merely absent. */
|
||||
#live(alias = ''): string {
|
||||
const prefix = alias ? `${alias}.` : '';
|
||||
return `${prefix}\`kind\` = ? AND ${prefix}\`deleted_at\` IS NULL`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excludes a suspended account and one that never took up its credential
|
||||
* (activation is `requires_password_change` clearing, not the password
|
||||
* existing — a provisioned seat holds the temporary one from birth).
|
||||
*/
|
||||
#activeAccount(alias = ''): string {
|
||||
const prefix = alias ? `${alias}.` : '';
|
||||
return (
|
||||
`(${prefix}\`suspended\` IS NULL OR ${prefix}\`suspended\` = 0) ` +
|
||||
`AND (${prefix}\`requires_password_change\` IS NULL ` +
|
||||
`OR ${prefix}\`requires_password_change\` = 0)`
|
||||
);
|
||||
}
|
||||
|
||||
// -- Reads --------------------------------------------------------
|
||||
|
||||
/** The team with this uid, or null. Soft-deleted ones are excluded. */
|
||||
async getByUid(uid: string): Promise<TeamRow | null> {
|
||||
return this.#cached(`team:row:${uid}`, () => this.#readByUid(uid));
|
||||
}
|
||||
|
||||
async #readByUid(uid: string): Promise<TeamRow | null> {
|
||||
const rows = await this.clients.db.read(
|
||||
`SELECT * FROM \`group\` WHERE \`uid\` = ? AND ${this.#live()}`,
|
||||
[uid, TEAM_KIND],
|
||||
);
|
||||
return (rows[0] as unknown as TeamRow) ?? null;
|
||||
}
|
||||
|
||||
/** For reading back a row this request just wrote; the replica may lag. */
|
||||
async #readByUidFromPrimary(uid: string): Promise<TeamRow | null> {
|
||||
const rows = await this.clients.db.pread(
|
||||
`SELECT * FROM \`group\` WHERE \`uid\` = ? AND ${this.#live()}`,
|
||||
[uid, TEAM_KIND],
|
||||
);
|
||||
return (rows[0] as unknown as TeamRow) ?? null;
|
||||
}
|
||||
|
||||
/** Includes soft-deleted rows, so an audit survives its team. */
|
||||
async getByUidIncludingDeleted(uid: string): Promise<TeamRow | null> {
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT * FROM `group` WHERE `uid` = ? AND `kind` = ?',
|
||||
[uid, TEAM_KIND],
|
||||
);
|
||||
return (rows[0] as unknown as TeamRow) ?? null;
|
||||
}
|
||||
|
||||
/** For availability checks and console resolution; callers address by uid. */
|
||||
async getByHandle(handle: string): Promise<TeamRow | null> {
|
||||
const rows = await this.clients.db.read(
|
||||
`SELECT * FROM \`group\` WHERE ${this.#handleMatch()} AND ${this.#live()}`,
|
||||
[handle, TEAM_KIND],
|
||||
);
|
||||
return (rows[0] as unknown as TeamRow) ?? null;
|
||||
}
|
||||
|
||||
/** Whether the handle is free to claim. Does not validate its spelling. */
|
||||
async isHandleAvailable(handle: string): Promise<boolean> {
|
||||
return (await this.getByHandle(handle)) === null;
|
||||
}
|
||||
|
||||
// -- Writes -------------------------------------------------------
|
||||
|
||||
/** Throws on an unusable or taken handle; the unique index is the arbiter. */
|
||||
async create(input: {
|
||||
ownerUserId: number;
|
||||
name: string;
|
||||
handle?: string | null;
|
||||
}): Promise<TeamRow> {
|
||||
const name = normalizeTeamName(input.name);
|
||||
const handle = input.handle ?? null;
|
||||
if (handle !== null) {
|
||||
const rejection = checkHandle(handle);
|
||||
if (rejection) {
|
||||
throw new Error(`unusable team handle: ${rejection}`);
|
||||
}
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
await this.clients.db.write(
|
||||
'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `handle`, `extra`, `metadata`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[uid, input.ownerUserId, TEAM_KIND, name, handle, '{}', '{}'],
|
||||
);
|
||||
|
||||
const created = await this.#readByUidFromPrimary(uid);
|
||||
if (!created) {
|
||||
// Unreachable to the caller, but still counts against their cap.
|
||||
await this.clients.db.write(
|
||||
'DELETE FROM `group` WHERE `uid` = ? AND `kind` = ?',
|
||||
[uid, TEAM_KIND],
|
||||
);
|
||||
throw new Error('team disappeared immediately after insert');
|
||||
}
|
||||
await this.#cachePut(`team:row:${uid}`, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Null when no live team has that uid. `handle: null` releases it. */
|
||||
async update(
|
||||
uid: string,
|
||||
changes: {
|
||||
name?: string;
|
||||
handle?: string | null;
|
||||
directoryEnabled?: boolean;
|
||||
require2fa?: boolean;
|
||||
},
|
||||
): Promise<TeamRow | null> {
|
||||
const sets: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (changes.name !== undefined) {
|
||||
sets.push('`name` = ?');
|
||||
params.push(normalizeTeamName(changes.name));
|
||||
}
|
||||
if (changes.handle !== undefined) {
|
||||
if (changes.handle !== null) {
|
||||
const rejection = checkHandle(changes.handle);
|
||||
if (rejection) {
|
||||
throw new Error(`unusable team handle: ${rejection}`);
|
||||
}
|
||||
}
|
||||
sets.push('`handle` = ?');
|
||||
params.push(changes.handle);
|
||||
}
|
||||
if (changes.directoryEnabled !== undefined) {
|
||||
sets.push('`directory_enabled` = ?');
|
||||
params.push(changes.directoryEnabled ? 1 : 0);
|
||||
}
|
||||
if (changes.require2fa !== undefined) {
|
||||
sets.push('`require_2fa` = ?');
|
||||
params.push(changes.require2fa ? 1 : 0);
|
||||
}
|
||||
if (sets.length === 0) return this.getByUid(uid);
|
||||
|
||||
await this.clients.db.write(
|
||||
`UPDATE \`group\` SET ${sets.join(', ')} WHERE \`uid\` = ? AND ${this.#live()}`,
|
||||
[...params, uid, TEAM_KIND],
|
||||
);
|
||||
await this.#bustRow(uid);
|
||||
const updated = await this.#readByUidFromPrimary(uid);
|
||||
if (updated) await this.#cachePut(`team:row:${uid}`, updated);
|
||||
return updated;
|
||||
}
|
||||
/** Releases the handle, since nothing addresses by it; keeps `name`. */
|
||||
async softDelete(uid: string): Promise<boolean> {
|
||||
// Both resolved first: once `deleted_at` is set neither lookup finds it.
|
||||
const team = await this.getByUid(uid);
|
||||
const memberIds = team
|
||||
? await this.#readMemberIds(team.id, NOTIFY_FANOUT_CAP)
|
||||
: [];
|
||||
const result = await this.clients.db.write(
|
||||
`UPDATE \`group\` SET \`deleted_at\` = CURRENT_TIMESTAMP, \`handle\` = NULL ` +
|
||||
`WHERE \`uid\` = ? AND ${this.#live()}`,
|
||||
[uid, TEAM_KIND],
|
||||
);
|
||||
if (result.anyRowsAffected && team) {
|
||||
// One update for the lot; bounded by the fan-out cap.
|
||||
await this.#bust(
|
||||
`team:members:${team.id}`,
|
||||
`team:row:${uid}`,
|
||||
...memberIds.map((id) => `team:member:${uid}:${id}`),
|
||||
);
|
||||
}
|
||||
return result.anyRowsAffected;
|
||||
}
|
||||
|
||||
// -- Membership ---- sole writer of team rows -----------------------
|
||||
|
||||
/** The membership row for this user in this team, or null. */
|
||||
async getMembership(
|
||||
teamUid: string,
|
||||
userId: number,
|
||||
): Promise<TeamMemberRow | null> {
|
||||
return this.#cached(`team:member:${teamUid}:${userId}`, () =>
|
||||
this.#readMembership(teamUid, userId),
|
||||
);
|
||||
}
|
||||
|
||||
async #readMembership(
|
||||
teamUid: string,
|
||||
userId: number,
|
||||
): Promise<TeamMemberRow | null> {
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT ug.`id`, ug.`user_id`, ug.`group_id`, ug.`org_owned`, ' +
|
||||
'ug.`created_at`, u.`username`, u.`uuid` FROM `jct_user_group` ug ' +
|
||||
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
|
||||
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
|
||||
`WHERE g.\`uid\` = ? AND ug.\`user_id\` = ? AND g.${this.#live()}`,
|
||||
[teamUid, userId, TEAM_KIND],
|
||||
);
|
||||
return (rows[0] as unknown as TeamMemberRow) ?? null;
|
||||
}
|
||||
|
||||
/** As `getMembership`, deleted team included; uncached — deletion is rare. */
|
||||
async getMembershipIncludingDeleted(
|
||||
teamUid: string,
|
||||
userId: number,
|
||||
): Promise<TeamMemberRow | null> {
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT ug.`id`, ug.`user_id`, ug.`group_id`, ug.`org_owned`, ' +
|
||||
'ug.`created_at`, u.`username`, u.`uuid` FROM `jct_user_group` ug ' +
|
||||
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
|
||||
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
|
||||
'WHERE g.`uid` = ? AND ug.`user_id` = ? AND g.`kind` = ?',
|
||||
[teamUid, userId, TEAM_KIND],
|
||||
);
|
||||
return (rows[0] as unknown as TeamMemberRow) ?? null;
|
||||
}
|
||||
|
||||
/** Whether this user belongs to this team. */
|
||||
async isMember(teamUid: string, userId: number): Promise<boolean> {
|
||||
return (await this.getMembership(teamUid, userId)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `userIds` belong to this team; bounded by its input, no page
|
||||
* cap.
|
||||
*/
|
||||
async memberIdsAmong(
|
||||
teamUid: string,
|
||||
userIds: number[],
|
||||
): Promise<number[]> {
|
||||
const ids = [...new Set(userIds)].filter((id) => Number.isFinite(id));
|
||||
if (ids.length === 0) return [];
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT ug.`user_id` FROM `jct_user_group` ug ' +
|
||||
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
|
||||
`WHERE g.\`uid\` = ? AND ${this.#live('g')} ` +
|
||||
`AND ug.\`user_id\` IN (${ids.map(() => '?').join(', ')})`,
|
||||
[teamUid, TEAM_KIND, ...ids],
|
||||
)) as { user_id: number }[];
|
||||
return rows.map((row) => Number(row.user_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* A team's members, keyset-paginated on `id` per doc/pagination.md.
|
||||
* `activeOnly` keeps only accounts the directory would list.
|
||||
*/
|
||||
async listMembers(
|
||||
teamUid: string,
|
||||
opts: { limit?: unknown; cursor?: string; activeOnly?: boolean } = {},
|
||||
): Promise<PageResult<TeamMemberRow>> {
|
||||
const limit =
|
||||
normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ??
|
||||
MEMBER_PAGE_SIZE;
|
||||
const after = openIdCursor(opts.cursor, this.config.jwt_secret_v2, {
|
||||
label: 'team member cursor',
|
||||
});
|
||||
|
||||
// One row past the limit is how we know a further page exists.
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT ug.`id`, ug.`user_id`, ug.`group_id`, ug.`org_owned`, ' +
|
||||
'ug.`created_at`, u.`username`, u.`uuid` FROM `jct_user_group` ug ' +
|
||||
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
|
||||
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
|
||||
`WHERE g.\`uid\` = ? AND g.${this.#live()}` +
|
||||
(opts.activeOnly ? ` AND ${this.#activeAccount('u')}` : '') +
|
||||
(after === null ? '' : ' AND ug.`id` > ?') +
|
||||
' ORDER BY ug.`id` LIMIT ?',
|
||||
after === null
|
||||
? [teamUid, TEAM_KIND, limit + 1]
|
||||
: [teamUid, TEAM_KIND, after, limit + 1],
|
||||
)) as unknown as TeamMemberRow[];
|
||||
|
||||
const page = keysetPage(rows, limit, this.config.jwt_secret_v2);
|
||||
return { items: page.rows, cursor: page.cursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory page: who a member may be suggested alongside. Excludes
|
||||
* suspended accounts and ones that never activated -- offering someone who
|
||||
* cannot sign in is noise, and their existence is not this list's to tell.
|
||||
*/
|
||||
async listDirectory(
|
||||
teamUid: string,
|
||||
opts: { limit?: unknown; cursor?: string } = {},
|
||||
): Promise<PageResult<{ id: number; username: string; uuid: string }>> {
|
||||
const limit =
|
||||
normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ??
|
||||
MEMBER_PAGE_SIZE;
|
||||
const after = openIdCursor(opts.cursor, this.config.jwt_secret_v2, {
|
||||
label: 'team directory cursor',
|
||||
});
|
||||
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT ug.`id`, u.`username`, u.`uuid` FROM `jct_user_group` ug ' +
|
||||
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
|
||||
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
|
||||
`WHERE g.\`uid\` = ? AND g.${this.#live()} ` +
|
||||
`AND ${this.#activeAccount('u')}` +
|
||||
(after === null ? '' : ' AND ug.`id` > ?') +
|
||||
' ORDER BY ug.`id` LIMIT ?',
|
||||
after === null
|
||||
? [teamUid, TEAM_KIND, limit + 1]
|
||||
: [teamUid, TEAM_KIND, after, limit + 1],
|
||||
)) as unknown as { id: number; username: string; uuid: string }[];
|
||||
|
||||
const page = keysetPage(rows, limit, this.config.jwt_secret_v2);
|
||||
return { items: page.rows, cursor: page.cursor };
|
||||
}
|
||||
|
||||
/** Internal ids of this user's live teams, for the share listing. */
|
||||
async listGroupIdsForUser(userId: number): Promise<number[]> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT g.`id` FROM `group` g ' +
|
||||
'JOIN `jct_user_group` ug ON ug.`group_id` = g.`id` ' +
|
||||
`WHERE ug.\`user_id\` = ? AND g.${this.#live()}`,
|
||||
[userId, TEAM_KIND],
|
||||
)) as { id: number }[];
|
||||
return rows.map((r) => Number(r.id));
|
||||
}
|
||||
|
||||
/** By internal id, soft-deleted included: those shares stay revocable. */
|
||||
async getByIdIncludingDeleted(id: number): Promise<TeamRow | null> {
|
||||
return (await this.getByIdsIncludingDeleted([id])).get(id) ?? null;
|
||||
}
|
||||
|
||||
/** `getByIdIncludingDeleted` for many ids in one read, keyed by id. */
|
||||
async getByIdsIncludingDeleted(
|
||||
ids: number[],
|
||||
): Promise<Map<number, TeamRow>> {
|
||||
const unique = [...new Set(ids)];
|
||||
if (unique.length === 0) return new Map();
|
||||
const rows = (await this.clients.db.read(
|
||||
`SELECT * FROM \`group\` WHERE \`id\` IN (${unique.map(() => '?').join(', ')}) AND \`kind\` = ?`,
|
||||
[...unique, TEAM_KIND],
|
||||
)) as unknown as TeamRow[];
|
||||
return new Map(rows.map((row) => [Number(row.id), row]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived, and safe for `getByUid`/`getMembership`: mutations bust it.
|
||||
*
|
||||
* `jct_user_group.user_id` is ON DELETE CASCADE, so deleting an account
|
||||
* changes membership without passing through this store. The TTL is the
|
||||
* backstop for that; the explicit busts cover everything else.
|
||||
*/
|
||||
async #cached<T>(key: string, read: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
const hit = await this.clients.redis.get(key);
|
||||
if (hit !== null) return JSON.parse(hit) as T;
|
||||
} catch {
|
||||
return read();
|
||||
}
|
||||
const value = await read();
|
||||
// A miss may just be a lagging replica; caching it holds for the TTL.
|
||||
if (value !== null && value !== undefined)
|
||||
await this.#cachePut(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async #cachePut(key: string, value: unknown): Promise<void> {
|
||||
try {
|
||||
await this.clients.redis.set(
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
'EX',
|
||||
TEAM_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} catch {
|
||||
/* a cache that cannot be written is still correct */
|
||||
}
|
||||
}
|
||||
|
||||
/** Peer regions cache these too, and membership gates access there. */
|
||||
async #bust(...keys: string[]): Promise<void> {
|
||||
await this.publishCacheKeys({ keys, broadcast: true });
|
||||
}
|
||||
|
||||
/** Busts everything keyed on this team's membership. */
|
||||
async bustMembership(groupId: number, userId?: number): Promise<void> {
|
||||
await this.#bust(
|
||||
`team:members:${groupId}`,
|
||||
...(userId === undefined ? [] : [`team:seat:${userId}`]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One membership pair. Authorization reads this, so every path that can
|
||||
* change it busts here -- the TTL is not the mechanism.
|
||||
*/
|
||||
async bustMember(teamUid: string, userId: number): Promise<void> {
|
||||
await this.#bust(`team:member:${teamUid}:${userId}`);
|
||||
}
|
||||
|
||||
async #bustRow(uid: string): Promise<void> {
|
||||
await this.#bust(`team:row:${uid}`);
|
||||
}
|
||||
|
||||
async listMemberIdsByGroupId(
|
||||
groupId: number,
|
||||
limit = NOTIFY_FANOUT_CAP,
|
||||
): Promise<number[]> {
|
||||
if (limit !== NOTIFY_FANOUT_CAP)
|
||||
return this.#readMemberIds(groupId, limit);
|
||||
return this.#cached(`team:members:${groupId}`, () =>
|
||||
this.#readMemberIds(groupId, limit),
|
||||
);
|
||||
}
|
||||
|
||||
async #readMemberIds(groupId: number, limit: number): Promise<number[]> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT ug.`user_id` FROM `jct_user_group` ug ' +
|
||||
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
|
||||
`WHERE ug.\`group_id\` = ? AND g.${this.#live()} ` +
|
||||
'ORDER BY ug.`id` LIMIT ?',
|
||||
[groupId, TEAM_KIND, limit + 1],
|
||||
)) as { user_id: number }[];
|
||||
return rows.map((r) => Number(r.user_id));
|
||||
}
|
||||
|
||||
/** A user's teams, oldest first; `openOnly` is the page an app may see. */
|
||||
async listTeamsForUser(
|
||||
userId: number,
|
||||
opts: {
|
||||
limit?: unknown;
|
||||
cursor?: string;
|
||||
includeTotal?: boolean;
|
||||
openOnly?: boolean;
|
||||
} = {},
|
||||
): Promise<PageResult<TeamRow>> {
|
||||
const limit =
|
||||
normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ??
|
||||
MEMBER_PAGE_SIZE;
|
||||
const after = openIdCursor(opts.cursor, this.config.jwt_secret_v2, {
|
||||
label: 'team cursor',
|
||||
});
|
||||
const open = opts.openOnly ? ' AND g.`directory_enabled` = 1' : '';
|
||||
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT g.* FROM `group` g ' +
|
||||
'JOIN `jct_user_group` ug ON ug.`group_id` = g.`id` ' +
|
||||
`WHERE ug.\`user_id\` = ? AND g.${this.#live()}` +
|
||||
open +
|
||||
(after === null ? '' : ' AND g.`id` > ?') +
|
||||
' ORDER BY g.`id` LIMIT ?',
|
||||
after === null
|
||||
? [userId, TEAM_KIND, limit + 1]
|
||||
: [userId, TEAM_KIND, after, limit + 1],
|
||||
)) as unknown as TeamRow[];
|
||||
|
||||
const { rows: items, cursor } = keysetPage(
|
||||
rows,
|
||||
limit,
|
||||
this.config.jwt_secret_v2,
|
||||
);
|
||||
|
||||
let total: number | undefined;
|
||||
if (opts.includeTotal) {
|
||||
const totals = (await this.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `group` g ' +
|
||||
'JOIN `jct_user_group` ug ON ug.`group_id` = g.`id` ' +
|
||||
`WHERE ug.\`user_id\` = ? AND g.${this.#live()}` +
|
||||
open,
|
||||
[userId, TEAM_KIND],
|
||||
)) as unknown as Array<{ n: number | string }>;
|
||||
total = Number(totals[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(total === undefined ? {} : { total }),
|
||||
};
|
||||
}
|
||||
|
||||
/** `orgOwned` decides who pays: 1 team-created, 0 the team owner. */
|
||||
async addMember(
|
||||
teamUid: string,
|
||||
userId: number,
|
||||
opts: { orgOwned: boolean },
|
||||
): Promise<boolean> {
|
||||
// A seat is created, never adopted; a password means it predates us.
|
||||
if (opts.orgOwned) {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT 1 AS hit FROM `user` WHERE `id` = ? AND `password` IS NOT NULL LIMIT 1',
|
||||
[userId],
|
||||
)) as { hit: number }[];
|
||||
if (rows.length > 0) {
|
||||
throw new Error('cannot make an existing account a team seat');
|
||||
}
|
||||
}
|
||||
|
||||
// Kind-filtered subquery, so a non-team uid inserts nothing.
|
||||
const result = await this.clients.db.write(
|
||||
`${this.clients.db.insertIgnoreInto('jct_user_group')} ` +
|
||||
'(`user_id`, `group_id`, `org_owned`) ' +
|
||||
'SELECT ?, g.`id`, ? FROM `group` g ' +
|
||||
`WHERE g.\`uid\` = ? AND g.${this.#live()}` +
|
||||
this.clients.db.insertIgnoreSuffix(),
|
||||
// Not `booleanValue`: it yields a real boolean on postgres.
|
||||
[userId, opts.orgOwned ? 1 : 0, teamUid, TEAM_KIND],
|
||||
);
|
||||
if (result.anyRowsAffected) await this.#bustForTeamUid(teamUid, userId);
|
||||
return result.anyRowsAffected;
|
||||
}
|
||||
|
||||
/** Seats the team has provisioned. The owner is not one of them. */
|
||||
async countSeats(teamId: number): Promise<number> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `jct_user_group` ' +
|
||||
'WHERE `group_id` = ? AND `org_owned` = 1',
|
||||
[teamId],
|
||||
)) as { n: number }[];
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seats that are provisioned and not suspended.
|
||||
*
|
||||
* Distinct from `countSeats` on purpose. That one bounds the cap, where a
|
||||
* suspended seat still counts because it still holds a username. This one
|
||||
* answers "how many accounts is the team getting the benefit of", which is
|
||||
* what the console tells the owner they pay per account for.
|
||||
*/
|
||||
async countActiveSeats(teamId: number): Promise<number> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `jct_user_group` ug ' +
|
||||
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
|
||||
'WHERE ug.`group_id` = ? AND ug.`org_owned` = 1 ' +
|
||||
'AND (u.`suspended` IS NULL OR u.`suspended` = 0)',
|
||||
[teamId],
|
||||
)) as { n: number }[];
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
/** Live teams this user owns. Soft-deleted ones do not count. */
|
||||
async countOwned(ownerUserId: number): Promise<number> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `group` ' +
|
||||
`WHERE \`owner_user_id\` = ? AND ${this.#live()}`,
|
||||
[ownerUserId, TEAM_KIND],
|
||||
)) as { n: number }[];
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teams this user owns that still hold provisioned accounts, soft-deleted
|
||||
* ones included: a deleted team's seats are suspended, not gone.
|
||||
*/
|
||||
async countOwnedTeamsWithAccounts(ownerUserId: number): Promise<number> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT COUNT(DISTINCT g.`id`) AS n FROM `group` g ' +
|
||||
'JOIN `jct_user_group` ug ON ug.`group_id` = g.`id` ' +
|
||||
'WHERE g.`owner_user_id` = ? AND g.`kind` = ? ' +
|
||||
'AND ug.`org_owned` = 1',
|
||||
[ownerUserId, TEAM_KIND],
|
||||
)) as { n: number }[];
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
/** How many members pay for themselves; the owner should be the only one. */
|
||||
async countPayers(teamId: number): Promise<number> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `jct_user_group` ' +
|
||||
'WHERE `group_id` = ? AND `org_owned` = 0',
|
||||
[teamId],
|
||||
)) as { n: number }[];
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
/** Soft-deleted teams count: their seats still hold billable bytes. */
|
||||
async getOrgSeat(userId: number): Promise<OrgSeatRow | null> {
|
||||
return this.#cached(`team:seat:${userId}`, () =>
|
||||
this.#readOrgSeat(userId),
|
||||
);
|
||||
}
|
||||
|
||||
async #readOrgSeat(userId: number): Promise<OrgSeatRow | null> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' +
|
||||
'g.`uid` AS `team_uid`, g.`name` AS `team_name`, ' +
|
||||
'g.`owner_user_id`, g.`require_2fa` ' +
|
||||
'FROM `jct_user_group` ug ' +
|
||||
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
|
||||
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
|
||||
'WHERE ug.`user_id` = ? AND ug.`org_owned` = 1 ' +
|
||||
'AND g.`kind` = ? ORDER BY g.`id` LIMIT 1',
|
||||
[userId, TEAM_KIND],
|
||||
)) as unknown as OrgSeatRow[];
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
// -- Audit ---- insert-only; no update or delete path exists --------
|
||||
|
||||
/** Records something the team did to an account. */
|
||||
async appendAudit(entry: {
|
||||
teamId: number;
|
||||
userId: number;
|
||||
actorUserId: number;
|
||||
action: string;
|
||||
reason?: string | null;
|
||||
}): Promise<void> {
|
||||
await this.clients.db.write(
|
||||
'INSERT INTO `audit_team_membership` ' +
|
||||
'(`group_id`, `group_id_keep`, `user_id`, `user_id_keep`, ' +
|
||||
'`actor_user_id`, `action`, `reason`) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
entry.teamId,
|
||||
entry.teamId,
|
||||
entry.userId,
|
||||
entry.userId,
|
||||
entry.actorUserId,
|
||||
entry.action,
|
||||
entry.reason ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** The same rows as `appendAudit`, in one statement per chunk. */
|
||||
async appendAuditMany(
|
||||
entries: Array<{
|
||||
teamId: number;
|
||||
userId: number;
|
||||
actorUserId: number;
|
||||
action: string;
|
||||
reason?: string | null;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
for (let offset = 0; offset < entries.length; offset += 200) {
|
||||
const chunk = entries.slice(offset, offset + 200);
|
||||
const values = chunk.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ');
|
||||
const params = chunk.flatMap((e) => [
|
||||
e.teamId,
|
||||
e.teamId,
|
||||
e.userId,
|
||||
e.userId,
|
||||
e.actorUserId,
|
||||
e.action,
|
||||
e.reason ?? null,
|
||||
]);
|
||||
await this.clients.db.write(
|
||||
'INSERT INTO `audit_team_membership` ' +
|
||||
'(`group_id`, `group_id_keep`, `user_id`, `user_id_keep`, ' +
|
||||
`\`actor_user_id\`, \`action\`, \`reason\`) VALUES ${values}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole team's audit, newest first, keyset-paginated on `id`. */
|
||||
async listAudit(
|
||||
teamId: number,
|
||||
opts: { limit?: unknown; cursor?: string } = {},
|
||||
): Promise<PageResult<TeamAuditRow>> {
|
||||
return this.#pageAudit('`group_id_keep` = ?', [teamId], opts);
|
||||
}
|
||||
|
||||
/** One member's own entries. Scoped by user, not by team. */
|
||||
async listAuditForUser(
|
||||
teamId: number,
|
||||
userId: number,
|
||||
opts: { limit?: unknown; cursor?: string } = {},
|
||||
): Promise<PageResult<TeamAuditRow>> {
|
||||
return this.#pageAudit(
|
||||
'`group_id_keep` = ? AND `user_id_keep` = ?',
|
||||
[teamId, userId],
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
/** Descending keyset, so older entries are reachable rather than dropped. */
|
||||
async #pageAudit(
|
||||
where: string,
|
||||
params: unknown[],
|
||||
opts: { limit?: unknown; cursor?: string },
|
||||
): Promise<PageResult<TeamAuditRow>> {
|
||||
const limit =
|
||||
normalizeLimit(opts.limit, { cap: AUDIT_PAGE_CAP }) ??
|
||||
AUDIT_PAGE_SIZE;
|
||||
const before = openIdCursor(opts.cursor, this.config.jwt_secret_v2, {
|
||||
label: 'team audit cursor',
|
||||
});
|
||||
|
||||
// Unix seconds in SQL: the mysql driver reads a stored UTC datetime
|
||||
// as local, which shifts every row away from the sign-ins.
|
||||
const epoch = this.clients.db.case({
|
||||
postgres: 'EXTRACT(EPOCH FROM `created_at`)::bigint',
|
||||
mysql: 'UNIX_TIMESTAMP(`created_at`)',
|
||||
otherwise: "CAST(strftime('%s', `created_at`) AS INTEGER)",
|
||||
});
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT `id`, `user_id_keep`, `actor_user_id`, `action`, `reason`, ' +
|
||||
`${epoch} AS \`created_at\` ` +
|
||||
`FROM \`audit_team_membership\` WHERE ${where}` +
|
||||
(before === null ? '' : ' AND `id` < ?') +
|
||||
' ORDER BY `id` DESC LIMIT ?',
|
||||
before === null
|
||||
? [...params, limit + 1]
|
||||
: [...params, before, limit + 1],
|
||||
)) as unknown as TeamAuditRow[];
|
||||
|
||||
const page = keysetPage(rows, limit, this.config.jwt_secret_v2);
|
||||
return { items: page.rows, cursor: page.cursor };
|
||||
}
|
||||
|
||||
/** Removes a member, returning whether a row was there to remove. */
|
||||
async removeMember(teamUid: string, userId: number): Promise<boolean> {
|
||||
const result = await this.clients.db.write(
|
||||
'DELETE FROM `jct_user_group` WHERE `user_id` = ? AND `group_id` = ' +
|
||||
`(SELECT \`id\` FROM \`group\` WHERE \`uid\` = ? AND ${this.#live()})`,
|
||||
[userId, teamUid, TEAM_KIND],
|
||||
);
|
||||
if (result.anyRowsAffected) await this.#bustForTeamUid(teamUid, userId);
|
||||
return result.anyRowsAffected;
|
||||
}
|
||||
|
||||
/** The membership row is gone by now, so the team is resolved by uid. */
|
||||
async #bustForTeamUid(teamUid: string, userId: number): Promise<void> {
|
||||
const team = await this.getByUidIncludingDeleted(teamUid);
|
||||
await this.bustMembership(team?.id ?? -1, userId);
|
||||
await this.bustMember(teamUid, userId);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { TeamStoreContract } from '../core/teams.js';
|
||||
|
||||
import type { puterClients } from '../clients';
|
||||
import type { IExtensionClientInstances } from '../clients/types';
|
||||
import type { IConfig, LayerInstances, WithLifecycle } from '../types';
|
||||
@@ -30,6 +32,8 @@ export interface IPuterStoreInstances {}
|
||||
|
||||
/** Extension-augmentable store registry; see `IExtensionClientInstances`. */
|
||||
export interface IExtensionStoreInstances {
|
||||
/** Teams ships separately; absent where it is not installed. */
|
||||
team?: TeamStoreContract;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
|
||||
const connection = {
|
||||
host: '127.0.0.1',
|
||||
port: Number(process.env.PUTER_TEST_MYSQL_PORT ?? 3307),
|
||||
user: 'root',
|
||||
password: 'puter',
|
||||
};
|
||||
const database = `recovery_codes_${Date.now().toString(36)}`;
|
||||
|
||||
describe.skipIf(!process.env.PUTER_TEST_MYSQL_PORT)(
|
||||
'atomic recovery codes (MySQL)',
|
||||
() => {
|
||||
let server: PuterServer;
|
||||
beforeAll(async () => {
|
||||
const admin = await mysql.createConnection(connection);
|
||||
await admin.query(`CREATE DATABASE \`${database}\``);
|
||||
await admin.end();
|
||||
server = await setupTestServer({
|
||||
database: {
|
||||
engine: 'mysql',
|
||||
...connection,
|
||||
database,
|
||||
migrationPaths: [
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
'../../clients/database/migrations/mysql',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
}, 240_000);
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
const admin = await mysql.createConnection(connection);
|
||||
await admin.query(`DROP DATABASE IF EXISTS \`${database}\``);
|
||||
await admin.end();
|
||||
});
|
||||
|
||||
const account = async (codes: string[]) => {
|
||||
const username = `recovery-${uuidv4().slice(0, 8)}`;
|
||||
const user = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: null,
|
||||
});
|
||||
await server.stores.user.update(user.id, {
|
||||
otp_enabled: true,
|
||||
otp_recovery_codes: codes.map(hashRecoveryCode).join(','),
|
||||
});
|
||||
return user;
|
||||
};
|
||||
|
||||
it('allows only one concurrent consumption of the same recovery code', async () => {
|
||||
const user = await account(['RECOVER1', 'RECOVER2']);
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 3 }, () =>
|
||||
server.stores.user.consumeOtpRecoveryCode(
|
||||
user.id,
|
||||
hashRecoveryCode('RECOVER1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(results.filter(Boolean)).toHaveLength(1);
|
||||
expect(
|
||||
(await server.stores.user.getById(user.id, { force: true }))!
|
||||
.otp_recovery_codes,
|
||||
).toBe(hashRecoveryCode('RECOVER2'));
|
||||
});
|
||||
|
||||
it('consumes ten different concurrent codes without lost updates', async () => {
|
||||
const codes = Array.from({ length: 10 }, (_, i) => `RECOVER${i}`);
|
||||
const user = await account(codes);
|
||||
const results = await Promise.all(
|
||||
codes.map((code) =>
|
||||
server.stores.user.consumeOtpRecoveryCode(
|
||||
user.id,
|
||||
hashRecoveryCode(code),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(results.every(Boolean)).toBe(true);
|
||||
expect(
|
||||
(await server.stores.user.getById(user.id, { force: true }))!
|
||||
.otp_recovery_codes,
|
||||
).toBe('');
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -76,6 +76,35 @@ describe('user row updates re-cached from a lagging replica', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it.each([true, false])(
|
||||
'uses current two-factor state %s while a replica lags',
|
||||
async (enabled) => {
|
||||
const username = `otp_${tag()}`;
|
||||
const password = 'otp-replica-password';
|
||||
const { user } = await establishedUser(username, password);
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_enabled: !enabled,
|
||||
otp_secret: 'TESTSECRETBASE32',
|
||||
});
|
||||
const before = await rawRow(user.id);
|
||||
await env.server.stores.user.update(user.id, {
|
||||
otp_enabled: enabled,
|
||||
});
|
||||
const replica = lagReplica(before);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await login(username, password, 'otp-replica-test');
|
||||
} finally {
|
||||
replica.catchUp();
|
||||
}
|
||||
expect(response.status).toBe(enabled ? 202 : 200);
|
||||
expect((await response.json()).next_step).toBe(
|
||||
enabled ? 'otp' : 'complete',
|
||||
);
|
||||
expect(replica.served()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects the old password after a reset while a replica lags', async () => {
|
||||
const username = `rs_${tag()}`;
|
||||
const oldPassword = 'old-password-1';
|
||||
|
||||
@@ -463,6 +463,36 @@ export class UserStore extends PuterStore {
|
||||
|
||||
// -- Writes -------------------------------------------------------
|
||||
|
||||
/** Consume one recovery hash without losing a concurrent code consumption. */
|
||||
async consumeOtpRecoveryCode(
|
||||
userId: number,
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
// A set contains ten codes. Retry only conflicts, always from the primary.
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const user = await this.getById(userId, { force: true });
|
||||
const previous = user?.otp_recovery_codes;
|
||||
if (typeof previous !== 'string') return false;
|
||||
const codes = previous.split(',').filter(Boolean);
|
||||
const index = codes.indexOf(hash);
|
||||
if (index === -1) return false;
|
||||
codes.splice(index, 1);
|
||||
if (
|
||||
await this.#write(
|
||||
userId,
|
||||
{ otp_recovery_codes: codes.join(',') },
|
||||
this.clients.db.case({
|
||||
mysql: 'BINARY `otp_recovery_codes` = BINARY ?',
|
||||
otherwise: '`otp_recovery_codes` = ?',
|
||||
}),
|
||||
[previous],
|
||||
)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge-update a user's `metadata` JSON blob. Reads current value, applies
|
||||
* `Object.assign` semantics, and writes back. Invalidates every cache key
|
||||
@@ -684,10 +714,8 @@ export class UserStore extends PuterStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared write path for `update` / `claimPlaceholder` /
|
||||
* `clearUnconfirmedEmail` / `claimReferralCode`. `guard` is extra SQL ANDed
|
||||
* into the WHERE clause; the write is reported as lost when it matches no
|
||||
* row.
|
||||
* Cache-aware write path. `guard` is extra SQL ANDed into the WHERE clause;
|
||||
* a guarded write reports false when it matches no row.
|
||||
*/
|
||||
async #write(
|
||||
userId: number,
|
||||
@@ -731,6 +759,9 @@ export class UserStore extends PuterStore {
|
||||
'email_confirmed',
|
||||
'requires_password_change',
|
||||
'temp_password_expires_at',
|
||||
'otp_enabled',
|
||||
'otp_secret',
|
||||
'otp_recovery_codes',
|
||||
].includes(key),
|
||||
);
|
||||
const before = touchesIdentity
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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,
|
||||
TeamServiceContract,
|
||||
TeamStoreContract,
|
||||
} from '../core/teams.js';
|
||||
|
||||
/**
|
||||
* Teams is optional, 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;
|
||||
|
||||
const storeStub: TeamStoreContract = {
|
||||
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.stores.team = storeStub;
|
||||
const serviceStub: TeamServiceContract = {
|
||||
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
|
||||
);
|
||||
},
|
||||
};
|
||||
server.services.team = serviceStub;
|
||||
|
||||
// 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;
|
||||
};
|
||||
@@ -1,170 +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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Two teams side by side, plus a user in neither: an unscoped query still
|
||||
* returns the right rows when only one exists, so one gives false confidence.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { IConfig } from '../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../testUtil.js';
|
||||
|
||||
/** An account that can call the API: verified, with a session token. */
|
||||
export type FixtureUser = {
|
||||
userId: number;
|
||||
username: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type FixtureTeam = {
|
||||
uid: string;
|
||||
handle: string;
|
||||
name: string;
|
||||
/** Owns the team and pays for it; `org_owned = 0`. */
|
||||
owner: FixtureUser;
|
||||
/** Provisioned seats, activated so they can make requests. */
|
||||
seats: FixtureUser[];
|
||||
};
|
||||
|
||||
export type TwoTeams = {
|
||||
env: PuterTestEnv;
|
||||
a: FixtureTeam;
|
||||
b: FixtureTeam;
|
||||
/** Signed in, in no team at all. */
|
||||
outsider: FixtureUser;
|
||||
call: (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) => Promise<Response>;
|
||||
shutdown: () => Promise<void>;
|
||||
};
|
||||
|
||||
const rand = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
/** Seats per team. Two is enough to tell "this one" from "all of them". */
|
||||
const SEATS_PER_TEAM = 2;
|
||||
|
||||
/** Own owner per team, so this runs against the real cap. */
|
||||
export const setupTwoTeams = async (
|
||||
configOverrides: Partial<IConfig> = {},
|
||||
): Promise<TwoTeams> => {
|
||||
const env = await setupPuterTestEnv({
|
||||
teams_enabled: true,
|
||||
...configOverrides,
|
||||
} as IConfig);
|
||||
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const tokenFor = async (userId: number): Promise<string> => {
|
||||
const row = await env.server.stores.user.getById(userId);
|
||||
const { token } = await env.server.services.auth.createSessionToken(
|
||||
row!,
|
||||
);
|
||||
return token;
|
||||
};
|
||||
|
||||
const makeUser = async (): Promise<FixtureUser> => {
|
||||
const username = `fx_${rand()}`;
|
||||
const created = (await env.server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
// `requireVerified` rejects an unconfirmed account outright.
|
||||
email_confirmed: true,
|
||||
})) as unknown as { id: number };
|
||||
return {
|
||||
userId: created.id,
|
||||
username,
|
||||
token: await tokenFor(created.id),
|
||||
};
|
||||
};
|
||||
|
||||
/** Provisioning leaves the seat unconfirmed and mid-password-change. */
|
||||
const activate = async (username: string): Promise<FixtureUser> => {
|
||||
const seat = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.stores.user.update(seat!.id, {
|
||||
email_confirmed: 1,
|
||||
requires_email_confirmation: 0,
|
||||
requires_password_change: 0,
|
||||
});
|
||||
return {
|
||||
userId: seat!.id,
|
||||
username,
|
||||
token: await tokenFor(seat!.id),
|
||||
};
|
||||
};
|
||||
|
||||
const expectOk = async (res: Response, what: string) => {
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`fixture: ${what} failed with ${res.status}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const makeTeam = async (name: string): Promise<FixtureTeam> => {
|
||||
const owner = await makeUser();
|
||||
const handle = `ws-${rand()}`;
|
||||
|
||||
const res = await expectOk(
|
||||
await call('POST', '/teams', owner.token, { name, handle }),
|
||||
`creating ${name}`,
|
||||
);
|
||||
const team = (await res.json()) as { uid: string };
|
||||
|
||||
const seats: FixtureUser[] = [];
|
||||
for (let i = 0; i < SEATS_PER_TEAM; i++) {
|
||||
const username = `st_${rand()}`;
|
||||
await expectOk(
|
||||
await call('POST', `/teams/${team.uid}/members`, owner.token, {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
}),
|
||||
`provisioning into ${name}`,
|
||||
);
|
||||
seats.push(await activate(username));
|
||||
}
|
||||
|
||||
return { uid: team.uid, handle, name, owner, seats };
|
||||
};
|
||||
|
||||
const a = await makeTeam('Team A');
|
||||
const b = await makeTeam('Team B');
|
||||
const outsider = await makeUser();
|
||||
|
||||
return { env, a, b, outsider, call, shutdown: () => env.shutdown() };
|
||||
};
|
||||
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
|
||||
// Importing npm `undici` has a process-wide side effect: if the global
|
||||
// dispatcher slot — `Symbol.for('undici.globalDispatcher.1')`, shared with
|
||||
// Node's built-in fetch — is empty, undici's module initializer installs its
|
||||
// dispatcher slots — `Symbol.for('undici.globalDispatcher.N')`, shared with
|
||||
// Node's built-in fetch — are empty, undici's module initializer installs its
|
||||
// own Agent there. From that moment EVERY plain `fetch()` in the process
|
||||
// (the AI provider SDKs, everything) is silently served by the npm copy of
|
||||
// undici instead of the copy bundled with — and security-patched by — Node.
|
||||
@@ -34,7 +34,12 @@
|
||||
// This module must therefore be imported before `undici` — it intentionally
|
||||
// imports nothing itself, and secureHttp.ts (the backend's only undici
|
||||
// importer) imports it before importing undici.
|
||||
const DISPATCHER_SLOT = Symbol.for('undici.globalDispatcher.1');
|
||||
//
|
||||
// Newer undici reads the `.2` slot and older undici the `.1` slot, and both
|
||||
// copies can share the process, so every present slot gets wrapped.
|
||||
const PRIMARY_SLOT = Symbol.for('undici.globalDispatcher.2');
|
||||
const LEGACY_SLOT = Symbol.for('undici.globalDispatcher.1');
|
||||
const DISPATCHER_SLOTS = [PRIMARY_SLOT, LEGACY_SLOT];
|
||||
const globalSlots = globalThis as unknown as Record<symbol, unknown>;
|
||||
|
||||
void fetch('data:text/plain,dispatcher-guard').catch(() => {
|
||||
@@ -52,7 +57,11 @@ void fetch('data:text/plain,dispatcher-guard').catch(() => {
|
||||
const OUTBOUND_FETCH_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
// Symbol.for so a re-imported copy of this module (fresh module registry,
|
||||
// same process) recognizes an already-wrapped dispatcher and doesn't re-wrap.
|
||||
const TIMEOUTS_APPLIED = Symbol.for('puter.outboundFetchTimeouts');
|
||||
// A set, not a marker property: compose() can return a Proxy, which would
|
||||
// write the marker through to the unwrapped dispatcher.
|
||||
const WRAPPED_DISPATCHERS = Symbol.for('puter.outboundFetchTimeouts');
|
||||
const wrappedDispatchers = (globalSlots[WRAPPED_DISPATCHERS] ??=
|
||||
new WeakSet<object>()) as WeakSet<object>;
|
||||
|
||||
type DispatchFn = (opts: Record<string, unknown>, handler: unknown) => boolean;
|
||||
interface ComposableDispatcher {
|
||||
@@ -61,29 +70,58 @@ interface ComposableDispatcher {
|
||||
) => ComposableDispatcher;
|
||||
}
|
||||
|
||||
const claimed = globalSlots[DISPATCHER_SLOT] as
|
||||
| (ComposableDispatcher & Record<symbol, unknown>)
|
||||
| undefined;
|
||||
const withOutboundTimeouts =
|
||||
(dispatch: DispatchFn): DispatchFn =>
|
||||
(opts, handler) =>
|
||||
// Defaults first so a per-request timeout, if one is ever passed,
|
||||
// still wins.
|
||||
dispatch(
|
||||
{
|
||||
headersTimeout: OUTBOUND_FETCH_TIMEOUT_MS,
|
||||
bodyTimeout: OUTBOUND_FETCH_TIMEOUT_MS,
|
||||
...opts,
|
||||
},
|
||||
handler,
|
||||
);
|
||||
|
||||
// Newer undici negotiates HTTP/2, whose 256 KB default per-stream receive
|
||||
// window caps a download at about window / RTT. That undici keeps a wrapper of
|
||||
// its Agent in the legacy slot, so swap in a same-class Agent with larger
|
||||
// windows and re-wrap it there.
|
||||
const H2_WINDOWS = {
|
||||
initialWindowSize: 4 * 1024 * 1024,
|
||||
connectionWindowSize: 16 * 1024 * 1024,
|
||||
};
|
||||
type DispatcherClass = new (options: unknown) => ComposableDispatcher;
|
||||
const primary = globalSlots[PRIMARY_SLOT] as ComposableDispatcher | undefined;
|
||||
const legacy = globalSlots[LEGACY_SLOT] as ComposableDispatcher | undefined;
|
||||
if (
|
||||
claimed &&
|
||||
typeof claimed.compose === 'function' &&
|
||||
!(TIMEOUTS_APPLIED in claimed)
|
||||
primary &&
|
||||
legacy &&
|
||||
primary !== legacy &&
|
||||
primary.constructor.name === 'Agent' &&
|
||||
!wrappedDispatchers.has(primary)
|
||||
) {
|
||||
const wrapped = claimed.compose(
|
||||
(dispatch) => (opts, handler) =>
|
||||
// Defaults first so a per-request timeout, if one is ever passed,
|
||||
// still wins.
|
||||
dispatch(
|
||||
{
|
||||
headersTimeout: OUTBOUND_FETCH_TIMEOUT_MS,
|
||||
bodyTimeout: OUTBOUND_FETCH_TIMEOUT_MS,
|
||||
...opts,
|
||||
},
|
||||
handler,
|
||||
),
|
||||
) as ComposableDispatcher & Record<symbol, unknown>;
|
||||
wrapped[TIMEOUTS_APPLIED] = true;
|
||||
globalSlots[DISPATCHER_SLOT] = wrapped;
|
||||
const agent = new (primary.constructor as DispatcherClass)({
|
||||
h2Options: H2_WINDOWS,
|
||||
});
|
||||
globalSlots[PRIMARY_SLOT] = agent;
|
||||
globalSlots[LEGACY_SLOT] = new (legacy.constructor as DispatcherClass)(
|
||||
agent,
|
||||
);
|
||||
}
|
||||
|
||||
for (const slot of DISPATCHER_SLOTS) {
|
||||
const claimed = globalSlots[slot] as ComposableDispatcher | undefined;
|
||||
if (
|
||||
!claimed ||
|
||||
typeof claimed.compose !== 'function' ||
|
||||
wrappedDispatchers.has(claimed)
|
||||
)
|
||||
continue;
|
||||
const wrapped = claimed.compose(withOutboundTimeouts);
|
||||
wrappedDispatchers.add(wrapped);
|
||||
globalSlots[slot] = wrapped;
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -17,6 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import diagnosticsChannel from 'node:diagnostics_channel';
|
||||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { Agent as UndiciAgent } from 'undici';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { configContainer } from '../exports.js';
|
||||
@@ -139,6 +142,97 @@ describe('global fetch dispatcher isolation', () => {
|
||||
globalSlots[SLOT] = before;
|
||||
}
|
||||
});
|
||||
|
||||
it('sends plain fetch() requests out with the 10-minute timeouts', async () => {
|
||||
const created: { headersTimeout?: number; bodyTimeout?: number }[] = [];
|
||||
const onCreate = (message: unknown) => {
|
||||
created.push((message as { request: (typeof created)[0] }).request);
|
||||
};
|
||||
const server = createServer((_req, res) => res.end('ok'));
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
diagnosticsChannel.subscribe('undici:request:create', onCreate);
|
||||
try {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`);
|
||||
expect(await response.text()).toBe('ok');
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].headersTimeout).toBe(10 * 60 * 1000);
|
||||
expect(created[0].bodyTimeout).toBe(10 * 60 * 1000);
|
||||
} finally {
|
||||
diagnosticsChannel.unsubscribe('undici:request:create', onCreate);
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('HTTP/2 receive windows', () => {
|
||||
// Stand-ins shaped like undici's: compose() returns a wrapper that
|
||||
// records what it wraps.
|
||||
class Agent {
|
||||
options: unknown;
|
||||
constructor(options?: unknown) {
|
||||
this.options = options;
|
||||
}
|
||||
compose() {
|
||||
return { wraps: this };
|
||||
}
|
||||
}
|
||||
class Dispatcher1Wrapper {
|
||||
agent: Agent;
|
||||
constructor(agent: Agent) {
|
||||
this.agent = agent;
|
||||
}
|
||||
compose() {
|
||||
return { wraps: this };
|
||||
}
|
||||
}
|
||||
const globalSlots = globalThis as unknown as Record<symbol, unknown>;
|
||||
const PRIMARY = Symbol.for('undici.globalDispatcher.2');
|
||||
const LEGACY = Symbol.for('undici.globalDispatcher.1');
|
||||
const runGuardWith = async (primary: unknown, legacy: unknown) => {
|
||||
const before = [globalSlots[PRIMARY], globalSlots[LEGACY]];
|
||||
globalSlots[PRIMARY] = primary;
|
||||
globalSlots[LEGACY] = legacy;
|
||||
try {
|
||||
vi.resetModules();
|
||||
await import('./nodeFetchDispatcherGuard.js');
|
||||
return {
|
||||
primary: (globalSlots[PRIMARY] as { wraps: unknown }).wraps,
|
||||
legacy: (globalSlots[LEGACY] as { wraps: unknown }).wraps,
|
||||
};
|
||||
} finally {
|
||||
[globalSlots[PRIMARY], globalSlots[LEGACY]] = before;
|
||||
}
|
||||
};
|
||||
|
||||
it('swaps in an Agent with 4 MB stream / 16 MB connection windows', async () => {
|
||||
const original = new Agent();
|
||||
const slots = await runGuardWith(
|
||||
original,
|
||||
new Dispatcher1Wrapper(original),
|
||||
);
|
||||
expect(slots.primary).toBeInstanceOf(Agent);
|
||||
expect(slots.primary).not.toBe(original);
|
||||
expect((slots.primary as Agent).options).toEqual({
|
||||
h2Options: {
|
||||
initialWindowSize: 4 * 1024 * 1024,
|
||||
connectionWindowSize: 16 * 1024 * 1024,
|
||||
},
|
||||
});
|
||||
expect(slots.legacy).toBeInstanceOf(Dispatcher1Wrapper);
|
||||
expect((slots.legacy as Dispatcher1Wrapper).agent).toBe(
|
||||
slots.primary,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a dispatcher both slots share', async () => {
|
||||
const shared = new Agent();
|
||||
const slots = await runGuardWith(shared, shared);
|
||||
expect(slots.primary).toBe(shared);
|
||||
expect(slots.legacy).toBe(shared);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('secureHttp URL validation', () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import type { OIDCService } from '../services/auth/OIDCService.js';
|
||||
import type { SystemKVStore } from '../stores/systemKv/SystemKVStore.js';
|
||||
import type { TeamStore } from '../stores/team/TeamStore.js';
|
||||
import type { TeamStoreContract } from '../core/teams.js';
|
||||
import type { UserRow } from '../stores/user/UserStore.js';
|
||||
import type { IConfig } from '../types.js';
|
||||
import {
|
||||
@@ -39,7 +39,7 @@ export interface UserDetailsDeps {
|
||||
};
|
||||
stores: TaskbarDeps['stores'] & {
|
||||
kv: Pick<SystemKVStore, 'get'>;
|
||||
team: Pick<TeamStore, 'getOrgSeat'>;
|
||||
team?: Pick<TeamStoreContract, 'getOrgSeat'>;
|
||||
};
|
||||
services: { oidc: Pick<OIDCService, 'getLinkedProviderForUser'> };
|
||||
}
|
||||
@@ -216,8 +216,12 @@ export async function buildUserDetails(
|
||||
const orgSeat = async () => {
|
||||
if (!isUser || config.teams_enabled !== true) return null;
|
||||
try {
|
||||
return { seat: await stores.team.getOrgSeat(user.id) };
|
||||
return { seat: await stores.team?.getOrgSeat(user.id) };
|
||||
} catch (error) {
|
||||
// Teams is optional, and an extension-registry read for one that
|
||||
// is not installed throws rather than answering undefined. That
|
||||
// is an absence, not a fault.
|
||||
if (/missing property/.test(String(error))) return { seat: null };
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Read a user's profile, returning null when it is not available.
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
Returns a user's profile: their picture, display name, and bio. A user can always read their own profile. Another user's profile is available only while that user is on a paid plan; otherwise the method resolves to `null`, the same as for a user who does not exist.
|
||||
Returns a user's profile: their picture, display name, and bio. Users set these on the [Account page](https://puter.com/dashboard#account) of their Puter dashboard. A user can always read their own profile. Another user's profile is available only while that user is on a paid plan; otherwise the method resolves to `null`, the same as for a user who does not exist.
|
||||
|
||||
## Syntax
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ puter.auth.signIn(options)
|
||||
|
||||
- `request_auth`: A boolean value that asks the popup to let the user re-pick their account, even when your site already holds a token for them. Puter otherwise skips that prompt for a site it has seen before. Useful for an explicit "switch account" button.
|
||||
|
||||
Each call completes from its own sign-in window, including when multiple calls run at once.
|
||||
|
||||
## Return value
|
||||
|
||||
A `Promise` that will resolve to a [`SignInResult`](/Objects/signinresult/) object when the user has signed in.
|
||||
|
||||
@@ -28,8 +28,8 @@ An object with the following properties:
|
||||
- `path` (String) - Path to the file to read. Required when passing options as the only argument.
|
||||
- `offset` (Number) (optional)
|
||||
The offset to start reading from.
|
||||
- `byte_count` (Number) (required if `offset` is provided)
|
||||
The number of bytes to read from the offset.
|
||||
- `byte_count` (Number) (optional)
|
||||
The number of bytes to read from the offset. When omitted with `offset`, reads through the end of the file.
|
||||
|
||||
## Return value
|
||||
|
||||
|
||||
@@ -401,8 +401,8 @@ Available only where the deployment has teams turned on; elsewhere `puter.teams`
|
||||
The mutation and read budgets are per user, per app, not per team: administering several teams spends one budget.
|
||||
|
||||
- **Seats.** A seat is a Puter account the team creates and its owner pays for. Over the seat limit, provisioning fails with `seat_limit_reached`; over the team limit, creation fails with `team_limit_reached`. Both errors include the limit in `fields.limit`.
|
||||
- **Seat plan.** A seat on a team with no paid tier is on the `org_seat_free` plan, which gets **half** the free allowance and the free rate limits. A seat on a paid team tier gets that tier.
|
||||
- **Changing the seat limit.** The limit follows the owner's plan, so upgrading raises it immediately. Lowering it never disables anyone; a team over the new limit just can't add seats until it's back under.
|
||||
- **Seat plan.** A seat on a team with no paid tier is on the `org_seat_free` plan, which gets **half** the free allowance and the free rate limits. A seat on a paid team tier gets that tier. A seat beyond the owner's current seat limit is on `org_seat_over_cap`, which has no allowance: only purchased credit can be spent.
|
||||
- **Changing the seat limit.** The limit follows the owner's plan, so upgrading raises it immediately. Lowering it disables no one, but a team over the new limit can't add seats, and its newest seats beyond the limit lose their allowance until the team is back under or the owner upgrades. Seats count oldest first, a suspended seat included, and a change reaches seats within about a minute.
|
||||
- **Deployment config.** `max_seats_per_team_free` and `max_seats_per_team_paid` set the two seat limits; a plan registered with `teamSeatCap` uses its own instead. `max_seats_per_team` sets one flat limit that overrides all of them. `max_teams_per_user` sets the team limit. These apply to every team on the deployment.
|
||||
- **Password resets.** A reset returns a temporary password once, valid for 24 hours. Until the member sets their own password, every request except signing in fails with `password_change_required`.
|
||||
- **Required 2FA.** A team can require two-factor authentication for the accounts it created (not for members who joined with their own accounts). Until a seat sets it up, every request except signing in and setting up 2FA fails with `two_factor_required`. The owner needs 2FA to turn this on, and can clear a member's second factor if they lose their device; the reset is logged and the member is emailed.
|
||||
|
||||
@@ -22,6 +22,7 @@ import UIWindowChangeEmail from '../Settings/UIWindowChangeEmail.js';
|
||||
import UIWindowChangeUsername from '../UIWindowChangeUsername.js';
|
||||
import UIWindowConfirmUserDeletion from '../Settings/UIWindowConfirmUserDeletion.js';
|
||||
import UIWindowCopyToken from '../UIWindowCopyToken.js';
|
||||
import UIWindowEditProfile from '../UIWindowEditProfile.js';
|
||||
import UIWindow from '../UIWindow.js';
|
||||
import UIProfilePictureCropModal from './UIProfilePictureCropModal.js';
|
||||
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
|
||||
@@ -59,6 +60,20 @@ const TabAccount = {
|
||||
// Account settings cards
|
||||
h += '<div class="dashboard-settings-grid">';
|
||||
|
||||
// Display name and bio card
|
||||
h += '<div class="dashboard-card dashboard-settings-card">';
|
||||
h += '<div class="dashboard-settings-card-content">';
|
||||
h += '<div class="dashboard-settings-card-icon">';
|
||||
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><circle cx="8" cy="12" r="2"/><path d="M14 10h4M14 14h4"/></svg>';
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-settings-card-info">';
|
||||
h += `<strong>${i18n('profile')}</strong>`;
|
||||
h += `<span class="dashboard-profile-display-name">${html_encode(window.user?.profile?.displayName || i18n('profile_empty', [], false))}</span>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
h += `<button class="button edit-profile">${i18n('edit_profile')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
// Username card
|
||||
h += '<div class="dashboard-card dashboard-settings-card">';
|
||||
h += '<div class="dashboard-settings-card-content">';
|
||||
@@ -163,6 +178,22 @@ const TabAccount = {
|
||||
},
|
||||
|
||||
init ($el_window) {
|
||||
$el_window.find('.dashboard-section-account .edit-profile').on('click', function (e) {
|
||||
UIWindowEditProfile({
|
||||
window_options: {
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
onSave: (profile) => {
|
||||
$el_window.find('.dashboard-section-account .dashboard-profile-display-name')
|
||||
.text(profile.displayName || i18n('profile_empty', [], false));
|
||||
},
|
||||
});
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .change-password').on('click', function (e) {
|
||||
UIWindowChangePassword({
|
||||
window_options: {
|
||||
|
||||
@@ -1,967 +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 teamActionButton from './teamActionIcons.js';
|
||||
import UIDashboardDialog from './UIDashboardDialog.js';
|
||||
import {
|
||||
annotateMembers,
|
||||
auditActionKey,
|
||||
auditReasonKey,
|
||||
auditSlice,
|
||||
avatarHue,
|
||||
billingSummaryKey,
|
||||
canDeleteAccount,
|
||||
initialOf,
|
||||
membersBillingSummary,
|
||||
memberPlanLabel,
|
||||
parseTimestamp,
|
||||
sortMembers,
|
||||
} from './teamsConsole.js';
|
||||
|
||||
const SECTION = '.dashboard-section-teams';
|
||||
|
||||
/** What the console last loaded, so a redraw needs no second round trip. */
|
||||
let state = { status: 'loading', teams: [], selected: null, members: [], audit: [], auditPage: 0, plan: null, credential: null };
|
||||
|
||||
// Paged for reading, not fetching: member state is derived from the whole
|
||||
// record, so it is already loaded.
|
||||
const AUDIT_ROWS_PER_PAGE = 10;
|
||||
|
||||
/** In flight, so `init` and the initial-route `onActivate` don't both load. */
|
||||
let loadPromise = null;
|
||||
/** One more load, queued behind `loadPromise` by a change made during it. */
|
||||
let reloadPromise = null;
|
||||
|
||||
/** Every dialog here is the dashboard's overlay card, mounted in the window. */
|
||||
const dialog = ($el_window, opts) => UIDashboardDialog({ $container: $el_window, ...opts });
|
||||
|
||||
const dateText = (value) => parseTimestamp(value)?.toLocaleDateString() ?? '';
|
||||
|
||||
/** The record needs the hour: several entries share a day. */
|
||||
const dateTimeText = (value) => {
|
||||
const date = parseTimestamp(value);
|
||||
return date ? date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '';
|
||||
};
|
||||
|
||||
const teamName = (team) => team?.name || team?.handle || i18n('teams_untitled');
|
||||
|
||||
/** The sidebar entry is hidden rather than shown broken where teams are off. */
|
||||
const setTabVisible = ($el_window, visible) => {
|
||||
$el_window.find('.dashboard-sidebar-item[data-section="teams"]').toggle(visible);
|
||||
};
|
||||
|
||||
// Inline like the other tabs' card icons: one-place glyphs, not shared assets.
|
||||
const SVG = (body) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||
const ICONS = {
|
||||
team: SVG('<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
|
||||
directory: SVG('<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/><circle cx="11" cy="9.5" r="2"/><path d="M7.5 15a4 4 0 0 1 7 0"/>'),
|
||||
shield: SVG('<path d="M12 3l7 3v5.5c0 4.2-2.9 7.7-7 8.5-4.1-.8-7-4.3-7-8.5V6z"/><path d="M9.5 12l1.8 1.8 3.4-3.6"/>'),
|
||||
copy: SVG('<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
|
||||
check: SVG('<path d="M20 6L9 17l-5-5"/>'),
|
||||
close: SVG('<path d="M18 6L6 18"/><path d="M6 6l12 12"/>'),
|
||||
warning: SVG('<path d="M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
|
||||
};
|
||||
|
||||
// -- Rendering ------------------------------------------------------------
|
||||
|
||||
/** A lettered tile in place of a picture: team accounts have none. */
|
||||
const renderAvatar = (name, extraClass = '') => {
|
||||
const hue = avatarHue(name);
|
||||
return `<span class="teams-avatar${extraClass}" style="--teams-avatar-hue: ${hue};" aria-hidden="true">${html_encode(initialOf(name))}</span>`;
|
||||
};
|
||||
|
||||
const renderTeamPicker = () => {
|
||||
if ( state.teams.length < 2 ) return '';
|
||||
let h = `<select class="teams-picker-select" aria-label="${html_encode(i18n('teams_team', [], false))}">`;
|
||||
for ( const team of state.teams ) {
|
||||
const selected = team.uid === state.selected?.uid ? ' selected' : '';
|
||||
h += `<option value="${html_encode(team.uid)}"${selected}>${html_encode(teamName(team))}</option>`;
|
||||
}
|
||||
h += '</select>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/** Name, handle and headcount for the selected team, plus who you are in it. */
|
||||
const renderHero = () => {
|
||||
const team = state.selected;
|
||||
const count = state.members.length;
|
||||
let h = '<div class="dashboard-card teams-panel teams-hero">';
|
||||
h += '<div class="teams-hero-main">';
|
||||
h += renderAvatar(teamName(team), ' teams-avatar-lg');
|
||||
h += '<div class="teams-info">';
|
||||
h += '<div class="teams-info-title">';
|
||||
h += `<strong>${html_encode(teamName(team))}</strong>`;
|
||||
h += `<span class="teams-role">${i18n(team.isOwner ? 'teams_role_owner' : 'teams_role_member')}</span>`;
|
||||
h += '</div>';
|
||||
const meta = [];
|
||||
// Nothing here sets a handle, so its absence is not worth a label.
|
||||
if ( team.handle ) meta.push(html_encode(`@${team.handle}`));
|
||||
meta.push(i18n(count === 1 ? 'teams_member_count_one' : 'teams_member_count', { count }));
|
||||
h += `<span class="teams-info-meta">${meta.join('<span class="teams-dot" aria-hidden="true">·</span>')}</span>`;
|
||||
h += '</div></div>';
|
||||
const picker = renderTeamPicker();
|
||||
const rename = team.isOwner ? `<button class="button teams-rename">${i18n('teams_rename')}</button>` : '';
|
||||
if ( picker || rename ) h += `<div class="teams-hero-actions">${picker}${rename}</div>`;
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderCardHead = (title, hint, count = null) => {
|
||||
let h = '<div class="teams-card-head">';
|
||||
h += `<h3>${title}`;
|
||||
if ( count !== null ) h += `<span class="teams-count">${count}</span>`;
|
||||
h += '</h3>';
|
||||
if ( hint ) h += `<p class="teams-panel-hint">${hint}</p>`;
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const planCell = (member) => {
|
||||
const label = memberPlanLabel(member, state.plan);
|
||||
if ( label.kind === 'tier' ) return html_encode(label.name);
|
||||
return i18n(`teams_member_plan_${label.kind}`);
|
||||
};
|
||||
|
||||
const renderStatus = (member) => {
|
||||
const kind = member.disabled ? 'disabled' : 'active';
|
||||
return `<span class="teams-status teams-status-${kind}">${i18n(`teams_member_state_${kind}`)}</span>`;
|
||||
};
|
||||
|
||||
const renderIdentity = (username, subline) => {
|
||||
const you = username === window.user?.username;
|
||||
let h = '<div class="teams-identity">';
|
||||
h += renderAvatar(username);
|
||||
h += '<div class="teams-identity-text">';
|
||||
h += `<span class="teams-identity-name">${html_encode(username)}`;
|
||||
if ( you ) h += `<span class="teams-you">${i18n('share_you')}</span>`;
|
||||
h += '</span>';
|
||||
if ( subline ) h += `<span class="teams-identity-sub">${subline}</span>`;
|
||||
h += '</div></div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderMemberActions = (member) => {
|
||||
if ( ! member.orgOwned ) return '';
|
||||
const forMember = { 'data-username': member.username };
|
||||
let h = '<div class="teams-member-actions">';
|
||||
if ( window.team_billing_ui && state.plan?.status === 'ready' ) {
|
||||
h += teamActionButton({
|
||||
className: 'teams-plan-change',
|
||||
icon: 'plan',
|
||||
label: i18n('teams_plan_change'),
|
||||
attrs: { ...forMember, 'data-uuid': member.uuid ?? '' },
|
||||
});
|
||||
}
|
||||
h += teamActionButton({
|
||||
className: 'teams-reset',
|
||||
icon: 'credential',
|
||||
label: i18n('teams_reissue_credential'),
|
||||
attrs: forMember,
|
||||
});
|
||||
// Only while the rule is on: otherwise a member can clear it themselves.
|
||||
if ( state.selected?.require2fa ) {
|
||||
h += teamActionButton({
|
||||
className: 'teams-reset-2fa',
|
||||
icon: 'shield',
|
||||
label: i18n('teams_reset_2fa'),
|
||||
attrs: forMember,
|
||||
});
|
||||
}
|
||||
h += member.disabled
|
||||
? teamActionButton({
|
||||
className: 'teams-enable',
|
||||
icon: 'enable',
|
||||
label: i18n('teams_enable_account'),
|
||||
attrs: forMember,
|
||||
})
|
||||
: teamActionButton({
|
||||
className: 'teams-disable',
|
||||
icon: 'suspend',
|
||||
label: i18n('teams_disable_account'),
|
||||
danger: true,
|
||||
attrs: forMember,
|
||||
});
|
||||
if ( canDeleteAccount(member) ) {
|
||||
h += teamActionButton({
|
||||
className: 'teams-delete-account',
|
||||
icon: 'remove',
|
||||
label: i18n('teams_delete_account'),
|
||||
danger: true,
|
||||
attrs: forMember,
|
||||
});
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
// `data-label` repeats the column heading so the phone layout, which drops
|
||||
// the header row, can print it beside each value.
|
||||
const labelled = (key) => `data-label="${html_encode(i18n(key, [], false))}"`;
|
||||
|
||||
const renderMemberRow = (member) => {
|
||||
const username = html_encode(member.username);
|
||||
let h = `<tr class="teams-member-row${member.disabled ? ' teams-member-disabled' : ''}" data-username="${username}">`;
|
||||
h += `<td class="teams-cell-identity">${renderIdentity(member.username, i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined'))}</td>`;
|
||||
h += `<td ${labelled('teams_member_state')}>${renderStatus(member)}</td>`;
|
||||
h += `<td ${labelled('teams_member_since')}>${html_encode(dateText(member.createdAt))}</td>`;
|
||||
h += `<td ${labelled('teams_member_plan')}>${planCell(member)}</td>`;
|
||||
h += `<td class="teams-cell-actions">${renderMemberActions(member)}</td>`;
|
||||
h += '</tr>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderMembers = () => {
|
||||
const annotated = annotateMembers(state.members, state.audit);
|
||||
const summary = membersBillingSummary(annotated);
|
||||
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += renderCardHead(
|
||||
i18n('teams_accounts'),
|
||||
i18n(billingSummaryKey(summary), { billed: summary.billed, disabled: summary.disabled }),
|
||||
annotated.length,
|
||||
);
|
||||
|
||||
if ( annotated.length === 0 ) {
|
||||
h += `<p class="teams-empty">${i18n('teams_no_accounts')}</p>`;
|
||||
} else {
|
||||
h += '<div class="teams-table-wrapper"><table class="teams-table teams-members-table"><thead><tr>';
|
||||
h += `<th>${i18n('teams_member_account')}</th>`;
|
||||
h += `<th>${i18n('teams_member_state')}</th>`;
|
||||
h += `<th>${i18n('teams_member_since')}</th>`;
|
||||
h += `<th>${i18n('teams_member_plan')}</th>`;
|
||||
h += `<th class="teams-cell-actions"><span class="sr-only">${i18n('teams_member_actions')}</span></th>`;
|
||||
h += '</tr></thead><tbody>';
|
||||
for ( const member of sortMembers(annotated) ) h += renderMemberRow(member);
|
||||
h += '</tbody></table></div>';
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderAddAccount = () => {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += renderCardHead(i18n('teams_add_account'), i18n('teams_add_account_hint'));
|
||||
h += '<form class="teams-form" novalidate>';
|
||||
h += '<div class="teams-field">';
|
||||
h += `<label for="teams-new-username">${i18n('username')}</label>`;
|
||||
h += '<input id="teams-new-username" class="teams-new-username" type="text" autocomplete="off" autocapitalize="off" spellcheck="false" required>';
|
||||
h += '</div>';
|
||||
h += '<div class="teams-field">';
|
||||
h += `<label for="teams-new-email">${i18n('teams_email_optional')}</label>`;
|
||||
h += '<input id="teams-new-email" class="teams-new-email" type="email" autocomplete="off" spellcheck="false">';
|
||||
h += '</div>';
|
||||
h += `<button type="submit" class="button button-primary teams-add-btn">${i18n('teams_add_account')}</button>`;
|
||||
h += '</form>';
|
||||
h += `<p class="teams-field-help">${i18n('teams_add_account_email_hint')}</p>`;
|
||||
// Kept in state, not just the DOM: every refresh repaints this card.
|
||||
const credential = state.credential?.teamUid === state.selected?.uid ? state.credential : null;
|
||||
h += `<div class="teams-credential" role="status" aria-live="polite"${credential ? '' : ' style="display:none;"'}>`;
|
||||
if ( credential ) h += renderCredential(credential);
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderAudit = ({ title, hint }) => {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += renderCardHead(title, hint, state.audit.length || null);
|
||||
|
||||
if ( state.audit.length === 0 ) {
|
||||
h += `<p class="teams-empty">${i18n('teams_no_audit')}</p>`;
|
||||
} else {
|
||||
const { items, page, pages, from, to, total } = auditSlice(
|
||||
state.audit,
|
||||
state.auditPage,
|
||||
AUDIT_ROWS_PER_PAGE,
|
||||
);
|
||||
h += '<div class="teams-table-wrapper"><table class="teams-table teams-audit-table"><thead><tr>';
|
||||
h += `<th>${i18n('teams_audit_action')}</th>`;
|
||||
h += `<th>${i18n('teams_audit_account')}</th>`;
|
||||
h += `<th>${i18n('teams_audit_actor')}</th>`;
|
||||
h += `<th>${i18n('teams_audit_when')}</th>`;
|
||||
h += '</tr></thead><tbody>';
|
||||
for ( const entry of items ) {
|
||||
const actionKey = auditActionKey(entry.action);
|
||||
const reasonKey = auditReasonKey(entry.reason);
|
||||
h += '<tr>';
|
||||
h += `<td class="teams-cell-action">${actionKey ? i18n(actionKey) : html_encode(entry.action)}`;
|
||||
if ( reasonKey ) h += ` <span class="teams-audit-reason">${i18n(reasonKey)}</span>`;
|
||||
h += '</td>';
|
||||
h += `<td ${labelled('teams_audit_account')}>${html_encode(entry.username ?? '')}</td>`;
|
||||
h += `<td ${labelled('teams_audit_actor')}>${html_encode(entry.actorUsername ?? '')}</td>`;
|
||||
h += `<td ${labelled('teams_audit_when')} class="teams-cell-when">${html_encode(dateTimeText(entry.createdAt))}</td>`;
|
||||
h += '</tr>';
|
||||
}
|
||||
h += '</tbody></table></div>';
|
||||
if ( pages > 1 ) {
|
||||
h += '<div class="teams-pager">';
|
||||
h += `<span class="teams-pager-count">${i18n('teams_audit_range', { from, to, total })}</span>`;
|
||||
h += '<span class="teams-pager-buttons">';
|
||||
h += `<button class="button button-small teams-audit-prev"${page === 0 ? ' disabled' : ''}>${i18n('previous')}</button>`;
|
||||
h += `<button class="button button-small teams-audit-next"${page >= pages - 1 ? ' disabled' : ''}>${i18n('next')}</button>`;
|
||||
h += '</span></div>';
|
||||
}
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/** Colleagues, by name. No state, no dates, no actions — none are theirs. */
|
||||
const renderRoster = () => {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += renderCardHead(i18n('teams_roster'), i18n('teams_roster_hint'), state.members.length || null);
|
||||
if ( state.members.length === 0 ) {
|
||||
h += `<p class="teams-empty">${i18n('teams_roster_empty')}</p>`;
|
||||
} else {
|
||||
h += '<ul class="teams-roster">';
|
||||
for ( const member of sortMembers(state.members) ) {
|
||||
h += `<li class="teams-roster-name">${renderIdentity(member.username)}</li>`;
|
||||
}
|
||||
h += '</ul>';
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/** What a member sees: who else is here, their own entries, nothing admin. */
|
||||
const renderMemberView = () => {
|
||||
return renderHero()
|
||||
+ renderRoster()
|
||||
+ renderAudit({
|
||||
title: i18n('teams_your_record'),
|
||||
hint: i18n('teams_your_record_hint', { team: teamName(state.selected) }),
|
||||
});
|
||||
};
|
||||
|
||||
const renderDirectory = () => {
|
||||
const on = state.selected?.directoryEnabled === true;
|
||||
let h = '<div class="dashboard-card dashboard-settings-card teams-directory">';
|
||||
h += '<div class="dashboard-settings-card-content">';
|
||||
h += `<div class="dashboard-settings-card-icon">${ICONS.directory}</div>`;
|
||||
h += '<div class="dashboard-settings-card-info">';
|
||||
h += `<strong>${i18n('teams_directory_label')}</strong>`;
|
||||
h += `<span class="teams-directory-note">${i18n(on ? 'teams_directory_on_note' : 'teams_directory_off_note')}</span>`;
|
||||
h += '</div></div>';
|
||||
h += '<label class="dashboard-switch teams-directory-toggle">';
|
||||
h += `<input type="checkbox" class="teams-directory-check"${on ? ' checked' : ''} aria-label="${html_encode(i18n('teams_directory_label', [], false))}">`;
|
||||
h += '<span class="dashboard-switch-slider"></span>';
|
||||
h += '</label>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderRequire2fa = () => {
|
||||
const on = state.selected?.require2fa === true;
|
||||
let h = '<div class="dashboard-card dashboard-settings-card teams-require-2fa">';
|
||||
h += '<div class="dashboard-settings-card-content">';
|
||||
h += `<div class="dashboard-settings-card-icon">${ICONS.shield}</div>`;
|
||||
h += '<div class="dashboard-settings-card-info">';
|
||||
h += `<strong>${i18n('teams_require_2fa_label')}</strong>`;
|
||||
h += `<span class="teams-directory-note">${i18n(on ? 'teams_require_2fa_on_note' : 'teams_require_2fa_off_note')}</span>`;
|
||||
h += '</div></div>';
|
||||
h += '<label class="dashboard-switch teams-require-2fa-toggle">';
|
||||
h += `<input type="checkbox" class="teams-require-2fa-check"${on ? ' checked' : ''} aria-label="${html_encode(i18n('teams_require_2fa_label', [], false))}">`;
|
||||
h += '<span class="dashboard-switch-slider"></span>';
|
||||
h += '</label>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderOwnerView = () => {
|
||||
let h = renderHero();
|
||||
h += renderDirectory();
|
||||
h += renderRequire2fa();
|
||||
h += renderAddAccount();
|
||||
h += renderMembers();
|
||||
h += renderAudit({ title: i18n('teams_audit'), hint: i18n('teams_audit_hint') });
|
||||
|
||||
h += '<div class="dashboard-danger-zone">';
|
||||
h += `<h3>${i18n('teams_danger_zone')}</h3>`;
|
||||
h += '<div class="dashboard-card dashboard-danger-card">';
|
||||
h += '<div class="dashboard-danger-card-content"><div class="dashboard-danger-card-info">';
|
||||
h += `<strong>${i18n('teams_delete_team')}</strong>`;
|
||||
h += `<span>${i18n('teams_delete_team_hint')}</span>`;
|
||||
h += '</div></div>';
|
||||
h += `<button class="button button-danger teams-delete">${i18n('teams_delete_team')}</button>`;
|
||||
h += '</div></div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/** Placeholder cards the size of the ones about to arrive, so nothing jumps. */
|
||||
const renderSkeleton = () => {
|
||||
let h = `<div class="teams-skeleton" role="status" aria-busy="true"><span class="sr-only">${i18n('teams_loading')}</span>`;
|
||||
h += '<div class="dashboard-card teams-panel teams-skeleton-card teams-skeleton-hero"><i class="teams-skeleton-tile"></i><span><i style="width: 40%"></i><i style="width: 25%"></i></span></div>';
|
||||
for ( let i = 0; i < 2; i++ ) {
|
||||
h += '<div class="dashboard-card teams-panel teams-skeleton-card"><span><i style="width: 30%"></i><i style="width: 70%"></i><i style="width: 55%"></i></span></div>';
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderState = ({ icon, title, hint, action, tone = '' }) => {
|
||||
let h = `<div class="dashboard-card teams-panel teams-state${tone ? ` teams-state-${tone}` : ''}">`;
|
||||
h += `<div class="teams-state-art">${icon}</div>`;
|
||||
h += `<h3>${title}</h3>`;
|
||||
if ( hint ) h += `<p class="teams-panel-hint">${hint}</p>`;
|
||||
if ( action ) h += action;
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
// Nothing at all where teams are off: an empty panel is the honest
|
||||
// rendering of a feature this deployment does not have.
|
||||
if ( state.status === 'unavailable' ) return '';
|
||||
if ( state.status === 'loading' ) return renderSkeleton();
|
||||
if ( state.status === 'error' ) {
|
||||
return renderState({
|
||||
icon: ICONS.warning,
|
||||
tone: 'warning',
|
||||
title: i18n('teams_load_failed'),
|
||||
action: `<button class="button teams-retry">${i18n('retry')}</button>`,
|
||||
});
|
||||
}
|
||||
if ( state.teams.length === 0 ) {
|
||||
return renderState({
|
||||
icon: ICONS.team,
|
||||
title: i18n('teams_create_team'),
|
||||
hint: i18n('teams_create_team_hint'),
|
||||
action: `<button class="button button-primary teams-create">${i18n('teams_create_team')}</button>`,
|
||||
});
|
||||
}
|
||||
return state.selected?.isOwner ? renderOwnerView() : renderMemberView();
|
||||
};
|
||||
|
||||
/**
|
||||
* Selectors that find the focused control again in a fresh render, most
|
||||
* specific first: the same control for the same account, then any control
|
||||
* for that account (re-enable becomes suspend), then the same control.
|
||||
*/
|
||||
const focusSelectors = (el) => {
|
||||
const cls = [...el.classList].find(c => c.startsWith('teams-'));
|
||||
const username = el.getAttribute('data-username');
|
||||
const who = username === null ? '' : `[data-username="${CSS.escape(username)}"]`;
|
||||
return [cls && who && `.${cls}${who}`, who && `button${who}`, cls && `.${cls}`].filter(Boolean);
|
||||
};
|
||||
|
||||
const paint = ($el_window) => {
|
||||
const $body = $el_window.find(`${SECTION} .teams-body`);
|
||||
// Every action repaints the panel; without this, keyboard focus would
|
||||
// drop to <body> after each one.
|
||||
const focused = document.activeElement;
|
||||
const selectors = $body[0]?.contains(focused) && focused !== $body[0] ? focusSelectors(focused) : [];
|
||||
$body.html(renderBody());
|
||||
for ( const selector of selectors ) {
|
||||
const target = $body.find(selector)[0];
|
||||
if ( target ) {
|
||||
target.focus({ preventScroll: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// -- Loading --------------------------------------------------------------
|
||||
|
||||
const loadSelected = async () => {
|
||||
if ( ! state.selected ) return;
|
||||
// A fresh record starts at the top; auditSlice clamps a stale page anyway.
|
||||
state.auditPage = 0;
|
||||
const { uid, isOwner } = state.selected;
|
||||
// Independent reads, so fetched together. Members too: the roster is
|
||||
// theirs to see, and the controller already withholds what is not.
|
||||
const [audit, members, plan] = await Promise.all([
|
||||
isOwner ? puter.teams.listAudit(uid) : puter.teams.listOwnAudit(uid),
|
||||
puter.teams.listMembers(uid),
|
||||
isOwner ? loadPlan(uid) : null,
|
||||
]);
|
||||
state.audit = audit;
|
||||
state.members = members;
|
||||
state.plan = plan;
|
||||
};
|
||||
|
||||
/** Served by a billing extension; absent is a normal answer. */
|
||||
const loadPlan = async (teamUid) => {
|
||||
const get = async (path) => {
|
||||
const resp = await fetch(`${window.api_origin}${path}`, {
|
||||
headers: { Authorization: `Bearer ${puter.authToken}` },
|
||||
});
|
||||
return resp.ok ? resp.json() : null;
|
||||
};
|
||||
try {
|
||||
const [cat, sub] = await Promise.all([
|
||||
get('/marketplace/subscriptions/team-offerings'),
|
||||
get(`/marketplace/teams/${encodeURIComponent(teamUid)}/subscription`),
|
||||
]);
|
||||
if ( ! cat ) return { status: 'unavailable', offerings: [], seatTiers: {} };
|
||||
const entry = sub?.subscription ?? null;
|
||||
return {
|
||||
status: 'ready',
|
||||
offerings: Array.isArray(cat.offerings) ? cat.offerings : [],
|
||||
seatTiers: entry?.seatTiers ?? {},
|
||||
seatStatuses: entry?.seatStatuses ?? {},
|
||||
tierQuantities: entry?.tierQuantities ?? {},
|
||||
subStatus: entry?.status ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { status: 'unavailable', offerings: [], seatTiers: {} };
|
||||
}
|
||||
};
|
||||
|
||||
/** The billing extension owns the picker, so the look matches personal plans. */
|
||||
const changeSeatPlan = ($el_window, username, uuid) => {
|
||||
if ( state.plan?.status !== 'ready' || ! uuid ) return;
|
||||
window.dispatchEvent(new CustomEvent('team-plan-purchase', {
|
||||
detail: {
|
||||
teamUid: state.selected.uid,
|
||||
seatUuid: uuid,
|
||||
username,
|
||||
currentTier: state.plan.seatTiers?.[uuid] ?? null,
|
||||
currentStatus: state.plan.seatStatuses?.[uuid] ?? null,
|
||||
onDone: () => refresh($el_window),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const load = async ($el_window) => {
|
||||
// The API can be on while the interface is not; same effect as no route.
|
||||
if ( ! window.teams_ui ) {
|
||||
state.status = 'unavailable';
|
||||
setTabVisible($el_window, false);
|
||||
return paint($el_window);
|
||||
}
|
||||
try {
|
||||
const teams = await puter.teams.list();
|
||||
state.teams = teams;
|
||||
state.selected = teams.find(t => t.uid === state.selected?.uid) ?? teams[0] ?? null;
|
||||
await loadSelected();
|
||||
state.status = 'ready';
|
||||
setTabVisible($el_window, true);
|
||||
} catch (e) {
|
||||
// A deployment with teams off registers no `/teams` route, so the
|
||||
// 404 is the feature gate rather than a failure worth reporting.
|
||||
state.status = e?.code === 'not_found' ? 'unavailable' : 'error';
|
||||
state.teams = [];
|
||||
state.selected = null;
|
||||
setTabVisible($el_window, false);
|
||||
}
|
||||
// What this load read may predate a change; the queued load paints it.
|
||||
if ( reloadPromise ) return;
|
||||
paint($el_window);
|
||||
};
|
||||
|
||||
const refresh = ($el_window) => {
|
||||
if ( ! loadPromise ) {
|
||||
loadPromise = load($el_window).finally(() => { loadPromise = null; });
|
||||
return loadPromise;
|
||||
}
|
||||
// The running load may have read the team before the caller's change (an
|
||||
// action, a team switch), so load once more after it.
|
||||
reloadPromise ??= loadPromise.catch(() => {}).then(() => {
|
||||
reloadPromise = null;
|
||||
return refresh($el_window);
|
||||
});
|
||||
return reloadPromise;
|
||||
};
|
||||
|
||||
// -- Actions --------------------------------------------------------------
|
||||
|
||||
// The plain error says what happened, not that a plan is what raises the cap.
|
||||
// The button appears only where a billing extension put the modal on the page.
|
||||
const showSeatLimit = async ($el_window, e) => {
|
||||
const limit = e?.limit ?? e?.fields?.limit;
|
||||
const canUpgrade = typeof window.UIUpgradeAccount === 'function';
|
||||
let message = `<p>${limit ? i18n('teams_seat_limit', { limit }) : html_encode(e.message)}</p>`;
|
||||
message += `<p>${i18n('teams_seat_limit_upgrade')}</p>`;
|
||||
const answer = await dialog($el_window, {
|
||||
title: i18n('teams_add_account', [], false),
|
||||
tone: 'warning',
|
||||
message,
|
||||
buttons: canUpgrade
|
||||
? [
|
||||
{ label: i18n('cancel', [], false), value: 'no' },
|
||||
{ label: i18n('teams_see_plans', [], false), value: 'upgrade', type: 'primary' },
|
||||
]
|
||||
: [{ label: i18n('ok', [], false), value: 'no', type: 'primary' }],
|
||||
});
|
||||
if ( answer === 'upgrade' ) new window.UIUpgradeAccount().open_as_window();
|
||||
};
|
||||
|
||||
const showError = ($el_window, e) => {
|
||||
if ( e?.code === 'seat_limit_reached' ) return showSeatLimit($el_window, e);
|
||||
// A rule answering, not a fault.
|
||||
if ( e?.code === 'too_many_requests' ) return dialog($el_window, {
|
||||
title: i18n('teams_add_account', [], false),
|
||||
tone: 'warning',
|
||||
message: html_encode(e?.message ?? ''),
|
||||
});
|
||||
return dialog($el_window, {
|
||||
title: i18n('something_went_wrong', [], false),
|
||||
tone: 'error',
|
||||
message: html_encode(e?.message ?? i18n('error_unknown_cause', [], false)),
|
||||
});
|
||||
};
|
||||
|
||||
/** `label` names the action and titles the dialog; `kind` colours its button. */
|
||||
const confirm = async ($el_window, message, label, kind = 'danger') => {
|
||||
const answer = await dialog($el_window, {
|
||||
title: label,
|
||||
tone: kind === 'danger' ? 'danger' : undefined,
|
||||
message,
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: 'no' },
|
||||
{ label, value: 'yes', type: kind },
|
||||
],
|
||||
});
|
||||
return answer === 'yes';
|
||||
};
|
||||
|
||||
const renderCredential = ({ username, temporaryPassword }) => {
|
||||
let h = '<div class="teams-credential-head">';
|
||||
h += `<strong>${i18n('teams_credential_heading', { username })}</strong>`;
|
||||
h += `<button type="button" class="teams-credential-dismiss" aria-label="${html_encode(i18n('close', [], false))}">${ICONS.close}</button>`;
|
||||
h += '</div>';
|
||||
h += '<div class="teams-credential-row">';
|
||||
h += `<code class="teams-credential-value" tabindex="-1">${html_encode(temporaryPassword)}</code>`;
|
||||
h += `<button type="button" class="button button-small teams-credential-copy" data-value="${html_encode(temporaryPassword)}">${ICONS.copy}<span>${i18n('copy')}</span></button>`;
|
||||
h += '</div>';
|
||||
h += `<span class="teams-credential-note">${i18n('teams_credential_once')}</span>`;
|
||||
return h;
|
||||
};
|
||||
|
||||
/** Shown once and never retrievable, so it stays on screen until dismissed. */
|
||||
const showCredential = ($el_window, username, temporaryPassword) => {
|
||||
state.credential = { teamUid: state.selected?.uid, username, temporaryPassword };
|
||||
const $box = $el_window.find(`${SECTION} .teams-credential`);
|
||||
$box.html(renderCredential(state.credential)).show();
|
||||
$box.find('.teams-credential-value').trigger('focus');
|
||||
};
|
||||
|
||||
/** Confirms on the button itself; a toast would appear away from the value. */
|
||||
const copyCredential = async ($button) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText($button.attr('data-value') ?? '');
|
||||
} catch {
|
||||
// Selecting the value is the fallback: it is one keystroke from copied.
|
||||
const node = $button.closest('.teams-credential').find('.teams-credential-value')[0];
|
||||
if ( node ) window.getSelection()?.selectAllChildren(node);
|
||||
return;
|
||||
}
|
||||
$button.addClass('teams-copied').html(`${ICONS.check}<span>${i18n('teams_credential_copied')}</span>`);
|
||||
setTimeout(() => {
|
||||
$button.removeClass('teams-copied').html(`${ICONS.copy}<span>${i18n('copy')}</span>`);
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
const addAccount = async ($el_window) => {
|
||||
const username = $el_window.find(`${SECTION} .teams-new-username`).val().trim();
|
||||
if ( ! username ) return;
|
||||
// With an address the credential is emailed too; without it, only shown here.
|
||||
const email = $el_window.find(`${SECTION} .teams-new-email`).val().trim();
|
||||
|
||||
const $button = $el_window.find(`${SECTION} .teams-add-btn`);
|
||||
$button.prop('disabled', true).addClass('teams-busy');
|
||||
try {
|
||||
const created = await puter.teams.createMember(state.selected.uid, {
|
||||
username,
|
||||
...(email ? { email } : {}),
|
||||
});
|
||||
await refresh($el_window);
|
||||
showCredential($el_window, created.username, created.temporaryPassword);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
} finally {
|
||||
$el_window.find(`${SECTION} .teams-add-btn`).prop('disabled', false).removeClass('teams-busy');
|
||||
}
|
||||
};
|
||||
|
||||
const resetTwoFactor = async ($el_window, username) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
`<p>${i18n('teams_reset_2fa_confirm', { username })}</p>`,
|
||||
i18n('teams_reset_2fa_confirm_action', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
await puter.teams.resetTwoFactor(state.selected.uid, username);
|
||||
await refresh($el_window);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
}
|
||||
};
|
||||
|
||||
const reissueCredential = async ($el_window, username) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
`<p>${i18n('teams_confirm_reissue', { username })}</p>`,
|
||||
i18n('teams_reissue_credential', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
const result = await puter.teams.resendActivation(state.selected.uid, username);
|
||||
await refresh($el_window);
|
||||
showCredential($el_window, result.username, result.temporaryPassword);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
}
|
||||
};
|
||||
|
||||
/** Accounts with a suspend/re-enable in flight; re-enable has no confirm to absorb a double click. */
|
||||
const togglingMembers = new Set();
|
||||
|
||||
const setMemberEnabled = async ($el_window, username, enabled) => {
|
||||
if ( togglingMembers.has(username) ) return;
|
||||
togglingMembers.add(username);
|
||||
try {
|
||||
if ( ! enabled ) {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
`<p>${i18n('teams_confirm_disable', { username })}</p>`,
|
||||
i18n('teams_disable_account', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
}
|
||||
await (enabled
|
||||
? puter.teams.enableMember(state.selected.uid, username)
|
||||
: puter.teams.disableMember(state.selected.uid, username));
|
||||
await refresh($el_window);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
} finally {
|
||||
togglingMembers.delete(username);
|
||||
}
|
||||
};
|
||||
|
||||
const renameTeam = async ($el_window) => {
|
||||
const name = await dialog($el_window, {
|
||||
title: i18n('teams_rename', [], false),
|
||||
message: i18n('teams_rename_prompt'),
|
||||
input: { placeholder: i18n('teams_team_name', [], false), value: state.selected.name ?? '' },
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: false },
|
||||
{ label: i18n('teams_rename', [], false), value: 'ok', type: 'primary' },
|
||||
],
|
||||
});
|
||||
if ( name === false || name.trim() === '' ) return;
|
||||
try {
|
||||
await puter.teams.update(state.selected.uid, { name: name.trim() });
|
||||
await refresh($el_window);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMemberAccount = async ($el_window, username) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
i18n('teams_confirm_delete_account', { username }),
|
||||
i18n('teams_delete_account', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
await puter.teams.deleteMemberAccount(state.selected.uid, username);
|
||||
await refresh($el_window);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
}
|
||||
};
|
||||
|
||||
const setRequire2fa = async ($el_window, enabled) => {
|
||||
// Turning it on locks out every account without 2FA until they enrol, so
|
||||
// it is confirmed; turning it off only lifts a requirement.
|
||||
if ( enabled ) {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
i18n('teams_require_2fa_confirm'),
|
||||
i18n('teams_require_2fa_confirm_action', [], false),
|
||||
'primary',
|
||||
);
|
||||
if ( ! ok ) return paint($el_window);
|
||||
}
|
||||
try {
|
||||
await puter.teams.update(state.selected.uid, { require2fa: enabled });
|
||||
await refresh($el_window);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
await refresh($el_window);
|
||||
}
|
||||
};
|
||||
|
||||
const setDirectoryEnabled = async ($el_window, enabled) => {
|
||||
// Turning it on is a disclosure, so it is confirmed; turning it off only
|
||||
// takes something away and does not need to interrupt anyone.
|
||||
if ( enabled ) {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
i18n('teams_directory_confirm'),
|
||||
i18n('teams_directory_confirm_action', [], false),
|
||||
// Reversible, and the wording says so — red would overstate it.
|
||||
'primary',
|
||||
);
|
||||
// Repaint so the checkbox does not sit checked after a refusal.
|
||||
if ( ! ok ) return paint($el_window);
|
||||
}
|
||||
try {
|
||||
await puter.teams.update(state.selected.uid, { directoryEnabled: enabled });
|
||||
await refresh($el_window);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
await refresh($el_window);
|
||||
}
|
||||
};
|
||||
|
||||
const createTeam = async ($el_window) => {
|
||||
const name = await dialog($el_window, {
|
||||
title: i18n('teams_create_team', [], false),
|
||||
message: i18n('teams_create_team_prompt'),
|
||||
input: { placeholder: i18n('teams_team_name', [], false) },
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: false },
|
||||
{ label: i18n('teams_create_team', [], false), value: 'ok', type: 'primary' },
|
||||
],
|
||||
});
|
||||
if ( name === false || name.trim() === '' ) return;
|
||||
try {
|
||||
const created = await puter.teams.create({ name: name.trim() });
|
||||
state.selected = created;
|
||||
await refresh($el_window);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTeam = async ($el_window) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
`<p>${i18n('teams_confirm_delete_team', { team: teamName(state.selected) })}</p>`
|
||||
+ `<p>${i18n('teams_confirm_delete_billing')}</p>`,
|
||||
i18n('teams_delete_team', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
await puter.teams.delete(state.selected.uid);
|
||||
state.selected = null;
|
||||
await refresh($el_window);
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
}
|
||||
};
|
||||
|
||||
// -- Tab ------------------------------------------------------------------
|
||||
|
||||
const TabTeams = {
|
||||
id: 'teams',
|
||||
label: i18n('teams'),
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
|
||||
|
||||
html () {
|
||||
let h = '<div class="dashboard-tab-content">';
|
||||
h += '<div class="dashboard-section-header">';
|
||||
h += `<h2>${i18n('teams')}</h2>`;
|
||||
h += `<p>${i18n('teams_subtitle')}</p>`;
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-settings-grid teams-body"></div>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
},
|
||||
|
||||
init ($el_window) {
|
||||
// Hidden until the first load says teams exist here, so a
|
||||
// deployment with them off never shows an entry that cannot work.
|
||||
setTabVisible($el_window, false);
|
||||
|
||||
// Delegated, because every action redraws the panel underneath them.
|
||||
$el_window.on('click', `${SECTION} .teams-audit-prev`, () => {
|
||||
state.auditPage = Math.max(0, state.auditPage - 1);
|
||||
paint($el_window);
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-audit-next`, () => {
|
||||
state.auditPage += 1;
|
||||
paint($el_window);
|
||||
});
|
||||
// Submit rather than click, so Enter in either field adds the account too.
|
||||
$el_window.on('submit', `${SECTION} .teams-form`, (e) => {
|
||||
e.preventDefault();
|
||||
addAccount($el_window);
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-credential-copy`, function () {
|
||||
copyCredential($(this));
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-credential-dismiss`, () => {
|
||||
state.credential = null;
|
||||
$el_window.find(`${SECTION} .teams-credential`).hide().empty();
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-retry`, () => {
|
||||
state.status = 'loading';
|
||||
paint($el_window);
|
||||
refresh($el_window);
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-create`, () => createTeam($el_window));
|
||||
$el_window.on('click', `${SECTION} .teams-rename`, () => renameTeam($el_window));
|
||||
$el_window.on('click', `${SECTION} .teams-delete`, () => deleteTeam($el_window));
|
||||
// Checkout is the billing extension's job; this only says what was asked for.
|
||||
$el_window.on('click', `${SECTION} .teams-plan-change`, function () {
|
||||
changeSeatPlan($el_window, $(this).attr('data-username'), $(this).attr('data-uuid'));
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-plan-buy`, function () {
|
||||
if ( ! state.selected ) return;
|
||||
window.dispatchEvent(new CustomEvent('team-plan-purchase', {
|
||||
detail: {
|
||||
teamUid: state.selected.uid,
|
||||
itemId: $(this).attr('data-item-id'),
|
||||
onDone: () => refresh($el_window),
|
||||
},
|
||||
}));
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-reset`, function () {
|
||||
reissueCredential($el_window, $(this).attr('data-username'));
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-disable`, function () {
|
||||
setMemberEnabled($el_window, $(this).attr('data-username'), false);
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-enable`, function () {
|
||||
setMemberEnabled($el_window, $(this).attr('data-username'), true);
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-delete-account`, function () {
|
||||
deleteMemberAccount($el_window, $(this).attr('data-username'));
|
||||
});
|
||||
$el_window.on('change', `${SECTION} .teams-directory-check`, function () {
|
||||
setDirectoryEnabled($el_window, $(this).is(':checked'));
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-reset-2fa`, function () {
|
||||
resetTwoFactor($el_window, $(this).attr('data-username'));
|
||||
});
|
||||
$el_window.on('change', `${SECTION} .teams-require-2fa-check`, function () {
|
||||
setRequire2fa($el_window, $(this).is(':checked'));
|
||||
});
|
||||
$el_window.on('change', `${SECTION} .teams-picker-select`, async function () {
|
||||
state.selected = state.teams.find(t => t.uid === $(this).val()) ?? state.selected;
|
||||
await refresh($el_window);
|
||||
});
|
||||
|
||||
refresh($el_window);
|
||||
},
|
||||
|
||||
onActivate ($el_window) {
|
||||
// A load already running (the one `init` started) is fresh enough.
|
||||
if ( ! loadPromise ) refresh($el_window);
|
||||
},
|
||||
};
|
||||
|
||||
export default TabTeams;
|
||||
@@ -1,290 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key, replacements) => {
|
||||
if ( replacements && typeof replacements === 'object' && ! Array.isArray(replacements) ) {
|
||||
return `${key}(${Object.entries(replacements).map(([k, v]) => `${k}=${v}`).join(',')})`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
|
||||
const { default: TabTeams } = await import('./TabTeams.js');
|
||||
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const team = { uid: 't1', name: 'Acme Design', handle: 'acme', isOwner: true, directoryEnabled: false };
|
||||
const members = [
|
||||
{ username: 'dana', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-dana' },
|
||||
{ username: 'boss', orgOwned: false, createdAt: '2026-09-01T00:00:00Z' },
|
||||
{ username: 'elliot', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-elliot' },
|
||||
];
|
||||
// Newest first, as the API sends it; unix seconds, as the audit routes send it.
|
||||
const audit = [
|
||||
{ action: 'disable', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789672096 },
|
||||
{ action: 'provision', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789600000 },
|
||||
{ action: 'provision', username: 'dana', reason: null, actorUsername: 'boss', createdAt: 1789600000 },
|
||||
];
|
||||
|
||||
const mount = async ({ teams = [team], plan = null } = {}) => {
|
||||
window.teams_ui = true;
|
||||
window.team_billing_ui = false;
|
||||
window.api_origin = 'http://api.test';
|
||||
window.user = { username: 'boss' };
|
||||
globalThis.puter = {
|
||||
authToken: 'tok',
|
||||
teams: {
|
||||
list: vi.fn(async () => teams),
|
||||
listAudit: vi.fn(async () => audit),
|
||||
listOwnAudit: vi.fn(async () => audit.filter(e => e.username === 'boss')),
|
||||
listMembers: vi.fn(async () => members),
|
||||
},
|
||||
};
|
||||
globalThis.fetch = vi.fn(async () => ({ ok: false }));
|
||||
if ( plan ) globalThis.fetch = plan;
|
||||
|
||||
document.body.innerHTML = `<div class="dashboard" data-element_uuid="w1">
|
||||
<a class="dashboard-sidebar-item" data-section="teams"></a>
|
||||
<div class="dashboard-section dashboard-section-teams">${TabTeams.html()}</div>
|
||||
</div>`;
|
||||
const $el_window = $('.dashboard');
|
||||
TabTeams.init($el_window);
|
||||
await flush();
|
||||
await flush();
|
||||
return $el_window;
|
||||
};
|
||||
|
||||
describe('the owner view', () => {
|
||||
let $w;
|
||||
beforeEach(async () => { $w = await mount(); });
|
||||
|
||||
it('names the team, its role and headcount in the hero', () => {
|
||||
const hero = $w.find('.teams-hero');
|
||||
expect(hero.find('strong').text()).toBe('Acme Design');
|
||||
expect(hero.find('.teams-role').text()).toBe('teams_role_owner');
|
||||
expect(hero.find('.teams-info-meta').text()).toContain('@acme');
|
||||
expect(hero.find('.teams-info-meta').text()).toContain('teams_member_count(count=3)');
|
||||
expect(hero.find('.teams-rename')).toHaveLength(1);
|
||||
// One team: no picker to choose it with.
|
||||
expect(hero.find('.teams-picker-select')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the directory as a switch that still carries the wired class', () => {
|
||||
const input = $w.find('.dashboard-switch input.teams-directory-check');
|
||||
expect(input).toHaveLength(1);
|
||||
expect(input.is(':checked')).toBe(false);
|
||||
expect($w.find('.teams-directory .teams-directory-note').text()).toBe('teams_directory_off_note');
|
||||
});
|
||||
|
||||
it('lists accounts with a status pill and folds the audit state in', () => {
|
||||
const rows = $w.find('.teams-members-table tbody tr');
|
||||
expect(rows).toHaveLength(3);
|
||||
// Suspended last, provisioned before joined.
|
||||
expect(rows.map((_, r) => $(r).attr('data-username')).get()).toEqual(['dana', 'boss', 'elliot']);
|
||||
expect(rows.eq(2).hasClass('teams-member-disabled')).toBe(true);
|
||||
expect(rows.eq(2).find('.teams-status').hasClass('teams-status-disabled')).toBe(true);
|
||||
expect(rows.eq(0).find('.teams-status').hasClass('teams-status-active')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the signed-in account and gives it no actions', () => {
|
||||
const you = $w.find('.teams-member-row[data-username="boss"]');
|
||||
expect(you.find('.teams-you')).toHaveLength(1);
|
||||
expect(you.find('.teams-member-actions')).toHaveLength(0);
|
||||
expect($w.find('.teams-member-row[data-username="dana"] .teams-you')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('offers restore and delete only for a suspended account', () => {
|
||||
const dana = $w.find('.teams-member-row[data-username="dana"]');
|
||||
const elliot = $w.find('.teams-member-row[data-username="elliot"]');
|
||||
expect(dana.find('.teams-disable')).toHaveLength(1);
|
||||
expect(dana.find('.teams-delete-account')).toHaveLength(0);
|
||||
expect(elliot.find('.teams-enable')).toHaveLength(1);
|
||||
expect(elliot.find('.teams-delete-account')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('labels every data cell for the phone layout, which drops the header row', () => {
|
||||
const cells = $w.find('.teams-members-table tbody tr').first().find('td[data-label]');
|
||||
expect(cells.map((_, c) => $(c).attr('data-label')).get()).toEqual([
|
||||
'teams_member_state', 'teams_member_since', 'teams_member_plan',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the actions in a block inside the cell, not on the cell itself', () => {
|
||||
const cell = $w.find('.teams-member-row[data-username="dana"] td.teams-cell-actions');
|
||||
expect(cell.children('.teams-member-actions')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prints audit seconds as a real date, not 1970', () => {
|
||||
const when = $w.find('.teams-audit-table tbody tr').first().find('.teams-cell-when').text();
|
||||
expect(when).toContain('2026');
|
||||
expect(when).not.toContain('1970');
|
||||
});
|
||||
|
||||
it('skips the suspended clause of the billing note when there is nothing suspended', async () => {
|
||||
expect($w.find('.teams-panel-hint').filter((_, el) => $(el).text().startsWith('teams_billing_summary')).text())
|
||||
.toBe('teams_billing_summary_one(billed=1,disabled=1)');
|
||||
});
|
||||
|
||||
it('adds an account on form submit, so Enter works, and shows the credential with a copy button', async () => {
|
||||
globalThis.puter.teams.createMember = vi.fn(async () => ({ username: 'gil', temporaryPassword: 'p4ss' }));
|
||||
$w.find('.teams-new-username').val('gil');
|
||||
$w.find('.teams-form').trigger('submit');
|
||||
await flush();
|
||||
await flush();
|
||||
await flush();
|
||||
expect(globalThis.puter.teams.createMember).toHaveBeenCalledWith('t1', { username: 'gil' });
|
||||
const box = $w.find('.teams-credential');
|
||||
expect(box.is(':visible') || box.css('display') !== 'none').toBe(true);
|
||||
expect(box.find('.teams-credential-value').text()).toBe('p4ss');
|
||||
expect(box.find('.teams-credential-copy').attr('data-value')).toBe('p4ss');
|
||||
box.find('.teams-credential-dismiss').trigger('click');
|
||||
expect(box.children()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the member view', () => {
|
||||
it('shows the roster and own record, and nothing an administrator would use', async () => {
|
||||
const $w = await mount({ teams: [{ ...team, isOwner: false }] });
|
||||
expect($w.find('.teams-role').text()).toBe('teams_role_member');
|
||||
expect($w.find('.teams-rename')).toHaveLength(0);
|
||||
expect($w.find('.teams-roster .teams-identity')).toHaveLength(3);
|
||||
expect($w.find('.teams-directory-check')).toHaveLength(0);
|
||||
expect($w.find('.teams-form')).toHaveLength(0);
|
||||
expect($w.find('.dashboard-danger-zone')).toHaveLength(0);
|
||||
expect($w.find('.teams-panel h3').first().text()).toContain('teams_roster');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a new account\'s one-time password', () => {
|
||||
it('stays on screen through a refresh until dismissed', async () => {
|
||||
const $w = await mount();
|
||||
globalThis.puter.teams.createMember = vi.fn(async () => ({ username: 'newbie', temporaryPassword: 'pw-123' }));
|
||||
$w.find('.teams-new-username').val('newbie');
|
||||
$w.find('.teams-form').trigger('submit');
|
||||
await flush();
|
||||
await flush();
|
||||
expect($w.find('.teams-credential-value').text()).toBe('pw-123');
|
||||
|
||||
// Coming back to the tab reloads and repaints it.
|
||||
await TabTeams.onActivate($w);
|
||||
await flush();
|
||||
expect($w.find('.teams-credential-value').text()).toBe('pw-123');
|
||||
expect($w.find('.teams-credential').css('display')).not.toBe('none');
|
||||
|
||||
$w.find('.teams-credential-dismiss').trigger('click');
|
||||
await TabTeams.onActivate($w);
|
||||
await flush();
|
||||
expect($w.find('.teams-credential-value')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a change made while the tab is loading', () => {
|
||||
it('is shown once that load finishes, not overwritten by it', async () => {
|
||||
const on = { ...team, directoryEnabled: true };
|
||||
const $w = await mount({ teams: [on] });
|
||||
// A revisit starts a slow load that read the directory as still on.
|
||||
let release;
|
||||
globalThis.puter.teams.list = vi.fn(() => new Promise(resolve => { release = () => resolve([on]); }));
|
||||
TabTeams.onActivate($w);
|
||||
// Meanwhile the owner turns it off.
|
||||
globalThis.puter.teams.update = vi.fn(async () => ({}));
|
||||
$w.find('.teams-directory-check').prop('checked', false).trigger('change');
|
||||
await flush();
|
||||
const listAfterChange = vi.fn(async () => [{ ...team, directoryEnabled: false }]);
|
||||
const stale = release;
|
||||
globalThis.puter.teams.list = listAfterChange;
|
||||
stale();
|
||||
for ( let i = 0; i < 8; i++ ) await flush();
|
||||
expect(listAfterChange).toHaveBeenCalled();
|
||||
expect($w.find('.teams-directory-check').is(':checked')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-enabling an account', () => {
|
||||
it('sends one request for a double click', async () => {
|
||||
const $w = await mount();
|
||||
globalThis.puter.teams.enableMember = vi.fn(async () => ({}));
|
||||
const $btn = $w.find('.teams-enable[data-username="elliot"]');
|
||||
expect($btn).toHaveLength(1);
|
||||
$btn.trigger('click');
|
||||
$btn.trigger('click');
|
||||
await flush();
|
||||
await flush();
|
||||
expect(globalThis.puter.teams.enableMember).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading a team', () => {
|
||||
it('reads the record and the roster at the same time', async () => {
|
||||
const $w = await mount();
|
||||
let releaseAudit;
|
||||
globalThis.puter.teams.listAudit = vi.fn(() => new Promise(resolve => { releaseAudit = () => resolve(audit); }));
|
||||
globalThis.puter.teams.listMembers = vi.fn(async () => members);
|
||||
TabTeams.onActivate($w);
|
||||
await flush();
|
||||
const rosterAskedWhileRecordLoads = globalThis.puter.teams.listMembers.mock.calls.length > 0;
|
||||
releaseAudit();
|
||||
await flush();
|
||||
await flush();
|
||||
expect(rosterAskedWhileRecordLoads).toBe(true);
|
||||
expect($w.find('.teams-hero')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard focus across a repaint', () => {
|
||||
it('stays on the control that was used', async () => {
|
||||
const on = { ...team, directoryEnabled: true };
|
||||
const $w = await mount({ teams: [on] });
|
||||
globalThis.puter.teams.update = vi.fn(async () => ({}));
|
||||
globalThis.puter.teams.list = vi.fn(async () => [{ ...team, directoryEnabled: false }]);
|
||||
$w.find('.teams-directory-check')[0].focus();
|
||||
$w.find('.teams-directory-check').prop('checked', false).trigger('change');
|
||||
for ( let i = 0; i < 6; i++ ) await flush();
|
||||
expect(document.activeElement.classList.contains('teams-directory-check')).toBe(true);
|
||||
expect(document.activeElement.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('returns to the same account\'s action', async () => {
|
||||
const $w = await mount();
|
||||
globalThis.puter.teams.enableMember = vi.fn(async () => ({}));
|
||||
const $btn = $w.find('.teams-enable[data-username="elliot"]');
|
||||
$btn[0].focus();
|
||||
$btn.trigger('click');
|
||||
for ( let i = 0; i < 6; i++ ) await flush();
|
||||
expect(document.activeElement.getAttribute('data-username')).toBe('elliot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('states', () => {
|
||||
it('shows a picker when there is more than one team', async () => {
|
||||
const $w = await mount({ teams: [team, { ...team, uid: 't2', name: 'Other' }] });
|
||||
expect($w.find('.teams-hero .teams-picker-select option')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('offers to create a team when there is none', async () => {
|
||||
const $w = await mount({ teams: [] });
|
||||
expect($w.find('.teams-state .teams-create')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('offers a retry when the load fails', async () => {
|
||||
window.teams_ui = true;
|
||||
const $w = await mount();
|
||||
globalThis.puter.teams.list = vi.fn(async () => { throw new Error('boom'); });
|
||||
await TabTeams.onActivate($w);
|
||||
await flush();
|
||||
expect($w.find('.teams-state .teams-retry')).toHaveLength(1);
|
||||
globalThis.puter.teams.list = vi.fn(async () => [team]);
|
||||
$w.find('.teams-retry').trigger('click');
|
||||
expect($w.find('.teams-skeleton')).toHaveLength(1);
|
||||
await flush();
|
||||
await flush();
|
||||
expect($w.find('.teams-hero')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The tab's dialogs mount into the window; only the tab-object contract is under test here.
|
||||
vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
|
||||
const { default: TabTeams } = await import('./TabTeams.js');
|
||||
|
||||
/**
|
||||
* UIDashboard reads exactly these keys off a tab, wraps `html()` in its own
|
||||
* `.dashboard-section-<id>` div, and calls `html()` synchronously before the
|
||||
* window exists.
|
||||
*/
|
||||
describe('the TabTeams tab object', () => {
|
||||
it('carries the id UIDashboard turns into the hash and the section class', () => {
|
||||
expect(TabTeams.id).toBe('teams');
|
||||
});
|
||||
|
||||
it('resolves its label at import time, before any render', () => {
|
||||
expect(TabTeams.label).toBe('teams');
|
||||
});
|
||||
|
||||
it('exposes an inline svg icon, which is injected unescaped', () => {
|
||||
expect(TabTeams.icon.startsWith('<svg')).toBe(true);
|
||||
});
|
||||
|
||||
it('has the render and wiring hooks UIDashboard calls', () => {
|
||||
expect(typeof TabTeams.html).toBe('function');
|
||||
expect(typeof TabTeams.init).toBe('function');
|
||||
expect(typeof TabTeams.onActivate).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabTeams.html()', () => {
|
||||
it('returns a string synchronously, since it runs before the window is built', () => {
|
||||
expect(typeof TabTeams.html()).toBe('string');
|
||||
});
|
||||
|
||||
it('renders the section header and the container the tab paints into', () => {
|
||||
const h = TabTeams.html();
|
||||
expect(h).toContain('dashboard-section-header');
|
||||
expect(h).toContain('teams-body');
|
||||
});
|
||||
|
||||
it('does not emit its own section wrapper, which UIDashboard already adds', () => {
|
||||
expect(TabTeams.html()).not.toContain('dashboard-section-teams');
|
||||
});
|
||||
|
||||
it('paints nothing until the first load, so a deployment without teams shows no content', () => {
|
||||
expect(TabTeams.html()).toContain('<div class="dashboard-settings-grid teams-body"></div>');
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,6 @@ import TabApps from './TabApps.js';
|
||||
import TabUsage from './TabUsage.js';
|
||||
import TabAccount from './TabAccount.js';
|
||||
import TabSecurity from './TabSecurity.js';
|
||||
import TabTeams from './TabTeams.js';
|
||||
import teamBadgeHtml from './teamBadge.js';
|
||||
|
||||
// Registry of built-in tabs
|
||||
@@ -60,7 +59,6 @@ const builtinTabs = [
|
||||
TabHome,
|
||||
TabApps,
|
||||
TabFiles,
|
||||
TabTeams,
|
||||
'-',
|
||||
TabUsage,
|
||||
TabAccount,
|
||||
|
||||
@@ -1,56 +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/>.
|
||||
*/
|
||||
|
||||
// A row carries up to five actions, and five labelled buttons do not fit.
|
||||
// Inline like the sidebar chevron: one-place glyphs, not shared assets.
|
||||
|
||||
const SVG = (body) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||
|
||||
export const TEAM_ACTION_ICONS = {
|
||||
/** Change plan — a card. */
|
||||
plan: SVG('<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>'),
|
||||
/** Reissue credential — a key. */
|
||||
credential: SVG('<circle cx="8" cy="12" r="4"/><path d="M12 12h9"/><path d="M17 12v4"/><path d="M20 12v3"/>'),
|
||||
/** Suspend — pause. */
|
||||
suspend: SVG('<rect x="7" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>'),
|
||||
/** Re-enable — play. */
|
||||
enable: SVG('<path d="M7 5l12 7-12 7z"/>'),
|
||||
/** Delete the account — a bin. */
|
||||
remove: SVG('<path d="M4 7h16"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/>'),
|
||||
/** Edit the account — a pencil. */
|
||||
edit: SVG('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
|
||||
};
|
||||
|
||||
/** The label stays as tooltip and screen-reader text, so nothing is lost. */
|
||||
export const teamActionButton = ({ className, icon, label, danger, attrs }) => {
|
||||
const safeLabel = window.html_encode(label);
|
||||
const extra = Object.entries(attrs ?? {})
|
||||
.map(([k, v]) => ` ${k}="${window.html_encode(v ?? '')}"`)
|
||||
.join('');
|
||||
return (
|
||||
`<button class="button button-small button-icon ${className}` +
|
||||
`${danger ? ' button-danger' : ''}" title="${safeLabel}"` +
|
||||
` aria-label="${safeLabel}"${extra}>` +
|
||||
`${TEAM_ACTION_ICONS[icon] ?? ''}` +
|
||||
`<span class="sr-only">${safeLabel}</span></button>`
|
||||
);
|
||||
};
|
||||
|
||||
export default teamActionButton;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
globalThis.window = { html_encode: (v) => String(v).replace(/[<"]/g, (c) => (c === '<' ? '<' : '"')) };
|
||||
|
||||
const { teamActionButton, TEAM_ACTION_ICONS } = await import('./teamActionIcons.js');
|
||||
|
||||
describe('an icon action button', () => {
|
||||
it('keeps the label reachable without showing it', () => {
|
||||
// Dropping the visible text must not drop it for a screen reader.
|
||||
const h = teamActionButton({
|
||||
className: 'teams-reset',
|
||||
icon: 'credential',
|
||||
label: 'Reissue password',
|
||||
});
|
||||
expect(h).toContain('title="Reissue password"');
|
||||
expect(h).toContain('aria-label="Reissue password"');
|
||||
expect(h).toContain('<span class="sr-only">Reissue password</span>');
|
||||
});
|
||||
|
||||
it('carries the class the click handler binds to', () => {
|
||||
const h = teamActionButton({ className: 'teams-disable', icon: 'suspend', label: 'x' });
|
||||
expect(h).toContain('teams-disable');
|
||||
});
|
||||
|
||||
it('passes through the data attributes an action needs', () => {
|
||||
const h = teamActionButton({
|
||||
className: 'teams-plan-change',
|
||||
icon: 'plan',
|
||||
label: 'Change plan',
|
||||
attrs: { 'data-username': 'ana', 'data-uuid': 'u-1' },
|
||||
});
|
||||
expect(h).toContain('data-username="ana"');
|
||||
expect(h).toContain('data-uuid="u-1"');
|
||||
});
|
||||
|
||||
it('marks only the destructive ones', () => {
|
||||
expect(teamActionButton({ className: 'a', icon: 'remove', label: 'x', danger: true }))
|
||||
.toContain('button-danger');
|
||||
expect(teamActionButton({ className: 'a', icon: 'plan', label: 'x' }))
|
||||
.not.toContain('button-danger');
|
||||
});
|
||||
|
||||
it('encodes a label and an attribute value', () => {
|
||||
const h = teamActionButton({
|
||||
className: 'a',
|
||||
icon: 'plan',
|
||||
label: '<script>',
|
||||
attrs: { 'data-username': '"quoted' },
|
||||
});
|
||||
expect(h).not.toContain('<script>');
|
||||
expect(h).not.toContain('data-username=""quoted"');
|
||||
});
|
||||
|
||||
it('renders an empty glyph rather than breaking on an unknown icon', () => {
|
||||
const h = teamActionButton({ className: 'a', icon: 'nope', label: 'x' });
|
||||
expect(h).toContain('<button');
|
||||
expect(h).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('hides every glyph from the accessibility tree', () => {
|
||||
// The label is on the button; the svg would be noise.
|
||||
for (const svg of Object.values(TEAM_ACTION_ICONS)) {
|
||||
expect(svg).toContain('aria-hidden="true"');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,274 +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/>.
|
||||
*/
|
||||
|
||||
// Pure logic behind the team admin console. No DOM, no network.
|
||||
|
||||
/**
|
||||
* Whether the team may permanently delete this account. The API refuses a live
|
||||
* one, so offering the button before it is disabled only ever produces an error.
|
||||
*
|
||||
* @param {{ orgOwned?: boolean, disabled?: boolean }} member
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canDeleteAccount (member) {
|
||||
return member?.orgOwned === true && member?.disabled === true;
|
||||
}
|
||||
|
||||
/** i18n keys for the actions a team's audit log records. */
|
||||
const AUDIT_ACTION_KEYS = {
|
||||
provision: 'teams_audit_provision',
|
||||
disable: 'teams_audit_disable',
|
||||
enable: 'teams_audit_enable',
|
||||
delete_team: 'teams_audit_delete_team',
|
||||
reset_member_password: 'teams_audit_reset_member_password',
|
||||
activate: 'teams_audit_activate',
|
||||
delete_account: 'teams_audit_delete_account',
|
||||
directory_enabled: 'teams_audit_directory_enabled',
|
||||
directory_disabled: 'teams_audit_directory_disabled',
|
||||
require_2fa_enabled: 'teams_audit_require_2fa_enabled',
|
||||
require_2fa_disabled: 'teams_audit_require_2fa_disabled',
|
||||
reset_member_2fa: 'teams_audit_reset_member_2fa',
|
||||
};
|
||||
|
||||
/** i18n keys for the reasons the team attaches to an action. */
|
||||
const AUDIT_REASON_KEYS = {
|
||||
team_deleted: 'teams_audit_reason_team_deleted',
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether each account is currently suspended, read off the audit log.
|
||||
*
|
||||
* The member listing carries no suspension flag, but every suspension and
|
||||
* restoration is recorded, so the newest `disable`/`enable` per account is the
|
||||
* current state. Entries arrive newest first, so the first match wins.
|
||||
*
|
||||
* @param {Array<{ action: string, username: string | null, reason: string | null, createdAt: string }>} entries
|
||||
* @returns {Record<string, { disabled: boolean, reason: string | null, at: string }>}
|
||||
*/
|
||||
export function memberStatesFromAudit (entries) {
|
||||
// No prototype, so usernames like `constructor` aren't already "in" it.
|
||||
/** @type {Record<string, { disabled: boolean, reason: string | null, at: string }>} */
|
||||
const states = Object.create(null);
|
||||
for ( const entry of entries ?? [] ) {
|
||||
const username = entry?.username;
|
||||
if ( ! username || username in states ) continue;
|
||||
if ( entry.action !== 'disable' && entry.action !== 'enable' ) continue;
|
||||
states[username] = {
|
||||
disabled: entry.action === 'disable',
|
||||
reason: entry.reason ?? null,
|
||||
at: entry.createdAt,
|
||||
};
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* The member listing with each account's suspension state folded in.
|
||||
*
|
||||
* Only accounts the team provisioned can be suspended by it, so an
|
||||
* account that merely joined is always reported active.
|
||||
*
|
||||
* @param {Array<{ username: string, orgOwned: boolean, createdAt: string }>} members
|
||||
* @param {Array<{ action: string, username: string | null, reason: string | null, createdAt: string }>} auditEntries
|
||||
* @returns {Array<{ username: string, orgOwned: boolean, createdAt: string, disabled: boolean, disabledReason: string | null }>}
|
||||
*/
|
||||
export function annotateMembers (members, auditEntries) {
|
||||
const states = memberStatesFromAudit(auditEntries);
|
||||
return (members ?? []).map(member => {
|
||||
const state = member.orgOwned ? states[member.username] : undefined;
|
||||
return {
|
||||
...member,
|
||||
disabled: state?.disabled === true,
|
||||
disabledReason: state?.disabled === true ? (state.reason ?? null) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What the team is currently being charged for. Suspended accounts are
|
||||
* counted separately because they stop costing a per-account charge and keep
|
||||
* costing for the bytes they hold — an administrator deciding what to clean up
|
||||
* needs the two apart.
|
||||
*
|
||||
* @param {Array<{ orgOwned: boolean, disabled: boolean }>} annotated
|
||||
* @returns {{ total: number, billed: number, disabled: number, joined: number }}
|
||||
*/
|
||||
export function membersBillingSummary (annotated) {
|
||||
const summary = { total: 0, billed: 0, disabled: 0, joined: 0 };
|
||||
for ( const member of annotated ?? [] ) {
|
||||
summary.total++;
|
||||
if ( ! member.orgOwned ) summary.joined++;
|
||||
else if ( member.disabled ) summary.disabled++;
|
||||
else summary.billed++;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* What plan a row in the accounts table is on.
|
||||
*
|
||||
* `payer` is the owner, who keeps their personal plan. A suspended seat is
|
||||
* `not_billed` -- it stops costing a per-account charge. Everyone else follows
|
||||
* the team: its tier if it bought one, otherwise the reduced free allowance.
|
||||
*
|
||||
* @param {{ orgOwned: boolean, disabled: boolean }} member
|
||||
* @param {{ current: { tier: string, name_en?: string } | null } | null} plan
|
||||
* @returns {{ kind: 'payer'|'not_billed'|'free'|'tier', name?: string }}
|
||||
*/
|
||||
export function memberPlanLabel (member, plan) {
|
||||
if ( ! member?.orgOwned ) return { kind: 'payer' };
|
||||
if ( member.disabled ) return { kind: 'not_billed' };
|
||||
// Per seat: a team can buy for some accounts and not others.
|
||||
const tier = plan?.seatTiers?.[member.uuid];
|
||||
if ( ! tier ) return { kind: 'free' };
|
||||
const offering = (plan.offerings ?? []).find(o => o.tier === tier);
|
||||
return { kind: 'tier', name: offering?.name_en || tier };
|
||||
}
|
||||
|
||||
/**
|
||||
* The i18n key for an audit action, or `null` for one this build does not know
|
||||
* about — a new backend action must show as itself rather than as nothing.
|
||||
*
|
||||
* @param {string} action
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function auditActionKey (action) {
|
||||
return AUDIT_ACTION_KEYS[action] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The i18n key for a recorded reason, or `null` when there is none to show.
|
||||
*
|
||||
* @param {string | null} reason
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function auditReasonKey (reason) {
|
||||
return reason ? (AUDIT_REASON_KEYS[reason] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Members ordered for display: suspended accounts last, then provisioned
|
||||
* before joined, then by username. Suspended accounts stay on the list rather
|
||||
* than being hidden, because they are still on the bill.
|
||||
*
|
||||
* @template {{ username: string, orgOwned: boolean, disabled: boolean }} T
|
||||
* @param {T[]} annotated
|
||||
* @returns {T[]}
|
||||
*/
|
||||
export function sortMembers (annotated) {
|
||||
return [...(annotated ?? [])].sort((a, b) => {
|
||||
if ( a.disabled !== b.disabled ) return a.disabled ? 1 : -1;
|
||||
if ( a.orgOwned !== b.orgOwned ) return a.orgOwned ? -1 : 1;
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the record, with the numbers the pager prints. Clamps the page:
|
||||
* deleting an account shortens the record, and a stale number would otherwise
|
||||
* show an empty table with no way back.
|
||||
*
|
||||
* @template T
|
||||
* @param {T[]} entries
|
||||
* @param {number} page - Zero-based.
|
||||
* @param {number} size
|
||||
* @returns {{ items: T[], page: number, pages: number, from: number, to: number, total: number }}
|
||||
*/
|
||||
export function auditSlice (entries, page, size) {
|
||||
const all = entries ?? [];
|
||||
const total = all.length;
|
||||
const perPage = size > 0 ? size : 1;
|
||||
const pages = Math.max(1, Math.ceil(total / perPage));
|
||||
const current = Math.min(Math.max(0, Math.trunc(page) || 0), pages - 1);
|
||||
const start = current * perPage;
|
||||
const items = all.slice(start, start + perPage);
|
||||
return {
|
||||
items,
|
||||
page: current,
|
||||
pages,
|
||||
from: total === 0 ? 0 : start + 1,
|
||||
to: start + items.length,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamp off the wire as a Date, or `null` when it is not one. Team
|
||||
* audit rows carry unix seconds; members and teams carry an ISO string, so
|
||||
* one reader covers both rather than each column guessing.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {Date | null}
|
||||
*/
|
||||
export function parseTimestamp (value) {
|
||||
if ( value === null || value === undefined || value === '' ) return null;
|
||||
let date;
|
||||
if ( typeof value === 'number' ) {
|
||||
// Anything below 1e12 cannot be milliseconds for a date after 2001.
|
||||
date = new Date(value < 1e12 ? value * 1000 : value);
|
||||
} else if ( typeof value === 'string' && /^\d+$/.test(value) ) {
|
||||
return parseTimestamp(Number(value));
|
||||
} else {
|
||||
date = new Date(/** @type {string} */ (value));
|
||||
}
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The letter an avatar tile shows for a name. Uppercased so a lowercase
|
||||
* username and a display name read the same in a row of tiles.
|
||||
*
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function initialOf (name) {
|
||||
const text = typeof name === 'string' ? name.trim() : '';
|
||||
if ( ! text ) return '?';
|
||||
// A leading handle sigil or bracket says nothing about the name.
|
||||
const letter = text.replace(/^[^\p{L}\p{N}]+/u, '') || text;
|
||||
return [...letter][0].toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which billing sentence fits the summary. The count of suspended accounts
|
||||
* only earns a mention when there are any -- "0 suspended cost nothing" is
|
||||
* noise on a healthy team.
|
||||
*
|
||||
* @param {{ billed: number, disabled: number }} summary
|
||||
* @returns {'teams_billing_summary_one'|'teams_billing_summary'|'teams_billing_summary_one_none'|'teams_billing_summary_none'}
|
||||
*/
|
||||
export function billingSummaryKey (summary) {
|
||||
const one = summary?.billed === 1;
|
||||
if ( (summary?.disabled ?? 0) > 0 ) return one ? 'teams_billing_summary_one' : 'teams_billing_summary';
|
||||
return one ? 'teams_billing_summary_one_none' : 'teams_billing_summary_none';
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable hue for a name's avatar tile, so the same account gets the same
|
||||
* colour in every row and the tiles are told apart at a glance.
|
||||
*
|
||||
* @param {unknown} name
|
||||
* @returns {number} degrees, 0-359
|
||||
*/
|
||||
export function avatarHue (name) {
|
||||
const text = typeof name === 'string' ? name : '';
|
||||
let hash = 0;
|
||||
for ( const ch of text ) hash = (hash * 31 + ch.codePointAt(0)) % 360;
|
||||
return hash;
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
annotateMembers,
|
||||
auditActionKey,
|
||||
auditSlice,
|
||||
avatarHue,
|
||||
billingSummaryKey,
|
||||
canDeleteAccount,
|
||||
auditReasonKey,
|
||||
initialOf,
|
||||
memberStatesFromAudit,
|
||||
parseTimestamp,
|
||||
membersBillingSummary,
|
||||
memberPlanLabel,
|
||||
sortMembers,
|
||||
} from './teamsConsole.js';
|
||||
|
||||
/** The audit log arrives newest first, which is what these fixtures encode. */
|
||||
const entry = (action, username, createdAt, reason = null) =>
|
||||
({ action, username, reason, createdAt, actorUsername: 'boss' });
|
||||
|
||||
const member = (username, orgOwned = true) =>
|
||||
({ username, orgOwned, createdAt: '2026-01-01T00:00:00Z' });
|
||||
|
||||
describe('memberStatesFromAudit', () => {
|
||||
it('takes the newest disable/enable per account', () => {
|
||||
const states = memberStatesFromAudit([
|
||||
entry('enable', 'ann', '2026-03-01T00:00:00Z'),
|
||||
entry('disable', 'ann', '2026-02-01T00:00:00Z'),
|
||||
entry('disable', 'bob', '2026-02-01T00:00:00Z'),
|
||||
entry('provision', 'bob', '2026-01-01T00:00:00Z'),
|
||||
]);
|
||||
expect(states.ann.disabled).toBe(false);
|
||||
expect(states.bob.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('carries the recorded reason and time of the suspension', () => {
|
||||
const states = memberStatesFromAudit([
|
||||
entry('disable', 'ann', '2026-02-01T00:00:00Z', 'team_deleted'),
|
||||
]);
|
||||
expect(states.ann).toEqual({
|
||||
disabled: true,
|
||||
reason: 'team_deleted',
|
||||
at: '2026-02-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores actions that do not move an account, and entries with no account', () => {
|
||||
const states = memberStatesFromAudit([
|
||||
entry('delete_team', null, '2026-03-01T00:00:00Z'),
|
||||
entry('provision', 'ann', '2026-01-01T00:00:00Z'),
|
||||
]);
|
||||
expect(states).toEqual({});
|
||||
});
|
||||
|
||||
it('reads accounts named like Object.prototype members', () => {
|
||||
const states = memberStatesFromAudit([
|
||||
entry('disable', 'constructor', '2026-02-01T00:00:00Z'),
|
||||
entry('disable', 'toString', '2026-02-01T00:00:00Z'),
|
||||
]);
|
||||
expect(states.constructor.disabled).toBe(true);
|
||||
expect(states.toString.disabled).toBe(true);
|
||||
expect(annotateMembers([member('constructor'), member('valueOf')], [
|
||||
entry('disable', 'constructor', '2026-02-01T00:00:00Z'),
|
||||
]).map(m => m.disabled)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('survives an empty or missing log', () => {
|
||||
expect(memberStatesFromAudit([])).toEqual({});
|
||||
expect(memberStatesFromAudit(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('annotateMembers', () => {
|
||||
it('marks a suspended account and keeps it on the list', () => {
|
||||
const annotated = annotateMembers(
|
||||
[member('ann'), member('bob')],
|
||||
[entry('disable', 'bob', '2026-02-01T00:00:00Z', 'team_deleted')],
|
||||
);
|
||||
expect(annotated).toHaveLength(2);
|
||||
expect(annotated.find(m => m.username === 'ann').disabled).toBe(false);
|
||||
expect(annotated.find(m => m.username === 'bob')).toMatchObject({
|
||||
disabled: true,
|
||||
disabledReason: 'team_deleted',
|
||||
});
|
||||
});
|
||||
|
||||
it('never reports an account the team does not own as suspended', () => {
|
||||
// A joined account can be suspended by Puter, which is not the
|
||||
// team's to show or to lift.
|
||||
const annotated = annotateMembers(
|
||||
[member('carol', false)],
|
||||
[entry('disable', 'carol', '2026-02-01T00:00:00Z')],
|
||||
);
|
||||
expect(annotated[0].disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the reason once an account is restored', () => {
|
||||
const annotated = annotateMembers(
|
||||
[member('ann')],
|
||||
[
|
||||
entry('enable', 'ann', '2026-03-01T00:00:00Z'),
|
||||
entry('disable', 'ann', '2026-02-01T00:00:00Z', 'team_deleted'),
|
||||
],
|
||||
);
|
||||
expect(annotated[0]).toMatchObject({ disabled: false, disabledReason: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('membersBillingSummary', () => {
|
||||
it('separates what is charged per account from what only costs storage', () => {
|
||||
const annotated = annotateMembers(
|
||||
[member('ann'), member('bob'), member('cat'), member('dan', false)],
|
||||
[entry('disable', 'bob', '2026-02-01T00:00:00Z')],
|
||||
);
|
||||
expect(membersBillingSummary(annotated)).toEqual({
|
||||
total: 4,
|
||||
billed: 2,
|
||||
disabled: 1,
|
||||
joined: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts nothing for an empty team', () => {
|
||||
expect(membersBillingSummary([])).toEqual({ total: 0, billed: 0, disabled: 0, joined: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the delete gate', () => {
|
||||
// The API refuses a live account, so the button must not exist for one.
|
||||
it('offers deletion only for a disabled account the team owns', () => {
|
||||
expect(canDeleteAccount({ orgOwned: true, disabled: true })).toBe(true);
|
||||
expect(canDeleteAccount({ orgOwned: true, disabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('never offers it for an account the team merely admitted', () => {
|
||||
expect(canDeleteAccount({ orgOwned: false, disabled: true })).toBe(false);
|
||||
expect(canDeleteAccount(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit labels', () => {
|
||||
it('names the actions the team records', () => {
|
||||
expect(auditActionKey('provision')).toBe('teams_audit_provision');
|
||||
expect(auditActionKey('disable')).toBe('teams_audit_disable');
|
||||
expect(auditActionKey('enable')).toBe('teams_audit_enable');
|
||||
expect(auditActionKey('delete_team')).toBe('teams_audit_delete_team');
|
||||
});
|
||||
|
||||
// The whole vocabulary `TeamService` can append, so an action added there
|
||||
// is not left rendering as its raw enum in the one view meant to read well.
|
||||
it.each([
|
||||
'provision', 'disable', 'enable', 'delete_team',
|
||||
'reset_member_password', 'activate', 'delete_account',
|
||||
'directory_enabled', 'directory_disabled',
|
||||
])('has a label for %s', (action) => {
|
||||
expect(auditActionKey(action)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an action this build does not know, so it shows as itself', () => {
|
||||
expect(auditActionKey('something_new')).toBeNull();
|
||||
});
|
||||
|
||||
it('names a recorded reason, and nothing when there is none', () => {
|
||||
expect(auditReasonKey('team_deleted')).toBe('teams_audit_reason_team_deleted');
|
||||
expect(auditReasonKey('unrecognized')).toBeNull();
|
||||
expect(auditReasonKey(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortMembers', () => {
|
||||
it('puts suspended accounts last, provisioned before joined, then alphabetical', () => {
|
||||
const annotated = annotateMembers(
|
||||
[member('zoe'), member('ann'), member('carol', false), member('bob')],
|
||||
[entry('disable', 'ann', '2026-02-01T00:00:00Z')],
|
||||
);
|
||||
expect(sortMembers(annotated).map(m => m.username)).toEqual(['bob', 'zoe', 'carol', 'ann']);
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const annotated = annotateMembers([member('zoe'), member('ann')], []);
|
||||
sortMembers(annotated);
|
||||
expect(annotated.map(m => m.username)).toEqual(['zoe', 'ann']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what plan a row in the accounts table shows', () => {
|
||||
const plan = {
|
||||
seatTiers: { 'u-1': 'team-basic', 'u-2': 'team-pro' },
|
||||
offerings: [
|
||||
{ tier: 'team-basic', name_en: 'Team Basic' },
|
||||
{ tier: 'team-pro', name_en: 'Team Pro' },
|
||||
],
|
||||
};
|
||||
|
||||
it('names the tier that seat is on', () => {
|
||||
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1' }, plan))
|
||||
.toEqual({ kind: 'tier', name: 'Team Basic' });
|
||||
});
|
||||
|
||||
it('lets two seats be on different tiers', () => {
|
||||
// The whole point of per-seat: one team, two plans.
|
||||
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-2' }, plan))
|
||||
.toEqual({ kind: 'tier', name: 'Team Pro' });
|
||||
});
|
||||
|
||||
it('falls back to the tier id when the catalogue has no name', () => {
|
||||
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1' },
|
||||
{ seatTiers: { 'u-1': 'team-basic' }, offerings: [] }))
|
||||
.toEqual({ kind: 'tier', name: 'team-basic' });
|
||||
});
|
||||
|
||||
it('says free for a seat nobody bought a tier for', () => {
|
||||
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-9' }, plan))
|
||||
.toEqual({ kind: 'free' });
|
||||
});
|
||||
|
||||
it('says the owner is the payer, not a seat', () => {
|
||||
expect(memberPlanLabel({ orgOwned: false, uuid: 'u-1' }, plan))
|
||||
.toEqual({ kind: 'payer' });
|
||||
});
|
||||
|
||||
it('says a suspended seat is not billed, whatever it was on', () => {
|
||||
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1', disabled: true }, plan))
|
||||
.toEqual({ kind: 'not_billed' });
|
||||
});
|
||||
|
||||
it('says free when nothing is bought at all', () => {
|
||||
for (const p of [null, undefined, { seatTiers: {} }]) {
|
||||
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1' }, p))
|
||||
.toEqual({ kind: 'free' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('paging the record', () => {
|
||||
const rows = (n) => Array.from({ length: n }, (_, i) => ({ id: i }));
|
||||
|
||||
it('cuts the list into pages and numbers them for the reader', () => {
|
||||
const page = auditSlice(rows(25), 1, 10);
|
||||
expect(page.items).toHaveLength(10);
|
||||
expect(page.items[0].id).toBe(10);
|
||||
expect(page).toMatchObject({ page: 1, pages: 3, from: 11, to: 20, total: 25 });
|
||||
});
|
||||
|
||||
it('reports a short last page honestly', () => {
|
||||
expect(auditSlice(rows(25), 2, 10)).toMatchObject({ from: 21, to: 25, pages: 3 });
|
||||
});
|
||||
|
||||
it('clamps a page the record has shrunk past', () => {
|
||||
// Deleting an account shortens the record; a stale page number would
|
||||
// otherwise show an empty table with no way back.
|
||||
const page = auditSlice(rows(5), 9, 10);
|
||||
expect(page.page).toBe(0);
|
||||
expect(page.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('clamps a negative or nonsensical page rather than throwing', () => {
|
||||
expect(auditSlice(rows(5), -3, 10).page).toBe(0);
|
||||
expect(auditSlice(rows(5), NaN, 10).page).toBe(0);
|
||||
});
|
||||
|
||||
it('says 0 of 0 for an empty record, not 1 of 0', () => {
|
||||
expect(auditSlice([], 0, 10)).toMatchObject({ from: 0, to: 0, total: 0, pages: 1 });
|
||||
expect(auditSlice(undefined, 0, 10).items).toEqual([]);
|
||||
});
|
||||
|
||||
it('never divides by a zero page size', () => {
|
||||
expect(auditSlice(rows(3), 0, 0).items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimestamp', () => {
|
||||
it('reads the unix seconds the audit routes send', () => {
|
||||
expect(parseTimestamp(1789672096)?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
|
||||
});
|
||||
|
||||
it('reads milliseconds and ISO strings, which members and teams use', () => {
|
||||
expect(parseTimestamp(1789672096000)?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
|
||||
expect(parseTimestamp('2026-09-17T19:08:16Z')?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
|
||||
expect(parseTimestamp('1789672096')?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
|
||||
});
|
||||
|
||||
it('is null for nothing or garbage, so a cell prints empty rather than 1970 or Invalid Date', () => {
|
||||
expect(parseTimestamp(null)).toBeNull();
|
||||
expect(parseTimestamp(undefined)).toBeNull();
|
||||
expect(parseTimestamp('')).toBeNull();
|
||||
expect(parseTimestamp('soon')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialOf', () => {
|
||||
it('uppercases the first letter', () => {
|
||||
expect(initialOf('dana')).toBe('D');
|
||||
expect(initialOf('Acme Design')).toBe('A');
|
||||
});
|
||||
|
||||
it('skips a leading sigil and copes with non-Latin names', () => {
|
||||
expect(initialOf('@acme')).toBe('A');
|
||||
expect(initialOf(' élan')).toBe('É');
|
||||
expect(initialOf('日本')).toBe('日');
|
||||
});
|
||||
|
||||
it('falls back to a question mark for nothing', () => {
|
||||
expect(initialOf('')).toBe('?');
|
||||
expect(initialOf(undefined)).toBe('?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('avatarHue', () => {
|
||||
it('is stable for a name and within the colour wheel', () => {
|
||||
expect(avatarHue('dana')).toBe(avatarHue('dana'));
|
||||
expect(avatarHue('dana')).toBeGreaterThanOrEqual(0);
|
||||
expect(avatarHue('dana')).toBeLessThan(360);
|
||||
expect(avatarHue(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('usually differs between neighbouring names', () => {
|
||||
expect(avatarHue('dana')).not.toBe(avatarHue('elliot'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('billingSummaryKey', () => {
|
||||
it('drops the suspended clause when nobody is suspended', () => {
|
||||
expect(billingSummaryKey({ billed: 3, disabled: 0 })).toBe('teams_billing_summary_none');
|
||||
expect(billingSummaryKey({ billed: 1, disabled: 0 })).toBe('teams_billing_summary_one_none');
|
||||
});
|
||||
|
||||
it('keeps it when there is something to say', () => {
|
||||
expect(billingSummaryKey({ billed: 3, disabled: 2 })).toBe('teams_billing_summary');
|
||||
expect(billingSummaryKey({ billed: 1, disabled: 1 })).toBe('teams_billing_summary_one');
|
||||
});
|
||||
});
|
||||
+13
-101
@@ -35,81 +35,7 @@ import { isWeblinkName, weblinkChangeIconMenuItem } from '../helpers/weblink.js'
|
||||
import { is_owned_by_me } from '../helpers/pathOwner.js';
|
||||
import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/sharedAccess.js';
|
||||
import { is_default_app } from '../helpers/defaultApp.js';
|
||||
|
||||
const AI_APP_NAME = 'ai';
|
||||
|
||||
const parseItemMetadataForAI = (metadata) => {
|
||||
if ( ! metadata ) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(metadata);
|
||||
} catch ( error ) {
|
||||
console.warn('Failed to parse item metadata for AI payload.', error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const buildAIPayloadFromItems = ($elements) => {
|
||||
return $elements.get().map((element) => {
|
||||
const $element = $(element);
|
||||
return {
|
||||
uid: $element.attr('data-uid'),
|
||||
path: $element.attr('data-path'),
|
||||
name: $element.attr('data-name'),
|
||||
is_dir: $element.attr('data-is_dir') === '1',
|
||||
is_shortcut: $element.attr('data-is_shortcut') === '1',
|
||||
shortcut_to: $element.attr('data-shortcut_to') || undefined,
|
||||
shortcut_to_path: $element.attr('data-shortcut_to_path') || undefined,
|
||||
size: $element.attr('data-size') || undefined,
|
||||
type: $element.attr('data-type') || undefined,
|
||||
modified: $element.attr('data-modified') || undefined,
|
||||
metadata: parseItemMetadataForAI($element.attr('data-metadata')),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const ensureAIAppIframe = async () => {
|
||||
let $aiWindow = $(`.window[data-app="${AI_APP_NAME}"]`);
|
||||
if ( $aiWindow.length === 0 ) {
|
||||
try {
|
||||
await launch_app({ name: AI_APP_NAME });
|
||||
} catch ( error ) {
|
||||
console.error('Failed to launch AI app.', error);
|
||||
return null;
|
||||
}
|
||||
$aiWindow = $(`.window[data-app="${AI_APP_NAME}"]`);
|
||||
}
|
||||
|
||||
if ( $aiWindow.length === 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$aiWindow.makeWindowVisible();
|
||||
const iframe = $aiWindow.find('.window-app-iframe').get(0);
|
||||
return iframe ?? null;
|
||||
};
|
||||
|
||||
const sendSelectionToAIApp = async ($elements) => {
|
||||
const items = buildAIPayloadFromItems($elements);
|
||||
if ( items.length === 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aiIframe = await ensureAIAppIframe();
|
||||
if ( !aiIframe || !aiIframe.contentWindow ) {
|
||||
await UIAlert({
|
||||
message: i18n('ai_app_unavailable'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
aiIframe.contentWindow.postMessage({
|
||||
msg: 'ai:openFsEntries',
|
||||
items,
|
||||
source: 'desktop-context-menu',
|
||||
}, '*');
|
||||
};
|
||||
import openWithAI from '../helpers/openWithAI.js';
|
||||
|
||||
async function UIItem (options) {
|
||||
const matching_appendto_count = $(options.appendTo).length;
|
||||
@@ -972,20 +898,6 @@ async function UIItem (options) {
|
||||
menu_items.push('-');
|
||||
}
|
||||
if ( ! are_trashed ) {
|
||||
// -------------------------------------------
|
||||
// Open in AI
|
||||
// -------------------------------------------
|
||||
menu_items.push({
|
||||
html: i18n('open_in_ai'),
|
||||
onClick: async function () {
|
||||
await sendSelectionToAIApp($selected_items);
|
||||
},
|
||||
});
|
||||
// -------------------------------------------
|
||||
// -
|
||||
// -------------------------------------------
|
||||
menu_items.push({ is_divider: true });
|
||||
|
||||
// -------------------------------------------
|
||||
// Donwload
|
||||
// -------------------------------------------
|
||||
@@ -1192,6 +1104,18 @@ async function UIItem (options) {
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------
|
||||
// Open with AI
|
||||
// -------------------------------------------
|
||||
if ( ! is_trash ) {
|
||||
menu_items.push({
|
||||
html: i18n('open_with_ai'),
|
||||
onClick: function () {
|
||||
openWithAI(el_item);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// -
|
||||
// -------------------------------------------
|
||||
@@ -1304,18 +1228,6 @@ async function UIItem (options) {
|
||||
menu_items.push('-');
|
||||
}
|
||||
}
|
||||
// -------------------------------------------
|
||||
// Open in AI
|
||||
// -------------------------------------------
|
||||
if ( !is_trashed && !is_trash ) {
|
||||
menu_items.push({
|
||||
html: i18n('open_in_ai'),
|
||||
onClick: async function () {
|
||||
await sendSelectionToAIApp($(el_item));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// Publish As Website
|
||||
// -------------------------------------------
|
||||
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user