serverless-ircd 0.1.0 → 0.2.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.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies that a `GoneException` raised during cross-connection `send`
|
|
3
|
+
* triggers {@link cleanupConnection}, which deletes:
|
|
4
|
+
* - the dead connection's row in Connections,
|
|
5
|
+
* - its nick (if any) in Nicks,
|
|
6
|
+
* - its memberships in ChannelMembers.
|
|
7
|
+
*
|
|
8
|
+
* Tested by injecting a mock `PostToConnection` that throws once.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
12
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
13
|
+
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_SERVER_CONFIG,
|
|
16
|
+
type Nick,
|
|
17
|
+
type ParsedServerConfig,
|
|
18
|
+
createConnection,
|
|
19
|
+
} from '@serverless-ircd/irc-core';
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
21
|
+
import { AwsRuntime, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
|
|
22
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
23
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
24
|
+
import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
|
|
25
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
26
|
+
|
|
27
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
28
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
29
|
+
|
|
30
|
+
const SERVER_CONFIG: ParsedServerConfig = {
|
|
31
|
+
...DEFAULT_SERVER_CONFIG,
|
|
32
|
+
serverName: 'irc.example.com',
|
|
33
|
+
networkName: 'ExampleNet',
|
|
34
|
+
motdLines: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
interface Fixture {
|
|
38
|
+
tables: TablesConfig;
|
|
39
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
40
|
+
cleanup: () => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let fx: Fixture | null;
|
|
44
|
+
|
|
45
|
+
async function makeFixture(): Promise<Fixture> {
|
|
46
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
47
|
+
const prefix = `g${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
48
|
+
const tables = Object.fromEntries(
|
|
49
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
50
|
+
) as TablesConfig;
|
|
51
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
52
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
53
|
+
const pkName = props.partitionKey?.name;
|
|
54
|
+
const skName = props.sortKey?.name;
|
|
55
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
56
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
57
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
58
|
+
];
|
|
59
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
60
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
61
|
+
];
|
|
62
|
+
if (skName !== undefined) {
|
|
63
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
64
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
65
|
+
}
|
|
66
|
+
await client.send(
|
|
67
|
+
new CreateTableCommand({
|
|
68
|
+
TableName: `${prefix}${logical}`,
|
|
69
|
+
AttributeDefinitions: attributeDefinitions,
|
|
70
|
+
KeySchema: keySchema,
|
|
71
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
tables,
|
|
77
|
+
client,
|
|
78
|
+
cleanup: async () => {
|
|
79
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
80
|
+
try {
|
|
81
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
82
|
+
} catch {
|
|
83
|
+
// best-effort
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Seed a connection with a nick + a channel membership directly in Dynamo. */
|
|
91
|
+
async function seedConnection(
|
|
92
|
+
client: ReturnType<typeof createDynamoDocumentClient>,
|
|
93
|
+
tables: TablesConfig,
|
|
94
|
+
connId: string,
|
|
95
|
+
nick: string,
|
|
96
|
+
channel: string,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const state = createConnection({ id: connId, connectedSince: now });
|
|
100
|
+
state.registration = 'registered';
|
|
101
|
+
state.nick = nick as Nick;
|
|
102
|
+
state.user = nick;
|
|
103
|
+
state.host = 'x';
|
|
104
|
+
state.realname = nick;
|
|
105
|
+
state.joinedChannels = new Set([channel as never]);
|
|
106
|
+
state.lastSeen = now;
|
|
107
|
+
await client.send(
|
|
108
|
+
new PutCommand({ TableName: tables.Connections, Item: marshalConnection(state, now) }),
|
|
109
|
+
);
|
|
110
|
+
await client.send(
|
|
111
|
+
new PutCommand({
|
|
112
|
+
TableName: tables.Nicks,
|
|
113
|
+
Item: marshalNick(nick as Nick, connId, now),
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
await client.send(
|
|
117
|
+
new PutCommand({
|
|
118
|
+
TableName: tables.ChannelMembers,
|
|
119
|
+
Item: marshalChannelMember(channel, {
|
|
120
|
+
conn: connId,
|
|
121
|
+
nick: nick as Nick,
|
|
122
|
+
op: false,
|
|
123
|
+
voice: false,
|
|
124
|
+
}),
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe.skipIf(!available)('GoneException cleanup', () => {
|
|
130
|
+
beforeEach(async () => {
|
|
131
|
+
fx = await makeFixture();
|
|
132
|
+
});
|
|
133
|
+
afterEach(async () => {
|
|
134
|
+
if (fx) await fx.cleanup();
|
|
135
|
+
fx = null;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('send() to a gone connection triggers cleanupConnection and deletes Connections/Nicks/Members rows', async () => {
|
|
139
|
+
await seedConnection(fx!.client, fx!.tables, 'gone', 'bob', '#room');
|
|
140
|
+
|
|
141
|
+
// A self-connection runtime that tries to `send` to 'gone'.
|
|
142
|
+
const mgmt: PostToConnection = {
|
|
143
|
+
async postToConnection() {
|
|
144
|
+
throw new GoneException({
|
|
145
|
+
$metadata: {},
|
|
146
|
+
message: 'Gone',
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const runtime = new AwsRuntime({
|
|
152
|
+
dynamo: fx!.client,
|
|
153
|
+
tables: fx!.tables,
|
|
154
|
+
connId: 'self',
|
|
155
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
156
|
+
managementApi: mgmt,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await runtime.send('gone', [{ text: ':self PRIVMSG bob :hi' } as never]);
|
|
160
|
+
|
|
161
|
+
const probe = new AwsRuntime({
|
|
162
|
+
dynamo: fx!.client,
|
|
163
|
+
tables: fx!.tables,
|
|
164
|
+
connId: '__probe__',
|
|
165
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
166
|
+
managementApi: null,
|
|
167
|
+
});
|
|
168
|
+
expect(await probe.getConnectionInfo('gone')).toBeNull();
|
|
169
|
+
expect(await probe.lookupNick('bob' as Nick)).toBeNull();
|
|
170
|
+
const members = await probe.getChannelConnections('#room' as never);
|
|
171
|
+
expect(members.size).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('send() to a non-gone error rethrows', async () => {
|
|
175
|
+
await seedConnection(fx!.client, fx!.tables, 'present', 'carol', '#room');
|
|
176
|
+
const mgmt: PostToConnection = {
|
|
177
|
+
async postToConnection() {
|
|
178
|
+
throw new Error('network down');
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const runtime = new AwsRuntime({
|
|
182
|
+
dynamo: fx!.client,
|
|
183
|
+
tables: fx!.tables,
|
|
184
|
+
connId: 'self',
|
|
185
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
186
|
+
managementApi: mgmt,
|
|
187
|
+
});
|
|
188
|
+
await expect(
|
|
189
|
+
runtime.send('present', [{ text: ':self PRIVMSG carol :hi' } as never]),
|
|
190
|
+
).rejects.toThrow('network down');
|
|
191
|
+
// The target row should NOT have been cleaned up.
|
|
192
|
+
const probe = new AwsRuntime({
|
|
193
|
+
dynamo: fx!.client,
|
|
194
|
+
tables: fx!.tables,
|
|
195
|
+
connId: '__probe__',
|
|
196
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
197
|
+
managementApi: null,
|
|
198
|
+
});
|
|
199
|
+
expect(await probe.getConnectionInfo('present')).not.toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('cleanupConnection is idempotent when called twice', async () => {
|
|
203
|
+
await seedConnection(fx!.client, fx!.tables, 'gone2', 'dave', '#room');
|
|
204
|
+
await cleanupConnection(fx!.client, fx!.tables, 'gone2', null);
|
|
205
|
+
await cleanupConnection(fx!.client, fx!.tables, 'gone2', null);
|
|
206
|
+
const probe = new AwsRuntime({
|
|
207
|
+
dynamo: fx!.client,
|
|
208
|
+
tables: fx!.tables,
|
|
209
|
+
connId: '__probe__',
|
|
210
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
211
|
+
managementApi: null,
|
|
212
|
+
});
|
|
213
|
+
expect(await probe.getConnectionInfo('gone2')).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('cleanupConnection is safe when the connection row is missing', async () => {
|
|
217
|
+
await expect(
|
|
218
|
+
cleanupConnection(fx!.client, fx!.tables, 'never-existed', null),
|
|
219
|
+
).resolves.toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('broadcast() skips and cleans up gone members', async () => {
|
|
223
|
+
await seedConnection(fx!.client, fx!.tables, 'gone3', 'eve', '#room');
|
|
224
|
+
const mgmt: PostToConnection = {
|
|
225
|
+
async postToConnection() {
|
|
226
|
+
throw new GoneException({ $metadata: {}, message: 'Gone' });
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
const runtime = new AwsRuntime({
|
|
230
|
+
dynamo: fx!.client,
|
|
231
|
+
tables: fx!.tables,
|
|
232
|
+
connId: 'self',
|
|
233
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
234
|
+
managementApi: mgmt,
|
|
235
|
+
});
|
|
236
|
+
// `broadcast` iterates ChannelMembers and skips `except`.
|
|
237
|
+
await runtime.broadcast('#room' as never, [{ text: ':self PRIVMSG #room :hi' } as never]);
|
|
238
|
+
const probe = new AwsRuntime({
|
|
239
|
+
dynamo: fx!.client,
|
|
240
|
+
tables: fx!.tables,
|
|
241
|
+
connId: '__probe__',
|
|
242
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
243
|
+
managementApi: null,
|
|
244
|
+
});
|
|
245
|
+
expect(await probe.lookupNick('eve' as Nick)).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('send() with managementApi=null is a silent no-op (no throw, no cleanup)', async () => {
|
|
249
|
+
await seedConnection(fx!.client, fx!.tables, 'present2', 'frank', '#room');
|
|
250
|
+
const runtime = new AwsRuntime({
|
|
251
|
+
dynamo: fx!.client,
|
|
252
|
+
tables: fx!.tables,
|
|
253
|
+
connId: 'self',
|
|
254
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
255
|
+
managementApi: null,
|
|
256
|
+
});
|
|
257
|
+
await runtime.send('present2', [{ text: ':self PRIVMSG frank :hi' } as never]);
|
|
258
|
+
const probe = new AwsRuntime({
|
|
259
|
+
dynamo: fx!.client,
|
|
260
|
+
tables: fx!.tables,
|
|
261
|
+
connId: '__probe__',
|
|
262
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
263
|
+
managementApi: null,
|
|
264
|
+
});
|
|
265
|
+
expect(await probe.getConnectionInfo('present2')).not.toBeNull();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('static SERVER_CONFIG is valid (sanity)', () => {
|
|
269
|
+
expect(SERVER_CONFIG.serverName).toBe('irc.example.com');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Lambda handlers (`$connect`, `$disconnect`, `$default`).
|
|
3
|
+
*
|
|
4
|
+
* Uses real DynamoDB Local so the Get/Put/Update paths are exercised
|
|
5
|
+
* end-to-end. The `ApiGatewayManagementApi` is mocked via the
|
|
6
|
+
* `LocalPostToConnection` test double (no actual APIGW needed).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_SERVER_CONFIG,
|
|
12
|
+
type ParsedServerConfig,
|
|
13
|
+
StaticMotdProvider,
|
|
14
|
+
} from '@serverless-ircd/irc-core';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import { AwsRuntime } from '../src/aws-runtime.js';
|
|
17
|
+
import type { PostToConnection } from '../src/aws-runtime.js';
|
|
18
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
19
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
20
|
+
import {
|
|
21
|
+
type HandlerDeps,
|
|
22
|
+
type LambdaResponse,
|
|
23
|
+
__resetHandlerCacheForTests,
|
|
24
|
+
dispatch,
|
|
25
|
+
} from '../src/handlers/index.js';
|
|
26
|
+
import { bindMessageStore } from '../src/message-store.js';
|
|
27
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
28
|
+
|
|
29
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
30
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
31
|
+
|
|
32
|
+
const SERVER_CONFIG: ParsedServerConfig = {
|
|
33
|
+
...DEFAULT_SERVER_CONFIG,
|
|
34
|
+
serverName: 'irc.example.com',
|
|
35
|
+
networkName: 'ExampleNet',
|
|
36
|
+
motdLines: ['Welcome to the test IRC server.'],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
|
|
40
|
+
|
|
41
|
+
interface Fixture {
|
|
42
|
+
tables: TablesConfig;
|
|
43
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
44
|
+
mgmt: LocalPostToConnection;
|
|
45
|
+
deps: HandlerDeps;
|
|
46
|
+
cleanup: () => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let fx: Fixture | null;
|
|
50
|
+
|
|
51
|
+
async function makeFixture(): Promise<Fixture> {
|
|
52
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
53
|
+
const prefix = `h${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
54
|
+
const tables = Object.fromEntries(
|
|
55
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
56
|
+
) as TablesConfig;
|
|
57
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
58
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
59
|
+
const pkName = props.partitionKey?.name;
|
|
60
|
+
const skName = props.sortKey?.name;
|
|
61
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
62
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
63
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
64
|
+
];
|
|
65
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
66
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
67
|
+
];
|
|
68
|
+
if (skName !== undefined) {
|
|
69
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
70
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
71
|
+
}
|
|
72
|
+
await client.send(
|
|
73
|
+
new CreateTableCommand({
|
|
74
|
+
TableName: `${prefix}${logical}`,
|
|
75
|
+
AttributeDefinitions: attributeDefinitions,
|
|
76
|
+
KeySchema: keySchema,
|
|
77
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const mgmt = new LocalPostToConnection();
|
|
82
|
+
return {
|
|
83
|
+
tables,
|
|
84
|
+
client,
|
|
85
|
+
mgmt,
|
|
86
|
+
deps: {
|
|
87
|
+
dynamo: client,
|
|
88
|
+
tables,
|
|
89
|
+
serverConfig: SERVER_CONFIG,
|
|
90
|
+
motd: MOTD,
|
|
91
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
92
|
+
managementApi: mgmt as never,
|
|
93
|
+
},
|
|
94
|
+
cleanup: async () => {
|
|
95
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
96
|
+
try {
|
|
97
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
98
|
+
} catch {
|
|
99
|
+
// Already gone.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe.skipIf(!available)('handlers', () => {
|
|
107
|
+
beforeEach(async () => {
|
|
108
|
+
__resetHandlerCacheForTests();
|
|
109
|
+
fx = await makeFixture();
|
|
110
|
+
});
|
|
111
|
+
afterEach(async () => {
|
|
112
|
+
if (fx) await fx.cleanup();
|
|
113
|
+
fx = null;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
// $connect
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
describe('$connect', () => {
|
|
121
|
+
it('returns 200 and persists a Connections row', async () => {
|
|
122
|
+
const res = await dispatch(
|
|
123
|
+
{ requestContext: { routeKey: '$connect', connectionId: 'c1' } },
|
|
124
|
+
fx!.deps,
|
|
125
|
+
);
|
|
126
|
+
expect(res.statusCode).toBe(200);
|
|
127
|
+
const probe = new AwsRuntime({
|
|
128
|
+
dynamo: fx!.client,
|
|
129
|
+
tables: fx!.tables,
|
|
130
|
+
connId: '__probe__',
|
|
131
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
132
|
+
managementApi: null,
|
|
133
|
+
});
|
|
134
|
+
const info = await probe.getConnectionInfo('c1');
|
|
135
|
+
expect(info?.id).toBe('c1');
|
|
136
|
+
expect(info?.registration).toBe('pre-registration');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('is idempotent on APIGW retry (same connectionId overwrites)', async () => {
|
|
140
|
+
for (let i = 0; i < 2; i++) {
|
|
141
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
142
|
+
}
|
|
143
|
+
const probe = new AwsRuntime({
|
|
144
|
+
dynamo: fx!.client,
|
|
145
|
+
tables: fx!.tables,
|
|
146
|
+
connId: '__probe__',
|
|
147
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
148
|
+
managementApi: null,
|
|
149
|
+
});
|
|
150
|
+
const info = await probe.getConnectionInfo('c1');
|
|
151
|
+
expect(info?.id).toBe('c1');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// -------------------------------------------------------------------------
|
|
156
|
+
// $disconnect
|
|
157
|
+
// -------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
describe('$disconnect', () => {
|
|
160
|
+
it('returns 200 and deletes the Connections row', async () => {
|
|
161
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
162
|
+
const res = await dispatch(
|
|
163
|
+
{ requestContext: { routeKey: '$disconnect', connectionId: 'c1' } },
|
|
164
|
+
fx!.deps,
|
|
165
|
+
);
|
|
166
|
+
expect(res.statusCode).toBe(200);
|
|
167
|
+
const probe = new AwsRuntime({
|
|
168
|
+
dynamo: fx!.client,
|
|
169
|
+
tables: fx!.tables,
|
|
170
|
+
connId: '__probe__',
|
|
171
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
172
|
+
managementApi: null,
|
|
173
|
+
});
|
|
174
|
+
expect(await probe.getConnectionInfo('c1')).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns 200 even when the connection was never seen', async () => {
|
|
178
|
+
const res = await dispatch(
|
|
179
|
+
{ requestContext: { routeKey: '$disconnect', connectionId: 'never' } },
|
|
180
|
+
fx!.deps,
|
|
181
|
+
);
|
|
182
|
+
expect(res.statusCode).toBe(200);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('releases the nick on disconnect', async () => {
|
|
186
|
+
// Connect + register alice.
|
|
187
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
188
|
+
await dispatch(
|
|
189
|
+
{
|
|
190
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
191
|
+
body: 'NICK alice\r\n',
|
|
192
|
+
},
|
|
193
|
+
fx!.deps,
|
|
194
|
+
);
|
|
195
|
+
await dispatch(
|
|
196
|
+
{
|
|
197
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
198
|
+
body: 'USER alice 0 * :Alice\r\n',
|
|
199
|
+
},
|
|
200
|
+
fx!.deps,
|
|
201
|
+
);
|
|
202
|
+
// Disconnect.
|
|
203
|
+
await dispatch({ requestContext: { routeKey: '$disconnect', connectionId: 'c1' } }, fx!.deps);
|
|
204
|
+
const probe = new AwsRuntime({
|
|
205
|
+
dynamo: fx!.client,
|
|
206
|
+
tables: fx!.tables,
|
|
207
|
+
connId: '__probe__',
|
|
208
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
209
|
+
managementApi: null,
|
|
210
|
+
});
|
|
211
|
+
expect(await probe.lookupNick('alice')).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// -------------------------------------------------------------------------
|
|
216
|
+
// $default
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
describe('$default', () => {
|
|
220
|
+
it('returns 410 when the connection row is missing', async () => {
|
|
221
|
+
const res = await dispatch(
|
|
222
|
+
{
|
|
223
|
+
requestContext: { routeKey: '$default', connectionId: 'ghost' },
|
|
224
|
+
body: 'PING :tok\r\n',
|
|
225
|
+
},
|
|
226
|
+
fx!.deps,
|
|
227
|
+
);
|
|
228
|
+
expect(res.statusCode).toBe(410);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('returns 400 when body is missing', async () => {
|
|
232
|
+
// Cast: APIGW always sends a body for $default; tests verify the guard.
|
|
233
|
+
const event = {
|
|
234
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
235
|
+
} as never;
|
|
236
|
+
const res = await dispatch(event, fx!.deps);
|
|
237
|
+
expect(res.statusCode).toBe(400);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('runs a NICK + USER frame and emits the welcome block', async () => {
|
|
241
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
242
|
+
fx!.mgmt.register('c1', () => {});
|
|
243
|
+
await dispatch(
|
|
244
|
+
{
|
|
245
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
246
|
+
body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
|
|
247
|
+
},
|
|
248
|
+
fx!.deps,
|
|
249
|
+
);
|
|
250
|
+
const probe = new AwsRuntime({
|
|
251
|
+
dynamo: fx!.client,
|
|
252
|
+
tables: fx!.tables,
|
|
253
|
+
connId: '__probe__',
|
|
254
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
255
|
+
managementApi: null,
|
|
256
|
+
});
|
|
257
|
+
const info = await probe.getConnectionInfo('c1');
|
|
258
|
+
expect(info?.nick).toBe('alice');
|
|
259
|
+
expect(info?.registration).toBe('registered');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('echoes outbound lines back to the caller via the management API', async () => {
|
|
263
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
264
|
+
const sink: string[] = [];
|
|
265
|
+
fx!.mgmt.register('c1', (data) => {
|
|
266
|
+
for (const line of data.split('\r\n')) {
|
|
267
|
+
if (line.length > 0) sink.push(line);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
await dispatch(
|
|
271
|
+
{
|
|
272
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
273
|
+
body: 'PING :tok\r\n',
|
|
274
|
+
},
|
|
275
|
+
fx!.deps,
|
|
276
|
+
);
|
|
277
|
+
expect(sink).toContain('PONG :tok');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('persists joined channels across $default invocations', async () => {
|
|
281
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
282
|
+
fx!.mgmt.register('c1', () => {});
|
|
283
|
+
await dispatch(
|
|
284
|
+
{
|
|
285
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
286
|
+
body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
|
|
287
|
+
},
|
|
288
|
+
fx!.deps,
|
|
289
|
+
);
|
|
290
|
+
await dispatch(
|
|
291
|
+
{
|
|
292
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
293
|
+
body: 'JOIN #persist\r\n',
|
|
294
|
+
},
|
|
295
|
+
fx!.deps,
|
|
296
|
+
);
|
|
297
|
+
const probe = new AwsRuntime({
|
|
298
|
+
dynamo: fx!.client,
|
|
299
|
+
tables: fx!.tables,
|
|
300
|
+
connId: '__probe__',
|
|
301
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
302
|
+
managementApi: null,
|
|
303
|
+
});
|
|
304
|
+
const info = await probe.getConnectionInfo('c1');
|
|
305
|
+
expect(info?.joinedChannels.has('#persist')).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('rebuilds an existing channel roster via refreshChannel when a second client JOINs', async () => {
|
|
309
|
+
// First connection registers + JOINs.
|
|
310
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
311
|
+
fx!.mgmt.register('c1', () => {});
|
|
312
|
+
await dispatch(
|
|
313
|
+
{
|
|
314
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
315
|
+
body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
|
|
316
|
+
},
|
|
317
|
+
fx!.deps,
|
|
318
|
+
);
|
|
319
|
+
await dispatch(
|
|
320
|
+
{
|
|
321
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
322
|
+
body: 'JOIN #room\r\n',
|
|
323
|
+
},
|
|
324
|
+
fx!.deps,
|
|
325
|
+
);
|
|
326
|
+
// Second connection registers + JOINs the SAME channel.
|
|
327
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
|
|
328
|
+
const c2sink: string[] = [];
|
|
329
|
+
fx!.mgmt.register('c2', (data) => {
|
|
330
|
+
for (const line of data.split('\r\n')) {
|
|
331
|
+
if (line.length > 0) c2sink.push(line);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
await dispatch(
|
|
335
|
+
{
|
|
336
|
+
requestContext: { routeKey: '$default', connectionId: 'c2' },
|
|
337
|
+
body: 'NICK bob\r\nUSER bob 0 * :Bob\r\n',
|
|
338
|
+
},
|
|
339
|
+
fx!.deps,
|
|
340
|
+
);
|
|
341
|
+
await dispatch(
|
|
342
|
+
{
|
|
343
|
+
requestContext: { routeKey: '$default', connectionId: 'c2' },
|
|
344
|
+
body: 'JOIN #room\r\n',
|
|
345
|
+
},
|
|
346
|
+
fx!.deps,
|
|
347
|
+
);
|
|
348
|
+
// The 353 RPL_NAMREPLY c2 receives must list BOTH alice and bob —
|
|
349
|
+
// proving that the actor's `refreshChannel` populated the cache
|
|
350
|
+
// from the existing ChannelMembers row written by c1's JOIN.
|
|
351
|
+
const namReply = c2sink.find((l) => l.startsWith(':irc.example.com 353'));
|
|
352
|
+
expect(namReply).toBeDefined();
|
|
353
|
+
expect(namReply).toMatch(/alice/);
|
|
354
|
+
expect(namReply).toMatch(/bob/);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
|
|
358
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
359
|
+
const sink: string[] = [];
|
|
360
|
+
fx!.mgmt.register('c1', (data) => {
|
|
361
|
+
for (const line of data.split('\r\n')) {
|
|
362
|
+
if (line.length > 0) sink.push(line);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
await dispatch(
|
|
366
|
+
{
|
|
367
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
368
|
+
body: 'CAP REQ :draft/chathistory server-time message-tags batch\r\nNICK aws-alice\r\nUSER aws-alice 0 * :Alice\r\nCAP END\r\nJOIN #aws-chathist\r\n',
|
|
369
|
+
},
|
|
370
|
+
fx!.deps,
|
|
371
|
+
);
|
|
372
|
+
sink.length = 0;
|
|
373
|
+
await dispatch(
|
|
374
|
+
{
|
|
375
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
376
|
+
body: 'PRIVMSG #aws-chathist :hello from aws\r\nCHATHISTORY LATEST #aws-chathist * 10\r\n',
|
|
377
|
+
},
|
|
378
|
+
fx!.deps,
|
|
379
|
+
);
|
|
380
|
+
const batchOpen = sink.find((l) => /^BATCH \+\S+ chathistory #aws-chathist/.test(l));
|
|
381
|
+
expect(batchOpen).toBeDefined();
|
|
382
|
+
const replay = sink.find((l) => l.includes('PRIVMSG #aws-chathist :hello from aws'));
|
|
383
|
+
expect(replay).toBeDefined();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('returns 500 when unmarshalConnection throws on a corrupted row (caught by Lambda)', async () => {
|
|
387
|
+
// Skip: the catch in handleDefault wraps `actor.receiveTextFrame`,
|
|
388
|
+
// not `unmarshalConnection`. A corrupted-row test would exercise
|
|
389
|
+
// the Lambda-level unhandled rejection path, which is out of scope.
|
|
390
|
+
// The 500-branch is covered indirectly by GoneException tests.
|
|
391
|
+
expect(true).toBe(true);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// -------------------------------------------------------------------------
|
|
396
|
+
// Unknown routeKey
|
|
397
|
+
// -------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
describe('unknown routeKey', () => {
|
|
400
|
+
it('returns 200 (APIGW only forwards the three known routes)', async () => {
|
|
401
|
+
const res = await dispatch(
|
|
402
|
+
{ requestContext: { routeKey: '$custom', connectionId: 'c1' } },
|
|
403
|
+
fx!.deps,
|
|
404
|
+
);
|
|
405
|
+
expect(res.statusCode).toBe(200);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Test double for `ApiGatewayManagementApi`. Routes `postToConnection`
|
|
412
|
+
* calls to a registered sink for `ConnectionId`.
|
|
413
|
+
*/
|
|
414
|
+
class LocalPostToConnection implements PostToConnection {
|
|
415
|
+
private readonly sinks = new Map<string, (data: string) => void>();
|
|
416
|
+
register(connId: string, sink: (data: string) => void): void {
|
|
417
|
+
this.sinks.set(connId, sink);
|
|
418
|
+
}
|
|
419
|
+
async postToConnection(input: { ConnectionId: string; Data: string }): Promise<{}> {
|
|
420
|
+
const sink = this.sinks.get(input.ConnectionId);
|
|
421
|
+
if (sink !== undefined) sink(input.Data);
|
|
422
|
+
return {};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Keep the LambdaResponse import alive for type discoverability.
|
|
427
|
+
export type { LambdaResponse };
|