serverless-ircd 0.10.0 → 0.11.0

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 (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
12
- import { GetCommand } from '@aws-sdk/lib-dynamodb';
12
+ import { type DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
13
13
  import { type ParsedServerConfig, StaticMotdProvider } from '@serverless-ircd/irc-core';
14
14
  import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
15
15
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -19,8 +19,10 @@ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
19
19
  import { createDynamoDocumentClient } from '../src/dynamo.js';
20
20
  import {
21
21
  type NlbStreamEvent,
22
+ type NlbStreamParams,
22
23
  deriveNlbConnectionId,
23
24
  handleNlbStream,
25
+ parseNlbFlowHeaders,
24
26
  } from '../src/handlers/nlb-stream.js';
25
27
  import { bindMessageStore } from '../src/message-store.js';
26
28
  import type { TablesConfig } from '../src/tables.js';
@@ -61,7 +63,7 @@ async function makeFixture(): Promise<Fixture> {
61
63
  const pkName = props.partitionKey?.name;
62
64
  const skName = props.sortKey?.name;
63
65
  if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
64
- const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
66
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
65
67
  { AttributeName: pkName, AttributeType: 'S' },
66
68
  ];
67
69
  const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
@@ -71,11 +73,35 @@ async function makeFixture(): Promise<Fixture> {
71
73
  attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
72
74
  keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
73
75
  }
76
+ // Secondary indexes declared by the CDK definition (the Connections
77
+ // per-IP admission GSI) are created here too — the NLB new-flow rate
78
+ // gate Queries the index, so a fixture without it diverges from the
79
+ // deployed schema.
80
+ const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
81
+ const gpk = g.partitionKey;
82
+ if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
83
+ const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
84
+ { AttributeName: gpk.name, KeyType: 'HASH' },
85
+ ];
86
+ attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
87
+ if (g.sortKey !== undefined) {
88
+ gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
89
+ attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
90
+ }
91
+ return {
92
+ IndexName: g.indexName,
93
+ KeySchema: gsiKeySchema,
94
+ Projection: { ProjectionType: 'ALL' as const },
95
+ };
96
+ });
74
97
  await client.send(
75
98
  new CreateTableCommand({
76
99
  TableName: `${prefix}${logical}`,
77
100
  AttributeDefinitions: attributeDefinitions,
78
101
  KeySchema: keySchema,
102
+ ...(globalSecondaryIndexes.length > 0
103
+ ? { GlobalSecondaryIndexes: globalSecondaryIndexes }
104
+ : {}),
79
105
  BillingMode: 'PAY_PER_REQUEST',
80
106
  }),
81
107
  );
@@ -130,6 +156,184 @@ describe.skipIf(!available)('deriveNlbConnectionId', () => {
130
156
  });
131
157
  });
132
158
 
