mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-01 09:38:21 +00:00
2511 lines
89 KiB
TypeScript
2511 lines
89 KiB
TypeScript
/**
|
||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||
*
|
||
* This file is part of Puter.
|
||
*
|
||
* Puter is free software: you can redistribute it and/or modify
|
||
* it under the terms of the GNU Affero General Public License as published
|
||
* by the Free Software Foundation, either version 3 of the License, or
|
||
* (at your option) any later version.
|
||
*
|
||
* This program is distributed in the hope that it will be useful,
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
* GNU Affero General Public License for more details.
|
||
*
|
||
* You should have received a copy of the GNU Affero General Public License
|
||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
*/
|
||
|
||
import 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 = <B>(init: {
|
||
body?: B;
|
||
query?: Record<string, unknown>;
|
||
headers?: Record<string, string>;
|
||
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 <T>(actor: Actor, fn: () => Promise<T>): Promise<T> =>
|
||
runWithContext({ actor }, fn);
|
||
|
||
const streamToString = async (stream: Readable): Promise<string> => {
|
||
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<SignedWriteRequest[]>({ 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<SignedWriteRequest[]>({ 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<SignedWriteRequest[]>({ 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<SignedWriteRequest[]>({ 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<SignedWriteRequest[]>({
|
||
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<CompleteWriteRequest[]>({
|
||
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<CompleteWriteRequest[]>({
|
||
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<SignedWriteRequest[]>({ 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<CompleteWriteRequest[]>({
|
||
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<SignedWriteRequest[]>({
|
||
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<CompleteWriteRequest[]>({
|
||
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<SignedWriteRequest[]>({
|
||
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<CompleteWriteRequest[]>({
|
||
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<SignedWriteRequest[]>({
|
||
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<CompleteWriteRequest[]>({
|
||
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<SignedWriteRequest[]>({
|
||
body: [{ fileMetadata: { path: target, size: 1 } }],
|
||
actor,
|
||
}),
|
||
firstStart.res,
|
||
),
|
||
);
|
||
const [firstResponse] = firstStart.captured
|
||
.body as SignedWriteResponse[];
|
||
await withActor(actor, () =>
|
||
controller.completeBatchWrites(
|
||
makeReq<CompleteWriteRequest[]>({
|
||
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<SignedWriteRequest[]>({
|
||
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<CompleteWriteRequest[]>({
|
||
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<string, unknown>;
|
||
};
|
||
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<SignedWriteRequest[]>({
|
||
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<CompleteWriteRequest[]>({
|
||
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<string, unknown>;
|
||
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<string, string>,
|
||
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<ReturnType<typeof makeUser>>['actor'],
|
||
body: Record<string, unknown>,
|
||
) => {
|
||
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<ReturnType<typeof makeUser>>['actor'],
|
||
body: Record<string, unknown>,
|
||
) => {
|
||
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<string, unknown>,
|
||
) => {
|
||
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<string, never>,
|
||
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<string, unknown>)
|
||
: {};
|
||
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<number | null> => {
|
||
const startRes = makeRes();
|
||
await withActor(actor, () =>
|
||
controller.startBatchWrites(
|
||
makeReq<SignedWriteRequest[]>({
|
||
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<CompleteWriteRequest[]>({
|
||
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);
|
||
});
|
||
});
|