From 81cef1ad6c12243b0587c4d1d8ab17db52e17a8d Mon Sep 17 00:00:00 2001 From: chick Date: Tue, 29 Sep 2026 18:49:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E4=BF=AE=E5=A4=8D=E6=97=A0?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E8=AF=B7=E6=B1=82=E8=A2=AB=20JSON=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=99=A8=E6=8B=92=E7=BB=9D=E5=AF=BC=E8=87=B4?= =?UTF-8?q?"=E5=88=A0=E9=99=A4=E8=8A=82=E7=82=B9"=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 控制台所有请求共用的 request() 无条件带上 Content-Type: application/json, 包括无正文的 DELETE /api/v1/instances/:id 与 POST test-connection。Fastify 的 JSON 解析器见到"声明了 JSON 却是空正文"会直接回 400 HTTP_400,请求在 进入路由之前就被拒——所以"删除节点"看起来点了没反应,日志里三次真实尝试 全部是 400,而路由本身完好。 复现(对 8788 实测): - DELETE + Content-Type: json + 空正文 → 400 HTTP_400(旧行为,坏) - DELETE 不带 Content-Type → 401 CONSOLE_AUTH_REQUIRED(到达路由) - DELETE + Content-Type: json + {} → 401(能解析,故非路由问题) - GET + Content-Type: json + 空 → 401(Fastify 对 GET 不解析正文) 修复:Content-Type 只在确实有正文时才发送。删除的意图本来就在头部 (If-Match / X-Preparation-Id / X-Confirmation-Token)里,无正文属正常形状。 同源的 POST test-connection 也一并修好。iOS 客户端本就用 route.body != nil 判断,与修复后的契约一致——Web 侧是唯一的例外。 回归测试: - web:断言 DELETE 只发 Accept、不发 Content-Type - api:断言真实形状到达路由并完成删除(202 且节点转 404), 同时断言"声称 JSON 却空正文"仍须显式 400、节点必须留存 验证:147 文件 / 1371 测试全绿;lint 干净;typecheck 干净;dist 已重建且 8788 正在服务的 bundle 与磁盘一致(md5 相同)。 --- .../interface/http/instance-routes.test.ts | 56 +++++++++++++++++++ .../instance-api-data-source.test.ts | 4 +- .../src/instances/instance-api-data-source.ts | 10 +++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/api/src/interface/http/instance-routes.test.ts b/apps/api/src/interface/http/instance-routes.test.ts index 10f3f78..588a120 100644 --- a/apps/api/src/interface/http/instance-routes.test.ts +++ b/apps/api/src/interface/http/instance-routes.test.ts @@ -226,6 +226,62 @@ describe('instance HTTP routes', () => { await app.close(); }); + it('accepts the bodyless confirmation DELETE the console sends, and rejects a JSON claim', async () => { + // Regression: the console used to send Content-Type: application/json on this bodyless + // request. Fastify then rejected the empty body with 400 HTTP_400 before the route ran, so + // "delete node" silently did nothing. Deletion carries its intent in headers, so a request + // that merely *claims* a JSON body must fail loudly while the real shape must reach the route. + const { app } = fixture(); + const created = await app.inject({ + method: 'POST', + url: '/api/v1/instances', + payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080' }, + }); + const id = (created.json() as { id: string }).id; + + const preparation = await app.inject({ + method: 'POST', + url: '/api/v1/operations/prepare', + payload: { + operationId: 'deleteInstance', + targets: [{ instanceId: id, revision: 1 }], + parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] }, + }, + }); + const prepared = preparation.json() as { id: string; confirmationToken: string }; + const confirmationHeaders = { + 'if-match': '"rev-1"', + 'x-preparation-id': prepared.id, + 'x-confirmation-token': prepared.confirmationToken, + }; + + // The old broken shape: an empty body that still declares itself as JSON. + const claimedJsonBody = await app.inject({ + method: 'DELETE', + url: `/api/v1/instances/${id}`, + headers: { ...confirmationHeaders, 'content-type': 'application/json' }, + }); + expect(claimedJsonBody.statusCode).toBe(400); + expect(claimedJsonBody.json()).toMatchObject({ code: 'HTTP_400' }); + // The node must survive the rejected attempt. + expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe( + 200, + ); + + // The real console shape: headers only, no Content-Type, no body. + const accepted = await app.inject({ + method: 'DELETE', + url: `/api/v1/instances/${id}`, + headers: confirmationHeaders, + }); + expect(accepted.statusCode).toBe(202); + expect(accepted.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' }); + expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe( + 404, + ); + await app.close(); + }); + it('rejects invalid input and maps domain errors to stable RFC problem details', async () => { const { app } = fixture(); const invalid = await app.inject({ diff --git a/apps/web/src/instances/instance-api-data-source.test.ts b/apps/web/src/instances/instance-api-data-source.test.ts index 221a4aa..749d6c2 100644 --- a/apps/web/src/instances/instance-api-data-source.test.ts +++ b/apps/web/src/instances/instance-api-data-source.test.ts @@ -109,12 +109,14 @@ describe('instance API data source', () => { method: 'DELETE', headers: { Accept: 'application/json', - 'Content-Type': 'application/json', 'If-Match': '"rev-3"', 'X-Preparation-Id': 'prep-1', 'X-Confirmation-Token': token, }, }); + // A bodyless request must not claim a JSON body: the server's parser rejects the empty body + // with 400 before the confirmation headers are ever read, so deletion never happens. + expect(fetch.mock.calls[2]?.[1]?.headers).not.toHaveProperty('Content-Type'); expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined(); // The confirmation is a bearer secret: it belongs in a header, never in a URL or a body. expect(fetch.mock.calls.every(([url]) => !String(url).includes(token))).toBe(true); diff --git a/apps/web/src/instances/instance-api-data-source.ts b/apps/web/src/instances/instance-api-data-source.ts index 5c3082d..84339c1 100644 --- a/apps/web/src/instances/instance-api-data-source.ts +++ b/apps/web/src/instances/instance-api-data-source.ts @@ -89,9 +89,17 @@ interface JsonResponse { readonly response: Response; } async function request(url: string, init?: RequestInit): Promise { + // Content-Type describes the body. Sending it on a bodyless request makes the server's JSON + // parser reject the empty body with 400 before the route runs — which silently killed every + // DELETE (deletion) and POST (test-connection) that carries its intent in headers alone. + const hasBody = init?.body !== undefined && init.body !== null; const response = await fetch(url, { ...init, - headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...init?.headers }, + headers: { + Accept: 'application/json', + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), + ...init?.headers, + }, }); if (!response.ok) throw await safeError(response); return {