159
+ describe('parseNlbFlowHeaders', () => {
160
+ it('parses a well-formed IPv4 flow', () => {
161
+ expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.1', '50001')))).toEqual({
162
+ sourceIp: '10.0.0.1',
163
+ sourcePort: '50001',
164
+ });
165
+ });
166
+
167
+ it('parses a well-formed IPv6 flow', () => {
168
+ expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('2001:db8::1', '50002')))).toEqual({
169
+ sourceIp: '2001:db8::1',
170
+ sourcePort: '50002',
171
+ });
172
+ });
173
+
174
+ it('matches header names case-insensitively', () => {
175
+ expect(
176
+ parseNlbFlowHeaders(
177
+ nlbEventWithHeaders({ 'X-Forwarded-For': '10.0.0.2', 'X-Forwarded-Port': '50003' }),
178
+ ),
179
+ ).toEqual({ sourceIp: '10.0.0.2', sourcePort: '50003' });
180
+ });
181
+
182
+ it('accepts the port boundaries 1 and 65535', () => {
183
+ expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.3', '1')))).not.toBeNull();
184
+ expect(
185
+ parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.3', '65535'))),
186
+ ).not.toBeNull();
187
+ });
188
+
189
+ it('rejects port 0 and ports above 65535', () => {
190
+ expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.4', '0')))).toBeNull();
191
+ expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.4', '65536')))).toBeNull();
192
+ });
193
+
194
+ it('rejects empty header values', () => {
195
+ expect(
196
+ parseNlbFlowHeaders(nlbEventWithHeaders({ 'x-forwarded-for': '', 'x-forwarded-port': '1' })),
197
+ ).toBeNull();
198
+ expect(
199
+ parseNlbFlowHeaders(
200
+ nlbEventWithHeaders({ 'x-forwarded-for': '10.0.0.5', 'x-forwarded-port': '' }),
201
+ ),
202
+ ).toBeNull();
203
+ });
204
+
205
+ it('rejects an out-of-range IPv4 octet', () => {
206
+ expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.256', '50005')))).toBeNull();
207
+ });
208
+
209
+ it('rejects a port with non-digit characters', () => {
210
+ expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.6', '12a')))).toBeNull();
211
+ expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.6', '+12')))).toBeNull();
212
+ });
213
+ });
214
+
215
+ /** Builds the canonical NLB flow header pair. */
216
+ function headersFor(sourceIp: string, sourcePort: string): Record<string, string> {
217
+ return { 'x-forwarded-for': sourceIp, 'x-forwarded-port': sourcePort };
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Flow header validation: a flow the NLB cannot identify (missing or
222
+ // malformed x-forwarded-for / x-forwarded-port) must be rejected with a
223
+ // 400 before any DynamoDB read, row creation, or actor invocation —
224
+ // otherwise every such client collapses onto the single literal
225
+ // connection id `nlb-unknown-0` and the flows overwrite each other's
226
+ // persisted ConnectionState per frame. These rejection paths never touch
227
+ // DynamoDB, so they run without the DDB Local fixture.
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe('NLB stream handler — flow header validation', () => {
231
+ it('rejects a flow missing x-forwarded-for with 400 before any DynamoDB I/O', async () => {
232
+ const { params, send } = rejectParams();
233
+ const res = await handleNlbStream(nlbEventWithHeaders({ 'x-forwarded-port': '50001' }), params);
234
+ expect(res.statusCode).toBe(400);
235
+ expect(send).not.toHaveBeenCalled();
236
+ });
237
+
238
+ it('rejects a flow missing x-forwarded-port with 400 before any DynamoDB I/O', async () => {
239
+ const { params, send } = rejectParams();
240
+ const res = await handleNlbStream(
241
+ nlbEventWithHeaders({ 'x-forwarded-for': '10.0.0.1' }),
242
+ params,
243
+ );
244
+ expect(res.statusCode).toBe(400);
245
+ expect(send).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it('rejects a flow with no headers object at all with 400', async () => {
249
+ const { params, send } = rejectParams();
250
+ const event: NlbStreamEvent = {
251
+ requestContext: {
252
+ elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' },
253
+ },
254
+ version: '2.0',
255
+ body: Buffer.from('PING :tok\r\n', 'utf8').toString('base64'),
256
+ isBase64Encoded: true,
257
+ };
258
+ const res = await handleNlbStream(event, params);
259
+ expect(res.statusCode).toBe(400);
260
+ expect(send).not.toHaveBeenCalled();
261
+ });
262
+
263
+ it('rejects a flow with a malformed (non-IP) x-forwarded-for with 400', async () => {
264
+ const { params, send } = rejectParams();
265
+ const res = await handleNlbStream(
266
+ nlbEventWithHeaders({ 'x-forwarded-for': 'not-an-ip', 'x-forwarded-port': '50001' }),
267
+ params,
268
+ );
269
+ expect(res.statusCode).toBe(400);
270
+ expect(send).not.toHaveBeenCalled();
271
+ });
272
+
273
+ it('rejects a comma-separated x-forwarded-for list with 400', async () => {
274
+ // NLB sends exactly one client IP; a list cannot identify a flow.
275
+ const { params, send } = rejectParams();
276
+ const res = await handleNlbStream(
277
+ nlbEventWithHeaders({
278
+ 'x-forwarded-for': '10.0.0.1, 192.168.0.1',
279
+ 'x-forwarded-port': '50001',
280
+ }),
281
+ params,
282
+ );
283
+ expect(res.statusCode).toBe(400);
284
+ expect(send).not.toHaveBeenCalled();
285
+ });
286
+
287
+ it('rejects a flow with a malformed (non-numeric) x-forwarded-port with 400', async () => {
288
+ const { params, send } = rejectParams();
289
+ const res = await handleNlbStream(
290
+ nlbEventWithHeaders({ 'x-forwarded-for': '10.0.0.1', 'x-forwarded-port': 'port' }),
291
+ params,
292
+ );
293
+ expect(res.statusCode).toBe(400);
294
+ expect(send).not.toHaveBeenCalled();
295
+ });
296
+
297
+ it('never produces the literal nlb-unknown-0 connection id for a happy-path flow', async () => {
298
+ // A well-formed flow derives the expected id — the shared-row hazard
299
+ // (`nlb-unknown-0`) is unreachable by construction: every input that
300
+ // could produce it is rejected above.
301
+ expect(deriveNlbConnectionId('10.0.0.1', '50001')).toBe('nlb-10-0-0-1-50001');
302
+ });
303
+ });
304
+
305
+ /** Builds an NLB event with arbitrary headers (base64-encodes the body). */
306
+ function nlbEventWithHeaders(headers: Record<string, string>): NlbStreamEvent {
307
+ return {
308
+ requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' } },
309
+ version: '2.0',
310
+ body: Buffer.from('PING :tok\r\n', 'utf8').toString('base64'),
311
+ isBase64Encoded: true,
312
+ headers,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Params double for rejection tests: a `send` spy that must never be
318
+ * invoked (rejections happen before any DynamoDB I/O).
319
+ */
320
+ function rejectParams(): { params: NlbStreamParams; send: ReturnType<typeof vi.fn> } {
321
+ const send = vi.fn();
322
+ const params: NlbStreamParams = {
323
+ dynamo: { send } as unknown as DynamoDBDocumentClient,
324
+ tables: {
325
+ Connections: 'Connections',
326
+ ChannelMeta: 'ChannelMeta',
327
+ ChannelMembers: 'ChannelMembers',
328
+ Nicks: 'Nicks',
329
+ },
330
+ serverConfig: SERVER_CONFIG,
331
+ motd: MOTD,
332
+ managementApi: null,
333
+ };
334
+ return { params, send };
335
+ }
336
+
133
337
  describe.skipIf(!available)('NLB stream handler — byte framing', () => {
134
338
  beforeEach(async () => {
135
339
  fx = await makeFixture();
@@ -515,10 +719,11 @@ describe.skipIf(!available)('NLB stream handler — streaming back', () => {
515
719
  expect(res.statusCode).toBe(200);
516
720
  });
517
721
 
518
- it('falls back to "unknown"/"0" when NLB headers are absent', async () => {
519
- // `readHeader` returns undefined when no header key matches. The
520
- // caller's `?? 'unknown'` / `?? '0'` fallbacks must apply so the
521
- // handler does not crash on a malformed event (defensive).
722
+ it('falls back to rejecting an event with unrelated headers only (no NLB flow metadata)', async () => {
723
+ // `readHeader` returns undefined when no header key matches; the
724
+ // handler must reject the flow with a 400 (see the flow-header
725
+ // validation block above) rather than admit it under a shared
726
+ // fallback id.
522
727
  const f = requireFx();
523
728
  const eventWithoutHeaders: NlbStreamEvent = {
524
729
  requestContext: {
@@ -529,6 +734,7 @@ describe.skipIf(!available)('NLB stream handler — streaming back', () => {
529
734
  isBase64Encoded: true,
530
735
  headers: { 'some-other-header': 'value' },
531
736
  };
737
+ const sendSpy = vi.spyOn(f.client, 'send');
532
738
  const res = await handleNlbStream(eventWithoutHeaders, {
533
739
  dynamo: f.client,
534
740
  tables: f.tables,
@@ -537,9 +743,9 @@ describe.skipIf(!available)('NLB stream handler — streaming back', () => {
537
743
  messages: bindMessageStore(SERVER_CONFIG),
538
744
  managementApi: f.mgmt,
539
745
  });
540
- expect(res.statusCode).toBe(200);
541
- // The fallback id (nlb-unknown-0) still received a PONG.
542
- expect(res.body).toBeDefined();
746
+ expect(res.statusCode).toBe(400);
747
+ expect(sendSpy).not.toHaveBeenCalled();
748
+ sendSpy.mockRestore();
543
749
  });
544
750
  });
545
751
 
@@ -558,3 +764,416 @@ class LocalPostToConnection implements PostToConnection {
558
764
  return {};
559
765
  }
560
766
  }
767
+
768
+ // ---------------------------------------------------------------------------
769
+ // Input size limits: two independent guards.
770
+ // 1. Payload pre-check — an event body decoding to more than 8 KiB is a
771
+ // misbehaving NLB integration (or an attacker); it is rejected with a
772
+ // 400 BEFORE any DynamoDB read or actor invocation.
773
+ // 2. Transport buffer cap — an unterminated tail that accumulates past
774
+ // 8 KiB (possibly across invocations via the persisted buffer) closes
775
+ // the connection: the ERROR line is streamed back and the Connections
776
+ // row is dropped so the oversized buffer is never persisted.
777
+ // ---------------------------------------------------------------------------
778
+
779
+ describe.skipIf(!available)('NLB stream handler — input size limits', () => {
780
+ beforeEach(async () => {
781
+ fx = await makeFixture();
782
+ });
783
+ afterEach(async () => {
784
+ if (fx) await fx.cleanup();
785
+ fx = null;
786
+ });
787
+
788
+ it('rejects an oversized base64 event body with 400 before touching DynamoDB or the actor', async () => {
789
+ const f = requireFx();
790
+ const sendSpy = vi.spyOn(f.client, 'send');
791
+ const res = await handleNlbStream(nlbEvent('10.0.0.60', '50060', 'x'.repeat(9 * 1024)), {
792
+ dynamo: f.client,
793
+ tables: f.tables,
794
+ serverConfig: SERVER_CONFIG,
795
+ motd: MOTD,
796
+ messages: bindMessageStore(SERVER_CONFIG),
797
+ managementApi: f.mgmt,
798
+ });
799
+ expect(res.statusCode).toBe(400);
800
+ // Rejected before any bookkeeping: not a single DynamoDB command ran.
801
+ expect(sendSpy).not.toHaveBeenCalled();
802
+ sendSpy.mockRestore();
803
+ });
804
+
805
+ it('rejects an oversized plain (non-base64) event body with 400', async () => {
806
+ const f = requireFx();
807
+ const event: NlbStreamEvent = {
808
+ requestContext: {
809
+ elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' },
810
+ },
811
+ version: '2.0',
812
+ body: 'y'.repeat(9 * 1024),
813
+ isBase64Encoded: false,
814
+ headers: { 'x-forwarded-for': '10.0.0.61', 'x-forwarded-port': '50061' },
815
+ };
816
+ const sendSpy = vi.spyOn(f.client, 'send');
817
+ const res = await handleNlbStream(event, {
818
+ dynamo: f.client,
819
+ tables: f.tables,
820
+ serverConfig: SERVER_CONFIG,
821
+ motd: MOTD,
822
+ messages: bindMessageStore(SERVER_CONFIG),
823
+ managementApi: f.mgmt,
824
+ });
825
+ expect(res.statusCode).toBe(400);
826
+ expect(sendSpy).not.toHaveBeenCalled();
827
+ sendSpy.mockRestore();
828
+ });
829
+
830
+ it('accepts a payload exactly at the 8 KiB cap (regression guard on legal large chunks)', async () => {
831
+ const f = requireFx();
832
+ // 8191 bytes of a terminated line + CRLF = 8193 bytes... use exactly
833
+ // 8190 content bytes + CRLF so the decoded body is exactly 8192.
834
+ const body = `${'a'.repeat(8190)}\r\n`;
835
+ expect(Buffer.byteLength(body, 'utf8')).toBe(8192);
836
+ const res = await handleNlbStream(nlbEvent('10.0.0.62', '50062', body), {
837
+ dynamo: f.client,
838
+ tables: f.tables,
839
+ serverConfig: SERVER_CONFIG,
840
+ motd: MOTD,
841
+ messages: bindMessageStore(SERVER_CONFIG),
842
+ managementApi: f.mgmt,
843
+ });
844
+ expect(res.statusCode).toBe(200);
845
+ });
846
+
847
+ it('closes the connection with ERROR and drops the row when a chunk pushes the buffer past the cap', async () => {
848
+ const f = requireFx();
849
+ const opts = {
850
+ dynamo: f.client,
851
+ tables: f.tables,
852
+ serverConfig: SERVER_CONFIG,
853
+ motd: MOTD,
854
+ messages: bindMessageStore(SERVER_CONFIG),
855
+ managementApi: f.mgmt,
856
+ };
857
+ // Establish the connection with a 7 KiB unterminated tail (under both
858
+ // the payload pre-check and the buffer cap — buffered and persisted).
859
+ await handleNlbStream(nlbEvent('10.0.0.63', '50063', 'x'.repeat(7 * 1024)), opts);
860
+ // A 2 KiB chunk (itself under the pre-check cap) crosses the 8 KiB
861
+ // accumulation cap: the transport buffer overflows.
862
+ const res = await handleNlbStream(nlbEvent('10.0.0.63', '50063', 'x'.repeat(2 * 1024)), opts);
863
+ expect(res.statusCode).toBe(200);
864
+ expect(res.isBase64Encoded).toBe(true);
865
+ const decoded = Buffer.from(res.body, 'base64').toString('utf8');
866
+ expect(decoded).toContain('ERROR :Closing link: input buffer overflow');
867
+ // The Connections row is dropped entirely.
868
+ const row = await f.client.send(
869
+ new GetCommand({
870
+ TableName: f.tables.Connections,
871
+ Key: { connectionId: deriveNlbConnectionId('10.0.0.63', '50063') },
872
+ }),
873
+ );
874
+ expect(row.Item).toBeUndefined();
875
+ });
876
+
877
+ it('closes on overflow accumulated across invocations and never persists an oversized buffer', async () => {
878
+ const f = requireFx();
879
+ const opts = {
880
+ dynamo: f.client,
881
+ tables: f.tables,
882
+ serverConfig: SERVER_CONFIG,
883
+ motd: MOTD,
884
+ messages: bindMessageStore(SERVER_CONFIG),
885
+ managementApi: f.mgmt,
886
+ };
887
+ await handleNlbStream(nlbEvent('10.0.0.64', '50064', ''), opts);
888
+ // 8 KiB unterminated: at the cap — buffered and persisted, NOT closed.
889
+ await handleNlbStream(nlbEvent('10.0.0.64', '50064', 'a'.repeat(8192)), opts);
890
+ const midRow = await f.client.send(
891
+ new GetCommand({
892
+ TableName: f.tables.Connections,
893
+ Key: { connectionId: deriveNlbConnectionId('10.0.0.64', '50064') },
894
+ }),
895
+ );
896
+ expect(midRow.Item?.transportBuffer).toBe('a'.repeat(8192));
897
+
898
+ // One more byte-past-cap chunk: the restored buffer + chunk overflow.
899
+ const res = await handleNlbStream(nlbEvent('10.0.0.64', '50064', 'b'.repeat(1024)), opts);
900
+ const decoded = Buffer.from(res.body, 'base64').toString('utf8');
901
+ expect(decoded).toContain('ERROR :Closing link: input buffer overflow');
902
+ // The row is deleted — the oversized buffer never reaches a write.
903
+ const row = await f.client.send(
904
+ new GetCommand({
905
+ TableName: f.tables.Connections,
906
+ Key: { connectionId: deriveNlbConnectionId('10.0.0.64', '50064') },
907
+ }),
908
+ );
909
+ expect(row.Item).toBeUndefined();
910
+ });
911
+ });
912
+
913
+ // ---------------------------------------------------------------------------
914
+ // Per-IP new-flow rate limit — stub-client unit tests (no DynamoDB Local
915
+ // required, mirroring the connect.test.ts stub convention).
916
+ // ---------------------------------------------------------------------------
917
+
918
+ import { GetCommand as GetCmd, QueryCommand, UpdateCommand as UpdCmd } from '@aws-sdk/lib-dynamodb';
919
+ import type { Clock } from '@serverless-ircd/irc-core';
920
+ /** Fixed epoch base so window membership is deterministic. */
921
+ const RATE_T0 = 1_700_000_000_000;
922
+
923
+ /** A minimal existing-flow row (shape the NLB handler reads back). */
924
+ function existingRow(connId: string): Record<string, unknown> {
925
+ return {
926
+ connectionId: connId,
927
+ registration: 'registered',
928
+ nick: 'existing',
929
+ capNegotiating: false,
930
+ secure: true,
931
+ caps: [],
932
+ userModes: { tls: true },
933
+ lastSeen: RATE_T0,
934
+ connectedSince: RATE_T0 - 5000,
935
+ idleSince: RATE_T0,
936
+ version: 1,
937
+ revision: 3,
938
+ };
939
+ }
940
+
941
+ interface NlbStub {
942
+ dynamo: DynamoDBDocumentClient;
943
+ /** QueryCommand inputs issued (rate-gate consultations). */
944
+ queries: Array<Record<string, unknown>>;
945
+ /** Whether the persist UpdateCommand ran (row written). */
946
+ persisted: boolean;
947
+ }
948
+
949
+ /**
950
+ * Stub client: `getRow` decides new-flow vs existing-flow; `gsiPages`
951
+ * scripts the sourceIp-GSI Query pages. Records queries + persists.
952
+ */
953
+ function makeNlbStub(
954
+ getRow: Record<string, unknown> | undefined,
955
+ gsiPages: Array<Array<{ connectedSince: number }>> = [[]],
956
+ ): NlbStub {
957
+ const queries: Array<Record<string, unknown>> = [];
958
+ const flags = { persisted: false };
959
+ let queryCall = 0;
960
+ const dynamo = {
961
+ send: async (cmd: unknown) => {
962
+ if (cmd instanceof GetCmd) return getRow === undefined ? {} : { Item: getRow };
963
+ if (cmd instanceof QueryCommand) {
964
+ queries.push(cmd.input as Record<string, unknown>);
965
+ const page = gsiPages[queryCall] ?? [];
966
+ const last = queryCall;
967
+ queryCall += 1;
968
+ return {
969
+ Items: page,
970
+ LastEvaluatedKey: last + 1 < gsiPages.length ? { connectionId: `p-${last}` } : undefined,
971
+ };
972
+ }
973
+ if (cmd instanceof UpdCmd) {
974
+ flags.persisted = true;
975
+ return {};
976
+ }
977
+ throw new Error(`unexpected command: ${String(cmd)}`);
978
+ },
979
+ } as unknown as DynamoDBDocumentClient;
980
+ return {
981
+ dynamo,
982
+ get queries() {
983
+ return queries;
984
+ },
985
+ get persisted() {
986
+ return flags.persisted;
987
+ },
988
+ };
989
+ }
990
+
991
+ /** Fixed clock at `t` so the sliding-window cutoff is deterministic. */
992
+ function clockAt(t: number): Clock {
993
+ return { now: () => t };
994
+ }
995
+
996
+ describe('NLB stream handler — per-IP new-flow rate limit', () => {
997
+ const RATE_CONFIG = makeTestServerConfig({
998
+ perIpConnectionRate: { max: 2, windowMs: 60_000 },
999
+ });
1000
+
1001
+ function opts(
1002
+ stub: NlbStub,
1003
+ clock: Clock = clockAt(RATE_T0),
1004
+ config: typeof RATE_CONFIG = RATE_CONFIG,
1005
+ ) {
1006
+ return {
1007
+ dynamo: stub.dynamo,
1008
+ tables: {
1009
+ Connections: 'Connections',
1010
+ ChannelMeta: 'ChannelMeta',
1011
+ ChannelMembers: 'ChannelMembers',
1012
+ Nicks: 'Nicks',
1013
+ } as TablesConfig,
1014
+ serverConfig: config,
1015
+ motd: MOTD,
1016
+ managementApi: null,
1017
+ clock,
1018
+ };
1019
+ }
1020
+
1021
+ it('admits a new flow under the budget and persists its row', async () => {
1022
+ const stub = makeNlbStub(undefined, [[{ connectedSince: RATE_T0 - 1000 }]]);
1023
+ const res = await handleNlbStream(nlbEvent('10.0.1.1', '60001', 'PING :tok\r\n'), opts(stub));
1024
+ expect(res.statusCode).toBe(200);
1025
+ expect(stub.persisted).toBe(true);
1026
+ });
1027
+
1028
+ it('rejects the over-budget new flow with 429 and writes no row', async () => {
1029
+ const stub = makeNlbStub(undefined, [
1030
+ [{ connectedSince: RATE_T0 - 1000 }, { connectedSince: RATE_T0 - 2000 }],
1031
+ ]);
1032
+ const res = await handleNlbStream(nlbEvent('10.0.1.2', '60002', 'PING :tok\r\n'), opts(stub));
1033
+ expect(res.statusCode).toBe(429);
1034
+ expect(res.body).toBe('');
1035
+ expect(res.isBase64Encoded).toBe(false);
1036
+ expect(stub.persisted).toBe(false);
1037
+ expect(stub.queries.length).toBe(1);
1038
+ expect(stub.queries[0]?.IndexName).toBe('sourceIp-connectedSince');
1039
+ expect(stub.queries[0]?.ExpressionAttributeValues).toMatchObject({ ':ip': '10.0.1.2' });
1040
+ });
1041
+
1042
+ it('does not consult the rate budget for an existing flow (rate gates establishment only)', async () => {
1043
+ // Rate-limiting EVERY chunk invocation would starve an active IRC
1044
+ // session (each client line is a fresh invocation); the budget
1045
+ // applies to connection establishment — the first chunk of a flow.
1046
+ const connId = deriveNlbConnectionId('10.0.1.3', '60003');
1047
+ const stub = makeNlbStub(existingRow(connId), [
1048
+ [{ connectedSince: RATE_T0 - 1000 }, { connectedSince: RATE_T0 - 2000 }],
1049
+ ]);
1050
+ const res = await handleNlbStream(nlbEvent('10.0.1.3', '60003', 'PING :tok\r\n'), opts(stub));
1051
+ expect(res.statusCode).toBe(200);
1052
+ expect(stub.queries.length).toBe(0);
1053
+ expect(stub.persisted).toBe(true);
1054
+ });
1055
+
1056
+ it('window-reset: admissions older than the window stop consuming budget', async () => {
1057
+ // Budget of 1; one prior admission stamped at RATE_T0.
1058
+ const budgetOne = makeTestServerConfig({
1059
+ perIpConnectionRate: { max: 1, windowMs: 60_000 },
1060
+ });
1061
+ const row = [{ connectedSince: RATE_T0 }];
1062
+ // Inside the window (clock at RATE_T0+30 s): the admission counts →
1063
+ // the new flow is over budget and rejected.
1064
+ const early = makeNlbStub(undefined, [row]);
1065
+ const earlyRes = await handleNlbStream(
1066
+ nlbEvent('10.0.1.4', '60004', 'PING :tok\r\n'),
1067
+ opts(early, clockAt(RATE_T0 + 30_000), budgetOne),
1068
+ );
1069
+ expect(earlyRes.statusCode).toBe(429);
1070
+
1071
+ // Past the window (clock at RATE_T0+61 s): the cutoff advanced past
1072
+ // the admission → the budget recovered and the flow is admitted.
1073
+ const later = makeNlbStub(undefined, [row]);
1074
+ const laterRes = await handleNlbStream(
1075
+ nlbEvent('10.0.1.4', '60005', 'PING :tok\r\n'),
1076
+ opts(later, clockAt(RATE_T0 + 61_000), budgetOne),
1077
+ );
1078
+ expect(laterRes.statusCode).toBe(200);
1079
+ });
1080
+
1081
+ it('paginates the GSI query when the per-IP partition spans pages', async () => {
1082
+ const stub = makeNlbStub(undefined, [
1083
+ [{ connectedSince: RATE_T0 - 1000 }, { connectedSince: RATE_T0 - 2000 }],
1084
+ [{ connectedSince: RATE_T0 - 3000 }],
1085
+ ]);
1086
+ const res = await handleNlbStream(nlbEvent('10.0.1.5', '60006', 'PING :tok\r\n'), opts(stub));
1087
+ expect(res.statusCode).toBe(429);
1088
+ expect(stub.queries.length).toBe(2);
1089
+ });
1090
+
1091
+ it('never consults the rate gate for a flow with missing flow headers', async () => {
1092
+ // Malformed/missing flow headers are rejected by header validation
1093
+ // (400) before any rate-gate consideration — and the stub's Get
1094
+ // (existing-flow probe) and Query (rate gate) both stay silent.
1095
+ const stub = makeNlbStub(undefined, [[]]);
1096
+ const event: NlbStreamEvent = {
1097
+ requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::tg' } },
1098
+ version: '2.0',
1099
+ body: Buffer.from('PING :tok\r\n', 'utf8').toString('base64'),
1100
+ isBase64Encoded: true,
1101
+ headers: { 'x-forwarded-port': '60007' },
1102
+ };
1103
+ const res = await handleNlbStream(event, opts(stub));
1104
+ expect(res.statusCode).toBe(400);
1105
+ expect(stub.queries.length).toBe(0);
1106
+ expect(stub.persisted).toBe(false);
1107
+ });
1108
+ });
1109
+
1110
+ describe.skipIf(!available)('NLB per-IP new-flow rate limit (real GSI)', () => {
1111
+ beforeEach(async () => {
1112
+ fx = await makeFixture();
1113
+ });
1114
+ afterEach(async () => {
1115
+ if (fx) await fx.cleanup();
1116
+ fx = null;
1117
+ });
1118
+
1119
+ /** Rate-pinned config: 2 new flows per 60 s window. */
1120
+ const cfg = makeTestServerConfig({ perIpConnectionRate: { max: 2, windowMs: 60_000 } });
1121
+
1122
+ function flowOpts(clock: Clock) {
1123
+ const f = requireFx();
1124
+ return {
1125
+ dynamo: f.client,
1126
+ tables: f.tables,
1127
+ serverConfig: cfg,
1128
+ motd: MOTD,
1129
+ messages: bindMessageStore(cfg),
1130
+ managementApi: f.mgmt,
1131
+ clock,
1132
+ };
1133
+ }
1134
+
1135
+ it('rejects the over-budget new flow with 429 (no row written)', async () => {
1136
+ const t0 = { now: () => RATE_T0 };
1137
+ const first = await handleNlbStream(nlbEvent('10.0.3.1', '61001', 'PING :a\r\n'), flowOpts(t0));
1138
+ expect(first.statusCode).toBe(200);
1139
+ const second = await handleNlbStream(
1140
+ nlbEvent('10.0.3.1', '61002', 'PING :b\r\n'),
1141
+ flowOpts(t0),
1142
+ );
1143
+ expect(second.statusCode).toBe(200);
1144
+ const third = await handleNlbStream(nlbEvent('10.0.3.1', '61003', 'PING :c\r\n'), flowOpts(t0));
1145
+ expect(third.statusCode).toBe(429);
1146
+ expect(third.body).toBe('');
1147
+ // No row for the rejected flow.
1148
+ const row = await requireFx().client.send(
1149
+ new GetCommand({
1150
+ TableName: requireFx().tables.Connections,
1151
+ Key: { connectionId: deriveNlbConnectionId('10.0.3.1', '61003') },
1152
+ }),
1153
+ );
1154
+ expect(row.Item).toBeUndefined();
1155
+ });
1156
+
1157
+ it('window-reset: the budget recovers once establishments age out', async () => {
1158
+ const t0 = { now: () => RATE_T0 };
1159
+ expect(
1160
+ (await handleNlbStream(nlbEvent('10.0.3.2', '61011', 'PING :a\r\n'), flowOpts(t0)))
1161
+ .statusCode,
1162
+ ).toBe(200);
1163
+ expect(
1164
+ (await handleNlbStream(nlbEvent('10.0.3.2', '61012', 'PING :b\r\n'), flowOpts(t0)))
1165
+ .statusCode,
1166
+ ).toBe(200);
1167
+ expect(
1168
+ (await handleNlbStream(nlbEvent('10.0.3.2', '61013', 'PING :c\r\n'), flowOpts(t0)))
1169
+ .statusCode,
1170
+ ).toBe(429);
1171
+ // Past the window both establishments decayed → the budget recovered.
1172
+ const later = { now: () => RATE_T0 + 61_000 };
1173
+ const after = await handleNlbStream(
1174
+ nlbEvent('10.0.3.2', '61014', 'PING :d\r\n'),
1175
+ flowOpts(later),
1176
+ );
1177
+ expect(after.statusCode).toBe(200);
1178
+ });
1179
+ });