/*
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
/**
* Route-level coverage for the sharing endpoints. The service unit tests drive
* the semantics; this suite exists to catch a route that was never registered,
* a gate that rejects a legitimate request, and anything the response shape
* leaks.
*/
describe('share endpoints over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (path: string, token: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const get = (path: string, token: string, params: Record) => {
const url = new URL(path, env.apiOrigin);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
return fetch(url, { headers: { authorization: `Bearer ${token}` } });
};
/** A file in the owner's home. Written directly — these tests are about
* the share routes, not the upload path. */
const makeFile = async (owner: { username: string }) => {
const uid = crypto.randomUUID();
const name = `share-http-${uid.slice(0, 8)}.txt`;
const path = `/${owner.username}/${name}`;
const user = await env.server.stores.user.getByUsername(owner.username);
await env.server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)',
[uid, name, path, user!.id, Math.floor(Date.now() / 1000)],
);
return { uid, path };
};
it('shares an item, lists it for the recipient, then revokes it', async () => {
const owner = env.users.user;
const recipient = env.users.other;
const file = await makeFile(owner);
const shareRes = await post('/share', owner.token, {
recipients: [recipient.username],
items: [{ uid: file.uid }],
mode: 'read',
});
expect(shareRes.status).toBe(200);
const shareBody = (await shareRes.json()) as {
status: string;
results: Array<{ status: string; mode?: string }>;
};
expect(shareBody.status).toBe('success');
expect(shareBody.results[0].mode).toBe('read');
const listRes = await get(
'/share/shared-with-me',
recipient.token,
{ includeTotal: 'true' },
);
expect(listRes.status).toBe(200);
const listed = (await listRes.json()) as {
items: Array>;
total?: number;
};
const row = listed.items.find((i) => i.uid_entry === file.uid);
expect(row).toBeDefined();
expect(row?.issuer).toBe(owner.username);
expect(row?.mode).toBe('read');
expect(typeof listed.total).toBe('number');
// Nothing internal rides along in the response.
for (const key of ['issuer_user_id', 'holder_user_id', 'fsentry_id']) {
expect(row).not.toHaveProperty(key);
}
const revokeRes = await post('/share/revoke', owner.token, {
recipients: [recipient.username],
items: [{ uid: file.uid }],
});
expect(revokeRes.status).toBe(200);
expect(await revokeRes.json()).toMatchObject({ revoked: 1 });
const afterRes = await get('/share/shared-with-me', recipient.token, {});
const after = (await afterRes.json()) as {
items: Array>;
};
expect(after.items.find((i) => i.uid_entry === file.uid)).toBeUndefined();
});
it('revokes every item in the request, not just the first', async () => {
const owner = env.users.user;
const recipient = env.users.other;
const fileA = await makeFile(owner);
const fileB = await makeFile(owner);
for (const file of [fileA, fileB]) {
const res = await post('/share', owner.token, {
recipients: [recipient.username],
items: [{ uid: file.uid }],
mode: 'read',
});
expect(res.status).toBe(200);
}
// A truncated revoke is a silent security failure: the caller is told
// "success" while items after the first keep their grants.
const revokeRes = await post('/share/revoke', owner.token, {
recipients: [recipient.username],
items: [{ uid: fileA.uid }, { uid: fileB.uid }],
});
expect(revokeRes.status).toBe(200);
expect(await revokeRes.json()).toMatchObject({
status: 'success',
revoked: 2,
});
const afterRes = await get('/share/shared-with-me', recipient.token, {});
const after = (await afterRes.json()) as {
items: Array>;
};
for (const file of [fileA, fileB]) {
expect(
after.items.find((i) => i.uid_entry === file.uid),
).toBeUndefined();
}
});
it('accepts tilde-rooted paths the way the FS routes do', async () => {
const owner = env.users.user;
const recipient = env.users.other;
const file = await makeFile(owner);
const tildePath = `~/${file.path.split('/').pop()}`;
const shareRes = await post('/share', owner.token, {
recipients: [recipient.username],
items: [tildePath],
mode: 'read',
});
expect(shareRes.status).toBe(200);
expect(await shareRes.json()).toMatchObject({ status: 'success' });
const listRes = await get('/share/shares', owner.token, {
path: tildePath,
});
expect(listRes.status).toBe(200);
const listed = (await listRes.json()) as {
items: Array<{ holder: string }>;
};
expect(
listed.items.some((i) => i.holder === recipient.username),
).toBe(true);
const revokeRes = await post('/share/revoke', owner.token, {
recipients: [recipient.username],
items: [tildePath],
});
expect(revokeRes.status).toBe(200);
expect(await revokeRes.json()).toMatchObject({ revoked: 1 });
});
it('reports per-pair outcomes when only some recipients resolve', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const res = await post('/share', owner.token, {
recipients: [env.users.other.username, 'nosuchuser-zzz'],
items: [{ uid: file.uid }],
mode: 'read',
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
status: string;
results: Array<{ status: string; recipient: string }>;
};
expect(body.status).toBe('mixed');
expect(body.results).toHaveLength(2);
expect(
body.results.find((r) => r.recipient === 'nosuchuser-zzz')?.status,
).toBe('error');
});
it('lists who can reach an item for its owner', async () => {
const owner = env.users.user;
const recipient = env.users.other;
const file = await makeFile(owner);
await post('/share', owner.token, {
recipients: [recipient.username],
items: [{ uid: file.uid }],
mode: 'write',
});
const res = await get('/share/shares', owner.token, { uid: file.uid });
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: Array<{ holder: string; mode: string }>;
};
expect(body.items).toHaveLength(1);
expect(body.items[0].holder).toBe(recipient.username);
expect(body.items[0].mode).toBe('write');
});
it('hides an item from a stranger asking who can reach it', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const res = await get('/share/shares', env.users.other.token, {
uid: file.uid,
});
expect(res.status).toBe(404);
});
it('rejects an unauthenticated share', async () => {
const res = await fetch(new URL('/share', env.apiOrigin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ recipients: ['x'], items: ['y'] }),
});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
it('caps how many recipients one request can reach', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const many = Array.from({ length: 64 }, (_, i) => `user-${i}`);
const res = await post('/share', owner.token, {
recipients: many,
items: [{ uid: file.uid }],
mode: 'read',
});
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
code: 'too_many_recipients',
});
});
it('caps how many items one request can carry', async () => {
const owner = env.users.user;
const many = Array.from({ length: 128 }, () => ({
uid: crypto.randomUUID(),
}));
const res = await post('/share', owner.token, {
recipients: [env.users.other.username],
items: many,
mode: 'read',
});
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'too_many_items' });
});
it('rejects a request with no recipients or no items', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
expect(
(await post('/share', owner.token, { items: [{ uid: file.uid }] }))
.status,
).toBe(400);
expect(
(
await post('/share', owner.token, {
recipients: [env.users.other.username],
})
).status,
).toBe(400);
});
});