mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 16:07:13 +00:00
1855 lines
66 KiB
TypeScript
1855 lines
66 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 { Readable } from 'node:stream';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
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 {
|
|
ClientSignedWriteResponse,
|
|
CompleteWriteRequest,
|
|
SignedWriteRequest,
|
|
WriteRequest,
|
|
} from './requestTypes.js';
|
|
import type { AbortWriteRequest, SignMultipartPartsRequest } from './types.js';
|
|
|
|
// The write-side of `/fs/*`: `/write`, `/batchWrite` (JSON and multipart),
|
|
// `/startWrite` + `/completeWrite` + `/abortWrite` + `/signMultipartParts`.
|
|
// Driven against a real in-memory server so the S3 object store, the pending
|
|
// session rows and the storage-allowance checks are all live.
|
|
|
|
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 (
|
|
extra?: Record<string, unknown>,
|
|
freeStorage = 100 * 1024 * 1024,
|
|
): Promise<{ actor: Actor; userId: number; username: string }> => {
|
|
const username = `fsw-${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: freeStorage,
|
|
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,
|
|
username: refreshed.username,
|
|
actor: {
|
|
user: {
|
|
id: refreshed.id,
|
|
uuid: refreshed.uuid,
|
|
username: refreshed.username,
|
|
email: refreshed.email ?? null,
|
|
email_confirmed: true,
|
|
...extra,
|
|
} as Actor['user'],
|
|
},
|
|
};
|
|
};
|
|
|
|
interface CapturedResponse {
|
|
statusCode: number;
|
|
body: unknown;
|
|
}
|
|
|
|
const makeReq = <B>(init: {
|
|
body?: B;
|
|
headers?: Record<string, string>;
|
|
actor: Actor;
|
|
withUser?: boolean;
|
|
}): Request =>
|
|
({
|
|
body: init.body ?? ({} as B),
|
|
query: {},
|
|
headers: init.headers ?? { 'content-type': 'application/json' },
|
|
actor: init.actor,
|
|
...(init.withUser === false
|
|
? {}
|
|
: {
|
|
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);
|
|
|
|
// -- multipart request builder ---------------------------------------
|
|
//
|
|
// `/fs/batchWrite` in multipart mode reads the raw request stream with
|
|
// busboy, so the fake request has to be a real Readable carrying multipart
|
|
// bytes and a matching boundary header.
|
|
|
|
type MultipartPart =
|
|
| { kind: 'field'; name: string; value: string }
|
|
| { kind: 'file'; name: string; filename: string; content: string };
|
|
|
|
const BOUNDARY = 'puter-test-boundary';
|
|
|
|
const buildMultipartBody = (parts: MultipartPart[]): Buffer => {
|
|
const chunks: string[] = [];
|
|
for (const part of parts) {
|
|
chunks.push(`--${BOUNDARY}\r\n`);
|
|
if (part.kind === 'field') {
|
|
chunks.push(
|
|
`Content-Disposition: form-data; name="${part.name}"\r\n\r\n`,
|
|
);
|
|
chunks.push(`${part.value}\r\n`);
|
|
} else {
|
|
chunks.push(
|
|
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n`,
|
|
);
|
|
chunks.push('Content-Type: application/octet-stream\r\n\r\n');
|
|
chunks.push(`${part.content}\r\n`);
|
|
}
|
|
}
|
|
chunks.push(`--${BOUNDARY}--\r\n`);
|
|
return Buffer.from(chunks.join(''), 'utf8');
|
|
};
|
|
|
|
const makeMultipartReq = (parts: MultipartPart[], actor: Actor): Request => {
|
|
const stream = Readable.from([buildMultipartBody(parts)]);
|
|
return Object.assign(stream, {
|
|
body: undefined,
|
|
query: {},
|
|
headers: {
|
|
'content-type': `multipart/form-data; boundary=${BOUNDARY}`,
|
|
},
|
|
actor,
|
|
user: { id: actor.user!.id!, username: actor.user!.username! },
|
|
}) as unknown as Request;
|
|
};
|
|
|
|
// -- /fs/write --------------------------------------------------------
|
|
|
|
describe('FSController.write', () => {
|
|
it('writes file content and returns a sanitized fsEntry', async () => {
|
|
const { actor, userId, username } = await makeUser();
|
|
const target = `/${username}/Documents/write-basic.txt`;
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: target,
|
|
size: 5,
|
|
contentType: 'text/plain',
|
|
},
|
|
fileContent: 'hello',
|
|
} as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
|
|
const body = captured.body as {
|
|
wasOverwrite: boolean;
|
|
fsEntry: Record<string, unknown>;
|
|
};
|
|
expect(body.wasOverwrite).toBe(false);
|
|
expect(body.fsEntry.path).toBe(target);
|
|
expect(body.fsEntry.isDir).toBe(false);
|
|
for (const field of [
|
|
'id',
|
|
'userId',
|
|
'parentId',
|
|
'bucket',
|
|
'bucketRegion',
|
|
'objectKey',
|
|
'publicToken',
|
|
'fileRequestToken',
|
|
]) {
|
|
expect(body.fsEntry).not.toHaveProperty(field);
|
|
}
|
|
|
|
const stored = await server.stores.fsEntry.getEntryByPath(target);
|
|
expect(stored?.userId).toBe(userId);
|
|
expect(stored?.size).toBe(5);
|
|
});
|
|
|
|
it('reports wasOverwrite and updates the row when overwriting', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const target = `/${username}/Documents/write-overwrite.txt`;
|
|
const write = (content: string, overwrite: boolean) =>
|
|
withActor(actor, () => {
|
|
const { res, captured } = makeRes();
|
|
return controller
|
|
.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: target,
|
|
size: content.length,
|
|
overwrite,
|
|
},
|
|
fileContent: content,
|
|
} as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
)
|
|
.then(() => captured.body as { wasOverwrite: boolean });
|
|
});
|
|
|
|
expect((await write('one', false)).wasOverwrite).toBe(false);
|
|
expect((await write('second', true)).wasOverwrite).toBe(true);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(target);
|
|
expect(stored?.size).toBe(6);
|
|
});
|
|
|
|
it('decodes base64 file content when `encoding` says so', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const target = `/${username}/Documents/write-b64.bin`;
|
|
const { res } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path: target, size: 3 },
|
|
fileContent: Buffer.from('abc').toString('base64'),
|
|
encoding: 'base64',
|
|
} as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(target);
|
|
expect(stored?.size).toBe(3);
|
|
});
|
|
|
|
it('persists a thumbnail supplied inline with the write', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const target = `/${username}/Documents/write-thumb.txt`;
|
|
const thumbnail = 'data:image/png;base64,aGVsbG8=';
|
|
const { res } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path: target, size: 2 },
|
|
fileContent: 'hi',
|
|
thumbnailData: thumbnail,
|
|
} as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(target);
|
|
expect(stored?.thumbnail).toBe(thumbnail);
|
|
});
|
|
|
|
it('drops an oversized inline thumbnail instead of storing it', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const target = `/${username}/Documents/write-big-thumb.txt`;
|
|
// 3 MiB of base64 payload — over the 2 MiB thumbnail cap.
|
|
const oversized = `data:image/png;base64,${'A'.repeat(3 * 1024 * 1024)}`;
|
|
const { res } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path: target, size: 2 },
|
|
fileContent: 'hi',
|
|
thumbnailData: oversized,
|
|
} as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(target);
|
|
expect(stored?.thumbnail).toBeNull();
|
|
});
|
|
|
|
it('skips thumbnails for AppData paths', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const target = `/${username}/AppData/some-app/write-thumb.txt`;
|
|
const { res } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: target,
|
|
size: 2,
|
|
createMissingParents: true,
|
|
},
|
|
fileContent: 'hi',
|
|
thumbnailData: 'data:image/png;base64,aGVsbG8=',
|
|
} as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(target);
|
|
expect(stored?.thumbnail).toBeNull();
|
|
});
|
|
|
|
it('rejects a write with no path', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: {},
|
|
fileContent: 'x',
|
|
} as unknown as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
|
});
|
|
|
|
it('rejects a blank path with `Path cannot be empty`', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path: ' ' },
|
|
fileContent: 'x',
|
|
} as unknown as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: 'Path cannot be empty',
|
|
});
|
|
});
|
|
|
|
it('rejects a non-normalized path containing `..`', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/../../etc/passwd`,
|
|
},
|
|
fileContent: 'x',
|
|
} as unknown as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400, message: 'Invalid path' });
|
|
});
|
|
|
|
it('rejects a write whose parent is the root with `cannot_write_to_root`', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path: '/top-level.txt' },
|
|
fileContent: 'x',
|
|
} as unknown as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
legacyCode: 'cannot_write_to_root',
|
|
});
|
|
});
|
|
|
|
it('rejects a write to the root path itself', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path: '/' },
|
|
fileContent: 'x',
|
|
} as unknown as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
legacyCode: 'cannot_write_to_root',
|
|
});
|
|
});
|
|
|
|
it("masks a write into another user's home as 404 subject_does_not_exist", async () => {
|
|
const attacker = await makeUser();
|
|
const victim = await makeUser();
|
|
const { res } = makeRes();
|
|
// The denial must not confirm that the victim's directory exists —
|
|
// the ACL layer downgrades "forbidden" to "does not exist" whenever
|
|
// the caller cannot even `see` the path.
|
|
await expect(
|
|
withActor(attacker.actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: `/${victim.username}/Documents/intruder.txt`,
|
|
size: 1,
|
|
},
|
|
fileContent: 'x',
|
|
} as WriteRequest,
|
|
actor: attacker.actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 404,
|
|
legacyCode: 'subject_does_not_exist',
|
|
});
|
|
expect(
|
|
await server.stores.fsEntry.getEntryByPath(
|
|
`/${victim.username}/Documents/intruder.txt`,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('throws 401 when the request carries no user identity', async () => {
|
|
const { res } = makeRes();
|
|
const actor = { user: {} } as Actor;
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path: '/x/y.txt' },
|
|
fileContent: 'x',
|
|
} as unknown as WriteRequest,
|
|
actor,
|
|
withUser: false,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 401,
|
|
legacyCode: 'unauthorized',
|
|
});
|
|
});
|
|
|
|
it('trims a trailing slash and accepts a relative path', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
// No leading slash, trailing slash: both normalized.
|
|
fileMetadata: {
|
|
path: `${username}/Documents/relative.txt/`,
|
|
size: 1,
|
|
},
|
|
fileContent: 'x',
|
|
} as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
expect(
|
|
(captured.body as { fsEntry: { path: string } }).fsEntry.path,
|
|
).toBe(`/${username}/Documents/relative.txt`);
|
|
});
|
|
});
|
|
|
|
// -- storage allowance -------------------------------------------------
|
|
//
|
|
// Quota enforcement is off in the default test config (`is_storage_limited`
|
|
// false makes the ceiling free disk space), so this group runs its own
|
|
// server with the limit switched on.
|
|
|
|
describe('FSController.write storage allowance', () => {
|
|
let limitedServer: PuterServer;
|
|
let limitedController: FSController;
|
|
|
|
beforeAll(async () => {
|
|
limitedServer = await setupTestServer({
|
|
is_storage_limited: true,
|
|
} as never);
|
|
limitedController = limitedServer.controllers
|
|
.fs as unknown as FSController;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await limitedServer?.shutdown();
|
|
});
|
|
|
|
const makeLimitedUser = async (extra?: Record<string, unknown>) => {
|
|
const username = `fsq-${Math.random().toString(36).slice(2, 10)}`;
|
|
const created = await limitedServer.stores.user.create({
|
|
username,
|
|
uuid: uuidv4(),
|
|
password: null,
|
|
email: `${username}@test.local`,
|
|
free_storage: 16,
|
|
requires_email_confirmation: false,
|
|
});
|
|
await generateDefaultFsentries(
|
|
limitedServer.clients.db,
|
|
limitedServer.stores.user,
|
|
created,
|
|
);
|
|
const refreshed = (await limitedServer.stores.user.getById(
|
|
created.id,
|
|
))!;
|
|
return {
|
|
username: refreshed.username,
|
|
actor: {
|
|
user: {
|
|
id: refreshed.id,
|
|
uuid: refreshed.uuid,
|
|
username: refreshed.username,
|
|
email: refreshed.email ?? null,
|
|
email_confirmed: true,
|
|
...extra,
|
|
} as Actor['user'],
|
|
} as Actor,
|
|
};
|
|
};
|
|
|
|
const writeSixtyFourBytes = (actor: Actor, path: string) => {
|
|
const { res } = makeRes();
|
|
return withActor(actor, () =>
|
|
limitedController.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path, size: 64 },
|
|
fileContent: 'x'.repeat(64),
|
|
} as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
};
|
|
|
|
it('rejects a write over the stored allowance with 413 storage_limit_reached', async () => {
|
|
const { actor, username } = await makeLimitedUser();
|
|
await expect(
|
|
writeSixtyFourBytes(actor, `/${username}/Documents/too-big.bin`),
|
|
).rejects.toMatchObject({
|
|
statusCode: 413,
|
|
legacyCode: 'storage_limit_reached',
|
|
});
|
|
});
|
|
|
|
it('lifts the ceiling when the actor carries a larger live allowance', async () => {
|
|
// `#getStorageAllowanceMaxOverride` takes the larger of the actor's
|
|
// `free_storage` / `actual_free_storage` fields; a live grant on the
|
|
// actor beats the smaller value stored on the user row.
|
|
const { actor, username } = await makeLimitedUser({
|
|
actual_free_storage: 1024 * 1024,
|
|
});
|
|
const target = `/${username}/Documents/allowed-by-override.bin`;
|
|
await writeSixtyFourBytes(actor, target);
|
|
expect(
|
|
(await limitedServer.stores.fsEntry.getEntryByPath(target))?.size,
|
|
).toBe(64);
|
|
});
|
|
|
|
it('ignores a negative live allowance and keeps the stored ceiling', async () => {
|
|
const { actor, username } = await makeLimitedUser({
|
|
free_storage: -1,
|
|
});
|
|
await expect(
|
|
writeSixtyFourBytes(actor, `/${username}/Documents/negative.bin`),
|
|
).rejects.toMatchObject({ statusCode: 413 });
|
|
});
|
|
});
|
|
|
|
// -- /fs/startWrite ---------------------------------------------------
|
|
|
|
describe('FSController.startWrite', () => {
|
|
it('creates a pending session and hides storage internals', async () => {
|
|
const { actor, userId, username } = await makeUser();
|
|
const target = `/${username}/Documents/signed-single.bin`;
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: { fileMetadata: { path: target, size: 9 } },
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const body = captured.body as ClientSignedWriteResponse;
|
|
expect(body.sessionId).toEqual(expect.any(String));
|
|
expect(body.uploadMode).toBe('single');
|
|
for (const field of ['bucket', 'bucketRegion', 'objectKey']) {
|
|
expect(body).not.toHaveProperty(field);
|
|
}
|
|
const session = await server.stores.fsEntry.getPendingEntryBySessionId(
|
|
body.sessionId,
|
|
);
|
|
expect(session?.targetPath).toBe(target);
|
|
expect(session?.userId).toBe(userId);
|
|
});
|
|
|
|
it('emits a pending GUI event carrying the operation id', async () => {
|
|
const { actor, userId, username } = await makeUser();
|
|
const events: Array<Record<string, unknown>> = [];
|
|
const listener = (_key: string, data: unknown) => {
|
|
events.push(data as Record<string, unknown>);
|
|
};
|
|
server.clients.event.on('outer.gui.item.pending', listener as never);
|
|
try {
|
|
const { res } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/pending.bin`,
|
|
size: 3,
|
|
},
|
|
guiMetadata: { operationId: 'op-1' },
|
|
},
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
} finally {
|
|
server.clients.event.off(
|
|
'outer.gui.item.pending',
|
|
listener as never,
|
|
);
|
|
}
|
|
expect(events).toHaveLength(1);
|
|
const payload = events[0] as {
|
|
user_id_list: number[];
|
|
response: Record<string, unknown>;
|
|
};
|
|
expect(payload.user_id_list).toEqual([userId]);
|
|
expect(payload.response.pending_upload).toBe(true);
|
|
expect(payload.response.operation_id).toBe('op-1');
|
|
expect(payload.response.status).toBe('pending');
|
|
});
|
|
|
|
it('creates a real directory entry (and no session) for `directory: true`', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const target = `/${username}/Documents/signed-dir`;
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: target,
|
|
size: 0,
|
|
createMissingParents: true,
|
|
},
|
|
directory: true,
|
|
},
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const created = await server.stores.fsEntry.getEntryByPath(target);
|
|
expect(created?.isDir).toBe(true);
|
|
expect(captured.body).not.toHaveProperty('objectKey');
|
|
});
|
|
|
|
it('attaches signed thumbnail upload targets published by a listener', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const listener = (_key: string, data: unknown) => {
|
|
const payload = data as {
|
|
items: Array<{
|
|
index: number;
|
|
uploadUrl?: string;
|
|
thumbnailUrl?: string;
|
|
}>;
|
|
};
|
|
for (const item of payload.items) {
|
|
item.uploadUrl = `https://thumbs.test/put/${item.index}`;
|
|
item.thumbnailUrl = `https://thumbs.test/get/${item.index}`;
|
|
}
|
|
};
|
|
server.clients.event.on('thumbnail.upload.prepare', listener as never);
|
|
try {
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/thumbed.bin`,
|
|
size: 4,
|
|
},
|
|
thumbnailMetadata: {
|
|
contentType: 'image/png',
|
|
size: 128,
|
|
},
|
|
},
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const body = captured.body as ClientSignedWriteResponse;
|
|
expect(body.thumbnailUploadUrl).toBe('https://thumbs.test/put/0');
|
|
expect(body.thumbnailUrl).toBe('https://thumbs.test/get/0');
|
|
} finally {
|
|
server.clients.event.off(
|
|
'thumbnail.upload.prepare',
|
|
listener as never,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('rejects thumbnailMetadata with a blank contentType', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/bad-thumb.bin`,
|
|
size: 1,
|
|
},
|
|
thumbnailMetadata: {
|
|
contentType: ' ',
|
|
} as unknown as SignedWriteRequest['thumbnailMetadata'],
|
|
},
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: expect.stringContaining('thumbnailMetadata.contentType'),
|
|
});
|
|
});
|
|
|
|
it('rejects a negative thumbnailMetadata size', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/bad-thumb2.bin`,
|
|
size: 1,
|
|
},
|
|
thumbnailMetadata: {
|
|
contentType: 'image/png',
|
|
size: -1,
|
|
},
|
|
},
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: expect.stringContaining('thumbnailMetadata.size'),
|
|
});
|
|
});
|
|
|
|
it('skips signed thumbnail preparation when the declared size is over the cap', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const prepared: unknown[] = [];
|
|
const listener = (_key: string, data: unknown) => {
|
|
prepared.push(data);
|
|
};
|
|
server.clients.event.on('thumbnail.upload.prepare', listener as never);
|
|
try {
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/huge-thumb.bin`,
|
|
size: 1,
|
|
},
|
|
thumbnailMetadata: {
|
|
contentType: 'image/png',
|
|
size: 8 * 1024 * 1024,
|
|
},
|
|
},
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
expect(prepared).toHaveLength(0);
|
|
expect(captured.body).not.toHaveProperty('thumbnailUploadUrl');
|
|
} finally {
|
|
server.clients.event.off(
|
|
'thumbnail.upload.prepare',
|
|
listener as never,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('resolves a client-supplied appUID to the numeric associatedAppId', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const appUid = `app-${uuidv4()}`;
|
|
await server.clients.db.write(
|
|
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[appUid, appUid, 'Assoc App', 'https://assoc.test/', null, 0],
|
|
);
|
|
const [appRow] = (await server.clients.db.read(
|
|
'SELECT id FROM apps WHERE uid = ?',
|
|
[appUid],
|
|
)) as Array<{ id: number }>;
|
|
|
|
const target = `/${username}/Documents/assoc.txt`;
|
|
const { res } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path: target, size: 1 },
|
|
fileContent: 'x',
|
|
appUID: appUid,
|
|
} as unknown as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(target);
|
|
expect(stored?.associatedAppId).toBe(appRow!.id);
|
|
});
|
|
|
|
it('drops an appUID that does not resolve to a known app', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const target = `/${username}/Documents/unknown-app.txt`;
|
|
const { res } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.write(
|
|
makeReq<WriteRequest>({
|
|
body: {
|
|
fileMetadata: { path: target, size: 1 },
|
|
fileContent: 'x',
|
|
appUID: `app-${uuidv4()}`,
|
|
} as unknown as WriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(target);
|
|
expect(stored?.associatedAppId).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- /fs/completeWrite, /fs/abortWrite, /fs/signMultipartParts --------
|
|
|
|
describe('FSController.completeWrite', () => {
|
|
const startSignedWrite = async (
|
|
actor: Actor,
|
|
path: string,
|
|
size: number,
|
|
extra: Partial<SignedWriteRequest> = {},
|
|
) => {
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: { fileMetadata: { path, size }, ...extra },
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
return captured.body as ClientSignedWriteResponse;
|
|
};
|
|
|
|
it('finalizes a pending session into a real fsentry', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const target = `/${username}/Documents/complete-single.txt`;
|
|
const started = await startSignedWrite(actor, target, 4);
|
|
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.completeWrite(
|
|
makeReq<CompleteWriteRequest>({
|
|
body: { uploadId: started.sessionId },
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const body = captured.body as {
|
|
wasOverwrite: boolean;
|
|
fsEntry: Record<string, unknown>;
|
|
};
|
|
expect(body.fsEntry.path).toBe(target);
|
|
expect(body.wasOverwrite).toBe(false);
|
|
expect(body.fsEntry).not.toHaveProperty('userId');
|
|
expect(
|
|
await server.stores.fsEntry.getEntryByPath(target),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it('rejects an inline `data:` thumbnail on the signed completion path', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.completeWrite(
|
|
makeReq<CompleteWriteRequest>({
|
|
body: {
|
|
uploadId: 'irrelevant',
|
|
thumbnailData: 'data:image/png;base64,AAA',
|
|
},
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('accepts a non-inline thumbnail URL on completion', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const started = await startSignedWrite(
|
|
actor,
|
|
`/${username}/Documents/complete-thumb.txt`,
|
|
2,
|
|
);
|
|
const { res } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.completeWrite(
|
|
makeReq<CompleteWriteRequest>({
|
|
body: {
|
|
uploadId: started.sessionId,
|
|
thumbnailData: 'https://thumbs.test/x.png',
|
|
},
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(
|
|
`/${username}/Documents/complete-thumb.txt`,
|
|
);
|
|
expect(stored?.thumbnail).toBe('https://thumbs.test/x.png');
|
|
});
|
|
|
|
it('404s an unknown upload id', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.completeWrite(
|
|
makeReq<CompleteWriteRequest>({
|
|
body: { uploadId: uuidv4() },
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 404 });
|
|
});
|
|
});
|
|
|
|
describe('FSController.abortWrite', () => {
|
|
it('rejects a request with no uploadId', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.abortWrite(
|
|
makeReq<AbortWriteRequest>({
|
|
body: {} as AbortWriteRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
legacyCode: 'bad_request',
|
|
});
|
|
});
|
|
|
|
it('drops the pending session and answers {ok: true}', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const start = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/aborted.bin`,
|
|
size: 3,
|
|
},
|
|
},
|
|
actor,
|
|
}),
|
|
start.res,
|
|
),
|
|
);
|
|
const { sessionId } = start.captured.body as ClientSignedWriteResponse;
|
|
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.abortWrite(
|
|
makeReq<AbortWriteRequest>({
|
|
body: { uploadId: sessionId },
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
expect(captured.body).toEqual({ ok: true });
|
|
const session =
|
|
await server.stores.fsEntry.getPendingEntryBySessionId(sessionId);
|
|
expect(session?.status).toBe('aborted');
|
|
});
|
|
|
|
it("refuses to abort another user's upload session", async () => {
|
|
const owner = await makeUser();
|
|
const attacker = await makeUser();
|
|
const start = makeRes();
|
|
await withActor(owner.actor, () =>
|
|
controller.startWrite(
|
|
makeReq<SignedWriteRequest>({
|
|
body: {
|
|
fileMetadata: {
|
|
path: `/${owner.username}/Documents/not-yours.bin`,
|
|
size: 3,
|
|
},
|
|
},
|
|
actor: owner.actor,
|
|
}),
|
|
start.res,
|
|
),
|
|
);
|
|
const { sessionId } = start.captured.body as ClientSignedWriteResponse;
|
|
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(attacker.actor, () =>
|
|
controller.abortWrite(
|
|
makeReq<AbortWriteRequest>({
|
|
body: { uploadId: sessionId },
|
|
actor: attacker.actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 403 });
|
|
});
|
|
});
|
|
|
|
describe('FSController.signMultipartParts', () => {
|
|
it('rejects a request with no uploadId', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.signMultipartParts(
|
|
makeReq<SignMultipartPartsRequest>({
|
|
body: {} as SignMultipartPartsRequest,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('404s a session that does not exist', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.signMultipartParts(
|
|
makeReq<SignMultipartPartsRequest>({
|
|
body: { uploadId: uuidv4(), partNumbers: [1, 2] },
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 404 });
|
|
});
|
|
});
|
|
|
|
// -- /fs/batchWrite (JSON mode) ---------------------------------------
|
|
|
|
describe('FSController.batchWrites (json)', () => {
|
|
it('writes every item and returns one sanitized entry per request', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeReq<WriteRequest[]>({
|
|
body: [
|
|
{
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/batch-a.txt`,
|
|
size: 1,
|
|
},
|
|
fileContent: 'a',
|
|
},
|
|
{
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/batch-b.txt`,
|
|
size: 2,
|
|
},
|
|
fileContent: 'bb',
|
|
},
|
|
] as WriteRequest[],
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
const body = captured.body as Array<{
|
|
fsEntry: Record<string, unknown>;
|
|
}>;
|
|
expect(body.map((r) => r.fsEntry.path).sort()).toEqual([
|
|
`/${username}/Documents/batch-a.txt`,
|
|
`/${username}/Documents/batch-b.txt`,
|
|
]);
|
|
for (const item of body) {
|
|
expect(item.fsEntry).not.toHaveProperty('bucket');
|
|
}
|
|
});
|
|
|
|
it('returns [] for a non-array body', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeReq<WriteRequest[]>({
|
|
body: undefined,
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
expect(captured.body).toEqual([]);
|
|
});
|
|
|
|
it('silently drops .DS_Store items and writes nothing', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const junkPath = `/${username}/Documents/.DS_Store`;
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeReq<WriteRequest[]>({
|
|
body: [
|
|
{
|
|
fileMetadata: { path: junkPath, size: 1 },
|
|
fileContent: 'x',
|
|
},
|
|
] as WriteRequest[],
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
expect(captured.body).toEqual([]);
|
|
expect(await server.stores.fsEntry.getEntryByPath(junkPath)).toBeNull();
|
|
});
|
|
|
|
it('rejects the whole batch when one item is denied', async () => {
|
|
const attacker = await makeUser();
|
|
const victim = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(attacker.actor, () =>
|
|
controller.batchWrites(
|
|
makeReq<WriteRequest[]>({
|
|
body: [
|
|
{
|
|
fileMetadata: {
|
|
path: `/${attacker.username}/Documents/ok.txt`,
|
|
size: 1,
|
|
},
|
|
fileContent: 'x',
|
|
},
|
|
{
|
|
fileMetadata: {
|
|
path: `/${victim.username}/Documents/nope.txt`,
|
|
size: 1,
|
|
},
|
|
fileContent: 'x',
|
|
},
|
|
] as WriteRequest[],
|
|
actor: attacker.actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 404,
|
|
legacyCode: 'subject_does_not_exist',
|
|
});
|
|
// The permitted sibling item must not have been committed either —
|
|
// the ACL sweep runs before any byte is written.
|
|
expect(
|
|
await server.stores.fsEntry.getEntryByPath(
|
|
`/${attacker.username}/Documents/ok.txt`,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('accepts the `text/plain;actually=json` content type puter.js sends', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeReq<WriteRequest[]>({
|
|
body: [
|
|
{
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/text-plain.txt`,
|
|
size: 1,
|
|
},
|
|
fileContent: 'x',
|
|
},
|
|
] as WriteRequest[],
|
|
headers: { 'content-type': 'text/plain;actually=json' },
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
);
|
|
expect(captured.body).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects an unsupported content type with 415', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeReq<WriteRequest[]>({
|
|
body: [],
|
|
headers: { 'content-type': 'application/xml' },
|
|
actor,
|
|
}),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 415,
|
|
legacyCode: 'bad_request',
|
|
});
|
|
});
|
|
|
|
it('rejects a request with no content type at all with 415', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeReq<WriteRequest[]>({ body: [], headers: {}, actor }),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({ statusCode: 415 });
|
|
});
|
|
});
|
|
|
|
// -- /fs/batchWrite (multipart mode) ----------------------------------
|
|
|
|
describe('FSController.batchWrites (multipart)', () => {
|
|
const manifestFor = (paths: string[]) =>
|
|
JSON.stringify({
|
|
items: paths.map((path, index) => ({
|
|
index,
|
|
fileMetadata: { path, size: 0 },
|
|
})),
|
|
});
|
|
|
|
it('streams each file part into the matching manifest entry', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const paths = [
|
|
`/${username}/Documents/mp-a.txt`,
|
|
`/${username}/Documents/mp-b.txt`,
|
|
];
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: manifestFor(paths),
|
|
},
|
|
{
|
|
kind: 'file',
|
|
name: 'file-0',
|
|
filename: 'a.txt',
|
|
content: 'alpha',
|
|
},
|
|
{
|
|
kind: 'file',
|
|
name: 'file-1',
|
|
filename: 'b.txt',
|
|
content: 'beta!!',
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
);
|
|
const body = captured.body as Array<{
|
|
fsEntry: Record<string, unknown>;
|
|
}>;
|
|
expect(body.map((r) => r.fsEntry.path).sort()).toEqual(
|
|
[...paths].sort(),
|
|
);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(paths[0]!);
|
|
expect(stored?.size).toBe(5);
|
|
});
|
|
|
|
it('maps positional `file` parts onto manifest order', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const paths = [`/${username}/Documents/mp-pos.txt`];
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: manifestFor(paths),
|
|
},
|
|
{
|
|
kind: 'file',
|
|
name: 'file',
|
|
filename: 'x.txt',
|
|
content: 'positional',
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
);
|
|
expect(captured.body).toHaveLength(1);
|
|
const stored = await server.stores.fsEntry.getEntryByPath(paths[0]!);
|
|
expect(stored?.size).toBe(10);
|
|
});
|
|
|
|
it('drains .DS_Store parts without writing them', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const junk = `/${username}/Documents/.DS_Store`;
|
|
const real = `/${username}/Documents/mp-keep.txt`;
|
|
const { res, captured } = makeRes();
|
|
await withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: manifestFor([junk, real]),
|
|
},
|
|
{
|
|
kind: 'file',
|
|
name: 'file-0',
|
|
filename: '.DS_Store',
|
|
content: 'junk',
|
|
},
|
|
{
|
|
kind: 'file',
|
|
name: 'file-1',
|
|
filename: 'keep.txt',
|
|
content: 'keepme',
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
);
|
|
expect(captured.body).toHaveLength(1);
|
|
expect(await server.stores.fsEntry.getEntryByPath(junk)).toBeNull();
|
|
expect(await server.stores.fsEntry.getEntryByPath(real)).not.toBeNull();
|
|
});
|
|
|
|
it('rejects a multipart body with no manifest', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'file',
|
|
name: 'file-0',
|
|
filename: 'a.txt',
|
|
content: 'x',
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: 'Batch write manifest is required',
|
|
});
|
|
});
|
|
|
|
it('rejects a manifest that is not valid JSON', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: '{not json',
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: 'Batch write manifest is not valid JSON',
|
|
});
|
|
});
|
|
|
|
it('rejects a manifest with an empty items array', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: JSON.stringify({ items: [] }),
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: expect.stringContaining('non-empty items array'),
|
|
});
|
|
});
|
|
|
|
it('rejects a manifest item without fileMetadata', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: JSON.stringify({
|
|
items: [{ index: 0 }],
|
|
}),
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: expect.stringContaining('missing fileMetadata'),
|
|
});
|
|
});
|
|
|
|
it('rejects a manifest item with a negative index', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: JSON.stringify({
|
|
items: [
|
|
{
|
|
index: -1,
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/x.txt`,
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: expect.stringContaining('item index is invalid'),
|
|
});
|
|
});
|
|
|
|
it('rejects a manifest with duplicate indexes as a conflict', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: JSON.stringify({
|
|
items: [
|
|
{
|
|
index: 0,
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/dup1.txt`,
|
|
},
|
|
},
|
|
{
|
|
index: 0,
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/dup2.txt`,
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
legacyCode: 'conflict',
|
|
});
|
|
});
|
|
|
|
it('rejects two manifest fields in one request', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const manifest = manifestFor([`/${username}/Documents/twice.txt`]);
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: manifest,
|
|
},
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: manifest,
|
|
},
|
|
{
|
|
kind: 'file',
|
|
name: 'file-0',
|
|
filename: 'a.txt',
|
|
content: 'x',
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
message: expect.stringContaining('more than once'),
|
|
});
|
|
});
|
|
|
|
it('cleans up already-uploaded objects when a late parse failure aborts the batch', async () => {
|
|
// Ordering matters: the duplicate `manifest` field has to land *after*
|
|
// the file part has already been streamed to storage, so `parseFailure`
|
|
// is set with a successful upload on the books. That object has no DB
|
|
// row and must be swept, otherwise a malformed request leaks storage.
|
|
// Feeding the body in delayed chunks is what puts the upload ahead of
|
|
// the failure — a single-buffer body settles both in the same tick.
|
|
const { actor, username } = await makeUser();
|
|
const manifest = manifestFor([`/${username}/Documents/late-fail.txt`]);
|
|
const segments = [
|
|
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="manifest"\r\n\r\n${manifest}\r\n`,
|
|
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="file-0"; filename="a.txt"\r\n` +
|
|
'Content-Type: application/octet-stream\r\n\r\nuploaded-then-aborted\r\n',
|
|
// Closing this part's boundary ends the file stream, so the upload
|
|
// runs to completion during the gap before the next segment.
|
|
`--${BOUNDARY}\r\n`,
|
|
`Content-Disposition: form-data; name="manifest"\r\n\r\n${manifest}\r\n--${BOUNDARY}--\r\n`,
|
|
];
|
|
const stream = Readable.from(
|
|
(async function* () {
|
|
for (const [i, segment] of segments.entries()) {
|
|
if (i > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
yield Buffer.from(segment, 'utf8');
|
|
}
|
|
})(),
|
|
);
|
|
const req = Object.assign(stream, {
|
|
body: undefined,
|
|
query: {},
|
|
headers: {
|
|
'content-type': `multipart/form-data; boundary=${BOUNDARY}`,
|
|
},
|
|
actor,
|
|
user: { id: actor.user!.id!, username: actor.user!.username! },
|
|
}) as unknown as Request;
|
|
|
|
const cleanupSpy = vi.spyOn(
|
|
server.services.fs,
|
|
'cleanupPreparedBatchUploads',
|
|
);
|
|
const { res } = makeRes();
|
|
try {
|
|
await expect(
|
|
withActor(actor, () => controller.batchWrites(req, res)),
|
|
).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
message: expect.stringContaining('more than once'),
|
|
});
|
|
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
expect(cleanupSpy.mock.calls[0]![1]).toHaveLength(1);
|
|
} finally {
|
|
cleanupSpy.mockRestore();
|
|
}
|
|
expect(
|
|
await server.stores.fsEntry.getEntryByPath(
|
|
`/${username}/Documents/late-fail.txt`,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('rejects file content that arrives before the manifest', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'file',
|
|
name: 'file-0',
|
|
filename: 'a.txt',
|
|
content: 'early',
|
|
},
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: manifestFor([
|
|
`/${username}/Documents/late.txt`,
|
|
]),
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
message: 'Batch write manifest must come before file content',
|
|
});
|
|
});
|
|
|
|
it('rejects duplicate file content for the same manifest index', async () => {
|
|
const { actor, username } = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: manifestFor([
|
|
`/${username}/Documents/dupfile.txt`,
|
|
]),
|
|
},
|
|
{
|
|
kind: 'file',
|
|
name: 'file-0',
|
|
filename: 'a.txt',
|
|
content: 'one',
|
|
},
|
|
{
|
|
kind: 'file',
|
|
name: 'file-0',
|
|
filename: 'a.txt',
|
|
content: 'two',
|
|
},
|
|
],
|
|
actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
message: expect.stringContaining('Duplicate file content'),
|
|
});
|
|
});
|
|
|
|
it('denies the whole multipart batch when a manifest path is not writable', async () => {
|
|
const attacker = await makeUser();
|
|
const victim = await makeUser();
|
|
const { res } = makeRes();
|
|
await expect(
|
|
withActor(attacker.actor, () =>
|
|
controller.batchWrites(
|
|
makeMultipartReq(
|
|
[
|
|
{
|
|
kind: 'field',
|
|
name: 'manifest',
|
|
value: manifestFor([
|
|
`/${victim.username}/Documents/stolen.txt`,
|
|
]),
|
|
},
|
|
{
|
|
kind: 'file',
|
|
name: 'file-0',
|
|
filename: 'a.txt',
|
|
content: 'x',
|
|
},
|
|
],
|
|
attacker.actor,
|
|
),
|
|
res,
|
|
),
|
|
),
|
|
).rejects.toMatchObject({
|
|
statusCode: 404,
|
|
legacyCode: 'subject_does_not_exist',
|
|
});
|
|
expect(
|
|
await server.stores.fsEntry.getEntryByPath(
|
|
`/${victim.username}/Documents/stolen.txt`,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
});
|