serverless-ircd 0.1.0 → 0.3.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/CHANGELOG.md +436 -0
- package/README.md +127 -84
- 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/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -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 +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -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 +74 -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/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -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 +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -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 +473 -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/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- 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/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -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/case-fold.ts +64 -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 +9 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +63 -10
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -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 +88 -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/account-store.test.ts +88 -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/case-fold.test.ts +80 -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/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +721 -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 +186 -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 +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -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 +6 -5
- 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/pnpm-workspace.yaml +1 -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/seed-aws-accounts.ts +80 -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/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,473 @@
|
|
|
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
|
+
InMemoryAccountStore,
|
|
13
|
+
type ParsedServerConfig,
|
|
14
|
+
StaticMotdProvider,
|
|
15
|
+
} from '@serverless-ircd/irc-core';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
17
|
+
import { AwsRuntime } from '../src/aws-runtime.js';
|
|
18
|
+
import type { PostToConnection } from '../src/aws-runtime.js';
|
|
19
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
20
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
21
|
+
import {
|
|
22
|
+
type HandlerDeps,
|
|
23
|
+
type LambdaResponse,
|
|
24
|
+
__resetHandlerCacheForTests,
|
|
25
|
+
dispatch,
|
|
26
|
+
} from '../src/handlers/index.js';
|
|
27
|
+
import { bindMessageStore } from '../src/message-store.js';
|
|
28
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
29
|
+
|
|
30
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
31
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
32
|
+
|
|
33
|
+
const SERVER_CONFIG: ParsedServerConfig = {
|
|
34
|
+
...DEFAULT_SERVER_CONFIG,
|
|
35
|
+
serverName: 'irc.example.com',
|
|
36
|
+
networkName: 'ExampleNet',
|
|
37
|
+
motdLines: ['Welcome to the test IRC server.'],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
|
|
41
|
+
|
|
42
|
+
interface Fixture {
|
|
43
|
+
tables: TablesConfig;
|
|
44
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
45
|
+
mgmt: LocalPostToConnection;
|
|
46
|
+
deps: HandlerDeps;
|
|
47
|
+
cleanup: () => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let fx: Fixture | null;
|
|
51
|
+
|
|
52
|
+
async function makeFixture(): Promise<Fixture> {
|
|
53
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
54
|
+
const prefix = `h${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
55
|
+
const tables = Object.fromEntries(
|
|
56
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
57
|
+
) as TablesConfig;
|
|
58
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
59
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
60
|
+
const pkName = props.partitionKey?.name;
|
|
61
|
+
const skName = props.sortKey?.name;
|
|
62
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
63
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
64
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
65
|
+
];
|
|
66
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
67
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
68
|
+
];
|
|
69
|
+
if (skName !== undefined) {
|
|
70
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
71
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
72
|
+
}
|
|
73
|
+
await client.send(
|
|
74
|
+
new CreateTableCommand({
|
|
75
|
+
TableName: `${prefix}${logical}`,
|
|
76
|
+
AttributeDefinitions: attributeDefinitions,
|
|
77
|
+
KeySchema: keySchema,
|
|
78
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const mgmt = new LocalPostToConnection();
|
|
83
|
+
return {
|
|
84
|
+
tables,
|
|
85
|
+
client,
|
|
86
|
+
mgmt,
|
|
87
|
+
deps: {
|
|
88
|
+
dynamo: client,
|
|
89
|
+
tables,
|
|
90
|
+
serverConfig: SERVER_CONFIG,
|
|
91
|
+
motd: MOTD,
|
|
92
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
93
|
+
managementApi: mgmt as never,
|
|
94
|
+
},
|
|
95
|
+
cleanup: async () => {
|
|
96
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
97
|
+
try {
|
|
98
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
99
|
+
} catch {
|
|
100
|
+
// Already gone.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe.skipIf(!available)('handlers', () => {
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
__resetHandlerCacheForTests();
|
|
110
|
+
fx = await makeFixture();
|
|
111
|
+
});
|
|
112
|
+
afterEach(async () => {
|
|
113
|
+
if (fx) await fx.cleanup();
|
|
114
|
+
fx = null;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// -------------------------------------------------------------------------
|
|
118
|
+
// $connect
|
|
119
|
+
// -------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
describe('$connect', () => {
|
|
122
|
+
it('returns 200 and persists a Connections row', async () => {
|
|
123
|
+
const res = await dispatch(
|
|
124
|
+
{ requestContext: { routeKey: '$connect', connectionId: 'c1' } },
|
|
125
|
+
fx!.deps,
|
|
126
|
+
);
|
|
127
|
+
expect(res.statusCode).toBe(200);
|
|
128
|
+
const probe = new AwsRuntime({
|
|
129
|
+
dynamo: fx!.client,
|
|
130
|
+
tables: fx!.tables,
|
|
131
|
+
connId: '__probe__',
|
|
132
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
133
|
+
managementApi: null,
|
|
134
|
+
});
|
|
135
|
+
const info = await probe.getConnectionInfo('c1');
|
|
136
|
+
expect(info?.id).toBe('c1');
|
|
137
|
+
expect(info?.registration).toBe('pre-registration');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('is idempotent on APIGW retry (same connectionId overwrites)', async () => {
|
|
141
|
+
for (let i = 0; i < 2; i++) {
|
|
142
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
143
|
+
}
|
|
144
|
+
const probe = new AwsRuntime({
|
|
145
|
+
dynamo: fx!.client,
|
|
146
|
+
tables: fx!.tables,
|
|
147
|
+
connId: '__probe__',
|
|
148
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
149
|
+
managementApi: null,
|
|
150
|
+
});
|
|
151
|
+
const info = await probe.getConnectionInfo('c1');
|
|
152
|
+
expect(info?.id).toBe('c1');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// -------------------------------------------------------------------------
|
|
157
|
+
// $disconnect
|
|
158
|
+
// -------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
describe('$disconnect', () => {
|
|
161
|
+
it('returns 200 and deletes the Connections row', async () => {
|
|
162
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
163
|
+
const res = await dispatch(
|
|
164
|
+
{ requestContext: { routeKey: '$disconnect', connectionId: 'c1' } },
|
|
165
|
+
fx!.deps,
|
|
166
|
+
);
|
|
167
|
+
expect(res.statusCode).toBe(200);
|
|
168
|
+
const probe = new AwsRuntime({
|
|
169
|
+
dynamo: fx!.client,
|
|
170
|
+
tables: fx!.tables,
|
|
171
|
+
connId: '__probe__',
|
|
172
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
173
|
+
managementApi: null,
|
|
174
|
+
});
|
|
175
|
+
expect(await probe.getConnectionInfo('c1')).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('returns 200 even when the connection was never seen', async () => {
|
|
179
|
+
const res = await dispatch(
|
|
180
|
+
{ requestContext: { routeKey: '$disconnect', connectionId: 'never' } },
|
|
181
|
+
fx!.deps,
|
|
182
|
+
);
|
|
183
|
+
expect(res.statusCode).toBe(200);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('releases the nick on disconnect', async () => {
|
|
187
|
+
// Connect + register alice.
|
|
188
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
189
|
+
await dispatch(
|
|
190
|
+
{
|
|
191
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
192
|
+
body: 'NICK alice\r\n',
|
|
193
|
+
},
|
|
194
|
+
fx!.deps,
|
|
195
|
+
);
|
|
196
|
+
await dispatch(
|
|
197
|
+
{
|
|
198
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
199
|
+
body: 'USER alice 0 * :Alice\r\n',
|
|
200
|
+
},
|
|
201
|
+
fx!.deps,
|
|
202
|
+
);
|
|
203
|
+
// Disconnect.
|
|
204
|
+
await dispatch({ requestContext: { routeKey: '$disconnect', connectionId: 'c1' } }, fx!.deps);
|
|
205
|
+
const probe = new AwsRuntime({
|
|
206
|
+
dynamo: fx!.client,
|
|
207
|
+
tables: fx!.tables,
|
|
208
|
+
connId: '__probe__',
|
|
209
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
210
|
+
managementApi: null,
|
|
211
|
+
});
|
|
212
|
+
expect(await probe.lookupNick('alice')).toBeNull();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// -------------------------------------------------------------------------
|
|
217
|
+
// $default
|
|
218
|
+
// -------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
describe('$default', () => {
|
|
221
|
+
it('returns 410 when the connection row is missing', async () => {
|
|
222
|
+
const res = await dispatch(
|
|
223
|
+
{
|
|
224
|
+
requestContext: { routeKey: '$default', connectionId: 'ghost' },
|
|
225
|
+
body: 'PING :tok\r\n',
|
|
226
|
+
},
|
|
227
|
+
fx!.deps,
|
|
228
|
+
);
|
|
229
|
+
expect(res.statusCode).toBe(410);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('returns 400 when body is missing', async () => {
|
|
233
|
+
// Cast: APIGW always sends a body for $default; tests verify the guard.
|
|
234
|
+
const event = {
|
|
235
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
236
|
+
} as never;
|
|
237
|
+
const res = await dispatch(event, fx!.deps);
|
|
238
|
+
expect(res.statusCode).toBe(400);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('runs a NICK + USER frame and emits the welcome block', async () => {
|
|
242
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
243
|
+
fx!.mgmt.register('c1', () => {});
|
|
244
|
+
await dispatch(
|
|
245
|
+
{
|
|
246
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
247
|
+
body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
|
|
248
|
+
},
|
|
249
|
+
fx!.deps,
|
|
250
|
+
);
|
|
251
|
+
const probe = new AwsRuntime({
|
|
252
|
+
dynamo: fx!.client,
|
|
253
|
+
tables: fx!.tables,
|
|
254
|
+
connId: '__probe__',
|
|
255
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
256
|
+
managementApi: null,
|
|
257
|
+
});
|
|
258
|
+
const info = await probe.getConnectionInfo('c1');
|
|
259
|
+
expect(info?.nick).toBe('alice');
|
|
260
|
+
expect(info?.registration).toBe('registered');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('echoes outbound lines back to the caller via the management API', async () => {
|
|
264
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
265
|
+
const sink: string[] = [];
|
|
266
|
+
fx!.mgmt.register('c1', (data) => {
|
|
267
|
+
for (const line of data.split('\r\n')) {
|
|
268
|
+
if (line.length > 0) sink.push(line);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
await dispatch(
|
|
272
|
+
{
|
|
273
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
274
|
+
body: 'PING :tok\r\n',
|
|
275
|
+
},
|
|
276
|
+
fx!.deps,
|
|
277
|
+
);
|
|
278
|
+
expect(sink).toContain('PONG :tok');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('persists joined channels across $default invocations', async () => {
|
|
282
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
283
|
+
fx!.mgmt.register('c1', () => {});
|
|
284
|
+
await dispatch(
|
|
285
|
+
{
|
|
286
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
287
|
+
body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
|
|
288
|
+
},
|
|
289
|
+
fx!.deps,
|
|
290
|
+
);
|
|
291
|
+
await dispatch(
|
|
292
|
+
{
|
|
293
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
294
|
+
body: 'JOIN #persist\r\n',
|
|
295
|
+
},
|
|
296
|
+
fx!.deps,
|
|
297
|
+
);
|
|
298
|
+
const probe = new AwsRuntime({
|
|
299
|
+
dynamo: fx!.client,
|
|
300
|
+
tables: fx!.tables,
|
|
301
|
+
connId: '__probe__',
|
|
302
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
303
|
+
managementApi: null,
|
|
304
|
+
});
|
|
305
|
+
const info = await probe.getConnectionInfo('c1');
|
|
306
|
+
expect(info?.joinedChannels.has('#persist')).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('rebuilds an existing channel roster via refreshChannel when a second client JOINs', async () => {
|
|
310
|
+
// First connection registers + JOINs.
|
|
311
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
312
|
+
fx!.mgmt.register('c1', () => {});
|
|
313
|
+
await dispatch(
|
|
314
|
+
{
|
|
315
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
316
|
+
body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
|
|
317
|
+
},
|
|
318
|
+
fx!.deps,
|
|
319
|
+
);
|
|
320
|
+
await dispatch(
|
|
321
|
+
{
|
|
322
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
323
|
+
body: 'JOIN #room\r\n',
|
|
324
|
+
},
|
|
325
|
+
fx!.deps,
|
|
326
|
+
);
|
|
327
|
+
// Second connection registers + JOINs the SAME channel.
|
|
328
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
|
|
329
|
+
const c2sink: string[] = [];
|
|
330
|
+
fx!.mgmt.register('c2', (data) => {
|
|
331
|
+
for (const line of data.split('\r\n')) {
|
|
332
|
+
if (line.length > 0) c2sink.push(line);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
await dispatch(
|
|
336
|
+
{
|
|
337
|
+
requestContext: { routeKey: '$default', connectionId: 'c2' },
|
|
338
|
+
body: 'NICK bob\r\nUSER bob 0 * :Bob\r\n',
|
|
339
|
+
},
|
|
340
|
+
fx!.deps,
|
|
341
|
+
);
|
|
342
|
+
await dispatch(
|
|
343
|
+
{
|
|
344
|
+
requestContext: { routeKey: '$default', connectionId: 'c2' },
|
|
345
|
+
body: 'JOIN #room\r\n',
|
|
346
|
+
},
|
|
347
|
+
fx!.deps,
|
|
348
|
+
);
|
|
349
|
+
// The 353 RPL_NAMREPLY c2 receives must list BOTH alice and bob —
|
|
350
|
+
// proving that the actor's `refreshChannel` populated the cache
|
|
351
|
+
// from the existing ChannelMembers row written by c1's JOIN.
|
|
352
|
+
const namReply = c2sink.find((l) => l.startsWith(':irc.example.com 353'));
|
|
353
|
+
expect(namReply).toBeDefined();
|
|
354
|
+
expect(namReply).toMatch(/alice/);
|
|
355
|
+
expect(namReply).toMatch(/bob/);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
|
|
359
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
360
|
+
const sink: string[] = [];
|
|
361
|
+
fx!.mgmt.register('c1', (data) => {
|
|
362
|
+
for (const line of data.split('\r\n')) {
|
|
363
|
+
if (line.length > 0) sink.push(line);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
await dispatch(
|
|
367
|
+
{
|
|
368
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
369
|
+
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',
|
|
370
|
+
},
|
|
371
|
+
fx!.deps,
|
|
372
|
+
);
|
|
373
|
+
sink.length = 0;
|
|
374
|
+
await dispatch(
|
|
375
|
+
{
|
|
376
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
377
|
+
body: 'PRIVMSG #aws-chathist :hello from aws\r\nCHATHISTORY LATEST #aws-chathist * 10\r\n',
|
|
378
|
+
},
|
|
379
|
+
fx!.deps,
|
|
380
|
+
);
|
|
381
|
+
const batchOpen = sink.find((l) => /^BATCH \+\S+ chathistory #aws-chathist/.test(l));
|
|
382
|
+
expect(batchOpen).toBeDefined();
|
|
383
|
+
const replay = sink.find((l) => l.includes('PRIVMSG #aws-chathist :hello from aws'));
|
|
384
|
+
expect(replay).toBeDefined();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('completes SASL PLAIN with 900/903 when an AccountStore is bound', async () => {
|
|
388
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
|
|
389
|
+
const sink: string[] = [];
|
|
390
|
+
fx!.mgmt.register('c1', (data) => {
|
|
391
|
+
for (const line of data.split('\r\n')) {
|
|
392
|
+
if (line.length > 0) sink.push(line);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
// Swap in a deps with a seeded AccountStore for this dispatch.
|
|
396
|
+
const depsWithAccounts: HandlerDeps = {
|
|
397
|
+
...fx!.deps,
|
|
398
|
+
accounts: new InMemoryAccountStore([{ username: 'aws-sasl', password: 's3cret' }]),
|
|
399
|
+
};
|
|
400
|
+
const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
|
|
401
|
+
await dispatch(
|
|
402
|
+
{
|
|
403
|
+
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
404
|
+
body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
|
|
405
|
+
},
|
|
406
|
+
depsWithAccounts,
|
|
407
|
+
);
|
|
408
|
+
expect(sink.some((l) => l.includes(' 900 '))).toBe(true);
|
|
409
|
+
expect(sink.some((l) => l.includes(' 903 '))).toBe(true);
|
|
410
|
+
expect(sink.some((l) => l.includes(' 904 '))).toBe(false);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('emits 904 when no AccountStore is bound (regression guard)', async () => {
|
|
414
|
+
await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
|
|
415
|
+
const sink: string[] = [];
|
|
416
|
+
fx!.mgmt.register('c2', (data) => {
|
|
417
|
+
for (const line of data.split('\r\n')) {
|
|
418
|
+
if (line.length > 0) sink.push(line);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
|
|
422
|
+
await dispatch(
|
|
423
|
+
{
|
|
424
|
+
requestContext: { routeKey: '$default', connectionId: 'c2' },
|
|
425
|
+
body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
|
|
426
|
+
},
|
|
427
|
+
fx!.deps,
|
|
428
|
+
);
|
|
429
|
+
expect(sink.some((l) => l.includes(' 904 '))).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('returns 500 when unmarshalConnection throws on a corrupted row (caught by Lambda)', async () => {
|
|
433
|
+
// Skip: the catch in handleDefault wraps `actor.receiveTextFrame`,
|
|
434
|
+
// not `unmarshalConnection`. A corrupted-row test would exercise
|
|
435
|
+
// the Lambda-level unhandled rejection path, which is out of scope.
|
|
436
|
+
// The 500-branch is covered indirectly by GoneException tests.
|
|
437
|
+
expect(true).toBe(true);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// -------------------------------------------------------------------------
|
|
442
|
+
// Unknown routeKey
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
describe('unknown routeKey', () => {
|
|
446
|
+
it('returns 200 (APIGW only forwards the three known routes)', async () => {
|
|
447
|
+
const res = await dispatch(
|
|
448
|
+
{ requestContext: { routeKey: '$custom', connectionId: 'c1' } },
|
|
449
|
+
fx!.deps,
|
|
450
|
+
);
|
|
451
|
+
expect(res.statusCode).toBe(200);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Test double for `ApiGatewayManagementApi`. Routes `postToConnection`
|
|
458
|
+
* calls to a registered sink for `ConnectionId`.
|
|
459
|
+
*/
|
|
460
|
+
class LocalPostToConnection implements PostToConnection {
|
|
461
|
+
private readonly sinks = new Map<string, (data: string) => void>();
|
|
462
|
+
register(connId: string, sink: (data: string) => void): void {
|
|
463
|
+
this.sinks.set(connId, sink);
|
|
464
|
+
}
|
|
465
|
+
async postToConnection(input: { ConnectionId: string; Data: string }): Promise<{}> {
|
|
466
|
+
const sink = this.sinks.get(input.ConnectionId);
|
|
467
|
+
if (sink !== undefined) sink(input.Data);
|
|
468
|
+
return {};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Keep the LambdaResponse import alive for type discoverability.
|
|
473
|
+
export type { LambdaResponse };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the AWS adapter's chat-history store binding helper.
|
|
3
|
+
*
|
|
4
|
+
* The helper centralises MessageStore construction so a future
|
|
5
|
+
* DynamoDB-backed persistent variant can replace the in-memory v1 impl
|
|
6
|
+
* at a single site. These tests prove the helper returns a functional
|
|
7
|
+
* store (record → query round-trip) without needing DynamoDB Local.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { DEFAULT_SERVER_CONFIG, type StoredMessage } from '@serverless-ircd/irc-core';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { bindMessageStore } from '../src/message-store.js';
|
|
13
|
+
|
|
14
|
+
function sampleMessage(chan: string, text: string): StoredMessage {
|
|
15
|
+
return {
|
|
16
|
+
msgid: 'm1',
|
|
17
|
+
time: 1_000,
|
|
18
|
+
chan: chan.toLowerCase(),
|
|
19
|
+
command: 'PRIVMSG',
|
|
20
|
+
nick: 'alice',
|
|
21
|
+
user: 'alice',
|
|
22
|
+
host: 'ex.org',
|
|
23
|
+
text,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('bindMessageStore', () => {
|
|
28
|
+
it('returns a store that records and replays a message via LATEST', () => {
|
|
29
|
+
const store = bindMessageStore(DEFAULT_SERVER_CONFIG);
|
|
30
|
+
store.record(sampleMessage('#aws', 'hello aws'));
|
|
31
|
+
|
|
32
|
+
const result = store.query({ chan: '#aws', direction: 'latest', limit: 10 });
|
|
33
|
+
expect(result).toHaveLength(1);
|
|
34
|
+
expect(result[0]?.text).toBe('hello aws');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns a store scoped per channel (no cross-channel leak)', () => {
|
|
38
|
+
const store = bindMessageStore(DEFAULT_SERVER_CONFIG);
|
|
39
|
+
store.record(sampleMessage('#a', 'in a'));
|
|
40
|
+
store.record(sampleMessage('#b', 'in b'));
|
|
41
|
+
|
|
42
|
+
expect(store.query({ chan: '#a', direction: 'latest', limit: 10 })).toHaveLength(1);
|
|
43
|
+
expect(store.query({ chan: '#b', direction: 'latest', limit: 10 })).toHaveLength(1);
|
|
44
|
+
expect(store.query({ chan: '#c', direction: 'latest', limit: 10 })).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns an independent store instance per call', () => {
|
|
48
|
+
const a = bindMessageStore(DEFAULT_SERVER_CONFIG);
|
|
49
|
+
const b = bindMessageStore(DEFAULT_SERVER_CONFIG);
|
|
50
|
+
a.record(sampleMessage('#aws', 'only in a'));
|
|
51
|
+
|
|
52
|
+
expect(a.query({ chan: '#aws', direction: 'latest', limit: 10 })).toHaveLength(1);
|
|
53
|
+
expect(b.query({ chan: '#aws', direction: 'latest', limit: 10 })).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
});
|