@remit/backend 0.0.143 → 0.0.144

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.
Files changed (269) hide show
  1. package/dist/dev-server/content-handler.d.ts +38 -0
  2. package/dist/dev-server/content-handler.js +46 -0
  3. package/dist/src/api.d.ts +7 -0
  4. package/dist/src/api.js +144 -0
  5. package/dist/src/auth.d.ts +30 -0
  6. package/dist/src/auth.js +73 -0
  7. package/dist/src/config/msoauth.d.ts +30 -0
  8. package/dist/src/config/msoauth.js +30 -0
  9. package/{src/data-backend.ts → dist/src/data-backend.d.ts} +2 -4
  10. package/dist/src/data-backend.js +12 -0
  11. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  12. package/dist/src/derive/autoMoved.js +49 -0
  13. package/dist/src/derive/contentSignature.d.ts +41 -0
  14. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +27 -72
  15. package/dist/src/derive/contentUrl.d.ts +46 -0
  16. package/dist/src/derive/contentUrl.js +46 -0
  17. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  18. package/dist/src/derive/deriveMuted.js +7 -0
  19. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  20. package/dist/src/derive/enrichThreadRows.js +151 -0
  21. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  22. package/dist/src/derive/filterThreadCriteria.js +24 -0
  23. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  24. package/dist/src/derive/pendingMoveCounts.js +53 -0
  25. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  26. package/dist/src/derive/senderTrust.js +19 -0
  27. package/dist/src/error.d.ts +21 -0
  28. package/dist/src/error.js +85 -0
  29. package/dist/src/handlers/account-guards.d.ts +36 -0
  30. package/dist/src/handlers/account-guards.js +107 -0
  31. package/dist/src/handlers/account-oauth.d.ts +14 -0
  32. package/dist/src/handlers/account-oauth.js +387 -0
  33. package/dist/src/handlers/account-overrides.d.ts +53 -0
  34. package/dist/src/handlers/account-overrides.js +233 -0
  35. package/dist/src/handlers/account-signature.d.ts +28 -0
  36. package/dist/src/handlers/account-signature.js +85 -0
  37. package/dist/src/handlers/account.d.ts +6 -0
  38. package/dist/src/handlers/account.js +350 -0
  39. package/dist/src/handlers/address.d.ts +35 -0
  40. package/dist/src/handlers/address.js +141 -0
  41. package/dist/src/handlers/calendar-event.d.ts +70 -0
  42. package/dist/src/handlers/calendar-event.js +425 -0
  43. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  44. package/dist/src/handlers/calendar-feed.js +192 -0
  45. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  46. package/dist/src/handlers/calendar-suggestion.js +212 -0
  47. package/dist/src/handlers/calendar.d.ts +116 -0
  48. package/dist/src/handlers/calendar.js +330 -0
  49. package/dist/src/handlers/config.d.ts +43 -0
  50. package/dist/src/handlers/config.js +176 -0
  51. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  52. package/dist/src/handlers/ensure-account-config.js +27 -0
  53. package/dist/src/handlers/filter.d.ts +3 -0
  54. package/dist/src/handlers/filter.js +103 -0
  55. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  56. package/dist/src/handlers/folder-role-appointments.js +168 -0
  57. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  58. package/dist/src/handlers/folder-role-labels.js +40 -0
  59. package/dist/src/handlers/folder-role.d.ts +9 -0
  60. package/dist/src/handlers/folder-role.js +40 -0
  61. package/dist/src/handlers/index.d.ts +2 -0
  62. package/dist/src/handlers/index.js +60 -0
  63. package/dist/src/handlers/label.d.ts +43 -0
  64. package/dist/src/handlers/label.js +114 -0
  65. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  66. package/dist/src/handlers/mailbox-bindings.js +61 -0
  67. package/dist/src/handlers/mailbox.d.ts +23 -0
  68. package/dist/src/handlers/mailbox.js +219 -0
  69. package/dist/src/handlers/me.d.ts +2 -0
  70. package/dist/src/handlers/me.js +138 -0
  71. package/dist/src/handlers/message.d.ts +84 -0
  72. package/dist/src/handlers/message.js +607 -0
  73. package/dist/src/handlers/organize.d.ts +10 -0
  74. package/dist/src/handlers/organize.js +112 -0
  75. package/dist/src/handlers/outbox-attachment.d.ts +23 -0
  76. package/dist/src/handlers/outbox-attachment.js +81 -0
  77. package/dist/src/handlers/outbox.d.ts +4 -0
  78. package/dist/src/handlers/outbox.js +144 -0
  79. package/dist/src/handlers/sync-progress.d.ts +46 -0
  80. package/dist/src/handlers/sync-progress.js +63 -0
  81. package/dist/src/handlers/sync.d.ts +28 -0
  82. package/dist/src/handlers/sync.js +82 -0
  83. package/dist/src/handlers/system-update.d.ts +2 -0
  84. package/dist/src/handlers/system-update.js +172 -0
  85. package/dist/src/handlers/thread.d.ts +148 -0
  86. package/dist/src/handlers/thread.js +233 -0
  87. package/dist/src/handlers/unified-threads.d.ts +247 -0
  88. package/dist/src/handlers/unified-threads.js +409 -0
  89. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  90. package/dist/src/handlers/vip-suggestions.js +16 -0
  91. package/dist/src/index.d.ts +2 -0
  92. package/dist/src/index.js +37 -0
  93. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  94. package/dist/src/json.js +10 -0
  95. package/dist/src/jwt-auth.d.ts +18 -0
  96. package/dist/src/jwt-auth.js +97 -0
  97. package/dist/src/request-context.d.ts +9 -0
  98. package/dist/src/request-context.js +22 -0
  99. package/dist/src/request.d.ts +3 -0
  100. package/dist/src/request.js +7 -0
  101. package/dist/src/response.d.ts +27 -0
  102. package/dist/src/response.js +87 -0
  103. package/dist/src/service/account-deletion.d.ts +22 -0
  104. package/dist/src/service/account-deletion.js +39 -0
  105. package/dist/src/service/compose-sqlite.d.ts +2 -0
  106. package/dist/src/service/compose-sqlite.js +11 -0
  107. package/dist/src/service/create-remit-client.d.ts +95 -0
  108. package/dist/src/service/create-remit-client.js +207 -0
  109. package/dist/src/service/data-client.d.ts +12 -0
  110. package/dist/src/service/data-client.js +44 -0
  111. package/dist/src/service/export-identity.d.ts +3 -0
  112. package/dist/src/service/export-identity.js +35 -0
  113. package/dist/src/service/fire-and-forget.d.ts +54 -0
  114. package/dist/src/service/fire-and-forget.js +38 -0
  115. package/dist/src/service/organize.d.ts +184 -0
  116. package/dist/src/service/organize.js +384 -0
  117. package/dist/src/service/refusal.d.ts +4 -0
  118. package/dist/src/service/refusal.js +20 -0
  119. package/dist/src/service/sender-category.d.ts +51 -0
  120. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  121. package/dist/src/service/sqs.d.ts +1 -0
  122. package/{src/service/sqs.ts → dist/src/service/sqs.js} +0 -1
  123. package/dist/src/service/trigger-sync.d.ts +51 -0
  124. package/dist/src/service/trigger-sync.js +39 -0
  125. package/dist/src/types.d.ts +49 -0
  126. package/dist/src/types.js +1 -0
  127. package/package.json +113 -98
  128. package/.env.test +0 -4
  129. package/dev-server/content-auth.test.ts +0 -113
  130. package/dev-server/content-auth.ts +0 -54
  131. package/dev-server/content-handler.test.ts +0 -105
  132. package/dev-server/content-handler.ts +0 -79
  133. package/dev-server/content-path.test.ts +0 -37
  134. package/dev-server/content-path.ts +0 -27
  135. package/dev-server/cors.test.ts +0 -48
  136. package/dev-server/cors.ts +0 -25
  137. package/dev-server/keep-alive.test.ts +0 -24
  138. package/dev-server/lambda-helpers.ts +0 -69
  139. package/dev-server/log-output.test.ts +0 -173
  140. package/dev-server/oauth-callback-route.test.ts +0 -66
  141. package/dev-server/relational-health.test.ts +0 -83
  142. package/dev-server/relational-health.ts +0 -39
  143. package/dev-server/server.ts +0 -384
  144. package/dev-server/sync-age.test.ts +0 -91
  145. package/dev-server/sync-age.ts +0 -55
  146. package/dev-server/upload-handler.ts +0 -132
  147. package/dev-server/upload-route.test.ts +0 -196
  148. package/scripts/backfill-classification.ts +0 -91
  149. package/scripts/backfill-list-id.ts +0 -83
  150. package/scripts/config-save.ts +0 -128
  151. package/src/auth.test.ts +0 -12
  152. package/src/auth.ts +0 -92
  153. package/src/config/msoauth.test.ts +0 -48
  154. package/src/config/msoauth.ts +0 -48
  155. package/src/data-backend.test.ts +0 -23
  156. package/src/derive/autoMoved.test.ts +0 -104
  157. package/src/derive/contentSignature.test.ts +0 -161
  158. package/src/derive/contentUrl.test.ts +0 -172
  159. package/src/derive/contentUrl.ts +0 -86
  160. package/src/derive/deriveMuted.test.ts +0 -34
  161. package/src/derive/enrichThreadRows.test.ts +0 -360
  162. package/src/derive/enrichThreadRows.ts +0 -234
  163. package/src/derive/filterThreadCriteria.test.ts +0 -126
  164. package/src/derive/filterThreadCriteria.ts +0 -51
  165. package/src/derive/senderTrust.test.ts +0 -67
  166. package/src/error.test.ts +0 -201
  167. package/src/error.ts +0 -123
  168. package/src/handlers/account-guards.test.ts +0 -67
  169. package/src/handlers/account-guards.ts +0 -169
  170. package/src/handlers/account-oauth-callback.test.ts +0 -155
  171. package/src/handlers/account-oauth-consent.test.ts +0 -336
  172. package/src/handlers/account-oauth-start.test.ts +0 -191
  173. package/src/handlers/account-oauth.test.ts +0 -439
  174. package/src/handlers/account-oauth.ts +0 -635
  175. package/src/handlers/account-overrides.test.ts +0 -88
  176. package/src/handlers/account-overrides.ts +0 -323
  177. package/src/handlers/account-ownership.ts +0 -25
  178. package/src/handlers/account-signature.ts +0 -117
  179. package/src/handlers/account-synced-services.test.ts +0 -245
  180. package/src/handlers/account.ts +0 -552
  181. package/src/handlers/address.test.ts +0 -381
  182. package/src/handlers/address.ts +0 -200
  183. package/src/handlers/calendar-event.test.ts +0 -685
  184. package/src/handlers/calendar-event.ts +0 -738
  185. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  186. package/src/handlers/calendar-feed.test.ts +0 -439
  187. package/src/handlers/calendar-feed.ts +0 -291
  188. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  189. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  190. package/src/handlers/calendar-suggestion.ts +0 -425
  191. package/src/handlers/calendar.test.ts +0 -1080
  192. package/src/handlers/calendar.ts +0 -517
  193. package/src/handlers/config.test.ts +0 -245
  194. package/src/handlers/config.ts +0 -332
  195. package/src/handlers/ensure-account-config.ts +0 -30
  196. package/src/handlers/filter.test.ts +0 -644
  197. package/src/handlers/filter.ts +0 -518
  198. package/src/handlers/folder-role-appointments.test.ts +0 -438
  199. package/src/handlers/folder-role-appointments.ts +0 -249
  200. package/src/handlers/folder-role-labels.ts +0 -63
  201. package/src/handlers/folder-role.test.ts +0 -209
  202. package/src/handlers/folder-role.ts +0 -80
  203. package/src/handlers/index.ts +0 -83
  204. package/src/handlers/label.test.ts +0 -112
  205. package/src/handlers/label.ts +0 -210
  206. package/src/handlers/mailbox-bindings.ts +0 -111
  207. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  208. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  209. package/src/handlers/mailbox.test.ts +0 -280
  210. package/src/handlers/mailbox.ts +0 -581
  211. package/src/handlers/me.test.ts +0 -81
  212. package/src/handlers/me.ts +0 -203
  213. package/src/handlers/message-read-backfill.test.ts +0 -103
  214. package/src/handlers/message.test.ts +0 -131
  215. package/src/handlers/message.ts +0 -1131
  216. package/src/handlers/organize.test.ts +0 -277
  217. package/src/handlers/organize.ts +0 -197
  218. package/src/handlers/outbox-attachment.test.ts +0 -765
  219. package/src/handlers/outbox-attachment.ts +0 -126
  220. package/src/handlers/outbox-list.test.ts +0 -179
  221. package/src/handlers/outbox.test.ts +0 -772
  222. package/src/handlers/outbox.ts +0 -258
  223. package/src/handlers/search.test.ts +0 -161
  224. package/src/handlers/search.ts +0 -105
  225. package/src/handlers/sync-progress.test.ts +0 -205
  226. package/src/handlers/sync-progress.ts +0 -99
  227. package/src/handlers/sync.test.ts +0 -168
  228. package/src/handlers/sync.ts +0 -118
  229. package/src/handlers/system-update.test.ts +0 -497
  230. package/src/handlers/system-update.ts +0 -230
  231. package/src/handlers/thread.test.ts +0 -396
  232. package/src/handlers/thread.ts +0 -427
  233. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  234. package/src/handlers/unified-threads.test.ts +0 -785
  235. package/src/handlers/unified-threads.ts +0 -669
  236. package/src/index.test.ts +0 -83
  237. package/src/index.ts +0 -228
  238. package/src/jwt-auth.test.ts +0 -152
  239. package/src/jwt-auth.ts +0 -132
  240. package/src/log-redaction.test.ts +0 -144
  241. package/src/openapi-spec.test.ts +0 -118
  242. package/src/request-context.test.ts +0 -61
  243. package/src/request-context.ts +0 -33
  244. package/src/request-pipeline.test.ts +0 -88
  245. package/src/request.ts +0 -10
  246. package/src/response.test.ts +0 -130
  247. package/src/response.ts +0 -144
  248. package/src/service/compose-sqlite.ts +0 -98
  249. package/src/service/config-import.ts +0 -39
  250. package/src/service/create-remit-client.ts +0 -522
  251. package/src/service/data-client.test.ts +0 -97
  252. package/src/service/data-client.ts +0 -58
  253. package/src/service/export-identity.ts +0 -39
  254. package/src/service/filter.ts +0 -40
  255. package/src/service/fire-and-forget.test.ts +0 -126
  256. package/src/service/fire-and-forget.ts +0 -79
  257. package/src/service/localhost-env-config.test.ts +0 -36
  258. package/src/service/organize-queue.ts +0 -98
  259. package/src/service/organize.test.ts +0 -1451
  260. package/src/service/organize.ts +0 -1025
  261. package/src/service/read-path-filter-config.test.ts +0 -243
  262. package/src/service/semantic-capability.test.ts +0 -78
  263. package/src/service/semantic-capability.ts +0 -95
  264. package/src/service/sender-category.test.ts +0 -235
  265. package/src/service/trigger-sync.test.ts +0 -205
  266. package/src/service/trigger-sync.ts +0 -100
  267. package/src/stdout-capture-fixture.ts +0 -40
  268. package/src/types.ts +0 -271
  269. package/tsconfig.json +0 -7
