mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-18 09:40:20 +00:00
* fix(kv): keep disableSharing entries out of other apps' events (PUT-1876) Private kv keys and values are no longer delivered to cross-app subscriptions, including forwarded and durable deliveries. * fix(fs): refuse usernames whose home path still holds another account's rows (PUT-1877) Username claims (signup, save_account, change-username, OIDC, team provisioning) now also check for leftover rows under /<name>/, and the root listing only returns the actor's own home. * fix(puter-js): reconnect the events channel after a server-side disconnect (PUT-1881) A server hang-up is retried with a bounded backoff; persistent handlers survive it and get an optional onError when the connection can't be restored. * fix(backend): let the graceful shutdown finish before telemetry exits (PUT-1948) The telemetry preload no longer exits on signals; the entry point drains, tears down top-down, flushes telemetry last, then exits. DB pools stay open until teardown. * fix(fs): reserve quota for signed uploads in progress (PUT-1880) startWrite/startBatchWrite hold each upload's declared size in a per-owner cache reservation until it completes, aborts or expires, so a burst of starts can't all read the same committed usage. Caps in-flight uploads at 10,000 per account. * fix(fs): refuse to complete a signed upload whose object never arrived (PUT-1951) Completion now 400s when the object store reports the key missing (transient errors stay lenient), abort no longer deletes an object a live entry still uses, and a ghost entry is only removed after a second miss at its recorded location. * fix(metering): move usage to v2 keys with a small totals item, per-model detail shards and a 3-month TTL (PUT-1949) The billed item holds only totals; per-model detail lives in 100 hash-sharded items read in one batch and cached for 60s, and more than 5,000 distinct usage types in a month fold into other. Global and per-app aggregates store totals only, appTotals comes from a prefix listing, a refused path only skips the key that refused it, exact reads near the allowance are throttled per key, and every metering record expires after 3 months. September usage restarts at deploy; the monthly-charge claim stays on the v1 key through September so recurring charges don't fire twice. * test(events): let the worker backoff test tolerate a loaded run * feat(events): scale KV event fan-out with the key owner's plan A KV change delivers to up to 512 matching subscriptions (2,048 filter evaluations) for a paid key owner and 128 (512) otherwise, inline values are dropped once more than 128 match, and paid accounts may hold up to 512 share handles per app. The owner's plan is only looked up when a change has more candidates than any cap. * test(metering): seed the 150-app listing test directly so it fits under coverage
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
|
|
|
/**
|
|
* Route-level coverage for `GET /fs/readdir`. The unit tests call the handler
|
|
* directly, which cannot catch a route that was never registered or a gate that
|
|
* rejects the request — so this suite drives real HTTP over a listening server,
|
|
* and authenticates purely through `?auth_token=` (no request headers), which is
|
|
* the whole point of offering the read as a GET.
|
|
*/
|
|
describe('GET /fs/readdir over HTTP', () => {
|
|
let env: PuterTestEnv;
|
|
|
|
beforeAll(async () => {
|
|
env = await setupPuterTestEnv();
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await env?.shutdown();
|
|
});
|
|
|
|
const readdirUrl = (params: Record<string, string>) => {
|
|
const url = new URL('/fs/readdir', env.apiOrigin);
|
|
for (const [key, value] of Object.entries(params)) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
return url;
|
|
};
|
|
|
|
it('lists a directory authenticated only by a query token', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(
|
|
readdirUrl({ path: `/${username}`, auth_token: token }),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as Array<{
|
|
name: string;
|
|
uuid: string;
|
|
}>;
|
|
expect(Array.isArray(body)).toBe(true);
|
|
// Default provisioned home directories.
|
|
expect(body.map((e) => e.name)).toContain('Documents');
|
|
for (const entry of body) {
|
|
expect(entry.uuid).toEqual(expect.any(String));
|
|
}
|
|
});
|
|
|
|
it('paginates via query params', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(
|
|
readdirUrl({
|
|
path: `/${username}`,
|
|
auth_token: token,
|
|
limit: '2',
|
|
cursor: '',
|
|
includeTotal: 'true',
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const page = (await response.json()) as {
|
|
items: unknown[];
|
|
cursor?: string;
|
|
total?: number;
|
|
};
|
|
expect(page.items.length).toBe(2);
|
|
expect(typeof page.total).toBe('number');
|
|
expect(page.cursor).toEqual(expect.any(String));
|
|
});
|
|
|
|
it('rejects an unauthenticated request', async () => {
|
|
const { username } = env.users.user;
|
|
const response = await fetch(readdirUrl({ path: `/${username}` }));
|
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
|
expect(response.status).toBeLessThan(500);
|
|
});
|
|
|
|
it('does not leak internal ids or storage columns over the wire', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(
|
|
readdirUrl({ path: `/${username}`, auth_token: token }),
|
|
);
|
|
const raw = await response.text();
|
|
const body = JSON.parse(raw) as Array<Record<string, unknown>>;
|
|
expect(body.length).toBeGreaterThan(0);
|
|
// Checked per-entry rather than by scanning the raw body: the nested
|
|
// `associatedApp` payload legitimately carries its own `id` (v1 has
|
|
// always exposed it), so a substring scan would false-alarm.
|
|
for (const entry of body) {
|
|
for (const field of [
|
|
'id',
|
|
'parentId',
|
|
'userId',
|
|
'associatedAppId',
|
|
'bucket',
|
|
'bucketRegion',
|
|
'publicToken',
|
|
'fileRequestToken',
|
|
]) {
|
|
expect(entry).not.toHaveProperty(field);
|
|
}
|
|
}
|
|
// No user-identifying data (emails, owner records) anywhere.
|
|
expect(raw).not.toContain('@');
|
|
expect(raw).not.toMatch(/"(email|owner|user_id|userId)"/);
|
|
});
|
|
|
|
it('lists a nested subtree recursively over GET', async () => {
|
|
const { username, token } = env.users.user;
|
|
const base = `/${username}/Documents/http-recursive-${Date.now()}`;
|
|
for (const path of [base, `${base}/a`, `${base}/a/b`]) {
|
|
const created = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path, auth_token: token }),
|
|
});
|
|
expect(created.status).toBe(200);
|
|
}
|
|
|
|
const response = await fetch(
|
|
readdirUrl({
|
|
path: base,
|
|
auth_token: token,
|
|
recursive: 'true',
|
|
depth: '5',
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const page = (await response.json()) as {
|
|
items: Array<{ path: string }>;
|
|
};
|
|
expect(
|
|
page.items.map((e) => e.path.slice(base.length + 1)).sort(),
|
|
).toEqual(['a', 'a/b']);
|
|
});
|
|
|
|
it('mkdir does not return internal ids, storage columns or tokens', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
path: `/${username}/Documents/http-mkdir-${Date.now()}`,
|
|
auth_token: token,
|
|
}),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const entry = (await response.json()) as Record<string, unknown>;
|
|
expect(entry.uuid).toEqual(expect.any(String));
|
|
expect(entry.isDir).toBe(true);
|
|
for (const field of [
|
|
'id',
|
|
'parentId',
|
|
'userId',
|
|
'associatedAppId',
|
|
'bucket',
|
|
'bucketRegion',
|
|
'publicToken',
|
|
'fileRequestToken',
|
|
]) {
|
|
expect(entry).not.toHaveProperty(field);
|
|
}
|
|
});
|
|
|
|
it('startBatchWrite does not expose storage internals', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(
|
|
new URL('/fs/startBatchWrite', env.apiOrigin),
|
|
{
|
|
method: 'POST',
|
|
// Array body, so there is no `auth_token` field for the auth
|
|
// probe to read — authenticate via the header instead.
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify([
|
|
{
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/http-signed-${Date.now()}.bin`,
|
|
size: 4,
|
|
},
|
|
},
|
|
]),
|
|
},
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const [target] = (await response.json()) as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
// What a client actually needs to upload.
|
|
expect(target!.sessionId).toEqual(expect.any(String));
|
|
expect(typeof target!.url).toBe('string');
|
|
// What it does not: where the bytes physically live.
|
|
for (const field of ['bucket', 'bucketRegion', 'objectKey']) {
|
|
expect(target).not.toHaveProperty(field);
|
|
}
|
|
});
|
|
|
|
it('still serves the POST form for existing callers', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(new URL('/fs/readdir', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: `/${username}`, auth_token: token }),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as Array<{ name: string }>;
|
|
expect(body.map((e) => e.name)).toContain('Documents');
|
|
});
|
|
|
|
describe('the share flag', () => {
|
|
const post = async (path: string, token: string, body: unknown) => {
|
|
const response = await fetch(new URL(path, env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
return response.json() as Promise<Record<string, unknown>>;
|
|
};
|
|
|
|
const mkdir = (path: string, token: string) =>
|
|
post('/fs/mkdir', token, { path });
|
|
|
|
const readdir = async (path: string, token: string) => {
|
|
const response = await fetch(readdirUrl({ path, auth_token: token }));
|
|
expect(response.status).toBe(200);
|
|
const entries = (await response.json()) as Array<{
|
|
name: string;
|
|
isShared: boolean | null;
|
|
}>;
|
|
return new Map(entries.map((e) => [e.name, e.isShared]));
|
|
};
|
|
|
|
const base = () =>
|
|
`/${env.users.user.username}/Documents/flag-${crypto.randomUUID().slice(0, 8)}`;
|
|
|
|
it('marks a shared child in a listing, and clears it on revoke', async () => {
|
|
const owner = env.users.user;
|
|
const recipient = env.users.other;
|
|
const parent = base();
|
|
await mkdir(parent, owner.token);
|
|
const shared = await mkdir(`${parent}/shared`, owner.token);
|
|
await mkdir(`${parent}/private`, owner.token);
|
|
|
|
// A write response says nothing about sharing.
|
|
expect(shared).not.toHaveProperty('isShared');
|
|
|
|
await post('/share', owner.token, {
|
|
recipients: [recipient.username],
|
|
items: [{ uid: shared.uuid }],
|
|
mode: 'read',
|
|
});
|
|
|
|
expect(await readdir(parent, owner.token)).toEqual(
|
|
new Map([
|
|
['shared', true],
|
|
['private', false],
|
|
]),
|
|
);
|
|
|
|
await post('/share/revoke', owner.token, {
|
|
recipients: [recipient.username],
|
|
items: [{ uid: shared.uuid }],
|
|
});
|
|
|
|
expect(await readdir(parent, owner.token)).toEqual(
|
|
new Map([
|
|
['shared', false],
|
|
['private', false],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('names the recipients in stat only when asked', async () => {
|
|
const owner = env.users.user;
|
|
const recipient = env.users.other;
|
|
const parent = base();
|
|
await mkdir(parent, owner.token);
|
|
const shared = await mkdir(`${parent}/shared`, owner.token);
|
|
|
|
await post('/share', owner.token, {
|
|
recipients: [recipient.username],
|
|
items: [{ uid: shared.uuid }],
|
|
mode: 'read',
|
|
});
|
|
|
|
const plain = await post('/fs/stat', owner.token, {
|
|
uid: shared.uuid,
|
|
});
|
|
expect(plain.isShared).toBe(true);
|
|
expect(plain).not.toHaveProperty('shares');
|
|
|
|
|
|
const withShares = await post('/fs/stat', owner.token, {
|
|
uid: shared.uuid,
|
|
return_shares: true,
|
|
});
|
|
const shares = withShares.shares as Array<Record<string, unknown>>;
|
|
expect(shares).toHaveLength(1);
|
|
expect(shares[0]).toMatchObject({
|
|
holder: recipient.username,
|
|
issuer: owner.username,
|
|
mode: 'read',
|
|
inherited_from: null,
|
|
});
|
|
for (const key of ['holder_user_id', 'issuer_user_id', 'fsentry_id']) {
|
|
expect(shares[0]).not.toHaveProperty(key);
|
|
}
|
|
});
|
|
|
|
it('tells a recipient nothing about who else can reach the item', async () => {
|
|
const owner = env.users.user;
|
|
const recipient = env.users.other;
|
|
const parent = base();
|
|
await mkdir(parent, owner.token);
|
|
const shared = await mkdir(`${parent}/shared`, owner.token);
|
|
|
|
await post('/share', owner.token, {
|
|
recipients: [recipient.username],
|
|
items: [{ uid: shared.uuid }],
|
|
mode: 'read',
|
|
});
|
|
|
|
const stat = await post('/fs/stat', recipient.token, {
|
|
uid: shared.uuid,
|
|
return_shares: true,
|
|
});
|
|
expect(stat.isShared).toBeNull();
|
|
// Asked for, so the key is there — but it names nobody.
|
|
expect(stat.shares).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST /fs/completeWrite over HTTP', () => {
|
|
let env: PuterTestEnv;
|
|
|
|
beforeAll(async () => {
|
|
env = await setupPuterTestEnv();
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await env?.shutdown();
|
|
});
|
|
|
|
it('refuses completion of a session whose object never arrived', async () => {
|
|
const { username, token } = env.users.user;
|
|
const started = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/http-nobytes-${Date.now()}.bin`,
|
|
size: 4,
|
|
},
|
|
}),
|
|
});
|
|
expect(started.status).toBe(200);
|
|
const { sessionId } = (await started.json()) as { sessionId: string };
|
|
|
|
// No PUT to the presigned URL.
|
|
const response = await fetch(
|
|
new URL('/fs/completeWrite', env.apiOrigin),
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ uploadId: sessionId }),
|
|
},
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = (await response.json()) as {
|
|
code?: string;
|
|
message?: string;
|
|
};
|
|
expect(body.code).toBe('bad_request');
|
|
expect(body.message).toBe('Upload content was not received');
|
|
});
|
|
});
|
|
|
|
describe('POST /fs/startWrite storage quota over HTTP', () => {
|
|
let env: PuterTestEnv;
|
|
|
|
beforeAll(async () => {
|
|
env = await setupPuterTestEnv({
|
|
is_storage_limited: true,
|
|
storage_capacity: 10 * 1024,
|
|
} as never);
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await env?.shutdown();
|
|
});
|
|
|
|
it('rejects the excess of a parallel burst of startWrite calls', async () => {
|
|
const { username, token } = env.users.user;
|
|
const user = await env.server.stores.user.getByUsername(username);
|
|
const fs = env.server.services.fs as unknown as {
|
|
getUsersStorageAllowance: (
|
|
userId: number,
|
|
) => Promise<{ curr: number; max: number }>;
|
|
};
|
|
const { curr, max } = await fs.getUsersStorageAllowance(user!.id);
|
|
const size = Math.floor((max - curr) / 3) + 1;
|
|
|
|
const responses = await Promise.all(
|
|
Array.from({ length: 8 }, (_, i) =>
|
|
fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/http-quota-burst-${i}.bin`,
|
|
size,
|
|
},
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const ok = responses.filter((response) => response.status === 200);
|
|
const rejected = responses.filter(
|
|
(response) => response.status === 413,
|
|
);
|
|
expect(ok).toHaveLength(2);
|
|
expect(rejected).toHaveLength(6);
|
|
|
|
for (const response of rejected) {
|
|
const body = (await response.json()) as { code?: string };
|
|
expect(body.code).toBe('storage_limit_reached');
|
|
}
|
|
});
|
|
|
|
// A raw JSON body carries `1e400` as a numeral, not a computed value, so
|
|
// `JSON.stringify` can't produce it (it turns `Infinity` into `null`) —
|
|
// the wire text is built by hand to exercise what the server's own
|
|
// `JSON.parse` does with it.
|
|
it('rejects a declared size of 1e400 (parses to Infinity) with 400', async () => {
|
|
const { username, token } = env.users.user;
|
|
const body = `{"fileMetadata":{"path":"/${username}/Documents/http-huge.bin","size":1e400}}`;
|
|
|
|
const response = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const responseBody = (await response.json()) as { code?: string };
|
|
expect(responseBody.code).toBe('bad_request');
|
|
});
|
|
});
|