@remit/backend 0.0.1
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/.env.test +7 -0
- package/dev-server/content-auth.test.ts +113 -0
- package/dev-server/content-auth.ts +54 -0
- package/dev-server/content-handler.test.ts +105 -0
- package/dev-server/content-handler.ts +79 -0
- package/dev-server/content-path.test.ts +37 -0
- package/dev-server/content-path.ts +27 -0
- package/dev-server/cors.test.ts +48 -0
- package/dev-server/cors.ts +25 -0
- package/dev-server/lambda-helpers.ts +69 -0
- package/dev-server/server.ts +289 -0
- package/package.json +82 -0
- package/src/auth.ts +92 -0
- package/src/config/msoauth.ts +60 -0
- package/src/data-backend.test.ts +25 -0
- package/src/data-backend.ts +20 -0
- package/src/derive/autoMoved.ts +28 -0
- package/src/derive/contentSignature.test.ts +153 -0
- package/src/derive/contentSignature.ts +139 -0
- package/src/derive/contentUrl.test.ts +156 -0
- package/src/derive/contentUrl.ts +70 -0
- package/src/derive/enrichThreadRows.ts +155 -0
- package/src/derive/filterThreadCriteria.test.ts +119 -0
- package/src/derive/filterThreadCriteria.ts +60 -0
- package/src/derive/pendingMoveCounts.ts +90 -0
- package/src/derive/senderTrust.test.ts +67 -0
- package/src/derive/senderTrust.ts +23 -0
- package/src/error.ts +55 -0
- package/src/handlers/account-guards.ts +137 -0
- package/src/handlers/account-oauth.test.ts +383 -0
- package/src/handlers/account-oauth.ts +452 -0
- package/src/handlers/account-overrides.test.ts +69 -0
- package/src/handlers/account-overrides.ts +286 -0
- package/src/handlers/account-ownership.ts +25 -0
- package/src/handlers/account-signature.ts +129 -0
- package/src/handlers/account.ts +524 -0
- package/src/handlers/address.ts +136 -0
- package/src/handlers/config.test.ts +184 -0
- package/src/handlers/config.ts +219 -0
- package/src/handlers/ensure-account-config.ts +30 -0
- package/src/handlers/filter.ts +294 -0
- package/src/handlers/folder-role-appointments.test.ts +250 -0
- package/src/handlers/folder-role-appointments.ts +272 -0
- package/src/handlers/folder-role.ts +76 -0
- package/src/handlers/index.ts +48 -0
- package/src/handlers/mailbox.ts +411 -0
- package/src/handlers/me.ts +190 -0
- package/src/handlers/message.ts +886 -0
- package/src/handlers/organize.test.ts +43 -0
- package/src/handlers/organize.ts +196 -0
- package/src/handlers/outbox.ts +218 -0
- package/src/handlers/search.test.ts +182 -0
- package/src/handlers/search.ts +105 -0
- package/src/handlers/sync-progress.test.ts +158 -0
- package/src/handlers/sync-progress.ts +64 -0
- package/src/handlers/sync.test.ts +146 -0
- package/src/handlers/sync.ts +119 -0
- package/src/handlers/thread.ts +361 -0
- package/src/handlers/unified-threads.ts +196 -0
- package/src/handlers/vip-suggestions.ts +21 -0
- package/src/index.ts +170 -0
- package/src/json.ts +11 -0
- package/src/jwt-auth.test.ts +89 -0
- package/src/jwt-auth.ts +95 -0
- package/src/request-context.test.ts +61 -0
- package/src/request-context.ts +29 -0
- package/src/request.ts +10 -0
- package/src/response.test.ts +107 -0
- package/src/response.ts +80 -0
- package/src/service/compose-postgres.ts +72 -0
- package/src/service/compose-sqlite.ts +80 -0
- package/src/service/create-remit-client.ts +358 -0
- package/src/service/dynamodb.test.ts +100 -0
- package/src/service/dynamodb.ts +58 -0
- package/src/service/filter.ts +55 -0
- package/src/service/fire-and-forget.test.ts +126 -0
- package/src/service/fire-and-forget.ts +79 -0
- package/src/service/localhost-env-config.test.ts +40 -0
- package/src/service/organize.test.ts +384 -0
- package/src/service/organize.ts +354 -0
- package/src/service/semantic-capability.test.ts +64 -0
- package/src/service/semantic-capability.ts +62 -0
- package/src/service/sqs.ts +4 -0
- package/src/service/trigger-sync.test.ts +211 -0
- package/src/service/trigger-sync.ts +102 -0
- package/src/types.ts +161 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,383 @@
|
|
|
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 {
|
|
16
|
+
getWebOrigin,
|
|
17
|
+
type OAuthState,
|
|
18
|
+
parseJwtClaims,
|
|
19
|
+
STATE_TTL_MS,
|
|
20
|
+
signState,
|
|
21
|
+
verifyState,
|
|
22
|
+
} from "./account-oauth.js";
|
|
23
|
+
|
|
24
|
+
function makeJwtToken(claims: Record<string, unknown>): string {
|
|
25
|
+
const header = Buffer.from(JSON.stringify({ alg: "RS256" })).toString(
|
|
26
|
+
"base64url",
|
|
27
|
+
);
|
|
28
|
+
const payload = Buffer.from(JSON.stringify(claims), "utf8").toString(
|
|
29
|
+
"base64url",
|
|
30
|
+
);
|
|
31
|
+
return `${header}.${payload}.fake-signature`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── HMAC state signing tests ─────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe("HMAC state signing", () => {
|
|
37
|
+
const SECRET = "test-client-secret-value";
|
|
38
|
+
|
|
39
|
+
it("signs and verifies a valid state", async () => {
|
|
40
|
+
const payload: OAuthState = {
|
|
41
|
+
accountConfigId: "acc-config-1",
|
|
42
|
+
nonce: "deadbeef",
|
|
43
|
+
timestamp: Date.now(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const state = await signState(payload, SECRET);
|
|
47
|
+
const verified = await verifyState(state, SECRET);
|
|
48
|
+
|
|
49
|
+
assert.equal(verified.accountConfigId, payload.accountConfigId);
|
|
50
|
+
assert.equal(verified.nonce, payload.nonce);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects a tampered payload", async () => {
|
|
54
|
+
const payload: OAuthState = {
|
|
55
|
+
accountConfigId: "acc-config-1",
|
|
56
|
+
nonce: "deadbeef",
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const state = await signState(payload, SECRET);
|
|
61
|
+
const [payloadPart, sigPart] = state.split(".");
|
|
62
|
+
// Tamper: replace the payload with a different one
|
|
63
|
+
const tamperedPayload = Buffer.from(
|
|
64
|
+
JSON.stringify({ ...payload, accountConfigId: "HACKED" }),
|
|
65
|
+
"utf8",
|
|
66
|
+
).toString("base64url");
|
|
67
|
+
const tampered = `${tamperedPayload}.${sigPart}`;
|
|
68
|
+
|
|
69
|
+
await assert.rejects(
|
|
70
|
+
() => verifyState(tampered, SECRET),
|
|
71
|
+
/State signature verification failed/,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Keep linter happy: payloadPart is used in the tamper to ensure it's different
|
|
75
|
+
assert.notEqual(tamperedPayload, payloadPart);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects a state signed with a different secret", async () => {
|
|
79
|
+
const payload: OAuthState = {
|
|
80
|
+
accountConfigId: "acc-config-1",
|
|
81
|
+
nonce: "deadbeef",
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const state = await signState(payload, SECRET);
|
|
86
|
+
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
() => verifyState(state, "different-secret"),
|
|
89
|
+
/State signature verification failed/,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects a state with a truncated signature", async () => {
|
|
94
|
+
const payload: OAuthState = {
|
|
95
|
+
accountConfigId: "acc-config-1",
|
|
96
|
+
nonce: "deadbeef",
|
|
97
|
+
timestamp: Date.now(),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const state = await signState(payload, SECRET);
|
|
101
|
+
// Remove last few chars of signature
|
|
102
|
+
const truncated = state.slice(0, -4);
|
|
103
|
+
|
|
104
|
+
await assert.rejects(() => verifyState(truncated, SECRET));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects a state that has no dot separator", async () => {
|
|
108
|
+
await assert.rejects(
|
|
109
|
+
() => verifyState("nodotanywhere", SECRET),
|
|
110
|
+
/Malformed state/,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects an expired state (timestamp > 10 minutes ago)", async () => {
|
|
115
|
+
const expired: OAuthState = {
|
|
116
|
+
accountConfigId: "acc-config-1",
|
|
117
|
+
nonce: "deadbeef",
|
|
118
|
+
timestamp: Date.now() - STATE_TTL_MS - 1000, // 1 second past expiry
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const state = await signState(expired, SECRET);
|
|
122
|
+
|
|
123
|
+
await assert.rejects(() => verifyState(state, SECRET), /State has expired/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("accepts a state right at the TTL boundary", async () => {
|
|
127
|
+
const almostExpired: OAuthState = {
|
|
128
|
+
accountConfigId: "acc-config-1",
|
|
129
|
+
nonce: "deadbeef",
|
|
130
|
+
timestamp: Date.now() - STATE_TTL_MS + 2000, // 2 seconds before expiry
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const state = await signState(almostExpired, SECRET);
|
|
134
|
+
const verified = await verifyState(state, SECRET);
|
|
135
|
+
assert.equal(verified.accountConfigId, "acc-config-1");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ─── JWT claims parsing ───────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe("parseJwtClaims", () => {
|
|
142
|
+
it("extracts preferred_username from a Microsoft-style access token", async () => {
|
|
143
|
+
const token = makeJwtToken({
|
|
144
|
+
sub: "12345",
|
|
145
|
+
preferred_username: "alice@example.com",
|
|
146
|
+
email: "alice@example.com",
|
|
147
|
+
});
|
|
148
|
+
const claims = await parseJwtClaims(token);
|
|
149
|
+
assert.ok(claims);
|
|
150
|
+
assert.equal(claims.preferred_username, "alice@example.com");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns null on a non-JWT string", async () => {
|
|
154
|
+
assert.equal(await parseJwtClaims("not-a-jwt"), null);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns null on a two-part string", async () => {
|
|
158
|
+
assert.equal(await parseJwtClaims("header.payload"), null);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("extracts email claim when preferred_username is absent", async () => {
|
|
162
|
+
const token = makeJwtToken({ email: "bob@outlook.com" });
|
|
163
|
+
const claims = await parseJwtClaims(token);
|
|
164
|
+
assert.ok(claims);
|
|
165
|
+
assert.equal(claims.email, "bob@outlook.com");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ─── Token leak test ──────────────────────────────────────────────────────────
|
|
170
|
+
//
|
|
171
|
+
// Verify that the handler module does not accidentally log raw tokens.
|
|
172
|
+
// We do this statically by reading the source and checking that refresh_token
|
|
173
|
+
// or refreshToken is only passed through the encrypt() call, never to logger.
|
|
174
|
+
|
|
175
|
+
describe("token leak prevention (static source check)", () => {
|
|
176
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
177
|
+
|
|
178
|
+
const handlerSource = readFileSync(
|
|
179
|
+
resolve(__dirname, "./account-oauth.ts"),
|
|
180
|
+
"utf-8",
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
it("does not log refreshToken directly", () => {
|
|
184
|
+
// The handler should only pass refresh token to secrets.encrypt(), never to logger
|
|
185
|
+
const logLines = handlerSource
|
|
186
|
+
.split("\n")
|
|
187
|
+
.filter((l) => l.includes("logger.") && l.includes("refreshToken"));
|
|
188
|
+
|
|
189
|
+
assert.equal(
|
|
190
|
+
logLines.length,
|
|
191
|
+
0,
|
|
192
|
+
`Found lines that may log refreshToken:\n${logLines.join("\n")}`,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does not log code or state query parameters directly", () => {
|
|
197
|
+
const logLines = handlerSource
|
|
198
|
+
.split("\n")
|
|
199
|
+
.filter(
|
|
200
|
+
(l) =>
|
|
201
|
+
l.includes("logger.") &&
|
|
202
|
+
(l.includes("qs.code") || l.includes("qs.state")),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
assert.equal(
|
|
206
|
+
logLines.length,
|
|
207
|
+
0,
|
|
208
|
+
`Found lines that may log auth code or state:\n${logLines.join("\n")}`,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("does not log the client secret", () => {
|
|
213
|
+
const logLines = handlerSource
|
|
214
|
+
.split("\n")
|
|
215
|
+
.filter(
|
|
216
|
+
(l) =>
|
|
217
|
+
l.includes("logger.") &&
|
|
218
|
+
(l.includes("clientSecret") || l.includes("client_secret")),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assert.equal(
|
|
222
|
+
logLines.length,
|
|
223
|
+
0,
|
|
224
|
+
`Found lines that may log clientSecret:\n${logLines.join("\n")}`,
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ─── Callback redirect behaviour (logic-only, no DynamoDB) ───────────────────
|
|
230
|
+
//
|
|
231
|
+
// These tests verify the redirect logic for error cases without calling the
|
|
232
|
+
// full handler (which would need DynamoDB + AWS credentials). The handler's
|
|
233
|
+
// error redirect paths are determined by simple conditional logic we can
|
|
234
|
+
// unit-test by re-implementing the decision tree here.
|
|
235
|
+
|
|
236
|
+
describe("callback redirect decision", () => {
|
|
237
|
+
function decideCallback(opts: {
|
|
238
|
+
hasError?: string;
|
|
239
|
+
hasCode?: boolean;
|
|
240
|
+
hasState?: boolean;
|
|
241
|
+
stateValid?: boolean;
|
|
242
|
+
exchangeOk?: boolean;
|
|
243
|
+
hasRefreshToken?: boolean;
|
|
244
|
+
emailInToken?: string | undefined;
|
|
245
|
+
}): string {
|
|
246
|
+
const webOrigin = "https://app.example.com";
|
|
247
|
+
const redirect = (path: string) => path;
|
|
248
|
+
|
|
249
|
+
if (opts.hasError) {
|
|
250
|
+
return redirect(
|
|
251
|
+
`${webOrigin}/settings/accounts?oauthError=${opts.hasError}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (!opts.hasCode || !opts.hasState) {
|
|
255
|
+
return redirect(
|
|
256
|
+
`${webOrigin}/settings/accounts?oauthError=missing_params`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (!opts.stateValid) {
|
|
260
|
+
return redirect(
|
|
261
|
+
`${webOrigin}/settings/accounts?oauthError=invalid_state`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (!opts.exchangeOk || !opts.hasRefreshToken) {
|
|
265
|
+
return redirect(
|
|
266
|
+
`${webOrigin}/settings/accounts?oauthError=exchange_failed`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (!opts.emailInToken) {
|
|
270
|
+
return redirect(
|
|
271
|
+
`${webOrigin}/settings/accounts?oauthError=missing_email`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return redirect(`${webOrigin}/settings/accounts?connected=new-account-id`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
it("redirects with oauthError=access_denied when Microsoft returns error", () => {
|
|
278
|
+
const url = decideCallback({ hasError: "access_denied" });
|
|
279
|
+
assert.ok(
|
|
280
|
+
url.includes("oauthError=access_denied"),
|
|
281
|
+
`Expected oauthError=access_denied in: ${url}`,
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("redirects with oauthError=missing_params when code/state absent", () => {
|
|
286
|
+
const url = decideCallback({ hasCode: false, hasState: false });
|
|
287
|
+
assert.ok(url.includes("oauthError=missing_params"));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("redirects with oauthError=invalid_state on bad HMAC", () => {
|
|
291
|
+
const url = decideCallback({
|
|
292
|
+
hasCode: true,
|
|
293
|
+
hasState: true,
|
|
294
|
+
stateValid: false,
|
|
295
|
+
});
|
|
296
|
+
assert.ok(url.includes("oauthError=invalid_state"));
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("redirects with oauthError=exchange_failed when token exchange fails", () => {
|
|
300
|
+
const url = decideCallback({
|
|
301
|
+
hasCode: true,
|
|
302
|
+
hasState: true,
|
|
303
|
+
stateValid: true,
|
|
304
|
+
exchangeOk: false,
|
|
305
|
+
});
|
|
306
|
+
assert.ok(url.includes("oauthError=exchange_failed"));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("redirects with oauthError=exchange_failed when no refresh_token in response", () => {
|
|
310
|
+
const url = decideCallback({
|
|
311
|
+
hasCode: true,
|
|
312
|
+
hasState: true,
|
|
313
|
+
stateValid: true,
|
|
314
|
+
exchangeOk: true,
|
|
315
|
+
hasRefreshToken: false,
|
|
316
|
+
});
|
|
317
|
+
assert.ok(url.includes("oauthError=exchange_failed"));
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("redirects with oauthError=missing_email when ID token has no email", () => {
|
|
321
|
+
const url = decideCallback({
|
|
322
|
+
hasCode: true,
|
|
323
|
+
hasState: true,
|
|
324
|
+
stateValid: true,
|
|
325
|
+
exchangeOk: true,
|
|
326
|
+
hasRefreshToken: true,
|
|
327
|
+
emailInToken: undefined,
|
|
328
|
+
});
|
|
329
|
+
assert.ok(url.includes("oauthError=missing_email"));
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("redirects to connected page on happy path", () => {
|
|
333
|
+
const url = decideCallback({
|
|
334
|
+
hasCode: true,
|
|
335
|
+
hasState: true,
|
|
336
|
+
stateValid: true,
|
|
337
|
+
exchangeOk: true,
|
|
338
|
+
hasRefreshToken: true,
|
|
339
|
+
emailInToken: "alice@outlook.com",
|
|
340
|
+
});
|
|
341
|
+
assert.ok(url.includes("connected=new-account-id"));
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ─── getWebOrigin helper ──────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
describe("web origin selection", () => {
|
|
348
|
+
let savedOrigins: string | undefined;
|
|
349
|
+
|
|
350
|
+
it("prefers HTTPS origin over HTTP", () => {
|
|
351
|
+
savedOrigins = process.env.CORS_ALLOWED_ORIGINS;
|
|
352
|
+
process.env.CORS_ALLOWED_ORIGINS =
|
|
353
|
+
"http://localhost:3000,https://app.example.com";
|
|
354
|
+
const origin = getWebOrigin();
|
|
355
|
+
process.env.CORS_ALLOWED_ORIGINS = savedOrigins;
|
|
356
|
+
assert.equal(origin, "https://app.example.com");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("returns first origin when no HTTPS available", () => {
|
|
360
|
+
savedOrigins = process.env.CORS_ALLOWED_ORIGINS;
|
|
361
|
+
process.env.CORS_ALLOWED_ORIGINS = "http://localhost:3000";
|
|
362
|
+
const origin = getWebOrigin();
|
|
363
|
+
process.env.CORS_ALLOWED_ORIGINS = savedOrigins;
|
|
364
|
+
assert.equal(origin, "http://localhost:3000");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("returns fallback when env var is empty", () => {
|
|
368
|
+
savedOrigins = process.env.CORS_ALLOWED_ORIGINS;
|
|
369
|
+
process.env.CORS_ALLOWED_ORIGINS = "";
|
|
370
|
+
const origin = getWebOrigin();
|
|
371
|
+
process.env.CORS_ALLOWED_ORIGINS = savedOrigins;
|
|
372
|
+
assert.equal(origin, "https://localhost:3000");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("handles multiple HTTPS origins by returning the first", () => {
|
|
376
|
+
savedOrigins = process.env.CORS_ALLOWED_ORIGINS;
|
|
377
|
+
process.env.CORS_ALLOWED_ORIGINS =
|
|
378
|
+
"https://staging.example.com,https://app.example.com";
|
|
379
|
+
const origin = getWebOrigin();
|
|
380
|
+
process.env.CORS_ALLOWED_ORIGINS = savedOrigins;
|
|
381
|
+
assert.equal(origin, "https://staging.example.com");
|
|
382
|
+
});
|
|
383
|
+
});
|