@remit/imap-worker 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/README.md +100 -0
- package/build.mjs +17 -0
- package/package.json +50 -0
- package/src/account-check.test.ts +122 -0
- package/src/account-check.ts +88 -0
- package/src/body-sync-gate.test.ts +185 -0
- package/src/body-sync-gate.ts +86 -0
- package/src/cli.ts +211 -0
- package/src/connection-scope.test.ts +266 -0
- package/src/connection-scope.ts +335 -0
- package/src/e2e-processor-shim.ts +248 -0
- package/src/emit.test.ts +44 -0
- package/src/emit.ts +142 -0
- package/src/events.ts +221 -0
- package/src/handlers/append-sent-message.ts +163 -0
- package/src/handlers/delete-account-objects.test.ts +81 -0
- package/src/handlers/delete-account-objects.ts +116 -0
- package/src/handlers/empty-trash.ts +136 -0
- package/src/handlers/flag-push.test.ts +25 -0
- package/src/handlers/flag-push.ts +224 -0
- package/src/handlers/mailbox-management.ts +266 -0
- package/src/handlers/mailbox-sync-order.test.ts +93 -0
- package/src/handlers/mailbox-sync-order.ts +65 -0
- package/src/handlers/message-copy.ts +219 -0
- package/src/handlers/message-delete.test.ts +176 -0
- package/src/handlers/message-delete.ts +283 -0
- package/src/handlers/message-move.test.ts +168 -0
- package/src/handlers/message-move.ts +298 -0
- package/src/handlers/placement-move-push.test.ts +234 -0
- package/src/handlers/placement-move-push.ts +434 -0
- package/src/handlers/sync-mailboxes.ts +241 -0
- package/src/handlers/sync-message-body.test.ts +375 -0
- package/src/handlers/sync-message-body.ts +337 -0
- package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
- package/src/handlers/sync-messages.test.ts +204 -0
- package/src/handlers/sync-messages.ts +412 -0
- package/src/handlers/sync-reserved-host.test.ts +97 -0
- package/src/index.test.ts +22 -0
- package/src/index.ts +70 -0
- package/src/poller.ts +49 -0
- package/src/processor.test.ts +58 -0
- package/src/processor.ts +66 -0
- package/src/scheduler/config.test.ts +40 -0
- package/src/scheduler/config.ts +52 -0
- package/src/scheduler/decide-due.test.ts +44 -0
- package/src/scheduler/decide-due.ts +26 -0
- package/src/scheduler/handler.ts +52 -0
- package/src/scheduler/local-runner.ts +76 -0
- package/src/scheduler/run-tick.test.ts +248 -0
- package/src/scheduler/run-tick.ts +141 -0
- package/src/with-oauth-lifecycle-deps.ts +62 -0
- package/src/with-oauth-lifecycle.test.ts +227 -0
- package/src/with-oauth-lifecycle.ts +125 -0
- package/tsconfig.json +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @remit/imap-worker
|
|
2
|
+
|
|
3
|
+
SQS-driven Lambda worker for handling IMAP lifecycle events in the Remit system.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Event Driven**: Processes events from SQS (`SYNC_MAILBOXES`, `SYNC_MESSAGES`, etc.)
|
|
8
|
+
- **CLI Tool**: Includes a CLI for manually triggering events during development
|
|
9
|
+
- **Bundled**: Built with esbuild for optimal Lambda performance
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install
|
|
15
|
+
npm run bundle
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Lambda Handler
|
|
21
|
+
|
|
22
|
+
The package exports a standard AWS Lambda SQS handler:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { handler } from "@remit/imap-worker";
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### CLI (Local Development)
|
|
29
|
+
|
|
30
|
+
Trigger events manually using the included CLI:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Sync all mailboxes for an account
|
|
34
|
+
npm run cli -- -t SYNC_MAILBOXES -a <accountId>
|
|
35
|
+
|
|
36
|
+
# Sync messages in a mailbox
|
|
37
|
+
npm run cli -- -t SYNC_MESSAGES -a <accountId> -m <mailboxId>
|
|
38
|
+
|
|
39
|
+
# Force full sync (ignore lastSyncUid)
|
|
40
|
+
npm run cli -- -t SYNC_MESSAGES -a <accountId> -m <mailboxId> --fullSync
|
|
41
|
+
|
|
42
|
+
# Sync message bodies for specific messages
|
|
43
|
+
npm run cli -- -t SYNC_MESSAGE_BODY -a <accountId> -m <mailboxId> --messageIds id1,id2,id3
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Environment Variables
|
|
47
|
+
|
|
48
|
+
| Variable | Required | Description |
|
|
49
|
+
| ----------------------------- | -------- | -------------------------------------------------------- |
|
|
50
|
+
| `DYNAMODB_TABLE_NAME` | Yes | Name of the DynamoDB table for Remit data |
|
|
51
|
+
| `SQS_QUEUE_URL_MAILBOXES` | Yes | FIFO queue URL for `SYNC_MAILBOXES` events |
|
|
52
|
+
| `SQS_QUEUE_URL_MESSAGES` | Yes | FIFO queue URL for `SYNC_MESSAGES` events |
|
|
53
|
+
| `SQS_QUEUE_URL_BODY` | Yes | FIFO queue URL for `SYNC_MESSAGE_BODY` events |
|
|
54
|
+
| `SQS_QUEUE_URL_FLAGS` | Yes | FIFO queue URL for `SYNC_FLAGS` events |
|
|
55
|
+
| `SQS_QUEUE_URL_MAILBOX_MGMT` | Yes | Standard queue URL for mailbox management events |
|
|
56
|
+
| `SQS_QUEUE_URL_MESSAGE_MGMT` | Yes | Standard queue URL for message management events |
|
|
57
|
+
| `S3_BUCKET` | Yes | S3 bucket for storing raw message content |
|
|
58
|
+
| `NODE_ENV` | No | Set to `development` for local execution |
|
|
59
|
+
| `LOG_LEVEL` | No | Logging level (default: `info`) |
|
|
60
|
+
|
|
61
|
+
## Event Types
|
|
62
|
+
|
|
63
|
+
| Event | Description | Required Fields |
|
|
64
|
+
| ------------------- | ---------------------------------------------- | --------------------------------------- |
|
|
65
|
+
| `SYNC_MAILBOXES` | Discovers and syncs mailboxes for an account | `accountId` |
|
|
66
|
+
| `SYNC_MESSAGES` | Fetches new messages for a mailbox (one batch) | `accountId`, `mailboxId` |
|
|
67
|
+
| `SYNC_MESSAGE_BODY` | Fetches and stores message bodies in batch | `accountId`, `mailboxId`, `messageIds` |
|
|
68
|
+
|
|
69
|
+
### Event Schema
|
|
70
|
+
|
|
71
|
+
All events share a base schema:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
interface BaseEvent {
|
|
75
|
+
accountId: string;
|
|
76
|
+
eventId: string; // Idempotency key
|
|
77
|
+
timestamp: number; // Unix timestamp
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Architecture
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
SQS Queue
|
|
85
|
+
│
|
|
86
|
+
▼
|
|
87
|
+
┌───────────────┐
|
|
88
|
+
│ Lambda/CLI │
|
|
89
|
+
│ (index.ts) │
|
|
90
|
+
└───────┬───────┘
|
|
91
|
+
│
|
|
92
|
+
▼
|
|
93
|
+
┌───────────────┐
|
|
94
|
+
│ processor │ ─── Routes events to handlers
|
|
95
|
+
└───────┬───────┘
|
|
96
|
+
│
|
|
97
|
+
├──► syncMailboxes
|
|
98
|
+
├──► syncMessages
|
|
99
|
+
└──► syncMessageBody
|
|
100
|
+
```
|
package/build.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as esbuild from "esbuild";
|
|
2
|
+
|
|
3
|
+
await esbuild.build({
|
|
4
|
+
entryPoints: ["src/index.ts", "src/cli.ts"],
|
|
5
|
+
bundle: true,
|
|
6
|
+
platform: "node",
|
|
7
|
+
target: "node20",
|
|
8
|
+
format: "esm",
|
|
9
|
+
outdir: "dist",
|
|
10
|
+
sourcemap: true,
|
|
11
|
+
external: [
|
|
12
|
+
"@aws-sdk/*", // Use Lambda-provided SDK
|
|
13
|
+
],
|
|
14
|
+
banner: {
|
|
15
|
+
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
|
|
16
|
+
},
|
|
17
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remit/imap-worker",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"remit-worker": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"bundle": "esbuild src/index.ts --sourcemap --bundle --platform=node --format=esm --outfile=dist/index.js",
|
|
11
|
+
"cli": "node --env-file=../../localhost-dev-aws.env src/cli.ts",
|
|
12
|
+
"test:typecheck": "tsgo --noEmit",
|
|
13
|
+
"test:run": "node --env-file=../../localhost-test-unit.env --test 'src/**/*.test.ts'",
|
|
14
|
+
"test": "npm run test:typecheck && npm run test:run"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@aws-sdk/client-sqs": "*",
|
|
18
|
+
"@aws-sdk/client-ssm": "*",
|
|
19
|
+
"@aws-sdk/core": "*",
|
|
20
|
+
"@remit/backend": "*",
|
|
21
|
+
"@remit/data-ports": "*",
|
|
22
|
+
"@remit/domain-enums": "*",
|
|
23
|
+
"@remit/logger-lambda": "*",
|
|
24
|
+
"@remit/mail-oauth-service": "*",
|
|
25
|
+
"@remit/mailbox-service": "*",
|
|
26
|
+
"@remit/search-index-worker": "*",
|
|
27
|
+
"@remit/secrets-service": "*",
|
|
28
|
+
"@remit/sqs-client": "*",
|
|
29
|
+
"@remit/storage-service": "*",
|
|
30
|
+
"@types/aws-lambda": "*",
|
|
31
|
+
"@types/nodemailer": "*",
|
|
32
|
+
"aws-sdk-client-mock": "*",
|
|
33
|
+
"esbuild": "^0.27.7",
|
|
34
|
+
"expect-env": "*",
|
|
35
|
+
"p-map": "*"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@aws-sdk/client-s3": "^3.1055.0",
|
|
39
|
+
"nodemailer": "^9.0.3"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/remit-mail/remit.git",
|
|
48
|
+
"directory": "packages/imap-worker"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
4
|
+
import {
|
|
5
|
+
isAccountDeleted,
|
|
6
|
+
isAccountReauthRequired,
|
|
7
|
+
isReservedHost,
|
|
8
|
+
isUnsyncableHost,
|
|
9
|
+
} from "./account-check.js";
|
|
10
|
+
|
|
11
|
+
const log = {
|
|
12
|
+
info: () => {},
|
|
13
|
+
warn: () => {},
|
|
14
|
+
error: () => {},
|
|
15
|
+
debug: () => {},
|
|
16
|
+
} as unknown as Parameters<typeof isAccountDeleted>[1];
|
|
17
|
+
|
|
18
|
+
const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
|
|
19
|
+
({
|
|
20
|
+
accountId: "acct-1",
|
|
21
|
+
connectionState: "authenticated",
|
|
22
|
+
...overrides,
|
|
23
|
+
}) as unknown as AccountItem;
|
|
24
|
+
|
|
25
|
+
describe("isAccountDeleted", () => {
|
|
26
|
+
it("returns false when deletedAt is absent", () => {
|
|
27
|
+
assert.equal(isAccountDeleted(buildAccount(), log), false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns true when deletedAt is set", () => {
|
|
31
|
+
assert.equal(
|
|
32
|
+
isAccountDeleted(buildAccount({ deletedAt: Date.now() }), log),
|
|
33
|
+
true,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("isReservedHost", () => {
|
|
39
|
+
const reserved = [
|
|
40
|
+
"invalid",
|
|
41
|
+
"example",
|
|
42
|
+
"imap.invalid",
|
|
43
|
+
"foo.invalid",
|
|
44
|
+
"server.invalid",
|
|
45
|
+
"SERVER.INVALID",
|
|
46
|
+
"mail.example",
|
|
47
|
+
"foo.example",
|
|
48
|
+
" imap.invalid ",
|
|
49
|
+
];
|
|
50
|
+
for (const host of reserved) {
|
|
51
|
+
it(`returns true for reserved host ${JSON.stringify(host)}`, () => {
|
|
52
|
+
assert.equal(isReservedHost(host), true);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// localhost / .localhost / .test are legitimately used by local & e2e envs,
|
|
57
|
+
// so they must NOT be skipped (issue #835 follow-up).
|
|
58
|
+
const real = [
|
|
59
|
+
"imap.hostnet.nl",
|
|
60
|
+
"imap.gmail.com",
|
|
61
|
+
"localhost",
|
|
62
|
+
"foo.localhost",
|
|
63
|
+
"foo.test",
|
|
64
|
+
"notlocalhost",
|
|
65
|
+
"invalid.com",
|
|
66
|
+
"example.com",
|
|
67
|
+
"test.org",
|
|
68
|
+
];
|
|
69
|
+
for (const host of real) {
|
|
70
|
+
it(`returns false for real host ${JSON.stringify(host)}`, () => {
|
|
71
|
+
assert.equal(isReservedHost(host), false);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("isUnsyncableHost", () => {
|
|
77
|
+
it("returns false for a resolvable host", () => {
|
|
78
|
+
assert.equal(
|
|
79
|
+
isUnsyncableHost(buildAccount({ imapHost: "imap.gmail.com" }), log),
|
|
80
|
+
false,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns true for a reserved host", () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
isUnsyncableHost(buildAccount({ imapHost: "imap.invalid" }), log),
|
|
87
|
+
true,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("isAccountReauthRequired", () => {
|
|
93
|
+
it("returns false for authenticated accounts", () => {
|
|
94
|
+
assert.equal(
|
|
95
|
+
isAccountReauthRequired(
|
|
96
|
+
buildAccount({ connectionState: "authenticated" }),
|
|
97
|
+
log,
|
|
98
|
+
),
|
|
99
|
+
false,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns false for not_authenticated accounts", () => {
|
|
104
|
+
assert.equal(
|
|
105
|
+
isAccountReauthRequired(
|
|
106
|
+
buildAccount({ connectionState: "not_authenticated" }),
|
|
107
|
+
log,
|
|
108
|
+
),
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns true when connectionState is reauth_required", () => {
|
|
114
|
+
assert.equal(
|
|
115
|
+
isAccountReauthRequired(
|
|
116
|
+
buildAccount({ connectionState: "reauth_required" }),
|
|
117
|
+
log,
|
|
118
|
+
),
|
|
119
|
+
true,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
2
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RFC 2606 reserved placeholder namespaces that are guaranteed never to resolve
|
|
6
|
+
* and are not used by any local/dev/e2e environment: `.invalid` (the actual
|
|
7
|
+
* smoke-test placeholder) and `.example`. An account pointed at one of these can
|
|
8
|
+
* never connect, so syncing it would retry and dead-letter forever — we skip it.
|
|
9
|
+
*
|
|
10
|
+
* `localhost`/`.localhost` and `.test` are deliberately NOT skipped: the e2e and
|
|
11
|
+
* mailfuzz suites run their IMAP server on `localhost`, and `.test` is commonly
|
|
12
|
+
* used for local testing.
|
|
13
|
+
*/
|
|
14
|
+
const RESERVED_NAMES = ["invalid", "example"] as const;
|
|
15
|
+
const RESERVED_HOST_SUFFIXES = RESERVED_NAMES.map((name) => `.${name}`);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* True when `host` is a reserved placeholder name that can never resolve.
|
|
19
|
+
*
|
|
20
|
+
* Matches the bare `invalid`/`example` and any subdomain of those namespaces
|
|
21
|
+
* (`mail.example`, `foo.invalid`, …). Suffix matching is anchored on the dot so
|
|
22
|
+
* real hosts like `invalid.com` or `imap.gmail.com` are NOT treated as reserved.
|
|
23
|
+
*/
|
|
24
|
+
export const isReservedHost = (host: string): boolean => {
|
|
25
|
+
const normalized = host.trim().toLowerCase();
|
|
26
|
+
if (RESERVED_NAMES.includes(normalized as (typeof RESERVED_NAMES)[number])) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return RESERVED_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if an account's IMAP host can never resolve (reserved TLD).
|
|
34
|
+
* Returns true if the account should be skipped cleanly — no connection
|
|
35
|
+
* attempt, no thrown error, so the event is acked rather than dead-lettered.
|
|
36
|
+
*/
|
|
37
|
+
export const isUnsyncableHost = (
|
|
38
|
+
account: AccountItem,
|
|
39
|
+
log: Logger,
|
|
40
|
+
): boolean => {
|
|
41
|
+
if (isReservedHost(account.imapHost)) {
|
|
42
|
+
log.warn(
|
|
43
|
+
{ accountId: account.accountId, imapHost: account.imapHost },
|
|
44
|
+
"Skipping account: IMAP host is a reserved, never-resolvable name",
|
|
45
|
+
);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if an account is deleted (tombstone pattern).
|
|
53
|
+
* Returns true if the account should be skipped.
|
|
54
|
+
*/
|
|
55
|
+
export const isAccountDeleted = (
|
|
56
|
+
account: AccountItem,
|
|
57
|
+
log: Logger,
|
|
58
|
+
): boolean => {
|
|
59
|
+
if (account.deletedAt) {
|
|
60
|
+
log.info(
|
|
61
|
+
{ accountId: account.accountId, deletedAt: account.deletedAt },
|
|
62
|
+
"Skipping deleted account",
|
|
63
|
+
);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if an account requires re-authentication (e.g. OAuth token revoked).
|
|
71
|
+
* Returns true if the account should be skipped until the user re-auths.
|
|
72
|
+
*/
|
|
73
|
+
export const isAccountReauthRequired = (
|
|
74
|
+
account: AccountItem,
|
|
75
|
+
log: Logger,
|
|
76
|
+
): boolean => {
|
|
77
|
+
if (account.connectionState === "reauth_required") {
|
|
78
|
+
log.info(
|
|
79
|
+
{
|
|
80
|
+
accountId: account.accountId,
|
|
81
|
+
connectionState: account.connectionState,
|
|
82
|
+
},
|
|
83
|
+
"Skipping account: reauth required",
|
|
84
|
+
);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
GetParameterCommand,
|
|
5
|
+
ParameterNotFound,
|
|
6
|
+
SSMClient,
|
|
7
|
+
} from "@aws-sdk/client-ssm";
|
|
8
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
9
|
+
import { mockClient } from "aws-sdk-client-mock";
|
|
10
|
+
import { isBodySyncEnabled, resetBodySyncGateCache } from "./body-sync-gate.js";
|
|
11
|
+
|
|
12
|
+
const parameterName = "/dev/Remit/bodySyncEnabled";
|
|
13
|
+
|
|
14
|
+
interface CapturedWarn {
|
|
15
|
+
args: unknown[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const createCapturingLogger = (): {
|
|
19
|
+
log: Logger;
|
|
20
|
+
warnCalls: CapturedWarn[];
|
|
21
|
+
} => {
|
|
22
|
+
const warnCalls: CapturedWarn[] = [];
|
|
23
|
+
const noop = () => {};
|
|
24
|
+
const log = {
|
|
25
|
+
info: noop,
|
|
26
|
+
warn: (...args: unknown[]) => warnCalls.push({ args }),
|
|
27
|
+
error: noop,
|
|
28
|
+
debug: noop,
|
|
29
|
+
fatal: noop,
|
|
30
|
+
trace: noop,
|
|
31
|
+
child: () => log,
|
|
32
|
+
} as unknown as Logger;
|
|
33
|
+
return { log, warnCalls };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const warnMessage = (warn: CapturedWarn): string => String(warn.args[1]);
|
|
37
|
+
|
|
38
|
+
describe("isBodySyncEnabled", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
resetBodySyncGateCache();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
mockClient(SSMClient).reset();
|
|
45
|
+
resetBodySyncGateCache();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns true when the parameter is 'true'", async () => {
|
|
49
|
+
const ssmMock = mockClient(SSMClient);
|
|
50
|
+
ssmMock
|
|
51
|
+
.on(GetParameterCommand, { Name: parameterName })
|
|
52
|
+
.resolves({ Parameter: { Value: "true" } });
|
|
53
|
+
const { log } = createCapturingLogger();
|
|
54
|
+
|
|
55
|
+
const enabled = await isBodySyncEnabled(
|
|
56
|
+
parameterName,
|
|
57
|
+
log,
|
|
58
|
+
ssmMock as unknown as SSMClient,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
assert.equal(enabled, true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns false when the parameter is 'false'", async () => {
|
|
65
|
+
const ssmMock = mockClient(SSMClient);
|
|
66
|
+
ssmMock
|
|
67
|
+
.on(GetParameterCommand, { Name: parameterName })
|
|
68
|
+
.resolves({ Parameter: { Value: "false" } });
|
|
69
|
+
const { log } = createCapturingLogger();
|
|
70
|
+
|
|
71
|
+
const enabled = await isBodySyncEnabled(
|
|
72
|
+
parameterName,
|
|
73
|
+
log,
|
|
74
|
+
ssmMock as unknown as SSMClient,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
assert.equal(enabled, false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("treats casing and whitespace as disabled for ' FALSE '", async () => {
|
|
81
|
+
const ssmMock = mockClient(SSMClient);
|
|
82
|
+
ssmMock
|
|
83
|
+
.on(GetParameterCommand, { Name: parameterName })
|
|
84
|
+
.resolves({ Parameter: { Value: " FALSE " } });
|
|
85
|
+
const { log } = createCapturingLogger();
|
|
86
|
+
|
|
87
|
+
const enabled = await isBodySyncEnabled(
|
|
88
|
+
parameterName,
|
|
89
|
+
log,
|
|
90
|
+
ssmMock as unknown as SSMClient,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
assert.equal(enabled, false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("fails open with a warning when the parameter does not exist", async () => {
|
|
97
|
+
const ssmMock = mockClient(SSMClient);
|
|
98
|
+
ssmMock
|
|
99
|
+
.on(GetParameterCommand, { Name: parameterName })
|
|
100
|
+
.rejects(new ParameterNotFound({ message: "not found", $metadata: {} }));
|
|
101
|
+
const { log, warnCalls } = createCapturingLogger();
|
|
102
|
+
|
|
103
|
+
const enabled = await isBodySyncEnabled(
|
|
104
|
+
parameterName,
|
|
105
|
+
log,
|
|
106
|
+
ssmMock as unknown as SSMClient,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
assert.equal(enabled, true);
|
|
110
|
+
assert.equal(warnCalls.length, 1);
|
|
111
|
+
assert.match(warnMessage(warnCalls[0]), /failing open/i);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("fails open with a warning when the parameter has no value", async () => {
|
|
115
|
+
const ssmMock = mockClient(SSMClient);
|
|
116
|
+
ssmMock
|
|
117
|
+
.on(GetParameterCommand, { Name: parameterName })
|
|
118
|
+
.resolves({ Parameter: {} });
|
|
119
|
+
const { log, warnCalls } = createCapturingLogger();
|
|
120
|
+
|
|
121
|
+
const enabled = await isBodySyncEnabled(
|
|
122
|
+
parameterName,
|
|
123
|
+
log,
|
|
124
|
+
ssmMock as unknown as SSMClient,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
assert.equal(enabled, true);
|
|
128
|
+
assert.equal(warnCalls.length, 1);
|
|
129
|
+
assert.match(warnMessage(warnCalls[0]), /missing a value/i);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("caches the result within the TTL — second call does not re-fetch", async () => {
|
|
133
|
+
const ssmMock = mockClient(SSMClient);
|
|
134
|
+
ssmMock
|
|
135
|
+
.on(GetParameterCommand, { Name: parameterName })
|
|
136
|
+
.resolves({ Parameter: { Value: "false" } });
|
|
137
|
+
const { log } = createCapturingLogger();
|
|
138
|
+
|
|
139
|
+
const first = await isBodySyncEnabled(
|
|
140
|
+
parameterName,
|
|
141
|
+
log,
|
|
142
|
+
ssmMock as unknown as SSMClient,
|
|
143
|
+
);
|
|
144
|
+
const second = await isBodySyncEnabled(
|
|
145
|
+
parameterName,
|
|
146
|
+
log,
|
|
147
|
+
ssmMock as unknown as SSMClient,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
assert.equal(first, false);
|
|
151
|
+
assert.equal(second, false);
|
|
152
|
+
assert.equal(ssmMock.commandCalls(GetParameterCommand).length, 1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("coalesces concurrent cold-cache calls into a single GetParameter", async () => {
|
|
156
|
+
const ssmMock = mockClient(SSMClient);
|
|
157
|
+
let resolveSend:
|
|
158
|
+
| ((value: { Parameter: { Value: string } }) => void)
|
|
159
|
+
| undefined;
|
|
160
|
+
ssmMock.on(GetParameterCommand, { Name: parameterName }).callsFake(
|
|
161
|
+
() =>
|
|
162
|
+
new Promise((resolve) => {
|
|
163
|
+
resolveSend = resolve;
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
const { log } = createCapturingLogger();
|
|
167
|
+
|
|
168
|
+
const a = isBodySyncEnabled(
|
|
169
|
+
parameterName,
|
|
170
|
+
log,
|
|
171
|
+
ssmMock as unknown as SSMClient,
|
|
172
|
+
);
|
|
173
|
+
const b = isBodySyncEnabled(
|
|
174
|
+
parameterName,
|
|
175
|
+
log,
|
|
176
|
+
ssmMock as unknown as SSMClient,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
assert.ok(resolveSend, "GetParameter should have been invoked");
|
|
180
|
+
resolveSend?.({ Parameter: { Value: "true" } });
|
|
181
|
+
|
|
182
|
+
assert.deepEqual(await Promise.all([a, b]), [true, true]);
|
|
183
|
+
assert.equal(ssmMock.commandCalls(GetParameterCommand).length, 1);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
2
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
3
|
+
|
|
4
|
+
const CACHE_TTL_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
interface CacheEntry {
|
|
7
|
+
readonly enabled: boolean;
|
|
8
|
+
readonly expiresAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let cache: CacheEntry | undefined;
|
|
12
|
+
let inFlight: Promise<boolean> | undefined;
|
|
13
|
+
let defaultClient: SSMClient | undefined;
|
|
14
|
+
|
|
15
|
+
const getDefaultClient = (): SSMClient => {
|
|
16
|
+
if (!defaultClient) {
|
|
17
|
+
defaultClient = new SSMClient({});
|
|
18
|
+
}
|
|
19
|
+
return defaultClient;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Test seam — clears the module-scope TTL cache between unit tests.
|
|
24
|
+
*/
|
|
25
|
+
export const resetBodySyncGateCache = (): void => {
|
|
26
|
+
cache = undefined;
|
|
27
|
+
inFlight = undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const readParameter = async (
|
|
31
|
+
ssm: SSMClient,
|
|
32
|
+
parameterName: string,
|
|
33
|
+
log: Logger,
|
|
34
|
+
): Promise<boolean> => {
|
|
35
|
+
const result = await ssm.send(
|
|
36
|
+
new GetParameterCommand({ Name: parameterName }),
|
|
37
|
+
);
|
|
38
|
+
const value = result.Parameter?.Value;
|
|
39
|
+
|
|
40
|
+
if (value === undefined) {
|
|
41
|
+
log.warn(
|
|
42
|
+
{ parameterName },
|
|
43
|
+
"Body-sync toggle parameter missing a value, failing open (enabled)",
|
|
44
|
+
);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return value.trim().toLowerCase() !== "false";
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads the `/{stage}/Remit/bodySyncEnabled` SSM parameter, cached at module
|
|
53
|
+
* scope for a short TTL. Returns `true` (enabled) when the parameter is absent
|
|
54
|
+
* or unreadable — body sync fails open so a missing toggle never halts prod.
|
|
55
|
+
*/
|
|
56
|
+
export const isBodySyncEnabled = async (
|
|
57
|
+
parameterName: string,
|
|
58
|
+
log: Logger,
|
|
59
|
+
ssm: SSMClient = getDefaultClient(),
|
|
60
|
+
): Promise<boolean> => {
|
|
61
|
+
if (cache && cache.expiresAt > Date.now()) {
|
|
62
|
+
return cache.enabled;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (inFlight) {
|
|
66
|
+
return inFlight;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
inFlight = readParameter(ssm, parameterName, log)
|
|
70
|
+
.catch((error) => {
|
|
71
|
+
log.warn(
|
|
72
|
+
{ parameterName, error },
|
|
73
|
+
"Failed to read body-sync toggle parameter, failing open (enabled)",
|
|
74
|
+
);
|
|
75
|
+
return true;
|
|
76
|
+
})
|
|
77
|
+
.then((enabled) => {
|
|
78
|
+
cache = { enabled, expiresAt: Date.now() + CACHE_TTL_MS };
|
|
79
|
+
return enabled;
|
|
80
|
+
})
|
|
81
|
+
.finally(() => {
|
|
82
|
+
inFlight = undefined;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return inFlight;
|
|
86
|
+
};
|