@remit/backend 0.0.116 → 0.0.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/error.test.ts +100 -13
- package/src/error.ts +66 -11
- package/src/handlers/account-guards.test.ts +67 -0
- package/src/handlers/account-guards.ts +7 -11
- package/src/handlers/account-oauth-start.test.ts +1 -0
- package/src/handlers/calendar-event.test.ts +10 -7
- package/src/handlers/calendar.test.ts +4 -4
- package/src/handlers/calendar.ts +20 -3
- package/src/handlers/outbox-attachment.ts +5 -0
- package/src/handlers/system-update.test.ts +47 -0
- package/src/handlers/system-update.ts +15 -10
- package/src/index.ts +23 -3
- package/src/jwt-auth.ts +2 -1
- package/src/openapi-spec.test.ts +74 -0
- package/src/request-pipeline.test.ts +88 -0
package/package.json
CHANGED
package/src/error.test.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import type { FolderRoleConflict } from "@remit/api-openapi-types";
|
|
3
|
+
import type { ApiError, FolderRoleConflict } from "@remit/api-openapi-types";
|
|
4
4
|
import {
|
|
5
|
+
BadRequestError,
|
|
5
6
|
ClientError,
|
|
7
|
+
ConflictError,
|
|
6
8
|
FolderRoleUnresolvedError,
|
|
7
9
|
ForbiddenError,
|
|
10
|
+
NotFoundError,
|
|
8
11
|
UnhandledError,
|
|
12
|
+
UnrecoverableBodyError,
|
|
9
13
|
} from "@remit/data-ports/errors";
|
|
10
14
|
import { NO_TRASH_FOLDER_REASON } from "@remit/data-ports/folder-role";
|
|
11
15
|
import { CanonicalMailboxRole } from "@remit/domain-enums";
|
|
@@ -15,6 +19,72 @@ import { handleError } from "./error.js";
|
|
|
15
19
|
const parseBody = (body: string): Record<string, unknown> =>
|
|
16
20
|
JSON.parse(body) as Record<string, unknown>;
|
|
17
21
|
|
|
22
|
+
// One shape for every error the API answers with (issue #371): flat, always a
|
|
23
|
+
// `code`, always a `message`. A status class that reaches a client without a
|
|
24
|
+
// code leaves it branching on prose.
|
|
25
|
+
describe("the wire shape of a refusal, per status class", () => {
|
|
26
|
+
const cases: ReadonlyArray<[string, Error, number, string]> = [
|
|
27
|
+
[
|
|
28
|
+
"400",
|
|
29
|
+
new BadRequestError("confirmEmail is required"),
|
|
30
|
+
400,
|
|
31
|
+
"invalid_request",
|
|
32
|
+
],
|
|
33
|
+
["401", new ClientError("Session expired"), 401, "unauthorized"],
|
|
34
|
+
["403", new ForbiddenError("Not your account"), 403, "forbidden"],
|
|
35
|
+
["404", new NotFoundError("Mailbox not found: mb-1"), 404, "not_found"],
|
|
36
|
+
[
|
|
37
|
+
"409",
|
|
38
|
+
new ConflictError("An update is already in progress."),
|
|
39
|
+
409,
|
|
40
|
+
"conflict",
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
"422",
|
|
44
|
+
new UnrecoverableBodyError("This message's body could not be read."),
|
|
45
|
+
422,
|
|
46
|
+
"unprocessable_entity",
|
|
47
|
+
],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
for (const [name, error, statusCode, code] of cases) {
|
|
51
|
+
it(`answers ${name} with a flat code and message`, async () => {
|
|
52
|
+
const response = await handleError(error);
|
|
53
|
+
|
|
54
|
+
assert.equal(response.statusCode, statusCode);
|
|
55
|
+
assert.deepEqual(parseBody(response.body), {
|
|
56
|
+
code,
|
|
57
|
+
message: error.message,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A 5xx is the one class whose message is not the thrower's. Those sentences
|
|
63
|
+
// are written for a log line and name hosts, queries and ids; the correlation
|
|
64
|
+
// id on the response is how the real one is found.
|
|
65
|
+
it("answers 500 with one code and one sentence, never the thrower's", async () => {
|
|
66
|
+
const response = await handleError(
|
|
67
|
+
new UnhandledError(
|
|
68
|
+
"pg://reader@db-3.internal:5432 timed out on SELECT * FROM message",
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
assert.equal(response.statusCode, 500);
|
|
73
|
+
assert.deepEqual(parseBody(response.body), {
|
|
74
|
+
code: "internal_error",
|
|
75
|
+
message: "Internal server error",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("types every one of those bodies as the declared ApiError", async () => {
|
|
80
|
+
const response = await handleError(new NotFoundError("Gone"));
|
|
81
|
+
const body: ApiError = JSON.parse(response.body);
|
|
82
|
+
|
|
83
|
+
assert.equal(body.code, "not_found");
|
|
84
|
+
assert.equal(body.message, "Gone");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
18
88
|
describe("handleError coded refusals", () => {
|
|
19
89
|
it("puts the code and every detail of a folder-role 409 on the wire", async () => {
|
|
20
90
|
const response = await handleError(
|
|
@@ -45,18 +115,19 @@ describe("handleError coded refusals", () => {
|
|
|
45
115
|
});
|
|
46
116
|
});
|
|
47
117
|
|
|
48
|
-
it("keeps an unhandled 500 to a message — no stack, no cause, no
|
|
118
|
+
it("keeps an unhandled 500 to a code and a message — no stack, no cause, no details", async () => {
|
|
49
119
|
const response = await handleError(
|
|
50
120
|
new UnhandledError("Something went wrong", new Error("connection reset")),
|
|
51
121
|
);
|
|
52
122
|
|
|
53
123
|
assert.equal(response.statusCode, 500);
|
|
54
124
|
assert.deepEqual(parseBody(response.body), {
|
|
55
|
-
|
|
125
|
+
code: "internal_error",
|
|
126
|
+
message: "Internal server error",
|
|
56
127
|
});
|
|
57
128
|
});
|
|
58
129
|
|
|
59
|
-
it("
|
|
130
|
+
it("replaces a code a 5xx should never have carried, and drops its details", async () => {
|
|
60
131
|
const error = new UnhandledError("Something went wrong");
|
|
61
132
|
error.publicApiError = {
|
|
62
133
|
code: "folder_role_unresolved",
|
|
@@ -67,22 +138,38 @@ describe("handleError coded refusals", () => {
|
|
|
67
138
|
|
|
68
139
|
assert.equal(response.statusCode, 500);
|
|
69
140
|
assert.deepEqual(parseBody(response.body), {
|
|
70
|
-
|
|
141
|
+
code: "internal_error",
|
|
142
|
+
message: "Internal server error",
|
|
71
143
|
});
|
|
72
144
|
});
|
|
73
145
|
|
|
74
|
-
it("
|
|
75
|
-
const
|
|
146
|
+
it("keeps an infrastructure failure to the generic message and code", async () => {
|
|
147
|
+
const error = Object.assign(
|
|
148
|
+
new Error("aws-error: ProvisionedThroughputExceeded on remit-main"),
|
|
149
|
+
{ name: "ElectroError" },
|
|
150
|
+
);
|
|
76
151
|
|
|
77
|
-
|
|
78
|
-
|
|
152
|
+
const response = await handleError(error);
|
|
153
|
+
|
|
154
|
+
assert.equal(response.statusCode, 500);
|
|
155
|
+
assert.deepEqual(parseBody(response.body), {
|
|
156
|
+
code: "internal_error",
|
|
157
|
+
message: "Database temporarily unavailable",
|
|
158
|
+
});
|
|
79
159
|
});
|
|
80
160
|
|
|
81
|
-
it("answers a
|
|
82
|
-
const
|
|
161
|
+
it("answers a malformed query as a 400 with the query's own message", async () => {
|
|
162
|
+
const error = Object.assign(new Error("Invalid attribute: sentDate"), {
|
|
163
|
+
name: "ElectroError",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const response = await handleError(error);
|
|
83
167
|
|
|
84
|
-
assert.equal(response.statusCode,
|
|
85
|
-
assert.deepEqual(parseBody(response.body), {
|
|
168
|
+
assert.equal(response.statusCode, 400);
|
|
169
|
+
assert.deepEqual(parseBody(response.body), {
|
|
170
|
+
code: "invalid_request",
|
|
171
|
+
message: "Invalid attribute: sentDate",
|
|
172
|
+
});
|
|
86
173
|
});
|
|
87
174
|
|
|
88
175
|
// The contract and the emitter cannot drift apart: this asserts the response
|
package/src/error.ts
CHANGED
|
@@ -3,6 +3,42 @@ import { logger } from "@remit/logger-lambda";
|
|
|
3
3
|
import type { APIGatewayProxyResult } from "aws-lambda";
|
|
4
4
|
import { formatResponse } from "./response.js";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The `code` a refusal carries when it does not name its own. Every error body
|
|
8
|
+
* the API emits is flat `{ code, message }` (issue #371), and `code` is the
|
|
9
|
+
* only field a client may branch on — a message is a sentence for a person and
|
|
10
|
+
* is free to change.
|
|
11
|
+
*
|
|
12
|
+
* A 5xx always answers `internal_error`, whatever the throw site said: a coded
|
|
13
|
+
* 5xx is a mistake at the throw site, not a contract, and a fixed token cannot
|
|
14
|
+
* carry anything about the failure out of the process.
|
|
15
|
+
*/
|
|
16
|
+
export const INTERNAL_ERROR_CODE = "internal_error";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The one sentence any 5xx answers with. A thrown message is written for a log
|
|
20
|
+
* line, not for a stranger — it names hosts, queries and ids — so it is
|
|
21
|
+
* replaced rather than trusted. The real message is already on the error log
|
|
22
|
+
* beside the correlation id the response carries.
|
|
23
|
+
*/
|
|
24
|
+
export const INTERNAL_ERROR_MESSAGE = "Internal server error";
|
|
25
|
+
|
|
26
|
+
const CODE_BY_STATUS: Record<number, string> = {
|
|
27
|
+
400: "invalid_request",
|
|
28
|
+
401: "unauthorized",
|
|
29
|
+
403: "forbidden",
|
|
30
|
+
404: "not_found",
|
|
31
|
+
409: "conflict",
|
|
32
|
+
412: "precondition_failed",
|
|
33
|
+
413: "payload_too_large",
|
|
34
|
+
422: "unprocessable_entity",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const defaultErrorCode = (statusCode: number): string => {
|
|
38
|
+
if (statusCode >= 500) return INTERNAL_ERROR_CODE;
|
|
39
|
+
return CODE_BY_STATUS[statusCode] ?? "request_refused";
|
|
40
|
+
};
|
|
41
|
+
|
|
6
42
|
export const handleError = async (
|
|
7
43
|
error: unknown,
|
|
8
44
|
): Promise<APIGatewayProxyResult> => {
|
|
@@ -16,20 +52,30 @@ export const handleError = async (
|
|
|
16
52
|
},
|
|
17
53
|
"Error with statusCode",
|
|
18
54
|
);
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
55
|
+
// An error that opted in names its own code and details, and only below
|
|
56
|
+
// 500: the status bound keeps a coded 5xx from reaching a client rather
|
|
57
|
+
// than trusting every future thrower to leave `publicApiError` alone.
|
|
58
|
+
// Everything else takes the status class's code, so no response goes out
|
|
59
|
+
// without one. A 5xx keeps neither the thrower's code nor its message —
|
|
60
|
+
// one status, one code, one sentence, whatever failed.
|
|
23
61
|
const statusCode =
|
|
24
62
|
typeof error.statusCode === "number" ? error.statusCode : 500;
|
|
63
|
+
if (statusCode >= 500) {
|
|
64
|
+
return formatResponse(
|
|
65
|
+
{ code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE },
|
|
66
|
+
statusCode,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
25
69
|
const publicApiError =
|
|
26
|
-
|
|
27
|
-
"publicApiError" in error &&
|
|
28
|
-
isPublicApiError(error.publicApiError)
|
|
70
|
+
"publicApiError" in error && isPublicApiError(error.publicApiError)
|
|
29
71
|
? error.publicApiError
|
|
30
72
|
: undefined;
|
|
31
73
|
return formatResponse(
|
|
32
|
-
{
|
|
74
|
+
{
|
|
75
|
+
code: defaultErrorCode(statusCode),
|
|
76
|
+
message: error.message,
|
|
77
|
+
...publicApiError,
|
|
78
|
+
},
|
|
33
79
|
statusCode,
|
|
34
80
|
);
|
|
35
81
|
}
|
|
@@ -53,16 +99,25 @@ export const handleError = async (
|
|
|
53
99
|
? "Database temporarily unavailable"
|
|
54
100
|
: error.message;
|
|
55
101
|
|
|
56
|
-
return formatResponse(
|
|
102
|
+
return formatResponse(
|
|
103
|
+
{ code: defaultErrorCode(statusCode), message: responseMessage },
|
|
104
|
+
statusCode,
|
|
105
|
+
);
|
|
57
106
|
}
|
|
58
107
|
|
|
59
108
|
logger.error(
|
|
60
109
|
{ error: error.message, name: error.name, stack: error.stack },
|
|
61
110
|
"Unhandled Error",
|
|
62
111
|
);
|
|
63
|
-
return formatResponse(
|
|
112
|
+
return formatResponse(
|
|
113
|
+
{ code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE },
|
|
114
|
+
500,
|
|
115
|
+
);
|
|
64
116
|
}
|
|
65
117
|
|
|
66
118
|
logger.error({ error: JSON.stringify(error) }, "Unknown error type");
|
|
67
|
-
return formatResponse(
|
|
119
|
+
return formatResponse(
|
|
120
|
+
{ code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE },
|
|
121
|
+
500,
|
|
122
|
+
);
|
|
68
123
|
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { AccountAuthType } from "@remit/domain-enums";
|
|
4
|
+
import type { APIGatewayProxyResult } from "aws-lambda";
|
|
5
|
+
import { handleError } from "../error.js";
|
|
6
|
+
import {
|
|
7
|
+
assertNotOAuthCreate,
|
|
8
|
+
assertPasswordProvided,
|
|
9
|
+
} from "./account-guards.js";
|
|
10
|
+
|
|
11
|
+
const refusalOf = (guard: () => void): Promise<APIGatewayProxyResult> => {
|
|
12
|
+
let thrown: unknown;
|
|
13
|
+
assert.throws(guard, (error: unknown) => {
|
|
14
|
+
thrown = error;
|
|
15
|
+
return true;
|
|
16
|
+
});
|
|
17
|
+
return handleError(thrown);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Both guards used to throw `{ status: 400 }`, and the emitter reads
|
|
21
|
+
// `statusCode`. The status never matched, so a create the API meant to refuse
|
|
22
|
+
// as malformed answered 500 with "Internal server error" — telling the caller
|
|
23
|
+
// the server broke rather than what to change (issue #371).
|
|
24
|
+
describe("the create-account guards", () => {
|
|
25
|
+
it("refuses an OAuth create as a 400 the caller can act on", async () => {
|
|
26
|
+
const response = await refusalOf(() =>
|
|
27
|
+
assertNotOAuthCreate(AccountAuthType.OauthMicrosoft),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
assert.equal(response.statusCode, 400);
|
|
31
|
+
const body = JSON.parse(response.body) as {
|
|
32
|
+
code: string;
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
assert.equal(body.code, "invalid_request");
|
|
36
|
+
assert.match(body.message, /OAuth connect flow/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("refuses a password create with no password as a 400", async () => {
|
|
40
|
+
const response = await refusalOf(() =>
|
|
41
|
+
assertPasswordProvided(AccountAuthType.Password, undefined),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
assert.equal(response.statusCode, 400);
|
|
45
|
+
const body = JSON.parse(response.body) as {
|
|
46
|
+
code: string;
|
|
47
|
+
message: string;
|
|
48
|
+
};
|
|
49
|
+
assert.equal(body.code, "invalid_request");
|
|
50
|
+
assert.match(body.message, /password is required/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("refuses a create that omits authType and password alike", async () => {
|
|
54
|
+
const response = await refusalOf(() =>
|
|
55
|
+
assertPasswordProvided(undefined, undefined),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
assert.equal(response.statusCode, 400);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("lets a password create with a password through", () => {
|
|
62
|
+
assert.doesNotThrow(() =>
|
|
63
|
+
assertPasswordProvided(AccountAuthType.Password, "hunter2"),
|
|
64
|
+
);
|
|
65
|
+
assert.doesNotThrow(() => assertNotOAuthCreate(AccountAuthType.Password));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
FolderAppointment,
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
5
|
import type { AccountItem } from "@remit/data-ports";
|
|
6
|
-
import { ConflictError } from "@remit/data-ports/errors";
|
|
6
|
+
import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
|
|
7
7
|
import { AccountAuthType } from "@remit/domain-enums";
|
|
8
8
|
import type { AccountOverrides } from "./account-overrides.js";
|
|
9
9
|
import type { AccountSignature } from "./account-signature.js";
|
|
@@ -65,11 +65,9 @@ export const assertNoDuplicateMailbox = (
|
|
|
65
65
|
/** Rejects OAuth accounts that must go through the dedicated connect flow. */
|
|
66
66
|
export const assertNotOAuthCreate = (authType: string | undefined): void => {
|
|
67
67
|
if (authType === AccountAuthType.OauthMicrosoft) {
|
|
68
|
-
throw
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"OAuth accounts must be created via the OAuth connect flow (POST /accounts/oauth/microsoft/start)",
|
|
72
|
-
});
|
|
68
|
+
throw new BadRequestError(
|
|
69
|
+
"OAuth accounts must be created via the OAuth connect flow (POST /accounts/oauth/microsoft/start)",
|
|
70
|
+
);
|
|
73
71
|
}
|
|
74
72
|
};
|
|
75
73
|
|
|
@@ -80,11 +78,9 @@ export const assertPasswordProvided = (
|
|
|
80
78
|
): void => {
|
|
81
79
|
const isPasswordAuth = authType === AccountAuthType.Password || !authType;
|
|
82
80
|
if (isPasswordAuth && !password) {
|
|
83
|
-
throw
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"password is required when authType is 'password' (or when authType is omitted)",
|
|
87
|
-
});
|
|
81
|
+
throw new BadRequestError(
|
|
82
|
+
"password is required when authType is 'password' (or when authType is omitted)",
|
|
83
|
+
);
|
|
88
84
|
}
|
|
89
85
|
};
|
|
90
86
|
|
|
@@ -110,6 +110,7 @@ const assertNamesOnly = (log: string, condition: string): void => {
|
|
|
110
110
|
const assertGenericFiveHundred = (response: APIGatewayProxyResult): void => {
|
|
111
111
|
assert.equal(response.statusCode, 500);
|
|
112
112
|
assert.deepEqual(JSON.parse(response.body), {
|
|
113
|
+
code: "internal_error",
|
|
113
114
|
message: "Internal server error",
|
|
114
115
|
});
|
|
115
116
|
};
|
|
@@ -239,7 +239,7 @@ describe("GET /calendar-events", () => {
|
|
|
239
239
|
);
|
|
240
240
|
|
|
241
241
|
assert.equal(listed.statusCode, 404);
|
|
242
|
-
assert.equal((listed.body as { code: string }).code, "
|
|
242
|
+
assert.equal((listed.body as { code: string }).code, "not_found");
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
it("refuses a window that runs backwards or covers more than a year", async () => {
|
|
@@ -259,7 +259,7 @@ describe("GET /calendar-events", () => {
|
|
|
259
259
|
);
|
|
260
260
|
|
|
261
261
|
assert.equal(backwards.statusCode, 400);
|
|
262
|
-
assert.equal((backwards.body as { code: string }).code, "
|
|
262
|
+
assert.equal((backwards.body as { code: string }).code, "invalid_window");
|
|
263
263
|
assert.equal(tooWide.statusCode, 400);
|
|
264
264
|
});
|
|
265
265
|
});
|
|
@@ -285,7 +285,7 @@ describe("POST /calendar-events", () => {
|
|
|
285
285
|
assert.equal(created.statusCode, 400);
|
|
286
286
|
assert.equal(
|
|
287
287
|
(created.body as { code: string }).code,
|
|
288
|
-
"
|
|
288
|
+
"invalid_recurrence_rule",
|
|
289
289
|
);
|
|
290
290
|
assert.deepEqual(
|
|
291
291
|
await client.calendarObject.listByCalendar(calendarId),
|
|
@@ -314,7 +314,7 @@ describe("POST /calendar-events", () => {
|
|
|
314
314
|
);
|
|
315
315
|
|
|
316
316
|
assert.equal(created.statusCode, 400);
|
|
317
|
-
assert.equal((created.body as { code: string }).code, "
|
|
317
|
+
assert.equal((created.body as { code: string }).code, "unknown_time_zone");
|
|
318
318
|
assert.deepEqual(
|
|
319
319
|
await client.calendarObject.listByCalendar(calendarId),
|
|
320
320
|
[],
|
|
@@ -403,7 +403,10 @@ describe("DELETE /calendar-events/{calendarObjectId}", () => {
|
|
|
403
403
|
);
|
|
404
404
|
|
|
405
405
|
assert.equal(removed.statusCode, 412);
|
|
406
|
-
assert.equal(
|
|
406
|
+
assert.equal(
|
|
407
|
+
(removed.body as { code: string }).code,
|
|
408
|
+
"precondition_failed",
|
|
409
|
+
);
|
|
407
410
|
const survivor = await client.calendarObject.find(
|
|
408
411
|
calendarId,
|
|
409
412
|
created.calendarObjectId,
|
|
@@ -545,9 +548,9 @@ describe("GET /calendar-free-busy", () => {
|
|
|
545
548
|
);
|
|
546
549
|
|
|
547
550
|
assert.equal(missing.statusCode, 400);
|
|
548
|
-
assert.equal((missing.body as { code: string }).code, "
|
|
551
|
+
assert.equal((missing.body as { code: string }).code, "invalid_window");
|
|
549
552
|
assert.equal(tooWide.statusCode, 400);
|
|
550
|
-
assert.equal((tooWide.body as { code: string }).code, "
|
|
553
|
+
assert.equal((tooWide.body as { code: string }).code, "invalid_window");
|
|
551
554
|
});
|
|
552
555
|
});
|
|
553
556
|
|
|
@@ -787,7 +787,7 @@ describe("the calendar collection wrappers", () => {
|
|
|
787
787
|
);
|
|
788
788
|
|
|
789
789
|
assert.equal(read.statusCode, 404);
|
|
790
|
-
assert.equal((read.body as { code: string }).code, "
|
|
790
|
+
assert.equal((read.body as { code: string }).code, "not_found");
|
|
791
791
|
});
|
|
792
792
|
|
|
793
793
|
it("refuses a second calendar under a segment the account already uses", async () => {
|
|
@@ -805,7 +805,7 @@ describe("the calendar collection wrappers", () => {
|
|
|
805
805
|
);
|
|
806
806
|
|
|
807
807
|
assert.equal(second.statusCode, 400);
|
|
808
|
-
assert.equal((second.body as { code: string }).code, "
|
|
808
|
+
assert.equal((second.body as { code: string }).code, "url_segment_taken");
|
|
809
809
|
});
|
|
810
810
|
|
|
811
811
|
it("refuses a rename that names a zone this server cannot resolve", async () => {
|
|
@@ -826,7 +826,7 @@ describe("the calendar collection wrappers", () => {
|
|
|
826
826
|
);
|
|
827
827
|
|
|
828
828
|
assert.equal(updated.statusCode, 400);
|
|
829
|
-
assert.equal((updated.body as { code: string }).code, "
|
|
829
|
+
assert.equal((updated.body as { code: string }).code, "unknown_time_zone");
|
|
830
830
|
});
|
|
831
831
|
|
|
832
832
|
it("answers 204 for a calendar it removed and 400 for the default one", async () => {
|
|
@@ -858,7 +858,7 @@ describe("the calendar collection wrappers", () => {
|
|
|
858
858
|
assert.equal(refused.statusCode, 400);
|
|
859
859
|
assert.equal(
|
|
860
860
|
(refused.body as { code: string }).code,
|
|
861
|
-
"
|
|
861
|
+
"default_calendar_undeletable",
|
|
862
862
|
);
|
|
863
863
|
});
|
|
864
864
|
});
|
package/src/handlers/calendar.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { normalizeCalendarUrlSegment } from "@remit/data-ports/id";
|
|
|
22
22
|
import { CalendarSource } from "@remit/domain-enums";
|
|
23
23
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
24
24
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
25
|
+
import { defaultErrorCode } from "../error.js";
|
|
25
26
|
import type { RemitClient } from "../service/data-client.js";
|
|
26
27
|
import { getClient } from "../service/data-client.js";
|
|
27
28
|
import type {
|
|
@@ -61,19 +62,35 @@ export const refuseCalendar = <T>(
|
|
|
61
62
|
message: string,
|
|
62
63
|
): CalendarOutcome<T> => ({ ok: false, error: { code, message } });
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* `CalendarRefusal.code` is this module's and `@remit/calendar-service`'s own
|
|
67
|
+
* routing token, in PascalCase; the API's `code` vocabulary is snake_case
|
|
68
|
+
* (issue #371). Derived rather than mapped by hand, so a refusal added to
|
|
69
|
+
* either producer reaches the wire under its own name instead of silently
|
|
70
|
+
* collapsing into the status-class code.
|
|
71
|
+
*/
|
|
72
|
+
const wireCode = (code: string): string =>
|
|
73
|
+
code.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A 400 keeps the refusal's own code — `unknown_time_zone` and
|
|
77
|
+
* `url_segment_taken` are what a client branches on, and the status class alone
|
|
78
|
+
* would not distinguish them. A 404 and a 412 have nothing to add to their
|
|
79
|
+
* status, so they take the shared token.
|
|
80
|
+
*/
|
|
64
81
|
export const badRequest = (error: CalendarRefusal) => ({
|
|
65
82
|
statusCode: 400,
|
|
66
|
-
body: error,
|
|
83
|
+
body: { code: wireCode(error.code), message: error.message },
|
|
67
84
|
});
|
|
68
85
|
|
|
69
86
|
export const notFound = (message: string) => ({
|
|
70
87
|
statusCode: 404,
|
|
71
|
-
body: { code:
|
|
88
|
+
body: { code: defaultErrorCode(404), message },
|
|
72
89
|
});
|
|
73
90
|
|
|
74
91
|
export const preconditionFailed = (message: string) => ({
|
|
75
92
|
statusCode: 412,
|
|
76
|
-
body: { code:
|
|
93
|
+
body: { code: defaultErrorCode(412), message },
|
|
77
94
|
});
|
|
78
95
|
|
|
79
96
|
/** The calendar half of the client, named so a handler takes only what it uses. */
|
|
@@ -14,6 +14,10 @@ import type { Context } from "openapi-backend";
|
|
|
14
14
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
15
15
|
import { getClient } from "../service/data-client.js";
|
|
16
16
|
|
|
17
|
+
// Every error body the API emits carries a `code` (issue #371). A refusal here
|
|
18
|
+
// is one kind, so the code is one value and `reason` says which refusal it was.
|
|
19
|
+
const ATTACHMENT_REJECTED_CODE = "attachment_rejected";
|
|
20
|
+
|
|
17
21
|
const TOO_LARGE: ReadonlySet<OutboxAttachmentRejectionReasonValue> = new Set([
|
|
18
22
|
OutboxAttachmentRejectionReason.FileTooLarge,
|
|
19
23
|
OutboxAttachmentRejectionReason.MessageTooLarge,
|
|
@@ -30,6 +34,7 @@ const refuse = (
|
|
|
30
34
|
): RejectionResponse => ({
|
|
31
35
|
statusCode: TOO_LARGE.has(detail.reason) ? 413 : 400,
|
|
32
36
|
body: {
|
|
37
|
+
code: ATTACHMENT_REJECTED_CODE,
|
|
33
38
|
reason: detail.reason,
|
|
34
39
|
message: detail.message,
|
|
35
40
|
limitBytes: detail.limitBytes,
|
|
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
14
14
|
import type { Context } from "openapi-backend";
|
|
15
15
|
import { normalizeRequest } from "../request.js";
|
|
16
|
+
import { formatResponse } from "../response.js";
|
|
16
17
|
import { SystemOperations } from "./system-update.js";
|
|
17
18
|
|
|
18
19
|
const tmpRoot = join(
|
|
@@ -448,3 +449,49 @@ describe("GET /system/update through the whole request pipeline", () => {
|
|
|
448
449
|
assert.deepEqual(JSON.parse(result.body), okState);
|
|
449
450
|
});
|
|
450
451
|
});
|
|
452
|
+
|
|
453
|
+
// A refusal here is the object `formatResponse` unwraps, never one that has
|
|
454
|
+
// already been serialized: handing it a `body` string publishes the envelope
|
|
455
|
+
// itself — `{"statusCode":…,"headers":…,"body":"{…}"}` — and what the caller
|
|
456
|
+
// then parses is not an ApiError at all. Only the status was ever asserted, so
|
|
457
|
+
// the shape went unguarded (issue #371).
|
|
458
|
+
describe("the wire body of a self-update refusal", () => {
|
|
459
|
+
it("answers an unconfigured seam with a flat not_found", async () => {
|
|
460
|
+
delete process.env.REMIT_UPDATE_MANIFEST_URL;
|
|
461
|
+
|
|
462
|
+
const result = await getUpdate(buildEvent(USER));
|
|
463
|
+
const wire = formatResponse(result as Record<string, unknown>);
|
|
464
|
+
|
|
465
|
+
assert.equal(wire.statusCode, 404);
|
|
466
|
+
assert.deepEqual(JSON.parse(wire.body), {
|
|
467
|
+
code: "not_found",
|
|
468
|
+
message: "Not found",
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("answers an unauthenticated caller with a flat unauthorized", async () => {
|
|
473
|
+
writeState(okState);
|
|
474
|
+
|
|
475
|
+
const result = await getUpdate(buildEvent());
|
|
476
|
+
const wire = formatResponse(result as Record<string, unknown>);
|
|
477
|
+
|
|
478
|
+
assert.equal(wire.statusCode, 401);
|
|
479
|
+
assert.deepEqual(JSON.parse(wire.body), {
|
|
480
|
+
code: "unauthorized",
|
|
481
|
+
message: "Unauthorized",
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("answers an unauthenticated install request the same way", async () => {
|
|
486
|
+
writeState(okState);
|
|
487
|
+
|
|
488
|
+
const result = await applyUpdate("v1.5.0", buildEvent());
|
|
489
|
+
const wire = formatResponse(result as Record<string, unknown>);
|
|
490
|
+
|
|
491
|
+
assert.equal(wire.statusCode, 401);
|
|
492
|
+
assert.deepEqual(JSON.parse(wire.body), {
|
|
493
|
+
code: "unauthorized",
|
|
494
|
+
message: "Unauthorized",
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
});
|
|
@@ -9,6 +9,7 @@ import { ConflictError, HTTPError } from "@remit/data-ports/errors";
|
|
|
9
9
|
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
10
10
|
import type { Context } from "openapi-backend";
|
|
11
11
|
import { getSubFromEvent } from "../auth.js";
|
|
12
|
+
import { defaultErrorCode } from "../error.js";
|
|
12
13
|
import type { OperationHandler, SystemOperationIds } from "../types.js";
|
|
13
14
|
|
|
14
15
|
class TargetVersionMismatchError extends HTTPError {
|
|
@@ -36,17 +37,21 @@ const manifestUrl = (): string | undefined =>
|
|
|
36
37
|
*/
|
|
37
38
|
const UNKNOWN_VERSION = "unknown";
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
/**
|
|
41
|
+
* A refusal in the shape `formatResponse` unwraps: the status, and the body as
|
|
42
|
+
* an object it serializes. Handing it an already-serialized `body` string
|
|
43
|
+
* instead publishes the whole envelope — `{"statusCode":…,"headers":…}` with
|
|
44
|
+
* the real body escaped inside it — which is not an `ApiError` at all.
|
|
45
|
+
*/
|
|
46
|
+
const refuse = (statusCode: number, message: string): APIGatewayProxyResult =>
|
|
47
|
+
({
|
|
48
|
+
statusCode,
|
|
49
|
+
body: { code: defaultErrorCode(statusCode), message },
|
|
50
|
+
}) as unknown as APIGatewayProxyResult;
|
|
44
51
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
body: JSON.stringify({ message: "Unauthorized" }),
|
|
49
|
-
});
|
|
52
|
+
const notFound = (): APIGatewayProxyResult => refuse(404, "Not found");
|
|
53
|
+
|
|
54
|
+
const unauthorized = (): APIGatewayProxyResult => refuse(401, "Unauthorized");
|
|
50
55
|
|
|
51
56
|
/**
|
|
52
57
|
* The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "openapi-backend";
|
|
14
14
|
import { assertLocalBypassNotInDeployedEnv } from "./auth.js";
|
|
15
15
|
import { usesBetterAuthJwt } from "./data-backend.js";
|
|
16
|
-
import { handleError } from "./error.js";
|
|
16
|
+
import { defaultErrorCode, handleError } from "./error.js";
|
|
17
17
|
import { handlers } from "./handlers/index.js";
|
|
18
18
|
import { authenticateSelfHostRequest } from "./jwt-auth.js";
|
|
19
19
|
import { normalizeRequest } from "./request.js";
|
|
@@ -90,6 +90,22 @@ const api = new OpenAPIBackend({
|
|
|
90
90
|
|
|
91
91
|
api.register("postResponseHandler", postResponseHandler);
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The validator's findings, narrowed to the three fields `RequestValidationDetail`
|
|
95
|
+
* declares. Copied field by field rather than passed through: an AJV error also
|
|
96
|
+
* carries `params`, and under `verbose` the rejected `data` itself — which for a
|
|
97
|
+
* refused request body is whatever the caller sent, a password among the
|
|
98
|
+
* possibilities. Naming the fields is what keeps that off the wire.
|
|
99
|
+
*/
|
|
100
|
+
const validationDetails = (
|
|
101
|
+
errors: OpenAPIContext["validation"]["errors"],
|
|
102
|
+
): Array<{ path: string; rule: string; message: string }> =>
|
|
103
|
+
(errors ?? []).map((error) => ({
|
|
104
|
+
path: error.instancePath,
|
|
105
|
+
rule: error.keyword,
|
|
106
|
+
message: error.message ?? "refused",
|
|
107
|
+
}));
|
|
108
|
+
|
|
93
109
|
// The errors and the operation, and nothing that came off the wire. A request
|
|
94
110
|
// that fails validation is logged at error level, and the request it carries
|
|
95
111
|
// holds the caller's headers and cookies — an Authorization bearer among them,
|
|
@@ -107,13 +123,17 @@ api.register("validationFail", (c: OpenAPIContext, req: Request) => {
|
|
|
107
123
|
return {
|
|
108
124
|
statusCode: 400,
|
|
109
125
|
body: {
|
|
126
|
+
code: defaultErrorCode(400),
|
|
110
127
|
message: "Invalid request",
|
|
111
|
-
errors: c.validation.errors,
|
|
128
|
+
errors: validationDetails(c.validation.errors),
|
|
112
129
|
},
|
|
113
130
|
};
|
|
114
131
|
});
|
|
115
132
|
|
|
116
|
-
api.register("notFound", () => ({
|
|
133
|
+
api.register("notFound", () => ({
|
|
134
|
+
statusCode: 404,
|
|
135
|
+
body: { code: defaultErrorCode(404), message: "Not found" },
|
|
136
|
+
}));
|
|
117
137
|
|
|
118
138
|
api.register("notImplemented", async (c: OpenAPIContext) => {
|
|
119
139
|
const { status, mock } = api.mockResponseForOperation(
|
package/src/jwt-auth.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { readCalendarFeedToken } from "@remit/calendar-service";
|
|
8
8
|
import { logger } from "@remit/logger-lambda";
|
|
9
9
|
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
10
|
+
import { defaultErrorCode } from "./error.js";
|
|
10
11
|
|
|
11
12
|
let verifier: JwtVerifier | null = null;
|
|
12
13
|
|
|
@@ -33,7 +34,7 @@ const readHeader = (
|
|
|
33
34
|
const unauthorized = (message: string): APIGatewayProxyResult => ({
|
|
34
35
|
statusCode: 401,
|
|
35
36
|
headers: { "Content-Type": "application/json" },
|
|
36
|
-
body: JSON.stringify({ message }),
|
|
37
|
+
body: JSON.stringify({ code: defaultErrorCode(401), message }),
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
const injectClaims = (
|
package/src/openapi-spec.test.ts
CHANGED
|
@@ -28,6 +28,80 @@ const queryParameters = (): Array<{
|
|
|
28
28
|
return found;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
type Schema = {
|
|
32
|
+
$ref?: string;
|
|
33
|
+
type?: string;
|
|
34
|
+
required?: string[];
|
|
35
|
+
properties?: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const resolve = (schema: Schema | undefined): Schema | undefined => {
|
|
39
|
+
if (!schema?.$ref) return schema;
|
|
40
|
+
const name = schema.$ref.replace("#/components/schemas/", "");
|
|
41
|
+
return (OpenAPISpec.components?.schemas?.[name] ?? undefined) as
|
|
42
|
+
| Schema
|
|
43
|
+
| undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const errorBodySchemas = (): Array<{
|
|
47
|
+
operationId: string;
|
|
48
|
+
status: string;
|
|
49
|
+
schema: Schema | undefined;
|
|
50
|
+
}> => {
|
|
51
|
+
const found: Array<{
|
|
52
|
+
operationId: string;
|
|
53
|
+
status: string;
|
|
54
|
+
schema: Schema | undefined;
|
|
55
|
+
}> = [];
|
|
56
|
+
for (const item of Object.values(OpenAPISpec.paths ?? {})) {
|
|
57
|
+
for (const operation of Object.values(item ?? {})) {
|
|
58
|
+
const { operationId, responses } = operation as {
|
|
59
|
+
operationId?: string;
|
|
60
|
+
responses?: Record<
|
|
61
|
+
string,
|
|
62
|
+
{ content?: Record<string, { schema?: Schema }> }
|
|
63
|
+
>;
|
|
64
|
+
};
|
|
65
|
+
if (!operationId || !responses) continue;
|
|
66
|
+
for (const [status, response] of Object.entries(responses)) {
|
|
67
|
+
if (Number(status) < 400) continue;
|
|
68
|
+
const schema = response.content?.["application/json"]?.schema;
|
|
69
|
+
if (!schema) continue;
|
|
70
|
+
found.push({ operationId, status, schema: resolve(schema) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return found;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Issue #371: one error shape for the whole API. Every JSON refusal body is
|
|
78
|
+
// flat — `code` and `message` at the top level, no `error` envelope — and
|
|
79
|
+
// `code` is required, because it is the only field a client may branch on.
|
|
80
|
+
describe("the declared shape of an error response", () => {
|
|
81
|
+
it("is a flat object carrying a required code and message", () => {
|
|
82
|
+
const offenders = errorBodySchemas()
|
|
83
|
+
.filter(({ schema }) => {
|
|
84
|
+
const required = schema?.required ?? [];
|
|
85
|
+
return (
|
|
86
|
+
schema?.type !== "object" ||
|
|
87
|
+
!required.includes("code") ||
|
|
88
|
+
!required.includes("message")
|
|
89
|
+
);
|
|
90
|
+
})
|
|
91
|
+
.map(({ operationId, status }) => `${operationId}.${status}`);
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(offenders, []);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("never wraps the body in an envelope property", () => {
|
|
97
|
+
const offenders = errorBodySchemas()
|
|
98
|
+
.filter(({ schema }) => schema?.properties?.error !== undefined)
|
|
99
|
+
.map(({ operationId, status }) => `${operationId}.${status}`);
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(offenders, []);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
31
105
|
describe("the built OpenAPI spec", () => {
|
|
32
106
|
it("declares every query parameter exploded", () => {
|
|
33
107
|
// openapi-backend splits a non-exploded query value on commas before it
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two refusals no handler ever sees: a path the spec does not route, and a
|
|
3
|
+
* request the spec refuses before routing. Both are answered by the framework,
|
|
4
|
+
* and both used to leave the one shape the API promises (issue #371) — the
|
|
5
|
+
* unmatched route with no body at all, the refused request with no `code` and
|
|
6
|
+
* with the validator's raw findings beside it.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
11
|
+
import { api } from "./index.js";
|
|
12
|
+
import { normalizeRequest } from "./request.js";
|
|
13
|
+
|
|
14
|
+
const SUB = "cognito-sub-pipeline";
|
|
15
|
+
|
|
16
|
+
const send = async (
|
|
17
|
+
method: string,
|
|
18
|
+
path: string,
|
|
19
|
+
body?: string,
|
|
20
|
+
): Promise<APIGatewayProxyResult> => {
|
|
21
|
+
const event = {
|
|
22
|
+
httpMethod: method,
|
|
23
|
+
path,
|
|
24
|
+
body: body ?? null,
|
|
25
|
+
queryStringParameters: null,
|
|
26
|
+
headers: body ? { "content-type": "application/json" } : {},
|
|
27
|
+
requestContext: { authorizer: { claims: { sub: SUB } } },
|
|
28
|
+
} as unknown as APIGatewayProxyEvent;
|
|
29
|
+
|
|
30
|
+
return (await api.handleRequest(
|
|
31
|
+
normalizeRequest(event),
|
|
32
|
+
event,
|
|
33
|
+
{} as never,
|
|
34
|
+
)) as APIGatewayProxyResult;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("a path the spec does not route", () => {
|
|
38
|
+
it("answers 404 with a body, not an empty one", async () => {
|
|
39
|
+
const response = await send("GET", "/no-such-resource");
|
|
40
|
+
|
|
41
|
+
assert.equal(response.statusCode, 404);
|
|
42
|
+
assert.deepEqual(JSON.parse(response.body), {
|
|
43
|
+
code: "not_found",
|
|
44
|
+
message: "Not found",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("a request the spec refuses before any handler runs", () => {
|
|
50
|
+
it("answers 400 with a code and the fields it refused", async () => {
|
|
51
|
+
const response = await send(
|
|
52
|
+
"POST",
|
|
53
|
+
"/calendars",
|
|
54
|
+
JSON.stringify({ displayName: 42 }),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
assert.equal(response.statusCode, 400);
|
|
58
|
+
const body = JSON.parse(response.body) as {
|
|
59
|
+
code: string;
|
|
60
|
+
message: string;
|
|
61
|
+
errors: { path: string; rule: string; message: string }[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
assert.equal(body.code, "invalid_request");
|
|
65
|
+
assert.equal(body.message, "Invalid request");
|
|
66
|
+
assert.ok(body.errors.length > 0);
|
|
67
|
+
for (const detail of body.errors) {
|
|
68
|
+
assert.deepEqual(Object.keys(detail).sort(), ["message", "path", "rule"]);
|
|
69
|
+
assert.equal(typeof detail.path, "string");
|
|
70
|
+
assert.equal(typeof detail.rule, "string");
|
|
71
|
+
assert.equal(typeof detail.message, "string");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// An AJV error also carries `params`, and under `verbose` the rejected value
|
|
76
|
+
// itself. A refused create body is whatever the caller sent — a password
|
|
77
|
+
// among the possibilities — so nothing but the three named fields may travel.
|
|
78
|
+
it("never echoes the value it refused", async () => {
|
|
79
|
+
const response = await send(
|
|
80
|
+
"POST",
|
|
81
|
+
"/accounts",
|
|
82
|
+
JSON.stringify({ password: "hunter2", imapPort: "not-a-number" }),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assert.equal(response.statusCode, 400);
|
|
86
|
+
assert.ok(!response.body.includes("hunter2"));
|
|
87
|
+
});
|
|
88
|
+
});
|