maestro/src/bridge/space-api.write-endpoint.test.ts
oss-sync 29ccaf1e92
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

216 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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-zipread ゲート: メンバーなら 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);
});
});
});