mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Merge branch 'feat/share-lane1-publish' into feat/share-comment-p0
This commit is contained in:
@@ -2893,6 +2893,7 @@ function exitWithStructuredError({ code, message, data }) {
|
||||
//
|
||||
// Daemon error envelopes come in two shapes in practice:
|
||||
// { error: { code, message, ... } } — newer routes using sendApiError
|
||||
// { error: '<code>', message, ... } — workspace authority routes
|
||||
// { error: '<message>' } — older flat-string routes
|
||||
// (e.g. POST /api/templates at
|
||||
// routes/project/index.ts)
|
||||
@@ -2911,7 +2912,9 @@ async function structuredHttpFailure(resp, fallbackCode = 'daemon-not-running')
|
||||
}
|
||||
const errorObj =
|
||||
typeof parsed?.error === 'string'
|
||||
? { message: parsed.error }
|
||||
? typeof parsed.message === 'string'
|
||||
? { ...parsed, code: parsed.error }
|
||||
: { message: parsed.error }
|
||||
: parsed?.error;
|
||||
const errCode = normalizeRecoverableErrorCode(errorObj?.code, errorObj?.message);
|
||||
if (errCode) {
|
||||
|
||||
@@ -6712,7 +6712,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
||||
'writeFiles',
|
||||
)) return;
|
||||
const target = await resolveProjectFilePath(PROJECTS_DIR, project.id, folderPath, project.metadata);
|
||||
if (!target.name || !(await ctx.node.fs.stat(target.filePath)).isDirectory()) {
|
||||
if (!target.name || !(await ctx.node.fs.promises.stat(target.filePath)).isDirectory()) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'target must be a non-root folder');
|
||||
}
|
||||
await ctx.stopPublicFilesBeforeDelete?.(project.id, { folderPath: target.name });
|
||||
@@ -7217,7 +7217,10 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
||||
res,
|
||||
status,
|
||||
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
||||
String(err),
|
||||
// Never expose filesystem error messages: they contain daemon-owned paths.
|
||||
status === 404 ? 'file not found'
|
||||
: err instanceof ProjectPublicFileStopPendingError ? 'PUBLIC_FILE_STOP_PENDING'
|
||||
: 'file could not be deleted',
|
||||
);
|
||||
}
|
||||
}));
|
||||
@@ -7930,7 +7933,10 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
||||
res,
|
||||
status,
|
||||
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
||||
String(err),
|
||||
// Never expose filesystem error messages: they contain daemon-owned paths.
|
||||
status === 404 ? 'file not found'
|
||||
: err instanceof ProjectPublicFileStopPendingError ? 'PUBLIC_FILE_STOP_PENDING'
|
||||
: 'file could not be deleted',
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -37,7 +37,7 @@ afterEach(async () => {
|
||||
tempRoot = '';
|
||||
});
|
||||
|
||||
async function startProjectStubServer(deleteResponse: unknown = { ok: true }, shareResponse?: unknown): Promise<StubServer> {
|
||||
async function startProjectStubServer(deleteResponse: unknown = { ok: true }, shareResponse?: unknown, shareStatus = 200): Promise<StubServer> {
|
||||
const requests: CapturedRequest[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let raw = '';
|
||||
@@ -114,7 +114,7 @@ async function startProjectStubServer(deleteResponse: unknown = { ok: true }, sh
|
||||
}
|
||||
if (['POST', 'GET'].includes(captured.method)
|
||||
&& captured.url === '/api/projects/project-1/files/nested%2Findex.html/publish-public') {
|
||||
if (shareResponse !== undefined) { res.end(JSON.stringify(shareResponse)); return; }
|
||||
if (shareResponse !== undefined) { res.statusCode = shareStatus; res.end(JSON.stringify(shareResponse)); return; }
|
||||
const publication = { url: 'https://example.invalid/returned-link', slug: 'returned-slug', fileName: 'nested/index.html' };
|
||||
res.end(JSON.stringify(captured.method === 'GET' ? { publication } : publication));
|
||||
return;
|
||||
@@ -247,6 +247,17 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
|
||||
}
|
||||
|
||||
describe('od project CLI', () => {
|
||||
it.each([false, true])('resume reports missing workspace rather than an unavailable daemon; nested=%s', async nested => {
|
||||
const code = 'WORKSPACE_CONTEXT_REQUIRED';
|
||||
const message = 'an explicit workspace context is required';
|
||||
stub = await startProjectStubServer(undefined, nested ? { error: { code, message } } : { error: code, message }, 400);
|
||||
const result = await runCli(['project', 'share', 'resume', 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl, '--json']);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(JSON.parse(result.stderr)).toEqual({ error: { code, message, data: {} } });
|
||||
expect(stub.requests).toHaveLength(1);
|
||||
expect(stub.requests[0]!.headers['x-od-workspace-id']).toBeUndefined();
|
||||
});
|
||||
it.each([false, true])('files delete preserves the HTTP response or human output, json=%s', async json => {
|
||||
const body = { ok: true };
|
||||
stub = await startProjectStubServer(body);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import http from 'node:http';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import nodeFs from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -18,7 +22,10 @@ it.each(['raw', 'files'] as const)('DELETE %s stops only the selected publicatio
|
||||
it('failed stop preserves file bytes and durably records the exact residual', async () => {
|
||||
await exercise('raw', true);
|
||||
});
|
||||
async function exercise(route: 'raw' | 'files', fail: boolean) {
|
||||
it.each(['raw', 'files'] as const)('DELETE %s conceals internal paths in HTTP and CLI failures', async route => {
|
||||
await exercise(route, false, true);
|
||||
});
|
||||
async function exercise(route: 'raw' | 'files', fail: boolean, missing = false) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'od-file-stop-'));
|
||||
const projects = path.join(root, 'projects');
|
||||
const db = openDatabase(root);
|
||||
@@ -40,15 +47,36 @@ async function exercise(route: 'raw' | 'files', fail: boolean) {
|
||||
} }));
|
||||
const app = express(); app.use(express.json());
|
||||
registerProjectFileRoutes(app, {
|
||||
db, paths: { PROJECTS_DIR: projects }, http: { sendApiError: (res: express.Response, status: number, code: string) => res.status(status).json({ error: code }) },
|
||||
db, paths: { PROJECTS_DIR: projects }, http: { sendApiError: (res: express.Response, status: number, code: string, message: string) => res.status(status).json({ error: { code, message } }) },
|
||||
projectStore: { getProject, getWorkspaceProject, getWorkspaceProjectByProjectId }, projectFiles,
|
||||
node: { fs }, uploads: {}, documents: {}, artifacts: {}, projectPreviewScopes: {},
|
||||
node: { fs: nodeFs }, uploads: {}, documents: {}, artifacts: {}, projectPreviewScopes: {},
|
||||
publicFileMutations: createPublicFileMutations(),
|
||||
stopPublicFilesBeforeDelete: (id: string, filePath?: string) => stop({ ...scope, projectId: id }, filePath),
|
||||
verifyWorkspaceRequestAuthority: async () => ({ ok: true, context: workspaceContextFromDirectoryItem({ workspaceId: scope.resourceTeamId, workspaceMemberId: scope.ownerMemberId, workspaceName: 'W', workspaceType: 'personal', role: 'owner', memberStatus: 'active', lifecycleState: 'active' }) }),
|
||||
} as unknown as Parameters<typeof registerProjectFileRoutes>[1]);
|
||||
server = app.listen(0); await new Promise<void>(resolve => server!.once('listening', resolve));
|
||||
const address = server.address(); if (!address || typeof address === 'string') throw new Error('missing port');
|
||||
if (missing) {
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const response = await fetch(`${base}/api/projects/${projectId}/${route}/missing.html`, { method: 'DELETE', headers: { 'x-od-workspace-id': scope.resourceTeamId, 'x-od-workspace-member-id': scope.ownerMemberId } });
|
||||
expect(response.status).toBe(404);
|
||||
const text = await response.text();
|
||||
expect.soft(text).not.toContain(root);
|
||||
expect.soft(JSON.parse(text)).toEqual({ error: { code: 'FILE_NOT_FOUND', message: 'file not found' } });
|
||||
if (route === 'files') {
|
||||
const env = { ...process.env }; delete env.NODE_OPTIONS;
|
||||
const result = await promisify(execFile)(process.execPath, ['--import', 'tsx', fileURLToPath(new URL('../../src/cli.ts', import.meta.url)), 'files', 'delete', projectId, 'missing.html', '--workspace', scope.resourceTeamId, '--workspace-member', scope.ownerMemberId, '--daemon-url', base, '--json'], { env, timeout: 15000 }).then(
|
||||
value => ({ ...value, code: 0 }),
|
||||
(error: { stdout: string; stderr: string; code: number }) => error,
|
||||
);
|
||||
expect(result.code).not.toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
expect.soft(result.stderr).not.toContain(root);
|
||||
expect.soft(JSON.parse(result.stderr)).toMatchObject({ error: { code: 'FILE_NOT_FOUND', message: 'file not found' } });
|
||||
}
|
||||
expect(remote.size).toBe(2);
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/api/projects/${projectId}/${route}/index.html`, { method: 'DELETE', headers: { 'x-od-workspace-id': scope.resourceTeamId, 'x-od-workspace-member-id': scope.ownerMemberId } });
|
||||
expect(response.status).toBe(fail ? 400 : 200);
|
||||
expect(remote.has('index.html')).toBe(fail);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs/promises';
|
||||
import nodeFs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
@@ -26,12 +27,12 @@ it.each([false, true])('folder deletion revokes descendants only; failure=%s pre
|
||||
const app=express();app.use(express.json());
|
||||
registerProjectFileRoutes(app,{
|
||||
db,paths:{PROJECTS_DIR:projects},http:{sendApiError:(res:express.Response,status:number,code:string)=>res.status(status).json({error:code})},
|
||||
projectStore:{getProject,getWorkspaceProject,getWorkspaceProjectByProjectId},projectFiles,node:{fs},uploads:{},documents:{},artifacts:{},projectPreviewScopes:{},
|
||||
projectStore:{getProject,getWorkspaceProject,getWorkspaceProjectByProjectId},projectFiles,node:{fs:nodeFs},uploads:{},documents:{},artifacts:{},projectPreviewScopes:{},
|
||||
publicFileMutations:createPublicFileMutations(),stopPublicFilesBeforeDelete:(id:string,target:Parameters<typeof stop>[1])=>stop({...scope,projectId:id},target),
|
||||
verifyWorkspaceRequestAuthority:async()=>({ok:true,context:workspaceContextFromDirectoryItem({workspaceId:scope.resourceTeamId,workspaceMemberId:scope.ownerMemberId,workspaceName:'W',workspaceType:'personal',role:'owner',memberStatus:'active',lifecycleState:'active'})}),
|
||||
} as unknown as Parameters<typeof registerProjectFileRoutes>[1]);
|
||||
server=app.listen(0);await new Promise<void>(resolve=>server!.once('listening',resolve));const address=server.address();if(!address||typeof address==='string')throw new Error('missing port');
|
||||
const response=await fetch(`http://127.0.0.1:${address.port}/api/projects/project/folders`,{method:'DELETE',headers:{'content-type':'application/json','x-od-workspace-id':scope.resourceTeamId,'x-od-workspace-member-id':scope.ownerMemberId},body:JSON.stringify({path:'folder/'})});
|
||||
const response=await fetch(`http://127.0.0.1:${address.port}/api/projects/project/folders`,{method:'DELETE',headers:{'content-type':'application/json','x-od-workspace-id':scope.resourceTeamId,'x-od-workspace-member-id':scope.ownerMemberId},body:JSON.stringify({path:'folder'})});
|
||||
expect(response.status).toBe(fail?400:200);
|
||||
for(const filePath of files){const kept=fail||!filePath.startsWith('folder/');expect(remote.has(filePath)).toBe(kept);expect(store.get({...scope,filePath})!==null).toBe(kept);if(kept)expect(await fs.readFile(path.join(projects,scope.projectId,filePath),'utf8')).toBe(filePath);else await expect(fs.stat(path.join(projects,scope.projectId,filePath))).rejects.toMatchObject({code:'ENOENT'});}
|
||||
expect(store.listStops().map(task=>task.filePath).sort()).toEqual(fail?files.slice(0,2).sort():[]);
|
||||
|
||||
Reference in New Issue
Block a user