1 Commits
Author SHA1 Message Date
chick 81cef1ad6c fix(web): 修复无正文请求被 JSON 解析器拒绝导致"删除节点"失效
控制台所有请求共用的 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 相同)。
2026-09-29 18:55:54 +08:00
3 changed files with 68 additions and 2 deletions
@@ -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({
@@ -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);
@@ -89,9 +89,17 @@ interface JsonResponse {
readonly response: Response;
}
async function request(url: string, init?: RequestInit): Promise<JsonResponse> {
// 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 {