Compare commits
1
Commits
ios-native-ipa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81cef1ad6c |
@@ -226,6 +226,62 @@ describe('instance HTTP routes', () => {
|
|||||||
await app.close();
|
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 () => {
|
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
|
||||||
const { app } = fixture();
|
const { app } = fixture();
|
||||||
const invalid = await app.inject({
|
const invalid = await app.inject({
|
||||||
|
|||||||
@@ -109,12 +109,14 @@ describe('instance API data source', () => {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'If-Match': '"rev-3"',
|
'If-Match': '"rev-3"',
|
||||||
'X-Preparation-Id': 'prep-1',
|
'X-Preparation-Id': 'prep-1',
|
||||||
'X-Confirmation-Token': token,
|
'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();
|
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.
|
// 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);
|
expect(fetch.mock.calls.every(([url]) => !String(url).includes(token))).toBe(true);
|
||||||
|
|||||||
@@ -89,9 +89,17 @@ interface JsonResponse {
|
|||||||
readonly response: Response;
|
readonly response: Response;
|
||||||
}
|
}
|
||||||
async function request(url: string, init?: RequestInit): Promise<JsonResponse> {
|
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, {
|
const response = await fetch(url, {
|
||||||
...init,
|
...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);
|
if (!response.ok) throw await safeError(response);
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user