216 lines
9.2 KiB
TypeScript
216 lines
9.2 KiB
TypeScript
// Programmatic single-file write endpoint (POST /files/write).
|
||
//
|
||
// Backs the workspace-apps AppRunner postMessage `writeFile` bridge. Unlike
|
||
// /files/upload (O_EXCL, collision-rename), this OVERWRITES the target path so
|
||
// an app can update its own data file. Same write gate as upload/delete
|
||
// (canEditInSpace) and same ensurePathWithin traversal guard.
|
||
|
||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||
import express from 'express';
|
||
import request from 'supertest';
|
||
import { mkdtempSync, rmSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||
import AdmZip from 'adm-zip';
|
||
import { join } from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import { Repository } from '../db/repository.js';
|
||
import { spaceFilesDir } from '../spaces/paths.js';
|
||
import { createSpaceApi, type SpaceApiDeps } from './space-api.js';
|
||
|
||
function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
|
||
return {
|
||
id,
|
||
email: `${id}@x.com`,
|
||
name: id,
|
||
avatarUrl: null,
|
||
role,
|
||
status: 'active',
|
||
orgIds,
|
||
defaultVisibility: 'private',
|
||
defaultVisibilityOrgId: null,
|
||
} as unknown as Express.User;
|
||
}
|
||
|
||
describe('space /files/write endpoint', () => {
|
||
let dir = '';
|
||
let repo: Repository;
|
||
let ownerId = '';
|
||
let editorId = '';
|
||
let viewerId = '';
|
||
let strangerId = '';
|
||
let spaceId = '';
|
||
let worktreeDir = '';
|
||
|
||
function appAs(u: Express.User | null, authActive = true): express.Application {
|
||
const app = express();
|
||
if (u) {
|
||
app.use((req, _res, next) => {
|
||
(req as unknown as { user: Express.User }).user = u;
|
||
next();
|
||
});
|
||
}
|
||
const deps: SpaceApiDeps = {
|
||
repo,
|
||
dataRoot: join(dir, 'data', 'users'),
|
||
worktreeDir,
|
||
authActive,
|
||
};
|
||
app.use('/api/local/spaces', createSpaceApi(deps));
|
||
return app;
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
dir = mkdtempSync(join(tmpdir(), 'space-write-'));
|
||
worktreeDir = join(dir, 'workspaces');
|
||
repo = new Repository(join(dir, 'db.sqlite'));
|
||
ownerId = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' }).id;
|
||
editorId = repo.createUser({ email: 'editor@x.com', name: 'editor', role: 'user', status: 'active' }).id;
|
||
viewerId = repo.createUser({ email: 'viewer@x.com', name: 'viewer', role: 'user', status: 'active' }).id;
|
||
strangerId = repo.createUser({ email: 'stranger@x.com', name: 'stranger', role: 'user', status: 'active' }).id;
|
||
|
||
const space = await repo.createSpace({ kind: 'case', title: '案件', ownerId, visibility: 'private' });
|
||
spaceId = space.id;
|
||
await repo.addSpaceMember({ spaceId, userId: editorId, role: 'editor', invitedBy: ownerId });
|
||
await repo.addSpaceMember({ spaceId, userId: viewerId, role: 'viewer', invitedBy: ownerId });
|
||
});
|
||
|
||
afterEach(() => {
|
||
repo.close();
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('editor writes text content under output/, file lands on disk', async () => {
|
||
const res = await request(appAs(user(editorId)))
|
||
.post(`/api/local/spaces/${spaceId}/files/write`)
|
||
.send({ path: 'output/report.txt', content: 'hello world' });
|
||
expect(res.status).toBe(200);
|
||
const abs = join(spaceFilesDir(worktreeDir, spaceId), 'output', 'report.txt');
|
||
expect(readFileSync(abs, 'utf-8')).toBe('hello world');
|
||
});
|
||
|
||
it('OVERWRITES an existing file (not collision-renamed like upload)', async () => {
|
||
const app = appAs(user(editorId));
|
||
await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'apps/foo/data/state.json', content: '{"v":1}' });
|
||
const res = await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'apps/foo/data/state.json', content: '{"v":2}' });
|
||
expect(res.status).toBe(200);
|
||
const abs = join(spaceFilesDir(worktreeDir, spaceId), 'apps', 'foo', 'data', 'state.json');
|
||
expect(readFileSync(abs, 'utf-8')).toBe('{"v":2}');
|
||
});
|
||
|
||
it('accepts base64 (binary) content', async () => {
|
||
const res = await request(appAs(user(editorId)))
|
||
.post(`/api/local/spaces/${spaceId}/files/write`)
|
||
.send({ path: 'output/blob.bin', contentBase64: Buffer.from([0, 1, 2, 255]).toString('base64') });
|
||
expect(res.status).toBe(200);
|
||
const abs = join(spaceFilesDir(worktreeDir, spaceId), 'output', 'blob.bin');
|
||
expect(Array.from(readFileSync(abs))).toEqual([0, 1, 2, 255]);
|
||
});
|
||
|
||
it('viewer member → 403 (read-only)', async () => {
|
||
const res = await request(appAs(user(viewerId)))
|
||
.post(`/api/local/spaces/${spaceId}/files/write`)
|
||
.send({ path: 'output/x.txt', content: 'no' });
|
||
expect(res.status).toBe(403);
|
||
});
|
||
|
||
it('non-member → 404 (read-gate, never reaches write gate)', async () => {
|
||
const res = await request(appAs(user(strangerId)))
|
||
.post(`/api/local/spaces/${spaceId}/files/write`)
|
||
.send({ path: 'output/x.txt', content: 'leak' });
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it('path traversal → 400', async () => {
|
||
const res = await request(appAs(user(editorId)))
|
||
.post(`/api/local/spaces/${spaceId}/files/write`)
|
||
.send({ path: '../../escape.txt', content: 'pwn' });
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('missing path → 400; missing body content → 400', async () => {
|
||
const app = appAs(user(editorId));
|
||
expect((await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ content: 'x' })).status).toBe(400);
|
||
expect((await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'output/x.txt' })).status).toBe(400);
|
||
});
|
||
|
||
it('writing onto an existing directory path → 400 (does not clobber dir)', async () => {
|
||
mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'output', 'adir'), { recursive: true });
|
||
const res = await request(appAs(user(editorId)))
|
||
.post(`/api/local/spaces/${spaceId}/files/write`)
|
||
.send({ path: 'output/adir', content: 'x' });
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('write outside apps/ or output/ → 403 (no arbitrary .html / AGENTS.md overwrite)', async () => {
|
||
const app = appAs(user(editorId));
|
||
// top-level file, AGENTS.md, source index, and a sneaky normalized path all rejected
|
||
for (const path of ['evil.html', 'AGENTS.md', 'source/index.jsonl', 'apps/../sneaky.html']) {
|
||
const res = await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path, content: 'x' });
|
||
expect([400, 403]).toContain(res.status); // apps/../ escapes-normalizes → not under apps/output → 403 (or 400 if guard trips)
|
||
expect(res.status).not.toBe(200);
|
||
}
|
||
// sanity: a legit apps/ path still works
|
||
expect((await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'apps/x/index.html', content: '<html></html>' })).status).toBe(200);
|
||
});
|
||
|
||
it('content over the 10MB cap → 400', async () => {
|
||
const big = 'a'.repeat(10 * 1024 * 1024 + 1);
|
||
const res = await request(appAs(user(editorId)))
|
||
.post(`/api/local/spaces/${spaceId}/files/write`)
|
||
.send({ path: 'output/big.txt', content: big });
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
// ── /files/download-zip(read ゲート: メンバーなら viewer でも可、非メンバーは 404)──
|
||
describe('download-zip', () => {
|
||
const zipReq = (app: express.Application, body: unknown) =>
|
||
request(app)
|
||
.post(`/api/local/spaces/${spaceId}/files/download-zip`)
|
||
.send(body as object)
|
||
.buffer(true)
|
||
.parse((res, cb) => {
|
||
const chunks: Buffer[] = [];
|
||
res.on('data', (c: Buffer) => chunks.push(c));
|
||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||
});
|
||
|
||
beforeEach(() => {
|
||
const filesDir = spaceFilesDir(worktreeDir, spaceId);
|
||
mkdirSync(join(filesDir, 'sub'), { recursive: true });
|
||
writeFileSync(join(filesDir, 'a.txt'), 'alpha');
|
||
writeFileSync(join(filesDir, 'sub', 'b.txt'), 'bravo');
|
||
});
|
||
|
||
it('owner zips selected files with relative entry names', async () => {
|
||
const res = await zipReq(appAs(user(ownerId)), { paths: ['a.txt', 'sub/b.txt'] });
|
||
expect(res.status).toBe(200);
|
||
expect(res.headers['content-type']).toContain('application/zip');
|
||
const zip = new AdmZip(res.body as Buffer);
|
||
expect(zip.getEntries().map((e) => e.entryName).sort()).toEqual(['a.txt', 'sub/b.txt']);
|
||
expect(zip.getEntry('a.txt')?.getData().toString()).toBe('alpha');
|
||
});
|
||
|
||
it('a read-only viewer member CAN download (read-gated, not edit)', async () => {
|
||
const res = await zipReq(appAs(user(viewerId)), { paths: ['a.txt'] });
|
||
expect(res.status).toBe(200);
|
||
});
|
||
|
||
it('a non-member stranger gets 404 (no read access)', async () => {
|
||
const res = await zipReq(appAs(user(strangerId)), { paths: ['a.txt'] });
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it('rejects traversal with 400', async () => {
|
||
const res = await request(appAs(user(ownerId)))
|
||
.post(`/api/local/spaces/${spaceId}/files/download-zip`)
|
||
.send({ paths: ['../../etc/passwd'] });
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toBe('Path escapes workspace');
|
||
});
|
||
|
||
it('returns 404 when nothing matches', async () => {
|
||
const res = await zipReq(appAs(user(ownerId)), { paths: ['nope.txt'] });
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
});
|