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,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gone-connection sweeper — a Lambda on a fixed EventBridge schedule
|
|
3
|
+
* that scans `Connections` for rows whose `idleSince` is older than a
|
|
4
|
+
* TTL threshold and runs {@link cleanupConnection} on each.
|
|
5
|
+
*
|
|
6
|
+
* Catches the case where a client vanished without APIGW ever emitting
|
|
7
|
+
* `$disconnect` (TCP RST behind a flaky NAT, mobile backgrounding,
|
|
8
|
+
* etc.). The DynamoDB TTL on `idleSince` eventually reaps the row too,
|
|
9
|
+
* but TTL expiry is eventual (up to ~48h) and does NOT clean up the
|
|
10
|
+
* matching `ChannelMembers` / `Nicks` rows — the sweeper does.
|
|
11
|
+
*
|
|
12
|
+
* Gated on `process.env.DDB_AVAILABLE` (real DynamoDB Local).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
16
|
+
import { PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
|
17
|
+
import { type ChanName, type Nick, createConnection } from '@serverless-ircd/irc-core';
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
19
|
+
import { cleanupConnection } from '../src/aws-runtime.js';
|
|
20
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
21
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
22
|
+
import { type SweepParams, type SweepResult, handleSweep } from '../src/handlers/sweeper.js';
|
|
23
|
+
import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
|
|
24
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
25
|
+
|
|
26
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
27
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
28
|
+
|
|
29
|
+
interface Fixture {
|
|
30
|
+
tables: TablesConfig;
|
|
31
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
32
|
+
cleanup: () => Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let fx: Fixture | null;
|
|
36
|
+
|
|
37
|
+
async function makeFixture(): Promise<Fixture> {
|
|
38
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
39
|
+
const prefix = `s${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
40
|
+
const tables = Object.fromEntries(
|
|
41
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
42
|
+
) as TablesConfig;
|
|
43
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
44
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
45
|
+
const pkName = props.partitionKey?.name;
|
|
46
|
+
const skName = props.sortKey?.name;
|
|
47
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
48
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
49
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
50
|
+
];
|
|
51
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
52
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
53
|
+
];
|
|
54
|
+
if (skName !== undefined) {
|
|
55
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
56
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
57
|
+
}
|
|
58
|
+
await client.send(
|
|
59
|
+
new CreateTableCommand({
|
|
60
|
+
TableName: `${prefix}${logical}`,
|
|
61
|
+
AttributeDefinitions: attributeDefinitions,
|
|
62
|
+
KeySchema: keySchema,
|
|
63
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
tables,
|
|
69
|
+
client,
|
|
70
|
+
cleanup: async () => {
|
|
71
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
72
|
+
try {
|
|
73
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
74
|
+
} catch {
|
|
75
|
+
// best-effort
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function params(overrides: Partial<SweepParams> = {}): SweepParams {
|
|
83
|
+
return {
|
|
84
|
+
dynamo: fx!.client,
|
|
85
|
+
tables: fx!.tables,
|
|
86
|
+
...overrides,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Seeds a connection row with the supplied idleSince + optional nick + memberships. */
|
|
91
|
+
async function seedConnection(opts: {
|
|
92
|
+
connId: string;
|
|
93
|
+
idleSince: number;
|
|
94
|
+
nick?: string;
|
|
95
|
+
channels?: ChanName[];
|
|
96
|
+
}): Promise<void> {
|
|
97
|
+
const state = createConnection({ id: opts.connId, connectedSince: opts.idleSince });
|
|
98
|
+
state.registration = 'registered';
|
|
99
|
+
state.lastSeen = opts.idleSince;
|
|
100
|
+
if (opts.nick !== undefined) {
|
|
101
|
+
state.nick = opts.nick as Nick;
|
|
102
|
+
state.user = opts.nick;
|
|
103
|
+
await fx!.client.send(
|
|
104
|
+
new PutCommand({
|
|
105
|
+
TableName: fx!.tables.Nicks,
|
|
106
|
+
Item: marshalNick(opts.nick as Nick, opts.connId, opts.idleSince),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
for (const chan of opts.channels ?? []) {
|
|
111
|
+
state.joinedChannels.add(chan);
|
|
112
|
+
await fx!.client.send(
|
|
113
|
+
new PutCommand({
|
|
114
|
+
TableName: fx!.tables.ChannelMembers,
|
|
115
|
+
Item: marshalChannelMember(chan.toLowerCase(), {
|
|
116
|
+
conn: opts.connId,
|
|
117
|
+
nick: (opts.nick ?? 'x') as Nick,
|
|
118
|
+
op: false,
|
|
119
|
+
voice: false,
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
await fx!.client.send(
|
|
125
|
+
new PutCommand({
|
|
126
|
+
TableName: fx!.tables.Connections,
|
|
127
|
+
Item: marshalConnection(state, opts.idleSince),
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function connectionExists(connId: string): Promise<boolean> {
|
|
133
|
+
const { GetCommand } = await import('@aws-sdk/lib-dynamodb');
|
|
134
|
+
const res = await fx!.client.send(
|
|
135
|
+
new GetCommand({ TableName: fx!.tables.Connections, Key: { connectionId: connId } }),
|
|
136
|
+
);
|
|
137
|
+
return res.Item !== undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function membersOf(channelKey: string): Promise<{ connectionId: string }[]> {
|
|
141
|
+
const res = await fx!.client.send(
|
|
142
|
+
new QueryCommand({
|
|
143
|
+
TableName: fx!.tables.ChannelMembers,
|
|
144
|
+
KeyConditionExpression: 'channelName = :k',
|
|
145
|
+
ExpressionAttributeValues: { ':k': channelKey },
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
return (res.Items ?? []) as { connectionId: string }[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function nickExists(nick: string): Promise<boolean> {
|
|
152
|
+
const { GetCommand } = await import('@aws-sdk/lib-dynamodb');
|
|
153
|
+
const res = await fx!.client.send(
|
|
154
|
+
new GetCommand({ TableName: fx!.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
|
|
155
|
+
);
|
|
156
|
+
return res.Item !== undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
describe.skipIf(!available)('gone-connection sweeper', () => {
|
|
160
|
+
beforeEach(async () => {
|
|
161
|
+
fx = await makeFixture();
|
|
162
|
+
});
|
|
163
|
+
afterEach(async () => {
|
|
164
|
+
if (fx) await fx.cleanup();
|
|
165
|
+
fx = null;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('removes a Connections row whose idleSince is older than the threshold', async () => {
|
|
169
|
+
const now = 10_000;
|
|
170
|
+
await seedConnection({ connId: 'stale', idleSince: 0 }); // very old
|
|
171
|
+
await seedConnection({ connId: 'fresh', idleSince: now }); // current
|
|
172
|
+
|
|
173
|
+
const result: SweepResult = await handleSweep(params({ now, staleAfterMs: 1_000 }));
|
|
174
|
+
|
|
175
|
+
expect(result.scanned).toBe(2);
|
|
176
|
+
expect(result.swept).toBe(1);
|
|
177
|
+
expect(await connectionExists('stale')).toBe(false);
|
|
178
|
+
expect(await connectionExists('fresh')).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('keeps a fresh connection that is exactly at the boundary (not swept)', async () => {
|
|
182
|
+
const now = 5_000;
|
|
183
|
+
const staleAfterMs = 1_000;
|
|
184
|
+
// idleSince == now - staleAfterMs is the cutoff; strictly older is swept.
|
|
185
|
+
await seedConnection({ connId: 'boundary', idleSince: now - staleAfterMs });
|
|
186
|
+
await seedConnection({ connId: 'oneBefore', idleSince: now - staleAfterMs - 1 });
|
|
187
|
+
|
|
188
|
+
const result = await handleSweep(params({ now, staleAfterMs }));
|
|
189
|
+
|
|
190
|
+
expect(result.swept).toBe(1);
|
|
191
|
+
expect(await connectionExists('boundary')).toBe(true);
|
|
192
|
+
expect(await connectionExists('oneBefore')).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('removes the swept connection’s ChannelMembers rows', async () => {
|
|
196
|
+
const now = 10_000;
|
|
197
|
+
await seedConnection({
|
|
198
|
+
connId: 'stale',
|
|
199
|
+
idleSince: 0,
|
|
200
|
+
nick: 'alice',
|
|
201
|
+
channels: ['#foo' as ChanName, '#bar' as ChanName],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await handleSweep(params({ now, staleAfterMs: 1_000 }));
|
|
205
|
+
|
|
206
|
+
expect(await membersOf('#foo')).toHaveLength(0);
|
|
207
|
+
expect(await membersOf('#bar')).toHaveLength(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('releases the swept connection’s nick', async () => {
|
|
211
|
+
const now = 10_000;
|
|
212
|
+
await seedConnection({ connId: 'stale', idleSince: 0, nick: 'alice' });
|
|
213
|
+
|
|
214
|
+
await handleSweep(params({ now, staleAfterMs: 1_000 }));
|
|
215
|
+
|
|
216
|
+
expect(await nickExists('alice')).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('leaves a fresh connection’s memberships and nick intact', async () => {
|
|
220
|
+
const now = 10_000;
|
|
221
|
+
await seedConnection({
|
|
222
|
+
connId: 'fresh',
|
|
223
|
+
idleSince: now,
|
|
224
|
+
nick: 'bob',
|
|
225
|
+
channels: ['#room' as ChanName],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await handleSweep(params({ now, staleAfterMs: 1_000 }));
|
|
229
|
+
|
|
230
|
+
expect(await connectionExists('fresh')).toBe(true);
|
|
231
|
+
expect(await nickExists('bob')).toBe(true);
|
|
232
|
+
expect(await membersOf('#room')).toHaveLength(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('is idempotent — running twice does not throw and sweeps 0 the second time', async () => {
|
|
236
|
+
const now = 10_000;
|
|
237
|
+
await seedConnection({ connId: 'stale', idleSince: 0 });
|
|
238
|
+
|
|
239
|
+
const first = await handleSweep(params({ now, staleAfterMs: 1_000 }));
|
|
240
|
+
const second = await handleSweep(params({ now, staleAfterMs: 1_000 }));
|
|
241
|
+
|
|
242
|
+
expect(first.swept).toBe(1);
|
|
243
|
+
expect(second.swept).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('reports scanned=0 / swept=0 on an empty table', async () => {
|
|
247
|
+
const result = await handleSweep(params({ now: 1_000, staleAfterMs: 1 }));
|
|
248
|
+
expect(result.scanned).toBe(0);
|
|
249
|
+
expect(result.swept).toBe(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('uses a sensible default threshold when staleAfterMs is omitted', async () => {
|
|
253
|
+
// Two-hour-old row should be swept by the default (2h) threshold.
|
|
254
|
+
const HOUR = 60 * 60 * 1000;
|
|
255
|
+
const now = 10 * HOUR;
|
|
256
|
+
await seedConnection({ connId: 'twoHoursOld', idleSince: now - 2 * HOUR - 1 });
|
|
257
|
+
await seedConnection({ connId: 'recent', idleSince: now - 30 * 60 * 1000 });
|
|
258
|
+
|
|
259
|
+
const result = await handleSweep(params({ now }));
|
|
260
|
+
|
|
261
|
+
expect(result.swept).toBe(1);
|
|
262
|
+
expect(await connectionExists('twoHoursOld')).toBe(false);
|
|
263
|
+
expect(await connectionExists('recent')).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('sweeps multiple stale connections in one pass', async () => {
|
|
267
|
+
const now = 10_000;
|
|
268
|
+
await seedConnection({ connId: 's1', idleSince: 0 });
|
|
269
|
+
await seedConnection({ connId: 's2', idleSince: 0 });
|
|
270
|
+
await seedConnection({ connId: 's3', idleSince: 0 });
|
|
271
|
+
await seedConnection({ connId: 'live', idleSince: now });
|
|
272
|
+
|
|
273
|
+
const result = await handleSweep(params({ now, staleAfterMs: 1_000 }));
|
|
274
|
+
|
|
275
|
+
expect(result.swept).toBe(3);
|
|
276
|
+
expect(await connectionExists('s1')).toBe(false);
|
|
277
|
+
expect(await connectionExists('s2')).toBe(false);
|
|
278
|
+
expect(await connectionExists('s3')).toBe(false);
|
|
279
|
+
expect(await connectionExists('live')).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('cleanupConnection is safe to call on a row the sweeper already removed (no throw)', async () => {
|
|
283
|
+
await seedConnection({ connId: 'stale', idleSince: 0, nick: 'zed' });
|
|
284
|
+
await cleanupConnection(fx!.client, fx!.tables, 'stale', null);
|
|
285
|
+
await expect(cleanupConnection(fx!.client, fx!.tables, 'stale', null)).resolves.toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('uses Date.now() when now is omitted (real-clock default path)', async () => {
|
|
289
|
+
// Seed a connection idle far in the past relative to the real clock.
|
|
290
|
+
await seedConnection({ connId: 'ancient', idleSince: 0 });
|
|
291
|
+
const before = Date.now();
|
|
292
|
+
const result = await handleSweep(params({ staleAfterMs: 1_000 }));
|
|
293
|
+
// At least one second has elapsed since epoch 0 under any clock.
|
|
294
|
+
expect(result.swept).toBeGreaterThanOrEqual(1);
|
|
295
|
+
expect(before).toBeGreaterThan(1_000);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('skips a row whose idleSince is not a number (defensive — corrupted row)', async () => {
|
|
299
|
+
// Write a Connections row directly with a non-numeric idleSince.
|
|
300
|
+
await fx!.client.send(
|
|
301
|
+
new PutCommand({
|
|
302
|
+
TableName: fx!.tables.Connections,
|
|
303
|
+
Item: {
|
|
304
|
+
connectionId: 'corrupt',
|
|
305
|
+
registration: 'registered',
|
|
306
|
+
capNegotiating: false,
|
|
307
|
+
caps: [],
|
|
308
|
+
userModes: {},
|
|
309
|
+
lastSeen: 0,
|
|
310
|
+
connectedSince: 0,
|
|
311
|
+
idleSince: 'not-a-number',
|
|
312
|
+
version: 1,
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const result = await handleSweep(params({ now: 10_000, staleAfterMs: 1 }));
|
|
318
|
+
expect(result.scanned).toBe(1);
|
|
319
|
+
expect(result.swept).toBe(0);
|
|
320
|
+
expect(await connectionExists('corrupt')).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('paginates: sweeps across multiple Scan pages (forces a small page size via repeated scans)', async () => {
|
|
324
|
+
// Seed 3 stale connections. handleSweep paginates while
|
|
325
|
+
// LastEvaluatedKey is set; with so few rows DynamoDB Local returns
|
|
326
|
+
// a single page, but the do/while still exercises the loop's
|
|
327
|
+
// "lastKey === undefined" exit branch.
|
|
328
|
+
await seedConnection({ connId: 'p1', idleSince: 0 });
|
|
329
|
+
await seedConnection({ connId: 'p2', idleSince: 0 });
|
|
330
|
+
const result = await handleSweep(params({ now: 10_000, staleAfterMs: 1 }));
|
|
331
|
+
expect(result.swept).toBe(2);
|
|
332
|
+
expect(result.scanned).toBe(2);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Scan-response edge cases (no DynamoDB Local — fake client)
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
import { type DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Scripted page for the fake Scan client.
|
|
344
|
+
* - `items` left unset -> response omits the `Items` field entirely
|
|
345
|
+
* (exercises the sweeper's `?? []` defensive branch).
|
|
346
|
+
* - `items` set to `[]` -> response has `Items: []`.
|
|
347
|
+
* - `lastKey` set -> response carries `LastEvaluatedKey`, forcing
|
|
348
|
+
* another scan iteration (pagination branch).
|
|
349
|
+
*/
|
|
350
|
+
interface ScriptedPage {
|
|
351
|
+
items?: unknown[];
|
|
352
|
+
lastKey?: unknown;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Fake document client that returns scripted `ScanCommand` pages and
|
|
357
|
+
* empty success for every other command (cleanupConnection's Get/Delete
|
|
358
|
+
* are no-ops). Lets the sweeper's pagination + defensive branches run
|
|
359
|
+
* without real DynamoDB.
|
|
360
|
+
*/
|
|
361
|
+
class FakeScanDynamo {
|
|
362
|
+
constructor(private readonly pages: ScriptedPage[]) {}
|
|
363
|
+
async send(cmd: unknown): Promise<unknown> {
|
|
364
|
+
if (cmd instanceof ScanCommand) {
|
|
365
|
+
const page = this.pages.shift() ?? {};
|
|
366
|
+
const resp: Record<string, unknown> = {};
|
|
367
|
+
if (page.items !== undefined) {
|
|
368
|
+
resp.Items = page.items;
|
|
369
|
+
}
|
|
370
|
+
if (page.lastKey !== undefined) {
|
|
371
|
+
resp.LastEvaluatedKey = page.lastKey;
|
|
372
|
+
}
|
|
373
|
+
return resp;
|
|
374
|
+
}
|
|
375
|
+
// Get / Delete / Put — return empty success.
|
|
376
|
+
return {};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function fakeSweepParams(client: FakeScanDynamo): SweepParams {
|
|
381
|
+
return {
|
|
382
|
+
dynamo: client as unknown as DynamoDBDocumentClient,
|
|
383
|
+
tables: {
|
|
384
|
+
Connections: 'C',
|
|
385
|
+
ChannelMeta: 'CM',
|
|
386
|
+
ChannelMembers: 'CMem',
|
|
387
|
+
Nicks: 'N',
|
|
388
|
+
Accounts: 'A',
|
|
389
|
+
},
|
|
390
|
+
now: 10_000,
|
|
391
|
+
staleAfterMs: 1_000,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
describe('gone-connection sweeper — scan edge cases', () => {
|
|
396
|
+
it('paginates across multiple Scan pages (ExclusiveStartKey carried between calls)', async () => {
|
|
397
|
+
const client = new FakeScanDynamo([
|
|
398
|
+
{ items: [{ connectionId: 'a', idleSince: 0 }], lastKey: { connectionId: 'a' } },
|
|
399
|
+
{ items: [{ connectionId: 'b', idleSince: 0 }], lastKey: { connectionId: 'b' } },
|
|
400
|
+
{ items: [{ connectionId: 'c', idleSince: 0 }] },
|
|
401
|
+
]);
|
|
402
|
+
const result = await handleSweep(fakeSweepParams(client));
|
|
403
|
+
expect(result.scanned).toBe(3);
|
|
404
|
+
expect(result.swept).toBe(3);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('treats a missing Items field as an empty page (defensive)', async () => {
|
|
408
|
+
const client = new FakeScanDynamo([{}]);
|
|
409
|
+
const result = await handleSweep(fakeSweepParams(client));
|
|
410
|
+
expect(result.scanned).toBe(0);
|
|
411
|
+
expect(result.swept).toBe(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('skips a row whose connectionId is not a string (defensive)', async () => {
|
|
415
|
+
const client = new FakeScanDynamo([{ items: [{ connectionId: 12345, idleSince: 0 }] }]);
|
|
416
|
+
const result = await handleSweep(fakeSweepParams(client));
|
|
417
|
+
expect(result.scanned).toBe(1);
|
|
418
|
+
expect(result.swept).toBe(0);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the runtime-safe table metadata in `src/tables.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Pure-function tests; no DynamoDB needed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import {
|
|
9
|
+
TABLE_KEYS,
|
|
10
|
+
TABLE_NAMES,
|
|
11
|
+
type TablesConfig,
|
|
12
|
+
tablesConfigFromNames,
|
|
13
|
+
} from '../src/tables.js';
|
|
14
|
+
|
|
15
|
+
describe('TABLE_NAMES', () => {
|
|
16
|
+
it('contains exactly the five canonical logical names', () => {
|
|
17
|
+
expect(TABLE_NAMES).toEqual([
|
|
18
|
+
'Connections',
|
|
19
|
+
'ChannelMeta',
|
|
20
|
+
'ChannelMembers',
|
|
21
|
+
'Nicks',
|
|
22
|
+
'Accounts',
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('TABLE_KEYS', () => {
|
|
28
|
+
it('exposes the connectionId PK for Connections', () => {
|
|
29
|
+
expect(TABLE_KEYS.Connections.pk).toBe('connectionId');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('exposes the channelName PK for ChannelMeta', () => {
|
|
33
|
+
expect(TABLE_KEYS.ChannelMeta.pk).toBe('channelName');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('exposes both PK and SK for ChannelMembers', () => {
|
|
37
|
+
expect(TABLE_KEYS.ChannelMembers.pk).toBe('channelName');
|
|
38
|
+
expect(TABLE_KEYS.ChannelMembers.sk).toBe('connectionId');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('exposes the nickLower PK for Nicks', () => {
|
|
42
|
+
expect(TABLE_KEYS.Nicks.pk).toBe('nickLower');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('exposes the account PK for Accounts', () => {
|
|
46
|
+
expect(TABLE_KEYS.Accounts.pk).toBe('account');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('tablesConfigFromNames', () => {
|
|
51
|
+
it('round-trips each name', () => {
|
|
52
|
+
const names: TablesConfig = {
|
|
53
|
+
Connections: 'p-Connections',
|
|
54
|
+
ChannelMeta: 'p-ChannelMeta',
|
|
55
|
+
ChannelMembers: 'p-ChannelMembers',
|
|
56
|
+
Nicks: 'p-Nicks',
|
|
57
|
+
Accounts: 'p-Accounts',
|
|
58
|
+
};
|
|
59
|
+
expect(tablesConfigFromNames(names)).toEqual(names);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns a new object (does not alias the input)', () => {
|
|
63
|
+
const names: TablesConfig = {
|
|
64
|
+
Connections: 'c',
|
|
65
|
+
ChannelMeta: 'cm',
|
|
66
|
+
ChannelMembers: 'cml',
|
|
67
|
+
Nicks: 'n',
|
|
68
|
+
Accounts: 'a',
|
|
69
|
+
};
|
|
70
|
+
const out = tablesConfigFromNames(names);
|
|
71
|
+
expect(out).not.toBe(names);
|
|
72
|
+
expect(out).toEqual(names);
|
|
73
|
+
});
|
|
74
|
+
});
|