@@ -1,191 +0,0 @@
1
- /**
2
- * What an operator can tell from the log when the Microsoft OAuth start fails.
3
- *
4
- * Every credential failure answers the same 500 with the same body, so the
5
- * backend log for that request is the only place the four are told apart: an
6
- * instance with no Microsoft app registered must not read like a live outage.
7
- * Driven through the real handler so what is asserted is the line a deployment
8
- * actually writes, correlation id and all.
9
- */
10
-
11
- import assert from "node:assert/strict";
12
- import { randomUUID } from "node:crypto";
13
- import { after, before, describe, it } from "node:test";
14
- import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
15
- import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
16
- import { captureStdout, restoreStdout } from "../stdout-capture-fixture.js";
17
-
18
- const WEB_ORIGIN = "https://mail.example.com";
19
- const START_PATH = "/accounts/oauth/microsoft/start";
20
- const SECRET_ARN =
21
- "arn:aws:secretsmanager:eu-west-1:000000000000:secret:msoauth-AbCdEf";
22
-
23
- let handler: (
24
- event: APIGatewayProxyEvent,
25
- context: unknown,
26
- ) => Promise<APIGatewayProxyResult>;
27
-
28
- const originalSend = SecretsManagerClient.prototype.send;
29
- let secretString: string | undefined;
30
-
31
- interface Outcome {
32
- response: APIGatewayProxyResult;
33
- log: string;
34
- logged: string;
35
- }
36
-
37
- const headerOf = (
38
- response: APIGatewayProxyResult,
39
- name: string,
40
- ): string | undefined => {
41
- for (const [key, value] of Object.entries(response.headers ?? {})) {
42
- if (key.toLowerCase() === name) return String(value);
43
- }
44
- return undefined;
45
- };
46
-
47
- const start = async (): Promise<Outcome> => {
48
- const awsRequestId = `req-${randomUUID()}`;
49
- const { result: response, logged } = await captureStdout(() =>
50
- handler(
51
- {
52
- httpMethod: "POST",
53
- path: START_PATH,
54
- headers: {},
55
- queryStringParameters: null,
56
- body: JSON.stringify({ email: "matthijs@example.com" }),
57
- requestContext: { authorizer: { claims: { sub: "cognito-sub" } } },
58
- } as unknown as APIGatewayProxyEvent,
59
- { awsRequestId, functionName: "test" },
60
- ),
61
- );
62
-
63
- assert.equal(
64
- headerOf(response, "x-correlation-id"),
65
- awsRequestId,
66
- "the id the client reports has to be the id the log lines carry",
67
- );
68
-
69
- const forThisRequest = logged
70
- .split("\n")
71
- .filter((line) => line.length > 0)
72
- .map((line) => JSON.parse(line) as Record<string, unknown>)
73
- .filter((line) => line.requestId === awsRequestId);
74
-
75
- assert.ok(
76
- forThisRequest.length > 0,
77
- "the failure left no line carrying the correlation id the client was handed",
78
- );
79
-
80
- return { response, log: JSON.stringify(forThisRequest), logged };
81
- };
82
-
83
- /** The name each condition, and only that condition, puts in the log. */
84
- const NO_APP_REGISTERED = "MicrosoftOAuthNotConfigured";
85
- const SECRET_HAS_NO_STRING = "MicrosoftOAuthSecretEmpty";
86
- const SECRET_MISSING_FIELDS = "MicrosoftOAuthSecretIncomplete";
87
- const SECRET_IS_NOT_JSON = "MicrosoftOAuthSecretUnparseable";
88
-
89
- const CONDITIONS = [
90
- NO_APP_REGISTERED,
91
- SECRET_HAS_NO_STRING,
92
- SECRET_MISSING_FIELDS,
93
- SECRET_IS_NOT_JSON,
94
- ];
95
-
96
- const assertNamesOnly = (log: string, condition: string): void => {
97
- assert.ok(
98
- log.includes(condition),
99
- `the log for this request never names the condition ${condition}: ${log}`,
100
- );
101
- for (const other of CONDITIONS) {
102
- if (other === condition) continue;
103
- assert.ok(
104
- !log.includes(other),
105
- `the log also names ${other}, so the two conditions are indistinguishable`,
106
- );
107
- }
108
- };
109
-
110
- const assertGenericFiveHundred = (response: APIGatewayProxyResult): void => {
111
- assert.equal(response.statusCode, 500);
112
- assert.deepEqual(JSON.parse(response.body), {
113
- code: "internal_error",
114
- message: "Internal server error",
115
- });
116
- };
117
-
118
- before(async () => {
119
- process.env.LOG_LEVEL = "error";
120
- process.env.DATA_BACKEND = "sqlite";
121
- process.env.CORS_ALLOWED_ORIGINS = WEB_ORIGIN;
122
- process.env.MSOAUTH_REDIRECT_URI = `${WEB_ORIGIN}/accounts/oauth/microsoft/callback`;
123
-
124
- SecretsManagerClient.prototype.send = (async () => ({
125
- SecretString: secretString,
126
- })) as typeof SecretsManagerClient.prototype.send;
127
-
128
- ({ handler } = (await import("../index.js")) as unknown as {
129
- handler: typeof handler;
130
- });
131
- });
132
-
133
- after(() => {
134
- SecretsManagerClient.prototype.send = originalSend;
135
- restoreStdout();
136
- });
137
-
138
- describe("a Microsoft OAuth start that cannot load its credentials", () => {
139
- it("names an instance with no Microsoft app registered", async () => {
140
- delete process.env.MSOAUTH_CLIENT_ID;
141
- delete process.env.MSOAUTH_CLIENT_SECRET;
142
- delete process.env.MSOAUTH_SECRET_ARN;
143
-
144
- const { response, log } = await start();
145
-
146
- assertGenericFiveHundred(response);
147
- assertNamesOnly(log, NO_APP_REGISTERED);
148
- });
149
-
150
- it("names a secret that carries no string value", async () => {
151
- delete process.env.MSOAUTH_CLIENT_ID;
152
- delete process.env.MSOAUTH_CLIENT_SECRET;
153
- process.env.MSOAUTH_SECRET_ARN = SECRET_ARN;
154
- secretString = undefined;
155
-
156
- const { response, log } = await start();
157
-
158
- assertGenericFiveHundred(response);
159
- assertNamesOnly(log, SECRET_HAS_NO_STRING);
160
- });
161
-
162
- it("names a secret whose credential fields are absent", async () => {
163
- delete process.env.MSOAUTH_CLIENT_ID;
164
- delete process.env.MSOAUTH_CLIENT_SECRET;
165
- process.env.MSOAUTH_SECRET_ARN = SECRET_ARN;
166
- secretString = JSON.stringify({ clientId: "an-app-id" });
167
-
168
- const { response, log } = await start();
169
-
170
- assertGenericFiveHundred(response);
171
- assertNamesOnly(log, SECRET_MISSING_FIELDS);
172
- });
173
-
174
- it("names a secret that is not JSON without quoting what it holds", async () => {
175
- const secretMaterial = "s3cr3t-not-json-9d41c0b7";
176
- delete process.env.MSOAUTH_CLIENT_ID;
177
- delete process.env.MSOAUTH_CLIENT_SECRET;
178
- process.env.MSOAUTH_SECRET_ARN = SECRET_ARN;
179
- secretString = secretMaterial;
180
-
181
- const { response, log, logged } = await start();
182
-
183
- assertGenericFiveHundred(response);
184
- assertNamesOnly(log, SECRET_IS_NOT_JSON);
185
- assert.equal(
186
- logged.includes(secretMaterial),
187
- false,
188
- "a parse failure quoting the secret into the log turns a misconfiguration into a leak",
189
- );
190
- });
191
- });
@@ -1,439 +0,0 @@
1
- /**
2
- * Tests for Microsoft OAuth connect flow handlers.
3
- *
4
- * These tests exercise the HMAC state signing/verification logic and the
5
- * handler business logic through pure function calls, without booting DynamoDB
6
- * or making real AWS API calls. The handlers themselves are tested via
7
- * integration / end-to-end tests; here we focus on the critical security
8
- * paths (state signing, expiry, token handling).
9
- */
10
- import assert from "node:assert/strict";
11
- import { readFileSync } from "node:fs";
12
- import { dirname, resolve } from "node:path";
13
- import { describe, it } from "node:test";
14
- import { fileURLToPath } from "node:url";
15
- import { AccountService } from "@remit/domain-enums";
16
- import {
17
- getWebOrigin,
18
- type OAuthState,
19
- parseJwtClaims,
20
- STATE_TTL_MS,
21
- signState,
22
- verifyState,
23
- } from "./account-oauth.js";
24
-
25
- function makeJwtToken(claims: Record<string, unknown>): string {
26
- const header = Buffer.from(JSON.stringify({ alg: "RS256" })).toString(
27
- "base64url",
28
- );
29
- const payload = Buffer.from(JSON.stringify(claims), "utf8").toString(
30
- "base64url",
31
- );
32
- return `${header}.${payload}.fake-signature`;
33
- }
34
-
35
- // ─── HMAC state signing tests ─────────────────────────────────────────────────
36
-
37
- describe("HMAC state signing", () => {
38
- const SECRET = "test-client-secret-value";
39
-
40
- it("signs and verifies a valid state", async () => {
41
- const payload: OAuthState = {
42
- accountConfigId: "acc-config-1",
43
- nonce: "deadbeef",
44
- timestamp: Date.now(),
45
- services: [AccountService.Mail],
46
- };
47
-
48
- const state = await signState(payload, SECRET);
49
- const verified = await verifyState(state, SECRET);
50
-
51
- assert.equal(verified.accountConfigId, payload.accountConfigId);
52
- assert.equal(verified.nonce, payload.nonce);
53
- });
54
-
55
- it("rejects a tampered payload", async () => {
56
- const payload: OAuthState = {
57
- accountConfigId: "acc-config-1",
58
- nonce: "deadbeef",
59
- timestamp: Date.now(),
60
- services: [AccountService.Mail],
61
- };
62
-
63
- const state = await signState(payload, SECRET);
64
- const [payloadPart, sigPart] = state.split(".");
65
- // Tamper: replace the payload with a different one
66
- const tamperedPayload = Buffer.from(
67
- JSON.stringify({ ...payload, accountConfigId: "HACKED" }),
68
- "utf8",
69
- ).toString("base64url");
70
- const tampered = `${tamperedPayload}.${sigPart}`;
71
-
72
- await assert.rejects(
73
- () => verifyState(tampered, SECRET),
74
- /State signature verification failed/,
75
- );
76
-
77
- // Keep linter happy: payloadPart is used in the tamper to ensure it's different
78
- assert.notEqual(tamperedPayload, payloadPart);
79
- });
80
-
81
- it("rejects a state signed with a different secret", async () => {
82
- const payload: OAuthState = {
83
- accountConfigId: "acc-config-1",
84
- nonce: "deadbeef",
85
- timestamp: Date.now(),
86
- services: [AccountService.Mail],
87
- };
88
-
89
- const state = await signState(payload, SECRET);
90
-
91
- await assert.rejects(
92
- () => verifyState(state, "different-secret"),
93
- /State signature verification failed/,
94
- );
95
- });
96
-
97
- it("rejects a state with a truncated signature", async () => {
98
- const payload: OAuthState = {
99
- accountConfigId: "acc-config-1",
100
- nonce: "deadbeef",
101
- timestamp: Date.now(),
102
- services: [AccountService.Mail],
103
- };
104
-
105
- const state = await signState(payload, SECRET);
106
- // Remove last few chars of signature
107
- const truncated = state.slice(0, -4);
108
-
109
- await assert.rejects(() => verifyState(truncated, SECRET));
110
- });
111
-
112
- it("rejects a state that has no dot separator", async () => {
113
- await assert.rejects(
114
- () => verifyState("nodotanywhere", SECRET),
115
- /Malformed state/,
116
- );
117
- });
118
-
119
- it("reads a state signed before services existed as mail only", async () => {
120
- const state = await signState(
121
- {
122
- accountConfigId: "acc-config-1",
123
- nonce: "deadbeef",
124
- timestamp: Date.now(),
125
- } as OAuthState,
126
- SECRET,
127
- );
128
-
129
- assert.deepEqual((await verifyState(state, SECRET)).services, [
130
- AccountService.Mail,
131
- ]);
132
- });
133
-
134
- it("rejects a state that names no requested services", async () => {
135
- const state = await signState(
136
- {
137
- accountConfigId: "acc-config-1",
138
- nonce: "deadbeef",
139
- timestamp: Date.now(),
140
- services: [],
141
- },
142
- SECRET,
143
- );
144
-
145
- await assert.rejects(
146
- () => verifyState(state, SECRET),
147
- /State names no requested services/,
148
- );
149
- });
150
-
151
- it("carries the requested services through the signed state", async () => {
152
- const state = await signState(
153
- {
154
- accountConfigId: "acc-config-1",
155
- nonce: "deadbeef",
156
- timestamp: Date.now(),
157
- services: [AccountService.Mail, AccountService.Calendar],
158
- },
159
- SECRET,
160
- );
161
-
162
- assert.deepEqual((await verifyState(state, SECRET)).services, [
163
- AccountService.Mail,
164
- AccountService.Calendar,
165
- ]);
166
- });
167
-
168
- it("rejects an expired state (timestamp > 10 minutes ago)", async () => {
169
- const expired: OAuthState = {
170
- accountConfigId: "acc-config-1",
171
- nonce: "deadbeef",
172
- timestamp: Date.now() - STATE_TTL_MS - 1000, // 1 second past expiry
173
- services: [AccountService.Mail],
174
- };
175
-
176
- const state = await signState(expired, SECRET);
177
-
178
- await assert.rejects(() => verifyState(state, SECRET), /State has expired/);
179
- });
180
-
181
- it("accepts a state right at the TTL boundary", async () => {
182
- const almostExpired: OAuthState = {
183
- accountConfigId: "acc-config-1",
184
- nonce: "deadbeef",
185
- timestamp: Date.now() - STATE_TTL_MS + 2000, // 2 seconds before expiry
186
- services: [AccountService.Mail],
187
- };
188
-
189
- const state = await signState(almostExpired, SECRET);
190
- const verified = await verifyState(state, SECRET);
191
- assert.equal(verified.accountConfigId, "acc-config-1");
192
- });
193
- });
194
-
195
- // ─── JWT claims parsing ───────────────────────────────────────────────────────
196
-
197
- describe("parseJwtClaims", () => {
198
- it("extracts preferred_username from a Microsoft-style access token", async () => {
199
- const token = makeJwtToken({
200
- sub: "12345",
201
- preferred_username: "alice@example.com",
202
- email: "alice@example.com",
203
- });
204
- const claims = await parseJwtClaims(token);
205
- assert.ok(claims);
206
- assert.equal(claims.preferred_username, "alice@example.com");
207
- });
208
-
209
- it("returns null on a non-JWT string", async () => {
210
- assert.equal(await parseJwtClaims("not-a-jwt"), null);
211
- });
212
-
213
- it("returns null on a two-part string", async () => {
214
- assert.equal(await parseJwtClaims("header.payload"), null);
215
- });
216
-
217
- it("extracts email claim when preferred_username is absent", async () => {
218
- const token = makeJwtToken({ email: "bob@outlook.com" });
219
- const claims = await parseJwtClaims(token);
220
- assert.ok(claims);
221
- assert.equal(claims.email, "bob@outlook.com");
222
- });
223
- });
224
-
225
- // ─── Token leak test ──────────────────────────────────────────────────────────
226
- //
227
- // Verify that the handler module does not accidentally log raw tokens.
228
- // We do this statically by reading the source and checking that refresh_token
229
- // or refreshToken is only passed through the encrypt() call, never to logger.
230
-
231
- describe("token leak prevention (static source check)", () => {
232
- const __dirname = dirname(fileURLToPath(import.meta.url));
233
-
234
- const handlerSource = readFileSync(
235
- resolve(__dirname, "./account-oauth.ts"),
236
- "utf-8",
237
- );
238
-
239
- it("does not log refreshToken directly", () => {
240
- // The handler should only pass refresh token to secrets.encrypt(), never to logger
241
- const logLines = handlerSource
242
- .split("\n")
243
- .filter((l) => l.includes("logger.") && l.includes("refreshToken"));
244
-
245
- assert.equal(
246
- logLines.length,
247
- 0,
248
- `Found lines that may log refreshToken:\n${logLines.join("\n")}`,
249
- );
250
- });
251
-
252
- it("does not log code or state query parameters directly", () => {
253
- const logLines = handlerSource
254
- .split("\n")
255
- .filter(
256
- (l) =>
257
- l.includes("logger.") &&
258
- (l.includes("qs.code") || l.includes("qs.state")),
259
- );
260
-
261
- assert.equal(
262
- logLines.length,
263
- 0,
264
- `Found lines that may log auth code or state:\n${logLines.join("\n")}`,
265
- );
266
- });
267
-
268
- it("does not log the client secret", () => {
269
- const logLines = handlerSource
270
- .split("\n")
271
- .filter(
272
- (l) =>
273
- l.includes("logger.") &&
274
- (l.includes("clientSecret") || l.includes("client_secret")),
275
- );
276
-
277
- assert.equal(
278
- logLines.length,
279
- 0,
280
- `Found lines that may log clientSecret:\n${logLines.join("\n")}`,
281
- );
282
- });
283
- });
284
-
285
- // ─── Callback redirect behaviour (logic-only, no DynamoDB) ───────────────────
286
- //
287
- // These tests verify the redirect logic for error cases without calling the
288
- // full handler (which would need DynamoDB + AWS credentials). The handler's
289
- // error redirect paths are determined by simple conditional logic we can
290
- // unit-test by re-implementing the decision tree here.
291
-
292
- describe("callback redirect decision", () => {
293
- function decideCallback(opts: {
294
- hasError?: string;
295
- hasCode?: boolean;
296
- hasState?: boolean;
297
- stateValid?: boolean;
298
- exchangeOk?: boolean;
299
- hasRefreshToken?: boolean;
300
- emailInToken?: string | undefined;
301
- }): string {
302
- const webOrigin = "https://app.example.com";
303
- const redirect = (path: string) => path;
304
-
305
- if (opts.hasError) {
306
- return redirect(
307
- `${webOrigin}/settings/accounts?oauthError=${opts.hasError}`,
308
- );
309
- }
310
- if (!opts.hasCode || !opts.hasState) {
311
- return redirect(
312
- `${webOrigin}/settings/accounts?oauthError=missing_params`,
313
- );
314
- }
315
- if (!opts.stateValid) {
316
- return redirect(
317
- `${webOrigin}/settings/accounts?oauthError=invalid_state`,
318
- );
319
- }
320
- if (!opts.exchangeOk || !opts.hasRefreshToken) {
321
- return redirect(
322
- `${webOrigin}/settings/accounts?oauthError=exchange_failed`,
323
- );
324
- }
325
- if (!opts.emailInToken) {
326
- return redirect(
327
- `${webOrigin}/settings/accounts?oauthError=missing_email`,
328
- );
329
- }
330
- return redirect(`${webOrigin}/settings/accounts?connected=new-account-id`);
331
- }
332
-
333
- it("redirects with oauthError=access_denied when Microsoft returns error", () => {
334
- const url = decideCallback({ hasError: "access_denied" });
335
- assert.ok(
336
- url.includes("oauthError=access_denied"),
337
- `Expected oauthError=access_denied in: ${url}`,
338
- );
339
- });
340
-
341
- it("redirects with oauthError=missing_params when code/state absent", () => {
342
- const url = decideCallback({ hasCode: false, hasState: false });
343
- assert.ok(url.includes("oauthError=missing_params"));
344
- });
345
-
346
- it("redirects with oauthError=invalid_state on bad HMAC", () => {
347
- const url = decideCallback({
348
- hasCode: true,
349
- hasState: true,
350
- stateValid: false,
351
- });
352
- assert.ok(url.includes("oauthError=invalid_state"));
353
- });
354
-
355
- it("redirects with oauthError=exchange_failed when token exchange fails", () => {
356
- const url = decideCallback({
357
- hasCode: true,
358
- hasState: true,
359
- stateValid: true,
360
- exchangeOk: false,
361
- });
362
- assert.ok(url.includes("oauthError=exchange_failed"));
363
- });
364
-
365
- it("redirects with oauthError=exchange_failed when no refresh_token in response", () => {
366
- const url = decideCallback({
367
- hasCode: true,
368
- hasState: true,
369
- stateValid: true,
370
- exchangeOk: true,
371
- hasRefreshToken: false,
372
- });
373
- assert.ok(url.includes("oauthError=exchange_failed"));
374
- });
375
-
376
- it("redirects with oauthError=missing_email when ID token has no email", () => {
377
- const url = decideCallback({
378
- hasCode: true,
379
- hasState: true,
380
- stateValid: true,
381
- exchangeOk: true,
382
- hasRefreshToken: true,
383
- emailInToken: undefined,
384
- });
385
- assert.ok(url.includes("oauthError=missing_email"));
386
- });
387
-
388
- it("redirects to connected page on happy path", () => {
389
- const url = decideCallback({
390
- hasCode: true,
391
- hasState: true,
392
- stateValid: true,
393
- exchangeOk: true,
394
- hasRefreshToken: true,
395
- emailInToken: "alice@outlook.com",
396
- });
397
- assert.ok(url.includes("connected=new-account-id"));
398
- });
399
- });
400
-
401
- // ─── getWebOrigin helper ──────────────────────────────────────────────────────
402
-
403
- describe("web origin selection", () => {
404
- let savedOrigins: string | undefined;
405
-
406
- it("prefers HTTPS origin over HTTP", () => {
407
- savedOrigins = process.env.CORS_ALLOWED_ORIGINS;
408
- process.env.CORS_ALLOWED_ORIGINS =
409
- "http://localhost:3000,https://app.example.com";
410
- const origin = getWebOrigin();
411
- process.env.CORS_ALLOWED_ORIGINS = savedOrigins;
412
- assert.equal(origin, "https://app.example.com");
413
- });
414
-
415
- it("returns first origin when no HTTPS available", () => {
416
- savedOrigins = process.env.CORS_ALLOWED_ORIGINS;
417
- process.env.CORS_ALLOWED_ORIGINS = "http://localhost:3000";
418
- const origin = getWebOrigin();
419
- process.env.CORS_ALLOWED_ORIGINS = savedOrigins;
420
- assert.equal(origin, "http://localhost:3000");
421
- });
422
-
423
- it("returns fallback when env var is empty", () => {
424
- savedOrigins = process.env.CORS_ALLOWED_ORIGINS;
425
- process.env.CORS_ALLOWED_ORIGINS = "";
426
- const origin = getWebOrigin();
427
- process.env.CORS_ALLOWED_ORIGINS = savedOrigins;
428
- assert.equal(origin, "https://localhost:3000");
429
- });
430
-
431
- it("handles multiple HTTPS origins by returning the first", () => {
432
- savedOrigins = process.env.CORS_ALLOWED_ORIGINS;
433
- process.env.CORS_ALLOWED_ORIGINS =
434
- "https://staging.example.com,https://app.example.com";
435
- const origin = getWebOrigin();
436
- process.env.CORS_ALLOWED_ORIGINS = savedOrigins;
437
- assert.equal(origin, "https://staging.example.com");
438
- });
439
- });