/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { Readable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } 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) => {
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>;
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;
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
>;
// 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>;
};
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>;
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('the root listing', () => {
const mint = async (permissions: string[]) => {
const response = await fetch(
new URL('/auth/create-access-token', env.apiOrigin),
{
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.users.user.token}`,
},
body: JSON.stringify({ permissions }),
},
);
expect(response.status).toBe(200);
return ((await response.json()) as { token: string }).token;
};
const listRoot = async (token: string) => {
const response = await fetch(
readdirUrl({ path: '/', auth_token: token }),
);
expect(response.status).toBe(200);
return response.text();
};
const rootPaths = async (token: string) => {
const entries = JSON.parse(await listRoot(token)) as Array<{
path: string;
}>;
return entries.map((entry) => entry.path);
};
const entryAt = async (path: string) =>
(await env.server.stores.fsEntry.getEntryByPath(path))!;
it('lists the home for a session and a full-access token', async () => {
const { username, token, apiToken } = env.users.user;
for (const credential of [token, apiToken]) {
expect(await rootPaths(credential)).toEqual([`/${username}`]);
}
});
it('leaves the home out for a token scoped below it', async () => {
const { username } = env.users.user;
const home = await entryAt(`/${username}`);
const documents = await entryAt(`/${username}/Documents`);
const scoped = await mint([`fs:${documents.uuid}:read`]);
const raw = await listRoot(scoped);
expect(JSON.parse(raw)).toEqual([]);
expect(raw).not.toContain(home.uuid);
expect(raw).not.toContain(username);
});
it('lists the home for a token that can list it', async () => {
const { username } = env.users.user;
const home = await entryAt(`/${username}`);
const scoped = await mint([`fs:${home.uuid}:list`]);
expect(await rootPaths(scoped)).toEqual([`/${username}`]);
});
});
});
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');
});
});
describe('file reads over HTTP when storage fails mid-download', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const writeFile = async (name: string, content: string) => {
const { username } = env.users.user;
const user = await env.server.stores.user.getByUsername(username);
const body = Buffer.from(content);
const { fsEntry } = await env.server.services.fs.write(user!.id, {
fileMetadata: {
path: `/${username}/Documents/${name}`,
size: body.byteLength,
contentType: 'text/plain',
},
fileContent: body,
});
return fsEntry;
};
// Storage hands over one chunk and then fails, the way a dropped
// connection or a checksum mismatch surfaces on a read in flight.
const breakReadsOf = (objectKey: string) => {
const s3 = env.server.stores.s3Object;
const client = env.server.clients.s3.get(s3.resolveRegion(null));
const send = client.send.bind(client);
return vi.spyOn(client, 'send').mockImplementation(((
command: unknown,
) => {
if (
!(command instanceof GetObjectCommand) ||
command.input.Key !== objectKey
) {
return send(command as never);
}
let reads = 0;
const body = new Readable({
read() {
reads += 1;
if (reads === 1) this.push('partial');
// Late enough for the headers and first chunk to go out.
else if (reads === 2)
setTimeout(
() => this.destroy(new Error('Checksum mismatch')),
25,
);
},
});
return Promise.resolve({
Body: body,
ContentLength: 64,
ContentType: 'text/plain',
});
}) as never);
};
it.each([
['/fs/read', 'path'],
['/read', 'file'],
])(
'GET %s fails that response and keeps serving others',
async (route, param) => {
const { username, token } = env.users.user;
const urlFor = (name: string) => {
const url = new URL(route, env.apiOrigin);
url.searchParams.set(param, `/${username}/Documents/${name}`);
return url;
};
const suffix = route.replace(/\W/g, '');
const broken = await writeFile(
`broken-${suffix}.txt`,
'x'.repeat(64),
);
await writeFile(`healthy-${suffix}.txt`, 'still fine');
const send = breakReadsOf(broken.uuid);
let status = 0;
let completed = false;
try {
const response = await fetch(urlFor(`broken-${suffix}.txt`), {
headers: { Authorization: `Bearer ${token}` },
});
status = response.status;
await response.arrayBuffer();
completed = true;
} catch {
// Expected: the transfer is cut short.
} finally {
send.mockRestore();
}
expect(status).toBe(200);
expect(completed).toBe(false);
const healthy = await fetch(urlFor(`healthy-${suffix}.txt`), {
headers: { Authorization: `Bearer ${token}` },
});
expect(healthy.status).toBe(200);
expect(await healthy.text()).toBe('still fine');
},
);
});