/** * 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 type { Request, Response } from 'express'; import type { Readable } from 'node:stream'; 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 { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import type { FSController } from './FSController.js'; import type { CompleteWriteRequest, SignedWriteRequest, SignedWriteResponse, } from './requestTypes.js'; // ── Test harness ──────────────────────────────────────────────────── // // Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock redis). // Each test creates its own user via `makeUser` and exercises the live // FSController against the wired services / stores. let server: PuterServer; let controller: FSController; beforeAll(async () => { server = await setupTestServer(); controller = server.controllers.fs as unknown as FSController; }); afterAll(async () => { await server?.shutdown(); }); const makeUser = async (): Promise<{ actor: Actor; userId: number }> => { const username = `fsc-${Math.random().toString(36).slice(2, 10)}`; const created = await server.stores.user.create({ username, uuid: uuidv4(), password: null, email: `${username}@test.local`, free_storage: 100 * 1024 * 1024, requires_email_confirmation: false, }); await generateDefaultFsentries( server.clients.db, server.stores.user, created, ); const refreshed = (await server.stores.user.getById(created.id))!; return { userId: refreshed.id, actor: { user: { id: refreshed.id, uuid: refreshed.uuid, username: refreshed.username, email: refreshed.email ?? null, email_confirmed: true, } as Actor['user'], }, }; }; interface CapturedResponse { statusCode: number; body: unknown; } const makeReq = (init: { body?: B; query?: Record; headers?: Record; actor: Actor; user?: { id: number; username: string }; }): Request => { return { body: init.body ?? ({} as B), query: init.query ?? {}, headers: init.headers ?? {}, actor: init.actor, // Some controller helpers fall back to `req.user` (set by the // session middleware) for id / username before reading `req.actor`. // Provide it so #getActorUserId / #getActorUsername resolve. user: init.user ?? { id: init.actor.user!.id!, username: init.actor.user!.username!, }, } as unknown as Request; }; const makeRes = () => { const captured: CapturedResponse = { statusCode: 200, body: undefined }; const res = { json: vi.fn((value: unknown) => { captured.body = value; return res; }), status: vi.fn((code: number) => { captured.statusCode = code; return res; }), setHeader: vi.fn(() => res), }; return { res: res as unknown as Response, captured }; }; const withActor = async (actor: Actor, fn: () => Promise): Promise => runWithContext({ actor }, fn); const streamToString = async (stream: Readable): Promise => { const chunks: Buffer[] = []; for await (const chunk of stream) { chunks.push(Buffer.from(chunk as Buffer)); } return Buffer.concat(chunks).toString('utf8'); }; // ── /startBatchWrite ──────────────────────────────────────────────── describe('FSController.startBatchWrites', () => { it('returns [] for an empty/undefined body without creating sessions', async () => { const { actor } = await makeUser(); const { res, captured } = makeRes(); const req = makeReq({ body: undefined, actor }); await withActor(actor, () => controller.startBatchWrites(req, res)); expect(captured.body).toEqual([]); }); it('creates a pending upload session per request and returns signed targets', async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; const body: SignedWriteRequest[] = [ { fileMetadata: { path: `/${username}/Documents/a.txt`, size: 5, }, }, { fileMetadata: { path: `/${username}/Documents/b.txt`, size: 10, }, }, ]; const { res, captured } = makeRes(); const req = makeReq({ body, actor }); await withActor(actor, () => controller.startBatchWrites(req, res)); const responses = captured.body as SignedWriteResponse[]; expect(responses).toHaveLength(2); for (const r of responses) { expect(r.sessionId).toEqual(expect.any(String)); expect(r.objectKey).toEqual(expect.any(String)); expect(r.bucket).toEqual(expect.any(String)); expect(r.uploadMode).toBe('single'); // In-memory mock S3 still returns a presigned-URL string for // single-mode uploads — verify it's there but don't assert // shape (varies by region/host config). expect(typeof r.url).toBe('string'); } // Pending sessions actually landed in the DB and point at the // expected paths for the right user. const sessions = await server.stores.fsEntry.getPendingEntriesBySessionIds( responses.map((r) => r.sessionId), ); expect(sessions.map((s) => s?.targetPath).sort()).toEqual([ `/${username}/Documents/a.txt`, `/${username}/Documents/b.txt`, ]); for (const session of sessions) { expect(session?.userId).toBe(userId); expect(session?.status).toBe('pending'); } }); it('expands `~/...` paths against the actor home before writing', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const body: SignedWriteRequest[] = [ { fileMetadata: { path: '~/Documents/tilde.txt', size: 3 } }, ]; const { res, captured } = makeRes(); const req = makeReq({ body, actor }); await withActor(actor, () => controller.startBatchWrites(req, res)); const [response] = captured.body as SignedWriteResponse[]; const session = await server.stores.fsEntry.getPendingEntryBySessionId( response!.sessionId, ); expect(session?.targetPath).toBe(`/${username}/Documents/tilde.txt`); }); it('materializes a directory entry when `directory: true`', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const target = `/${username}/Documents/new-batch-dir`; const body: SignedWriteRequest[] = [ { // `createMissingParents` lets the service materialize the // target dir even though only `/Documents` exists in the // newly-provisioned home tree. fileMetadata: { path: target, size: 0, createMissingParents: true, }, directory: true, }, ]; const { res } = makeRes(); const req = makeReq({ body, actor }); await withActor(actor, () => controller.startBatchWrites(req, res)); // Directory items aren't pending uploads — they're created // immediately by the service. The fsentry should be queryable. const created = await server.stores.fsEntry.getEntryByPath(target); expect(created).not.toBeNull(); expect(created?.isDir).toBe(true); }); it('rejects the batch when ACL denies any item', async () => { const a = await makeUser(); const b = await makeUser(); // User a tries to drop a file inside user b's home. const body: SignedWriteRequest[] = [ { fileMetadata: { path: `/${b.actor.user!.username}/Documents/intruder.txt`, size: 1, }, }, ]; const { res } = makeRes(); const req = makeReq({ body, actor: a.actor, }); const err = await withActor(a.actor, () => controller.startBatchWrites(req, res).then( () => null, (e: unknown) => e, ), ); const status = (err as { statusCode?: number } | null)?.statusCode; // 404 (can't see) or 403 (can see, can't write) are both valid // denials per ACLService.getSafeAclError. expect([403, 404]).toContain(status); }); }); // ── /completeBatchWrite ──────────────────────────────────────────── describe('FSController.completeBatchWrites', () => { it('returns [] for an empty body', async () => { const { actor } = await makeUser(); const { res, captured } = makeRes(); const req = makeReq({ body: undefined, actor, }); await withActor(actor, () => controller.completeBatchWrites(req, res)); expect(captured.body).toEqual([]); }); it('rejects an inline `data:` thumbnail with 400', async () => { const { actor } = await makeUser(); const { res } = makeRes(); const req = makeReq({ body: [ { uploadId: 'whatever', thumbnailData: 'data:image/png;base64,AAA', }, ], actor, }); await expect( withActor(actor, () => controller.completeBatchWrites(req, res)), ).rejects.toMatchObject({ statusCode: 400 }); }); it('finalizes pending sessions into real fsentries', async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; // 1) Start two batched uploads. The signed-write flow inserts // pending session rows and gives us back the upload IDs we'll // feed to /completeBatchWrite. const startBody: SignedWriteRequest[] = [ { fileMetadata: { path: `/${username}/Documents/c.txt`, size: 5, contentType: 'text/plain', }, }, { fileMetadata: { path: `/${username}/Documents/d.txt`, size: 7, contentType: 'text/plain', }, }, ]; const startRes = makeRes(); await withActor(actor, () => controller.startBatchWrites( makeReq({ body: startBody, actor }), startRes.res, ), ); const startResponses = startRes.captured.body as SignedWriteResponse[]; expect(startResponses).toHaveLength(2); // 2) Complete via the controller. Single-mode completion only // needs the session row → it doesn't read the S3 object back, // so we can skip the actual upload step in this test. const { res, captured } = makeRes(); const completeBody: CompleteWriteRequest[] = startResponses.map( (r) => ({ uploadId: r.sessionId }), ); await withActor(actor, () => controller.completeBatchWrites( makeReq({ body: completeBody, actor, }), res, ), ); const responses = captured.body as Array<{ sessionId: string; wasOverwrite: boolean; fsEntry: { path: string; userId: number; isDir: boolean }; }>; expect(responses.map((r) => r.fsEntry.path).sort()).toEqual([ `/${username}/Documents/c.txt`, `/${username}/Documents/d.txt`, ]); for (const response of responses) { expect(response.wasOverwrite).toBe(false); expect(response.fsEntry.userId).toBe(userId); expect(response.fsEntry.isDir).toBe(false); } // The real fsentries were committed and are now resolvable. for (const path of [ `/${username}/Documents/c.txt`, `/${username}/Documents/d.txt`, ]) { const entry = await server.stores.fsEntry.getEntryByPath(path); expect(entry).not.toBeNull(); expect(entry?.userId).toBe(userId); } }); it('reports wasOverwrite=true when finalizing onto an existing entry', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const target = `/${username}/Documents/overwrite-me.txt`; // First write — establishes an entry to overwrite. const firstStart = makeRes(); await withActor(actor, () => controller.startBatchWrites( makeReq({ body: [{ fileMetadata: { path: target, size: 1 } }], actor, }), firstStart.res, ), ); const [firstResponse] = firstStart.captured .body as SignedWriteResponse[]; const firstComplete = makeRes(); await withActor(actor, () => controller.completeBatchWrites( makeReq({ body: [{ uploadId: firstResponse!.sessionId }], actor, }), firstComplete.res, ), ); // Second write with overwrite=true onto the same path. const secondStart = makeRes(); await withActor(actor, () => controller.startBatchWrites( makeReq({ body: [ { fileMetadata: { path: target, size: 2, overwrite: true, }, }, ], actor, }), secondStart.res, ), ); const [secondResponse] = secondStart.captured .body as SignedWriteResponse[]; const secondComplete = makeRes(); await withActor(actor, () => controller.completeBatchWrites( makeReq({ body: [{ uploadId: secondResponse!.sessionId }], actor, }), secondComplete.res, ), ); const [finalized] = secondComplete.captured.body as Array<{ wasOverwrite: boolean; }>; expect(finalized?.wasOverwrite).toBe(true); }); // Regression: a signed (direct-to-S3) upload could declare a tiny size // and PUT far more — the presigned URL doesn't bound the body. The // completion path must reconcile the recorded size against the object's // true size, or storage accounting is permanently understated and the // quota is bypassable. it('reconciles the recorded size to the real uploaded bytes (ignores under-declared size)', async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; const target = `/${username}/Documents/under-declared.bin`; // Declare 1 byte. const start = makeRes(); await withActor(actor, () => controller.startBatchWrites( makeReq({ body: [{ fileMetadata: { path: target, size: 1 } }], actor, }), start.res, ), ); const [started] = start.captured.body as SignedWriteResponse[]; // Actually upload 4096 bytes to the session's object key (simulating // a client that PUTs more than it declared via the signed URL). const realBytes = Buffer.alloc(4096, 0x41); await server.stores.s3Object.uploadFromServer( { bucket: started!.bucket, objectKey: started!.objectKey, contentType: 'application/octet-stream', body: realBytes, contentLength: realBytes.byteLength, }, started!.bucketRegion, ); const complete = makeRes(); await withActor(actor, () => controller.completeBatchWrites( makeReq({ body: [{ uploadId: started!.sessionId }], actor, }), complete.res, ), ); const entry = await server.stores.fsEntry.getEntryByPath(target); expect(entry).not.toBeNull(); // Recorded size is the true 4096 bytes, not the declared 1. expect(entry?.size).toBe(4096); // And the user's accounted usage reflects the real bytes. const allowance = await server.stores.fsEntry.getUserStorageAllowance(userId); expect(allowance.curr).toBeGreaterThanOrEqual(4096); }); it('emits updated events with GUI metadata when overwriting via batch completion', async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; const target = `/${username}/Documents/overwrite-event.js`; const firstStart = makeRes(); await withActor(actor, () => controller.startBatchWrites( makeReq({ body: [{ fileMetadata: { path: target, size: 1 } }], actor, }), firstStart.res, ), ); const [firstResponse] = firstStart.captured .body as SignedWriteResponse[]; await withActor(actor, () => controller.completeBatchWrites( makeReq({ body: [{ uploadId: firstResponse!.sessionId }], actor, }), makeRes().res, ), ); const targetEntry = await server.stores.fsEntry.getEntryByPath(target); expect(targetEntry).not.toBeNull(); await server.stores.subdomain.create({ userId, subdomain: `workers.puter.${username}-worker`, rootDirId: targetEntry!.id, }); const secondStart = makeRes(); await withActor(actor, () => controller.startBatchWrites( makeReq({ body: [ { fileMetadata: { path: target, size: 2, overwrite: true, }, }, ], actor, }), secondStart.res, ), ); const [secondResponse] = secondStart.captured .body as SignedWriteResponse[]; const emitSpy = vi.spyOn(server.clients.event, 'emit'); let updatedCall: (typeof emitSpy.mock.calls)[number] | undefined; try { await withActor(actor, () => controller.completeBatchWrites( makeReq({ body: [ { uploadId: secondResponse!.sessionId, guiMetadata: { operationId: 'op-123', itemUploadId: 'item-456', socketId: 'socket-789', originalClientSocketId: 'socket-789', }, }, ], actor, }), makeRes().res, ), ); updatedCall = emitSpy.mock.calls.find( ([eventName]) => eventName === 'outer.gui.item.updated', ); } finally { emitSpy.mockRestore(); } expect(updatedCall).toBeTruthy(); const payload = updatedCall?.[1] as { user_id_list?: number[]; response?: Record; }; expect(payload.user_id_list).toEqual([userId]); expect(payload.response).toMatchObject({ uid: expect.any(String), uuid: expect.any(String), id: expect.any(String), path: target, name: 'overwrite-event.js', is_dir: false, type: expect.stringMatching(/^application\/javascript/), workers: [ expect.objectContaining({ subdomain: `workers.puter.${username}-worker`, address: expect.stringContaining(`${username}-worker`), }), ], from_new_service: true, operation_id: 'op-123', item_upload_id: 'item-456', socket_id: 'socket-789', original_client_socket_id: 'socket-789', }); }); it("rejects another user's session ids with a 4xx", async () => { const a = await makeUser(); const b = await makeUser(); // a starts a batch; b tries to complete it. const startA = makeRes(); await withActor(a.actor, () => controller.startBatchWrites( makeReq({ body: [ { fileMetadata: { path: `/${a.actor.user!.username}/Documents/x.txt`, size: 1, }, }, ], actor: a.actor, }), startA.res, ), ); const [aResponse] = startA.captured.body as SignedWriteResponse[]; const err = await withActor(b.actor, () => controller .completeBatchWrites( makeReq({ body: [{ uploadId: aResponse!.sessionId }], actor: b.actor, }), makeRes().res, ) .then( () => null, (e: unknown) => e, ), ); const status = (err as { statusCode?: number } | null)?.statusCode; // FSService.batchCompleteUrlWrite throws 403 on session/user // mismatch (`Upload session access denied`). expect([403, 404]).toContain(status); }); }); // ── /stat (statEntry) ─────────────────────────────────────────────── describe('FSController.statEntry', () => { it('returns the v2-native entry shape with isDir/path', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; // Seed via mkdirEntry so the entry surely exists. const mkdirRes = makeRes(); await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/stat-me` }, actor, }), mkdirRes.res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.statEntry( makeReq({ body: { path: `/${username}/Documents/stat-me` }, actor, }), res, ), ); const body = captured.body as { path: string; isDir: boolean; name: string; }; expect(body.path).toBe(`/${username}/Documents/stat-me`); expect(body.isDir).toBe(true); expect(body.name).toBe('stat-me'); }); it('does not leak backend-internal fields to the client', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/no-leak` }, actor, }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.statEntry( makeReq({ body: { path: `/${username}/Documents/no-leak` }, actor, }), res, ), ); const body = captured.body as Record; for (const field of [ 'bucket', 'bucketRegion', 'userId', 'publicToken', 'fileRequestToken', ]) { expect(body).not.toHaveProperty(field); } }); it('includes the subtree size when return_size is set on a directory', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/sized` }, actor, }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.statEntry( makeReq({ body: { path: `/${username}/Documents/sized`, return_size: true, }, actor, }), res, ), ); const body = captured.body as { size: number }; expect(body.size).toBe(0); }); it('throws 401 when no actor is on the request', async () => { const { actor } = await makeUser(); const { res } = makeRes(); const req = { ...makeReq({ body: { path: '/x' }, actor }), actor: undefined, } as unknown as Request; await expect(controller.statEntry(req, res)).rejects.toMatchObject({ statusCode: 401, }); }); }); // ── /readdir (readdirEntries) ─────────────────────────────────────── describe('FSController.readdirEntries', () => { it('lists children of a directory', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; for (const name of ['alpha', 'beta']) { await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/${name}` }, actor, }), makeRes().res, ), ); } const { res, captured } = makeRes(); await withActor(actor, () => controller.readdirEntries( makeReq({ body: { path: `/${username}/Documents` }, actor, }), res, ), ); const entries = captured.body as Array<{ name: string }>; expect(Array.isArray(entries)).toBe(true); const names = entries.map((e) => e.name); expect(names).toContain('alpha'); expect(names).toContain('beta'); }); it('returns root listing when path = "/"', async () => { const { actor } = await makeUser(); const { res, captured } = makeRes(); await withActor(actor, () => controller.readdirEntries( makeReq({ body: { path: '/' }, actor }), res, ), ); expect(Array.isArray(captured.body)).toBe(true); }); it('throws 400 when the target is not a directory', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; // touch → non-directory entry await withActor(actor, () => controller.touchEntry( makeReq({ body: { path: `/${username}/Documents/touched.txt`, set_modified_to_now: true, }, actor, }), makeRes().res, ), ); await expect( withActor(actor, () => controller.readdirEntries( makeReq({ body: { path: `/${username}/Documents/touched.txt` }, actor, }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400, // Matches the legacy `/readdir` code the SDK moved off of. legacyCode: 'dest_is_not_a_directory', }); }); }); // ── /search (searchEntries) ───────────────────────────────────────── describe('FSController.searchEntries', () => { it('rejects an empty query with 400', async () => { const { actor } = await makeUser(); await expect( withActor(actor, () => controller.searchEntries( makeReq({ body: { query: ' ' }, actor }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('finds entries by name', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const needle = `sneedle-${Math.random().toString(36).slice(2, 8)}`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/${needle}` }, actor, }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.searchEntries( makeReq({ body: { query: needle }, actor }), res, ), ); const results = captured.body as Array<{ name: string }>; expect(Array.isArray(results)).toBe(true); expect(results.some((r) => r.name === needle)).toBe(true); }); it('scopes app-under-user actors to their AppData root', async () => { const { actor: userActor } = await makeUser(); const username = userActor.user!.username!; const appUid = `app-search-${uuidv4()}`; const appActor: Actor = { ...userActor, app: { uid: appUid } }; const needle = `appneedle-${Math.random().toString(36).slice(2, 8)}`; // User-owned entry outside AppData — must NOT appear for the app. await withActor(userActor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/${needle}` }, actor: userActor, }), makeRes().res, ), ); // Entry under the app's own AppData — must appear. await withActor(userActor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/AppData/${appUid}/${needle}`, create_missing_parents: true, }, actor: userActor, }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(appActor, () => controller.searchEntries( makeReq({ body: { query: needle }, actor: appActor }), res, ), ); const results = captured.body as Array<{ name: string; path: string }>; expect(results.length).toBeGreaterThan(0); for (const r of results) { expect( r.path === `/${username}/AppData/${appUid}` || r.path.startsWith(`/${username}/AppData/${appUid}/`), ).toBe(true); } expect( results.some((r) => r.path === `/${username}/Documents/${needle}`), ).toBe(false); }); it('returns nothing for an app actor when no AppData entries match', async () => { const { actor: userActor } = await makeUser(); const username = userActor.user!.username!; const appUid = `app-search-${uuidv4()}`; const appActor: Actor = { ...userActor, app: { uid: appUid } }; const needle = `appneedle-${Math.random().toString(36).slice(2, 8)}`; // Only seed outside AppData — the app must not be able to find it. await withActor(userActor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/${needle}` }, actor: userActor, }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(appActor, () => controller.searchEntries( makeReq({ body: { query: needle }, actor: appActor }), res, ), ); expect(captured.body).toEqual([]); }); }); // ── /read (readEntry, validation paths) ───────────────────────────── describe('FSController.readEntry', () => { it('throws 400 when reading a directory', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; await expect( withActor(actor, () => controller.readEntry( makeReq({ query: { path: `/${username}/Documents` }, actor, }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('throws 401 when no actor', async () => { const { actor } = await makeUser(); const req = { ...makeReq({ query: { path: `/${actor.user!.username}/Documents` }, actor, }), actor: undefined, } as unknown as Request; await expect( controller.readEntry(req, makeRes().res), ).rejects.toMatchObject({ statusCode: 401 }); }); }); // ── /mkdir (mkdirEntry) ───────────────────────────────────────────── describe('FSController.mkdirEntry', () => { it('throws 400 on missing path', async () => { const { actor } = await makeUser(); await expect( withActor(actor, () => controller.mkdirEntry( makeReq({ body: {}, actor }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('throws 400 when path normalizes to "/"', async () => { const { actor } = await makeUser(); await expect( withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: '/' }, actor }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('creates a directory and emits the GUI added event', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const { res, captured } = makeRes(); await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/created` }, actor, }), res, ), ); const body = captured.body as { path: string; isDir: boolean }; expect(body.path).toBe(`/${username}/Documents/created`); expect(body.isDir).toBe(true); }); it('dedupes an existing directory when dedupe_name is true', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const target = `/${username}/Documents/hello`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: target }, actor, }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: target, dedupe_name: true }, actor, }), res, ), ); const body = captured.body as { path: string; name: string; isDir: boolean; }; expect(body.path).toBe(`/${username}/Documents/hello (1)`); expect(body.name).toBe('hello (1)'); expect(body.isDir).toBe(true); expect( await server.stores.fsEntry.getEntryByPath( `/${username}/Documents/hello (1)`, ), ).toMatchObject({ isDir: true }); }); it('requires parent write when deduping an existing directory', async () => { const { actor: userActor } = await makeUser(); const username = userActor.user!.username!; const appUid = `app-mkdir-${uuidv4()}`; const appActor: Actor = { ...userActor, app: { uid: appUid } }; const target = `/${username}/AppData/${appUid}`; await withActor(userActor, () => controller.mkdirEntry( makeReq({ body: { path: target }, actor: userActor, }), makeRes().res, ), ); await expect( withActor(appActor, () => controller.mkdirEntry( makeReq({ body: { path: target, dedupe_name: true }, actor: appActor, }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 404 }); expect( await server.stores.fsEntry.getEntryByPath( `/${username}/AppData/${appUid} (1)`, ), ).toBeNull(); }); it('expands ~/ in the path to the user home', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const { res, captured } = makeRes(); await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: '~/Documents/tilde' }, actor, }), res, ), ); const body = captured.body as { path: string }; expect(body.path).toBe(`/${username}/Documents/tilde`); }); }); // ── /touch (touchEntry) ───────────────────────────────────────────── describe('FSController.touchEntry', () => { it('throws 400 on missing path', async () => { const { actor } = await makeUser(); await expect( withActor(actor, () => controller.touchEntry( makeReq({ body: {}, actor }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('throws 400 when path normalizes to "/"', async () => { const { actor } = await makeUser(); await expect( withActor(actor, () => controller.touchEntry( makeReq({ body: { path: '/' }, actor }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('creates a non-directory placeholder entry', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const { res, captured } = makeRes(); await withActor(actor, () => controller.touchEntry( makeReq({ body: { path: `/${username}/Documents/note.txt`, set_modified_to_now: true, }, actor, }), res, ), ); const body = captured.body as { isDir: boolean; name: string }; expect(body.isDir).toBe(false); expect(body.name).toBe('note.txt'); }); }); // ── /rename (renameEntry) ─────────────────────────────────────────── describe('FSController.renameEntry', () => { it('throws 400 on missing new_name', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; await expect( withActor(actor, () => controller.renameEntry( makeReq({ body: { path: `/${username}/Documents` }, actor, }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('renames an existing entry', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/before` }, actor, }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.renameEntry( makeReq({ body: { path: `/${username}/Documents/before`, new_name: 'after', }, actor, }), res, ), ); const body = captured.body as { path: string; name: string }; expect(body.name).toBe('after'); expect(body.path).toBe(`/${username}/Documents/after`); }); }); // ── /delete (deleteEntry) ─────────────────────────────────────────── describe('FSController.deleteEntry', () => { it('removes an entry by path and responds {ok: true}', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const target = `/${username}/Documents/doomed`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: target }, actor }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.deleteEntry( makeReq({ body: { path: target, recursive: true }, actor, }), res, ), ); expect(captured.body).toEqual({ ok: true }); }); it('throws 404 when the entry does not exist', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; await expect( withActor(actor, () => controller.deleteEntry( makeReq({ body: { path: `/${username}/Documents/does-not-exist-${uuidv4()}`, }, actor, }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 404 }); }); }); // ── /move (moveEntry) ─────────────────────────────────────────────── describe('FSController.moveEntry', () => { it('moves an entry to a new parent', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const src = `/${username}/Documents/movable`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: src }, actor }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.moveEntry( makeReq({ body: { source: { path: src }, destination: { path: `/${username}/Pictures` }, }, actor, }), res, ), ); const body = captured.body as { path: string }; expect(body.path).toBe(`/${username}/Pictures/movable`); }); }); // ── /copy (copyEntry) ─────────────────────────────────────────────── describe('FSController.copyEntry', () => { it('copies an entry into another folder', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const src = `/${username}/Documents/c-orig`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: src }, actor }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.copyEntry( makeReq({ body: { source: { path: src }, destination: { path: `/${username}/Pictures` }, }, actor, }), res, ), ); const body = captured.body as { path: string }; expect(body.path).toBe(`/${username}/Pictures/c-orig`); }); }); // ── /read (readEntry, full read) ──────────────────────────────────── describe('FSController.readEntry (file streaming)', () => { const makeStreamingRes = () => { const captured = { statusCode: 200, headers: {} as Record, bodyChunks: [] as Buffer[], }; // eslint-disable-next-line @typescript-eslint/no-require-imports const { Writable } = require('node:stream') as typeof import('node:stream'); const writable = new Writable({ write(chunk: Buffer, _enc, cb) { captured.bodyChunks.push(chunk); cb(); }, }); // Decorate with the Express helpers the controller calls. const res = writable as unknown as Response & { status: (code: number) => unknown; setHeader: (k: string, v: string) => unknown; json: (v: unknown) => unknown; send: (v: unknown) => unknown; }; res.status = (code: number) => { captured.statusCode = code; return res; }; res.setHeader = (k: string, v: string) => { captured.headers[k] = v; return res; }; res.json = vi.fn(() => res); res.send = vi.fn(() => res); return { res, captured }; }; const writeFile = async ( userId: number, path: string, body: Buffer, contentType = 'application/octet-stream', ) => { await server.services.fs.write(userId, { fileMetadata: { path, size: body.byteLength, contentType, }, fileContent: body, }); }; it('streams the file body with 200 and Content-Type/Length/Disposition headers', async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; const body = Buffer.from('hello world'); const target = `/${username}/Documents/read.txt`; await writeFile(userId, target, body, 'text/plain'); const { res, captured } = makeStreamingRes(); await withActor(actor, () => controller.readEntry( makeReq({ query: { path: target }, actor }), res, ), ); // Pipeline awaits the stream-end on success. expect(captured.statusCode).toBe(200); expect(captured.headers['Content-Type']).toMatch(/text\/plain/); expect(captured.headers['Content-Length']).toBe( String(body.byteLength), ); expect(captured.headers['Content-Disposition']).toMatch( /inline; filename=/, ); expect(Buffer.concat(captured.bodyChunks).equals(body)).toBe(true); }); it('returns 206 with Range honored when a Range header is supplied', async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; const body = Buffer.from('abcdefghij'); const target = `/${username}/Documents/ranged.bin`; await writeFile(userId, target, body, 'application/octet-stream'); const { res, captured } = makeStreamingRes(); await withActor(actor, () => controller.readEntry( makeReq({ query: { path: target }, actor, headers: { range: 'bytes=0-3' }, }), res, ), ); // Range presence flips the status to 206 — Content-Range may or // may not be set depending on the underlying S3 mock; the status // transition is the wire-level promise this code holds. expect(captured.statusCode).toBe(206); }); it('throws 404 when the path does not exist', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; await expect( withActor(actor, () => controller.readEntry( makeReq({ query: { path: `/${username}/Documents/missing-${uuidv4()}.txt`, }, actor, }), makeStreamingRes().res, ), ), ).rejects.toMatchObject({ statusCode: 404 }); }); }); // ── /readdir extras: sort + limit/offset ──────────────────────────── describe('FSController.readdirEntries sort + limit', () => { it('accepts sort_by + sort_order and passes them through to listDirectory', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; // Spy on the service so we can verify the controller-side // parsing of sort_by/sort_order/limit/offset. const listSpy = vi .spyOn(server.services.fs, 'listDirectory') .mockResolvedValueOnce([] as never); try { await withActor(actor, () => controller.readdirEntries( makeReq({ body: { path: `/${username}/Documents`, sort_by: 'name', sort_order: 'desc', limit: 10, offset: 5, }, actor, }), makeRes().res, ), ); expect(listSpy).toHaveBeenCalledTimes(1); const opts = listSpy.mock.calls[0]![1]!; expect(opts.sortBy).toBe('name'); expect(opts.sortOrder).toBe('desc'); expect(opts.limit).toBe(10); expect(opts.offset).toBe(5); } finally { listSpy.mockRestore(); } }); it('defaults invalid sort_by/sort_order to null', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const listSpy = vi .spyOn(server.services.fs, 'listDirectory') .mockResolvedValueOnce([] as never); try { await withActor(actor, () => controller.readdirEntries( makeReq({ body: { path: `/${username}/Documents`, sort_by: 'totally-fake', sort_order: 'sideways', }, actor, }), makeRes().res, ), ); const opts = listSpy.mock.calls[0]![1]!; expect(opts.sortBy).toBeNull(); expect(opts.sortOrder).toBeNull(); } finally { listSpy.mockRestore(); } }); }); // -- /readdir pagination envelope -- describe('FSController.readdirEntries pagination', () => { const makeDocs = async (names: string[]) => { const { actor } = await makeUser(); const username = actor.user!.username!; for (const name of names) { await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/${name}` }, actor, }), makeRes().res, ), ); } return { actor, path: `/${username}/Documents` }; }; const readdir = async ( actor: Awaited>['actor'], body: Record, ) => { const { res, captured } = makeRes(); await withActor(actor, () => controller.readdirEntries(makeReq({ body, actor }), res), ); return captured.body; }; it('keeps the bare array response for limit/offset requests', async () => { const { actor, path } = await makeDocs(['a', 'b', 'c']); const body = await readdir(actor, { path, limit: 2, offset: 1 }); expect(Array.isArray(body)).toBe(true); expect((body as unknown[]).length).toBe(2); }); it('returns the envelope when cursor is present (null = first page)', async () => { const { actor, path } = await makeDocs(['a', 'b', 'c']); const page = (await readdir(actor, { path, cursor: null })) as { items: Array<{ name: string }>; cursor?: string; }; expect(page.items.map((e) => e.name)).toEqual(['a', 'b', 'c']); expect(page.cursor).toBeUndefined(); }); it('pages through children with cursors in sort order', async () => { const { actor, path } = await makeDocs(['d1', 'd2', 'd3', 'd4', 'd5']); const names: string[] = []; let cursor: string | null | undefined = null; do { const page = (await readdir(actor, { path, limit: 2, cursor, })) as { items: Array<{ name: string }>; cursor?: string }; names.push(...page.items.map((e) => e.name)); cursor = page.cursor; } while (cursor); expect(names).toEqual(['d1', 'd2', 'd3', 'd4', 'd5']); }); it('respects descending sort across pages', async () => { const { actor, path } = await makeDocs(['a', 'b', 'c', 'd']); const first = (await readdir(actor, { path, limit: 2, cursor: null, sortBy: 'name', sortOrder: 'desc', })) as { items: Array<{ name: string }>; cursor?: string }; expect(first.items.map((e) => e.name)).toEqual(['d', 'c']); const second = (await readdir(actor, { path, limit: 2, cursor: first.cursor, })) as { items: Array<{ name: string }>; cursor?: string }; expect(second.items.map((e) => e.name)).toEqual(['b', 'a']); }); it('rejects a cursor that conflicts with the requested sort', async () => { const { actor, path } = await makeDocs(['a', 'b', 'c']); const first = (await readdir(actor, { path, limit: 1, cursor: null, sortBy: 'name', })) as { cursor?: string }; await expect( withActor(actor, () => controller.readdirEntries( makeReq({ body: { path, limit: 1, cursor: first.cursor, sortBy: 'size', }, actor, }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('reports total with includeTotal', async () => { const { actor, path } = await makeDocs(['a', 'b', 'c']); const page = (await readdir(actor, { path, limit: 1, cursor: null, includeTotal: true, })) as { items: unknown[]; total?: number }; expect(page.items.length).toBe(1); expect(page.total).toBe(3); }); it('wraps the root listing in an envelope when asked', async () => { const { actor } = await makeUser(); const page = (await readdir(actor, { path: '/', cursor: null, includeTotal: true, })) as { items: unknown[]; total?: number; cursor?: string }; expect(Array.isArray(page.items)).toBe(true); expect(page.total).toBe(page.items.length); expect(page.cursor).toBeUndefined(); }); }); // -- /readdir recursive (nested listing) -- describe('FSController.readdirEntries recursive', () => { // Seed a nested tree under Documents/tree and return its base path. // Relative depths: l1a/l1b = 1, l2a = 2, l3a = 3, l4a = 4. const makeTree = async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; const base = `/${username}/Documents/tree`; const dirs = [ base, `${base}/l1a`, `${base}/l1b`, `${base}/l1a/l2a`, `${base}/l1a/l2a/l3a`, `${base}/l1a/l2a/l3a/l4a`, ]; for (const path of dirs) { await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path }, actor }), makeRes().res, ), ); } return { actor, userId, base }; }; const readdir = async ( actor: Awaited>['actor'], body: Record, ) => { const { res, captured } = makeRes(); await withActor(actor, () => controller.readdirEntries(makeReq({ body, actor }), res), ); return captured.body; }; const rel = (base: string, items: Array<{ path: string }>) => items.map((e) => e.path.slice(base.length + 1)).sort(); it('depth 1 returns only direct children (like a normal readdir)', async () => { const { actor, base } = await makeTree(); const page = (await readdir(actor, { path: base, recursive: true, depth: 1, })) as { items: Array<{ path: string }>; cursor?: string }; expect(rel(base, page.items)).toEqual(['l1a', 'l1b']); }); it('deeper levels appear as depth grows', async () => { const { actor, base } = await makeTree(); const d2 = (await readdir(actor, { path: base, recursive: true, depth: 2, })) as { items: Array<{ path: string }> }; expect(rel(base, d2.items)).toEqual(['l1a', 'l1a/l2a', 'l1b']); const d3 = (await readdir(actor, { path: base, recursive: true, depth: 3, })) as { items: Array<{ path: string }> }; expect(rel(base, d3.items)).toEqual([ 'l1a', 'l1a/l2a', 'l1a/l2a/l3a', 'l1b', ]); }); it('caps depth at 10 so a huge depth returns the whole subtree', async () => { const { actor, base } = await makeTree(); const page = (await readdir(actor, { path: base, recursive: true, depth: 9999, })) as { items: Array<{ path: string }> }; expect(rel(base, page.items)).toEqual([ 'l1a', 'l1a/l2a', 'l1a/l2a/l3a', 'l1a/l2a/l3a/l4a', 'l1b', ]); }); it('pages through the whole subtree with cursors, no dupes or gaps', async () => { const { actor, base } = await makeTree(); const seen: string[] = []; let cursor: string | null | undefined = null; do { const page = (await readdir(actor, { path: base, recursive: true, depth: 10, limit: 2, cursor, })) as { items: Array<{ path: string }>; cursor?: string }; expect(page.items.length).toBeLessThanOrEqual(2); seen.push(...page.items.map((e) => e.path)); cursor = page.cursor; } while (cursor); expect(rel(base, seen.map((path) => ({ path })))).toEqual([ 'l1a', 'l1a/l2a', 'l1a/l2a/l3a', 'l1a/l2a/l3a/l4a', 'l1b', ]); }); it('counts the subtree with includeTotal', async () => { const { actor, base } = await makeTree(); const page = (await readdir(actor, { path: base, recursive: true, depth: 2, cursor: null, includeTotal: true, })) as { items: unknown[]; total?: number }; expect(page.total).toBe(3); // l1a, l1b, l2a }); it('enriches entries with type, thumbnail and associatedApp', async () => { const { actor, base } = await makeTree(); const page = (await readdir(actor, { path: base, recursive: true, depth: 1, })) as { items: Array<{ type?: unknown; thumbnail?: unknown; associatedApp?: unknown; }>; }; for (const item of page.items) { expect(item.type).toBe('folder'); expect(item.thumbnail ?? null).toBeNull(); expect('associatedApp' in item).toBe(true); } }); it('gives files a MIME type and an associatedApp field', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const dir = `/${username}/Documents/files`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: dir }, actor }), makeRes().res, ), ); await withActor(actor, () => controller.touchEntry( makeReq({ body: { path: `${dir}/pic.png` }, actor }), makeRes().res, ), ); const page = (await readdir(actor, { path: dir, recursive: true, depth: 1, })) as { items: Array<{ name: string; type?: unknown; associatedApp?: unknown; }>; }; const file = page.items.find((e) => e.name === 'pic.png')!; expect(String(file.type)).toContain('image/png'); expect('associatedApp' in file).toBe(true); }); it('masks denials for app-under-user actors as a 404 (legacy parity)', async () => { const { actor: userActor } = await makeUser(); const username = userActor.user!.username!; const appActor: Actor = { ...userActor, app: { uid: `app-readdir-${uuidv4()}` }, }; // The user's Documents is outside the app's AppData subtree, so the // app can't list it. Legacy `/readdir` masks this as a 404 // subject_does_not_exist rather than leaking a 403. await expect( withActor(appActor, () => controller.readdirEntries( makeReq({ body: { path: `/${username}/Documents` }, actor: appActor, }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 404, legacyCode: 'subject_does_not_exist', }); }); it('rejects recursive listing at the root', async () => { const { actor } = await makeUser(); await expect( withActor(actor, () => controller.readdirEntries( makeReq({ body: { path: '/', recursive: true }, actor, }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('does not leak another users identically-named subtree', async () => { const { actor, base } = await makeTree(); // A second user with the same relative tree must not appear. await makeTree(); const page = (await readdir(actor, { path: base, recursive: true, depth: 10, })) as { items: Array<{ path: string }> }; for (const item of page.items) { expect(item.path.startsWith(`${base}/`)).toBe(true); } expect(page.items).toHaveLength(5); }); }); // ── /touch additional branches ────────────────────────────────────── describe('FSController.touchEntry additional branches', () => { it('forwards set_accessed_to_now / set_created_to_now / create_missing_parents flags', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const touchSpy = vi .spyOn(server.services.fs, 'touch') .mockResolvedValueOnce({ path: `/${username}/Documents/spy.txt`, name: 'spy.txt', isDir: false, } as never); try { await withActor(actor, () => controller.touchEntry( makeReq({ body: { path: `/${username}/Documents/spy.txt`, set_accessed_to_now: true, set_modified_to_now: 'yes', // string coercion set_created_to_now: 1, // numeric coercion create_missing_parents: 'true', }, actor, }), makeRes().res, ), ); const opts = touchSpy.mock.calls[0]![1]!; expect(opts.setAccessed).toBe(true); expect(opts.setModified).toBe(true); expect(opts.setCreated).toBe(true); expect(opts.createMissingParents).toBe(true); } finally { touchSpy.mockRestore(); } }); }); // ── /delete additional branches ───────────────────────────────────── describe('FSController.deleteEntry additional branches', () => { it('forwards descendants_only + recursive flags', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const target = `/${username}/Documents/dscnd`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: target }, actor }), makeRes().res, ), ); const removeSpy = vi .spyOn(server.services.fs, 'remove') .mockResolvedValueOnce(undefined as never); try { await withActor(actor, () => controller.deleteEntry( makeReq({ body: { path: target, recursive: 'yes', descendants_only: '1', }, actor, }), makeRes().res, ), ); const opts = removeSpy.mock.calls[0]![1]!; expect(opts.recursive).toBe(true); expect(opts.descendantsOnly).toBe(true); } finally { removeSpy.mockRestore(); } }); }); // ── /move additional branches ─────────────────────────────────────── describe('FSController.moveEntry additional branches', () => { it('forwards new_name, overwrite, and dedupe_name (via change_name alias)', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const src = `/${username}/Documents/mv-orig`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: src }, actor }), makeRes().res, ), ); const moveSpy = vi .spyOn(server.services.fs, 'move') .mockResolvedValueOnce({ path: `/${username}/Pictures/renamed`, } as never); try { await withActor(actor, () => controller.moveEntry( makeReq({ body: { source: { path: src }, destination: { path: `/${username}/Pictures` }, new_name: 'renamed', overwrite: 'true', change_name: 'true', // alias for dedupe_name }, actor, }), makeRes().res, ), ); const opts = moveSpy.mock.calls[0]![1]!; expect(opts.newName).toBe('renamed'); expect(opts.overwrite).toBe(true); expect(opts.dedupeName).toBe(true); } finally { moveSpy.mockRestore(); } }); }); // ── /copy additional branches ─────────────────────────────────────── describe('FSController.copyEntry additional branches', () => { it('forwards new_name with dedupe_name defaulting to true', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const src = `/${username}/Documents/cp-orig`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: src }, actor }), makeRes().res, ), ); const copySpy = vi .spyOn(server.services.fs, 'copy') .mockResolvedValueOnce({ path: `/${username}/Pictures/cp-orig`, } as never); try { await withActor(actor, () => controller.copyEntry( makeReq({ body: { source: { path: src }, destination: { path: `/${username}/Pictures` }, new_name: 'cp-renamed', }, actor, }), makeRes().res, ), ); const opts = copySpy.mock.calls[0]![1]!; expect(opts.newName).toBe('cp-renamed'); // Default for copy is dedupeName=true (unlike move which is false). expect(opts.dedupeName).toBe(true); } finally { copySpy.mockRestore(); } }); }); // ── /search additional ────────────────────────────────────────────── describe('FSController.searchEntries fallback fields', () => { it('falls back to body.text when body.query is missing', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const needle = `txtfb-${Math.random().toString(36).slice(2, 8)}`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: `/${username}/Documents/${needle}` }, actor, }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.searchEntries( // No `query` key — only `text`. makeReq({ body: { text: needle }, actor }), res, ), ); const results = captured.body as Array<{ name: string }>; expect(results.some((r) => r.name === needle)).toBe(true); }); it('forwards `limit` to searchByName when provided', async () => { const { actor } = await makeUser(); const searchSpy = vi .spyOn(server.services.fs, 'searchByName') .mockResolvedValueOnce([] as never); try { await withActor(actor, () => controller.searchEntries( makeReq({ body: { query: 'anything', limit: 50 }, actor, }), makeRes().res, ), ); expect(searchSpy.mock.calls[0]![2]).toBe(50); } finally { searchSpy.mockRestore(); } }); }); // ── /stat additional ──────────────────────────────────────────────── describe('FSController.statEntry additional branches', () => { it('returns return_size for a directory containing files', async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; const dir = `/${username}/Documents/sized-with-file`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: dir }, actor }), makeRes().res, ), ); const fileBody = Buffer.from('123'); await server.services.fs.write(userId, { fileMetadata: { path: `${dir}/a.txt`, size: fileBody.byteLength, contentType: 'text/plain', }, fileContent: fileBody, }); const { res, captured } = makeRes(); await withActor(actor, () => controller.statEntry( makeReq({ body: { path: dir, return_size: true }, actor, }), res, ), ); const body = captured.body as { size: number }; expect(body.size).toBeGreaterThanOrEqual(fileBody.byteLength); }); }); // ── #getReportedCosts ─────────────────────────────────────────────── describe('FSController.getReportedCosts', () => { it('mirrors every FS_COSTS entry as a per-byte line item', async () => { const { FS_COSTS } = await import('./costs.js'); const reported = controller.getReportedCosts(); expect(reported.length).toBe(Object.keys(FS_COSTS).length); for (const [usageType, ucentsPerUnit] of Object.entries(FS_COSTS)) { expect(reported).toContainEqual({ usageType, ucentsPerUnit, unit: 'byte', source: 'controller:fs', }); } }); }); // ── /mkshortcut (mkshortcutEntry) ─────────────────────────────────── describe('FSController.mkshortcutEntry', () => { it('throws 400 on missing name', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; await expect( withActor(actor, () => controller.mkshortcutEntry( makeReq({ body: { parent: { path: `/${username}/Documents` }, target: { path: `/${username}/Pictures` }, }, actor, }), makeRes().res, ), ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('creates a shortcut entry pointing at the target', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; const target = `/${username}/Documents/shortcut-target`; await withActor(actor, () => controller.mkdirEntry( makeReq({ body: { path: target }, actor }), makeRes().res, ), ); const { res, captured } = makeRes(); await withActor(actor, () => controller.mkshortcutEntry( makeReq({ body: { parent: { path: `/${username}/Pictures` }, target: { path: target }, name: 'my-shortcut', }, actor, }), res, ), ); const body = captured.body as { name: string; isShortcut: boolean; }; expect(body.name).toBe('my-shortcut'); expect(body.isShortcut).toBe(true); }); }); describe('FSController metadata.objectKey injection', () => { const writeFile = async ( actor: Actor, path: string, content: string, metadata?: Record, ) => { await withActor(actor, () => controller.write( makeReq({ body: { fileMetadata: { path, size: Buffer.byteLength(content), contentType: 'text/plain', overwrite: true, ...(metadata ? { metadata } : {}), }, fileContent: content, encoding: 'utf8', }, actor, }) as unknown as Request< Record, null, import('./requestTypes.js').WriteRequest >, makeRes().res, ), ); const entry = await server.stores.fsEntry.getEntryByPath(path, { skipCache: true, }); if (!entry) throw new Error(`entry not found after write: ${path}`); return entry; }; it("does not stream another user's file when a client injects metadata.objectKey on write", async () => { const victim = await makeUser(); const attacker = await makeUser(); const victimSecret = 'VICTIM-TOP-SECRET-PAYLOAD'; const attackerDecoy = 'attacker-own-decoy-bytes'; const victimEntry = await writeFile( victim.actor, `/${victim.actor.user!.username}/Documents/secret.txt`, victimSecret, ); const victimRead = await server.services.fs.readContent(victimEntry); expect(await streamToString(victimRead.body)).toBe(victimSecret); const attackerEntry = await writeFile( attacker.actor, `/${attacker.actor.user!.username}/Documents/loot.txt`, attackerDecoy, { objectKey: victimEntry.uuid }, ); const persisted = attackerEntry.metadata ? (JSON.parse(attackerEntry.metadata) as Record) : {}; expect(persisted.objectKey).toBeUndefined(); const attackerRead = await server.services.fs.readContent(attackerEntry); const got = await streamToString(attackerRead.body); expect(got).toBe(attackerDecoy); expect(got).not.toBe(victimSecret); }); it('read path ignores a divergent metadata.objectKey on an already-poisoned row', async () => { const victim = await makeUser(); const attacker = await makeUser(); const victimSecret = 'VICTIM-SECRET-FOR-POISON-TEST'; const attackerDecoy = 'attacker-decoy-for-poison-test'; const victimEntry = await writeFile( victim.actor, `/${victim.actor.user!.username}/Documents/secret2.txt`, victimSecret, ); const attackerEntry = await writeFile( attacker.actor, `/${attacker.actor.user!.username}/Documents/loot2.txt`, attackerDecoy, ); await server.stores.fsEntry.updateEntry(attackerEntry.uuid, { metadata: JSON.stringify({ objectKey: victimEntry.uuid }), }); const poisoned = await server.stores.fsEntry.getEntryByPath( attackerEntry.path, { skipCache: true }, ); if (!poisoned) throw new Error('poisoned entry not found'); expect( (JSON.parse(poisoned.metadata!) as { objectKey: string }).objectKey, ).toBe(victimEntry.uuid); const read = await server.services.fs.readContent(poisoned); expect(await streamToString(read.body)).toBe(attackerDecoy); }); it('scrubs objectKey from move newMetadata while preserving legit trash metadata', async () => { const victim = await makeUser(); const attacker = await makeUser(); const victimSecret = 'VICTIM-SECRET-FOR-MOVE-TEST'; const attackerDecoy = 'attacker-decoy-for-move-test'; const username = attacker.actor.user!.username!; const victimEntry = await writeFile( victim.actor, `/${victim.actor.user!.username}/Documents/secret3.txt`, victimSecret, ); const attackerEntry = await writeFile( attacker.actor, `/${username}/Documents/loot3.txt`, attackerDecoy, ); const documents = await server.stores.fsEntry.getEntryByPath( `/${username}/Documents`, { skipCache: true }, ); if (!documents) throw new Error('Documents dir not found'); const moved = await withActor(attacker.actor, () => server.services.fs.move(attacker.userId, { source: attackerEntry, destinationParent: documents, newName: 'loot3-moved.txt', newMetadata: { original_path: `/${username}/Documents/loot3.txt`, trashed_ts: 1700000000, objectKey: victimEntry.uuid, }, }), ); const persisted = JSON.parse(moved.metadata!) as Record< string, unknown >; expect(persisted.objectKey).toBeUndefined(); expect(persisted.original_path).toBe( `/${username}/Documents/loot3.txt`, ); expect(persisted.trashed_ts).toBe(1700000000); const read = await server.services.fs.readContent(moved); expect(await streamToString(read.body)).toBe(attackerDecoy); }); }); // ── associatedAppId entitlement gate ──────────────────────────────── // // `associatedAppId` is client-supplied write metadata that's echoed back in // legacy FS responses. Binding a file to another tenant's private app would // turn `/stat` into an app-row enumeration oracle, so the write path drops // any association the actor isn't entitled to make. describe('FSController associatedAppId entitlement gate', () => { // Seed an app row with a direct insert. `create` treats is_private as a // read-only column, and going through the store would prime the cache — // a raw insert leaves nothing cached so the gate's getById reads the DB. const makeApp = async ( ownerUserId: number, opts: { is_private?: boolean } = {}, ): Promise<{ id: number }> => { const uid = `app-${uuidv4()}`; await server.clients.db.write( `INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`) VALUES (?, ?, ?, ?, ?, ?)`, [ uid, uid, 'Gate App', 'https://gate-app.puter.site/', ownerUserId, opts.is_private ? 1 : 0, ], ); const row = ( await server.clients.db.read( 'SELECT id FROM apps WHERE uid = ?', [uid], ) )[0] as { id: number }; return { id: row.id }; }; // Write a file via the signed-write flow with the given associatedAppId // and return the committed entry's stored associatedAppId. const writeWithAssociation = async ( actor: Actor, path: string, associatedAppId: number, ): Promise => { const startRes = makeRes(); await withActor(actor, () => controller.startBatchWrites( makeReq({ body: [{ fileMetadata: { path, size: 3, associatedAppId } }], actor, }), startRes.res, ), ); const [started] = startRes.captured.body as SignedWriteResponse[]; const completeRes = makeRes(); await withActor(actor, () => controller.completeBatchWrites( makeReq({ body: [{ uploadId: started.sessionId }], actor, }), completeRes.res, ), ); const entry = await server.stores.fsEntry.getEntryByPath(path); return entry?.associatedAppId ?? null; }; it("drops an association to another tenant's private app", async () => { const victim = await makeUser(); const attacker = await makeUser(); const victimApp = await makeApp(victim.userId, { is_private: true }); const stored = await writeWithAssociation( attacker.actor, `/${attacker.actor.user!.username}/Documents/probe.txt`, victimApp.id, ); expect(stored).toBeNull(); }); it('keeps an association to a public app the actor does not own', async () => { const owner = await makeUser(); const other = await makeUser(); const publicApp = await makeApp(owner.userId, { is_private: false }); const stored = await writeWithAssociation( other.actor, `/${other.actor.user!.username}/Documents/public-assoc.txt`, publicApp.id, ); expect(stored).toBe(publicApp.id); }); it('keeps an association to the actor’s own private app', async () => { const owner = await makeUser(); const ownApp = await makeApp(owner.userId, { is_private: true }); const stored = await writeWithAssociation( owner.actor, `/${owner.actor.user!.username}/Documents/own-assoc.txt`, ownApp.id, ); expect(stored).toBe(ownApp.id); }); });