Files
hub/tools/mystore-test.js
T
nmemmertandClaude Sonnet 5.5 efd17f4263 Extensions: hub bridge for tasks, contacts and calendar (asked of the person once), Sites in My Store, and four example extensions
- hub.tasks / hub.contacts / hub.calendar in the extension sandbox; each kind of access is approved by the person and remembered per extension
- My Store: Sites kind (any web link, optionally shown inside the hub)
- Examples: notes and checklists, habit tracker, kitchen converter and scaler, weather and verse of the day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:25:39 -04:00

63 lines
9.0 KiB
JavaScript

// My Store: the admin adds items and files, everyone reads and downloads, books go onto a shelf, music streams with ranges, extensions are served sandboxed.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), cp = require('child_process');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-ms-'));
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-msfx-')); cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]);
const epub = fs.readFileSync(path.join(fx, 'test.epub')), gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64');
const users = require('../users'), books = require('../books'), ms = require('../mystore');
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['store', 'read'], handle: 'Yg' }) };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => req.url.startsWith('/api/read/') ? books.handle(req, res) : ms.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port, B = base + '/api/store/my', bob = { 'x-user': 'b' };
const post = (p, b, h = {}) => fetch(B + p, { method: 'POST', body: JSON.stringify(b), headers: { 'content-type': 'application/json', ...h } });
const put = (p, body, h = {}) => fetch(B + p, { method: 'POST', body, headers: h });
try {
assert.equal((await post('/items', { kind: 'book', title: 'X' }, bob)).status, 403, 'only the admin adds things');
assert.equal((await post('/items', { kind: 'nope', title: 'X' })).status, 400); assert.equal((await post('/items', { kind: 'book', title: ' ' })).status, 400);
// a book: one file, goes on the shelf
let book = await (await post('/items', { kind: 'book', title: 'My Own Book', creator: 'Me', description: 'Hello' })).json(); assert.match(book.id, /^[0-9a-f]{12}$/);
assert.equal((await put(`/items/${book.id}/files?name=x.exe`, epub, bob)).status, 403);
assert.equal((await put(`/items/${book.id}/files?name=x.exe`, epub)).status, 400, 'a book must be a book file');
book = await (await put(`/items/${book.id}/files?name=../../my%20book.epub`, epub)).json(); assert.equal(book.files[0].name, 'my book.epub', 'a name cannot reach other folders'); assert.equal(book.files[0].size, epub.length);
assert.equal((await put(`/items/${book.id}/files?name=two.epub`, epub)).status, 400, 'a book holds one file');
assert.equal((await put(`/items/${book.id}/cover`, 'not a picture', { 'content-type': 'text/html' })).status, 400); book = await (await put(`/items/${book.id}/cover`, gif, { 'content-type': 'image/gif' })).json(); assert.equal(book.cover, true);
let r = await fetch(`${B}/items/${book.id}/cover`, { headers: bob }); assert.equal(r.headers.get('content-type'), 'image/gif');
let list = await (await fetch(B + '/items', { headers: bob })).json(); assert.equal(list.items.length, 1); assert.equal(list.admin, false); assert.equal((await (await fetch(B + '/items')).json()).admin, true);
r = await fetch(`${base}/api/store/my/items/${book.id}/files/${book.files[0].id}`, { headers: bob }); assert.equal(r.status, 200); assert.match(r.headers.get('content-disposition'), /attachment/); assert.equal(Buffer.from(await r.arrayBuffer()).length, epub.length);
r = await fetch(`${B}/items/${book.id}/to-library`, { method: 'POST', headers: bob }); const sh = await r.json(); assert.equal(r.status, 200); assert.equal(sh.title, 'My Own Book'); assert.equal(sh.cover, true);
assert.equal((await (await fetch(base + '/api/read/list', { headers: bob })).json()).length, 1, 'it is on Bob\'s shelf, not Ann\'s'); assert.equal((await (await fetch(base + '/api/read/list')).json()).length, 0);
// music: several files, streamed with ranges
let alb = await (await post('/items', { kind: 'music', title: 'Album', creator: 'Band' })).json(); const mp3 = Buffer.from('0123456789abcdefghij');
alb = await (await put(`/items/${alb.id}/files?name=01.mp3`, mp3)).json(); alb = await (await put(`/items/${alb.id}/files?name=02.mp3`, mp3)).json(); assert.equal(alb.files.length, 2);
r = await fetch(`${B}/items/${alb.id}/files/${alb.files[0].id}`, { headers: { ...bob, range: 'bytes=2-5' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '2345'); assert.equal(r.headers.get('content-range'), 'bytes 2-5/20'); assert.equal(r.headers.get('content-type'), 'audio/mpeg');
r = await fetch(`${B}/items/${alb.id}/files/${alb.files[0].id}`, { headers: { range: 'bytes=-4' } }); assert.equal(await r.text(), 'ghij'); assert.equal((await fetch(`${B}/items/${alb.id}/files/${alb.files[0].id}`, { headers: { range: 'bytes=50-60' } })).status, 416);
alb = await (await fetch(`${B}/items/${alb.id}/files/${alb.files[1].id}`, { method: 'DELETE' })).json(); assert.equal(alb.files.length, 1);
// an app: any file, plus a link
let app = await (await post('/items', { kind: 'app', title: 'Tool', version: '1.2', url: 'https://example.com/tool' })).json(); app = await (await put(`/items/${app.id}/files?name=tool.apk`, 'apk-bytes')).json(); assert.equal(app.version, '1.2');
assert.equal((await post('/items', { kind: 'app', title: 'Bad', url: 'javascript:alert(1)' })).status, 400);
// an extension: one html file, framed with a sandbox
let ext = await (await post('/items', { kind: 'extension', title: 'Counter', icon: '#' })).json(); assert.equal((await put(`/items/${ext.id}/files?name=a.js`, 'x')).status, 400);
ext = await (await put(`/items/${ext.id}/files?name=counter.html`, '<!doctype html><html><head><title>c</title></head><body>hi</body></html>')).json();
r = await fetch(`${B}/ext/${ext.id}/page`, { headers: bob }); const page = await r.text(); assert.match(r.headers.get('content-security-policy'), /^sandbox allow-scripts/); assert.match(r.headers.get('content-security-policy'), /default-src 'none'/); assert.ok(!/allow-same-origin/.test(r.headers.get('content-security-policy')), 'no same-origin: it cannot reach the hub');
assert.match(page, /window\.hub=/); assert.match(page, /<body>hi/);
assert.deepEqual((await (await post(`/ext/${ext.id}/install`, {}, bob)).json()).installed, [ext.id]); assert.deepEqual((await (await fetch(B + '/items')).json()).installed, [], 'installing is per person');
assert.equal((await post(`/ext/${ext.id}/data`, { n: 3 }, bob)).status, 200); assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/data`, { headers: bob })).json(), { n: 3 }); assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/data`)).json(), {});
assert.equal((await post(`/ext/${ext.id}/data`, { big: 'x'.repeat(300000) }, bob)).status, 413);
assert.deepEqual((await (await post(`/ext/${ext.id}/uninstall`, {}, bob)).json()).installed, []); assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/data`, { headers: bob })).json(), {}, 'uninstalling forgets its settings');
assert.equal((await fetch(`${B}/ext/${book.id}/page`)).status, 404, 'only extensions are framed');
// permissions an extension has been given
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json(), { grants: [] }); assert.equal((await post(`/ext/${ext.id}/install`, {}, bob)).status, 200);
assert.deepEqual((await (await post(`/ext/${ext.id}/grants`, { grants: ['tasks.read', 'tasks.read', 'root.everything'] }, bob)).json()).grants, ['tasks.read'], 'only known permissions are kept');
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`)).json(), { grants: [] }, 'they are per person'); await post(`/ext/${ext.id}/uninstall`, {}, bob); assert.deepEqual((await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json()).grants, [], 'removing the extension takes them back');
// a site (a link, optionally shown inside the hub)
assert.equal((await post('/items', { kind: 'link', title: 'Site' })).status, 400, 'a site needs an address'); let site = await (await post('/items', { kind: 'link', title: 'Docs', url: 'https://example.com/', embed: true, icon: 'D' })).json(); assert.equal(site.embed, true); assert.equal(site.icon, 'D');
assert.equal((await put(`/items/${site.id}/files?name=a.html`, 'x')).status, 400, 'a site has no files');
// a Seafile copy needs a sign-in
r = await post(`/items/${alb.id}/files`, { repo: 'a'.repeat(36), path: '/x.mp3' }); assert.equal(r.status, 401);
// changing, deleting
book = await (await post('/items', { id: book.id, title: 'Renamed' })).json(); assert.equal(book.title, 'Renamed'); assert.equal(book.creator, '', 'details are replaced as a whole');
assert.equal((await fetch(`${B}/items/${alb.id}`, { method: 'DELETE', headers: bob })).status, 403); assert.equal((await fetch(`${B}/items/${alb.id}`, { method: 'DELETE' })).status, 200);
assert.equal(fs.readdirSync(path.join(process.env.DATA_DIR, 'mystore', 'files')).filter(f => f.startsWith(alb.id)).length, 0, 'its files are removed too');
console.log('mystore ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});