serverless-ircd 0.7.0 → 0.8.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 +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +546 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +5 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +112 -2
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +390 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +21 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +71 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +623 -12
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit tests for `handleConnect
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* policy itself is covered by `admission.test.ts`.
|
|
2
|
+
* Unit tests for `handleConnect`. A hand-rolled stub client stands in for
|
|
3
|
+
* `DynamoDBDocumentClient`, simulating the atomic admission counter
|
|
4
|
+
* (`UpdateItem` increment/decrement on the meta row) and the `Put` of the
|
|
5
|
+
* new connection row. The admission policy itself is covered by
|
|
6
|
+
* `admission.test.ts`; the counter primitives by `connection-counter.test.ts`.
|
|
8
7
|
*/
|
|
9
|
-
|
|
10
|
-
import { type DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
8
|
+
import { type DynamoDBDocumentClient, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
11
9
|
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
12
10
|
import { describe, expect, it } from 'vitest';
|
|
13
11
|
import { handleConnect } from '../src/handlers/connect.js';
|
|
@@ -27,53 +25,114 @@ interface Stub {
|
|
|
27
25
|
puts: number;
|
|
28
26
|
/** Most recently PutCommand Item (undefined when no put happened). */
|
|
29
27
|
lastPutItem: Record<string, unknown> | undefined;
|
|
28
|
+
/** Current simulated counter value. */
|
|
29
|
+
count: number;
|
|
30
|
+
increments: number;
|
|
31
|
+
decrements: number;
|
|
32
|
+
/** Commands issued, in order (for asserting no Scan is used). */
|
|
33
|
+
commands: string[];
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Builds a stub client with an initial admission counter value. The stub
|
|
38
|
+
* honours the floor-at-0 decrement condition the real handler relies on.
|
|
39
|
+
*/
|
|
40
|
+
function makeStub(initialCount: number): Stub {
|
|
41
|
+
const state = { count: initialCount, puts: 0, increments: 0, decrements: 0 };
|
|
36
42
|
let lastPutItem: Record<string, unknown> | undefined;
|
|
43
|
+
const commands: string[] = [];
|
|
37
44
|
const dynamo = {
|
|
38
45
|
send: async (cmd: unknown) => {
|
|
39
|
-
if (cmd instanceof
|
|
40
|
-
|
|
46
|
+
if (cmd instanceof UpdateCommand) {
|
|
47
|
+
const expr = (cmd.input as { UpdateExpression?: string }).UpdateExpression ?? '';
|
|
48
|
+
const cond = (cmd.input as { ConditionExpression?: string }).ConditionExpression;
|
|
49
|
+
commands.push('Update');
|
|
50
|
+
if (expr.includes('+ :one')) {
|
|
51
|
+
state.count += 1;
|
|
52
|
+
state.increments += 1;
|
|
53
|
+
return { Attributes: { count: state.count } };
|
|
54
|
+
}
|
|
55
|
+
if (expr.includes('- :one')) {
|
|
56
|
+
// Floor at 0: when a condition is present and count <= 0, reject.
|
|
57
|
+
if (cond !== undefined && state.count <= 0) {
|
|
58
|
+
throw Object.assign(new Error('conditional request failed'), {
|
|
59
|
+
name: 'ConditionalCheckFailedException',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (state.count > 0) {
|
|
63
|
+
state.count -= 1;
|
|
64
|
+
state.decrements += 1;
|
|
65
|
+
}
|
|
66
|
+
return { Attributes: { count: state.count } };
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`unexpected Update expression: ${expr}`);
|
|
41
69
|
}
|
|
42
70
|
if (cmd instanceof PutCommand) {
|
|
43
|
-
|
|
71
|
+
commands.push('Put');
|
|
72
|
+
state.puts += 1;
|
|
44
73
|
lastPutItem = cmd.input.Item as Record<string, unknown> | undefined;
|
|
45
74
|
return {};
|
|
46
75
|
}
|
|
47
|
-
|
|
76
|
+
commands.push('Unknown');
|
|
77
|
+
throw new Error(`unexpected command (Scan must not be used): ${String(cmd)}`);
|
|
48
78
|
},
|
|
49
79
|
} as unknown as DynamoDBDocumentClient;
|
|
50
80
|
return {
|
|
51
81
|
dynamo,
|
|
52
82
|
get puts() {
|
|
53
|
-
return
|
|
83
|
+
return state.puts;
|
|
54
84
|
},
|
|
55
85
|
get lastPutItem() {
|
|
56
86
|
return lastPutItem;
|
|
57
87
|
},
|
|
88
|
+
get count() {
|
|
89
|
+
return state.count;
|
|
90
|
+
},
|
|
91
|
+
get increments() {
|
|
92
|
+
return state.increments;
|
|
93
|
+
},
|
|
94
|
+
get decrements() {
|
|
95
|
+
return state.decrements;
|
|
96
|
+
},
|
|
97
|
+
get commands() {
|
|
98
|
+
return commands;
|
|
99
|
+
},
|
|
58
100
|
};
|
|
59
101
|
}
|
|
60
102
|
|
|
61
103
|
describe('handleConnect admission plumbing', () => {
|
|
62
|
-
it('
|
|
63
|
-
const stub = makeStub(
|
|
104
|
+
it('admits (writes the row) and reserves a counter slot when below the cap', async () => {
|
|
105
|
+
const stub = makeStub(0);
|
|
64
106
|
const outcome = await handleConnect({
|
|
65
107
|
dynamo: stub.dynamo,
|
|
66
108
|
tables: TABLES,
|
|
67
|
-
connectionId: 'c-
|
|
109
|
+
connectionId: 'c-admit',
|
|
68
110
|
now: 42,
|
|
69
111
|
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
70
112
|
});
|
|
71
113
|
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
72
114
|
expect(stub.puts).toBe(1);
|
|
115
|
+
expect(stub.increments).toBe(1);
|
|
116
|
+
expect(stub.decrements).toBe(0);
|
|
117
|
+
expect(stub.count).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('uses only O(1) UpdateItem for admission (no full-table Scan)', async () => {
|
|
121
|
+
const stub = makeStub(0);
|
|
122
|
+
await handleConnect({
|
|
123
|
+
dynamo: stub.dynamo,
|
|
124
|
+
tables: TABLES,
|
|
125
|
+
connectionId: 'c-noop',
|
|
126
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
127
|
+
});
|
|
128
|
+
// Every command must be Update or Put — a Scan would throw via the
|
|
129
|
+
// stub's Unknown branch, and the recorded commands prove the shape.
|
|
130
|
+
expect(stub.commands.every((c) => c === 'Update' || c === 'Put')).toBe(true);
|
|
131
|
+
expect(stub.commands).toContain('Update');
|
|
73
132
|
});
|
|
74
133
|
|
|
75
134
|
it('does not write a row when the count reaches the cap', async () => {
|
|
76
|
-
const stub = makeStub(
|
|
135
|
+
const stub = makeStub(5);
|
|
77
136
|
const outcome = await handleConnect({
|
|
78
137
|
dynamo: stub.dynamo,
|
|
79
138
|
tables: TABLES,
|
|
@@ -83,11 +142,70 @@ describe('handleConnect admission plumbing', () => {
|
|
|
83
142
|
expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
|
|
84
143
|
expect(stub.puts).toBe(0);
|
|
85
144
|
});
|
|
145
|
+
|
|
146
|
+
it('rolls back the counter reservation when admission rejects', async () => {
|
|
147
|
+
const stub = makeStub(5);
|
|
148
|
+
await handleConnect({
|
|
149
|
+
dynamo: stub.dynamo,
|
|
150
|
+
tables: TABLES,
|
|
151
|
+
connectionId: 'c-rollback',
|
|
152
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
153
|
+
});
|
|
154
|
+
expect(stub.increments).toBe(1);
|
|
155
|
+
expect(stub.decrements).toBe(1); // reservation released
|
|
156
|
+
expect(stub.count).toBe(5); // back to the starting count
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rolls back the reservation when the row Put fails', async () => {
|
|
160
|
+
// Stub whose Put throws; count starts below cap so admission passes.
|
|
161
|
+
let lastPutItem: Record<string, unknown> | undefined;
|
|
162
|
+
const state = { count: 0, increments: 0, decrements: 0 };
|
|
163
|
+
const dynamo = {
|
|
164
|
+
send: async (cmd: unknown) => {
|
|
165
|
+
if (cmd instanceof UpdateCommand) {
|
|
166
|
+
const expr = (cmd.input as { UpdateExpression?: string }).UpdateExpression ?? '';
|
|
167
|
+
if (expr.includes('+ :one')) {
|
|
168
|
+
state.count += 1;
|
|
169
|
+
state.increments += 1;
|
|
170
|
+
return { Attributes: { count: state.count } };
|
|
171
|
+
}
|
|
172
|
+
if (state.count > 0) {
|
|
173
|
+
state.count -= 1;
|
|
174
|
+
state.decrements += 1;
|
|
175
|
+
}
|
|
176
|
+
return { Attributes: { count: state.count } };
|
|
177
|
+
}
|
|
178
|
+
if (cmd instanceof PutCommand) {
|
|
179
|
+
lastPutItem = cmd.input.Item as Record<string, unknown> | undefined;
|
|
180
|
+
throw new Error('Put failed');
|
|
181
|
+
}
|
|
182
|
+
throw new Error('unexpected');
|
|
183
|
+
},
|
|
184
|
+
} as unknown as DynamoDBDocumentClient;
|
|
185
|
+
await expect(
|
|
186
|
+
handleConnect({
|
|
187
|
+
dynamo,
|
|
188
|
+
tables: TABLES,
|
|
189
|
+
connectionId: 'c-putfail',
|
|
190
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
191
|
+
}),
|
|
192
|
+
).rejects.toThrow('Put failed');
|
|
193
|
+
expect(state.increments).toBe(1);
|
|
194
|
+
expect(state.decrements).toBe(1); // released despite the failed Put
|
|
195
|
+
expect(state.count).toBe(0);
|
|
196
|
+
void lastPutItem;
|
|
197
|
+
});
|
|
86
198
|
});
|
|
87
199
|
|
|
88
200
|
describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
89
|
-
it('
|
|
90
|
-
|
|
201
|
+
it('falls back to legacy when only binary.ircv3.net is offered (API Gateway cannot transport binary frames)', async () => {
|
|
202
|
+
// API Gateway WebSocket APIs carry TEXT frames only; agreeing to
|
|
203
|
+
// binary.ircv3.net makes APIGW close the connection with code 1003
|
|
204
|
+
// ("Binary is not supported") the instant it is negotiated. A client
|
|
205
|
+
// that offers only binary is therefore downgraded to legacy (no
|
|
206
|
+
// subprotocol, no wsMode column) rather than being handed a binary
|
|
207
|
+
// agreement APIGW cannot honour.
|
|
208
|
+
const stub = makeStub(0);
|
|
91
209
|
const outcome = await handleConnect({
|
|
92
210
|
dynamo: stub.dynamo,
|
|
93
211
|
tables: TABLES,
|
|
@@ -96,14 +214,14 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
|
96
214
|
secWebSocketProtocol: 'binary.ircv3.net',
|
|
97
215
|
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
98
216
|
});
|
|
99
|
-
expect(outcome).toEqual({ admitted: true, subprotocol:
|
|
217
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
100
218
|
expect(stub.puts).toBe(1);
|
|
101
219
|
const item = stub.lastPutItem as MarshalledConnection | undefined;
|
|
102
|
-
expect(item?.wsMode).
|
|
220
|
+
expect(item?.wsMode).toBeUndefined();
|
|
103
221
|
});
|
|
104
222
|
|
|
105
223
|
it('selects text.ircv3.net and persists a spec-text mode row', async () => {
|
|
106
|
-
const stub = makeStub(
|
|
224
|
+
const stub = makeStub(0);
|
|
107
225
|
const outcome = await handleConnect({
|
|
108
226
|
dynamo: stub.dynamo,
|
|
109
227
|
tables: TABLES,
|
|
@@ -117,21 +235,29 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
|
117
235
|
expect(item?.wsMode).toBe('spec-text');
|
|
118
236
|
});
|
|
119
237
|
|
|
120
|
-
it('
|
|
121
|
-
|
|
238
|
+
it('selects text.ircv3.net even when binary.ircv3.net is offered first (API Gateway is text-only)', async () => {
|
|
239
|
+
// Reproduces the 1003 close from production: the IRCv3 relay
|
|
240
|
+
// (tcp-ws-forwarder) offers `binary.ircv3.net, text.ircv3.net` (binary
|
|
241
|
+
// first by preference). The generic core selector would agree to
|
|
242
|
+
// binary and APIGW immediately closes with code 1003
|
|
243
|
+
// "Binary is not supported". On the APIGW transport the handler MUST
|
|
244
|
+
// select text.ircv3.net regardless of client-preference order.
|
|
245
|
+
const stub = makeStub(0);
|
|
122
246
|
const outcome = await handleConnect({
|
|
123
247
|
dynamo: stub.dynamo,
|
|
124
248
|
tables: TABLES,
|
|
125
249
|
connectionId: 'c-pref',
|
|
126
250
|
now: 42,
|
|
127
|
-
secWebSocketProtocol: '
|
|
251
|
+
secWebSocketProtocol: 'binary.ircv3.net, text.ircv3.net',
|
|
128
252
|
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
129
253
|
});
|
|
130
254
|
expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
|
|
255
|
+
const item = stub.lastPutItem as MarshalledConnection | undefined;
|
|
256
|
+
expect(item?.wsMode).toBe('spec-text');
|
|
131
257
|
});
|
|
132
258
|
|
|
133
259
|
it('falls back to legacy (null subprotocol, no mode column) when no offer is present', async () => {
|
|
134
|
-
const stub = makeStub(
|
|
260
|
+
const stub = makeStub(0);
|
|
135
261
|
const outcome = await handleConnect({
|
|
136
262
|
dynamo: stub.dynamo,
|
|
137
263
|
tables: TABLES,
|
|
@@ -145,7 +271,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
|
145
271
|
});
|
|
146
272
|
|
|
147
273
|
it('falls back to legacy when only unsupported subprotocols are offered', async () => {
|
|
148
|
-
const stub = makeStub(
|
|
274
|
+
const stub = makeStub(0);
|
|
149
275
|
const outcome = await handleConnect({
|
|
150
276
|
dynamo: stub.dynamo,
|
|
151
277
|
tables: TABLES,
|
|
@@ -160,7 +286,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
|
160
286
|
});
|
|
161
287
|
|
|
162
288
|
it('does not negotiate a subprotocol when the admission gate rejects', async () => {
|
|
163
|
-
const stub = makeStub(
|
|
289
|
+
const stub = makeStub(5);
|
|
164
290
|
const outcome = await handleConnect({
|
|
165
291
|
dynamo: stub.dynamo,
|
|
166
292
|
tables: TABLES,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the O(1) admission counter (`connection-counter.ts`).
|
|
3
|
+
*
|
|
4
|
+
* A hand-rolled stub stands in for `DynamoDBDocumentClient` so the atomic
|
|
5
|
+
* increment/decrement `UpdateItem` shapes and the floor-at-0 behaviour are
|
|
6
|
+
* verified without DynamoDB Local.
|
|
7
|
+
*/
|
|
8
|
+
import { type DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
CONNECTION_COUNT_ATTR,
|
|
12
|
+
CONNECTION_COUNT_META_ID,
|
|
13
|
+
decrementConnectionCount,
|
|
14
|
+
incrementConnectionCount,
|
|
15
|
+
} from '../src/connection-counter.js';
|
|
16
|
+
|
|
17
|
+
interface Stub {
|
|
18
|
+
dynamo: DynamoDBDocumentClient;
|
|
19
|
+
/** Current simulated counter value (`undefined` = meta row absent). */
|
|
20
|
+
count: number | undefined;
|
|
21
|
+
updates: Array<{ setExpr: string; conditionExpr?: string }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeStub(count: number | undefined): Stub {
|
|
25
|
+
const state = { count };
|
|
26
|
+
const updates: Array<{ setExpr: string; conditionExpr?: string }> = [];
|
|
27
|
+
const dynamo = {
|
|
28
|
+
send: async (cmd: unknown) => {
|
|
29
|
+
if (cmd instanceof UpdateCommand) {
|
|
30
|
+
const input = cmd.input as {
|
|
31
|
+
UpdateExpression?: string;
|
|
32
|
+
ConditionExpression?: string;
|
|
33
|
+
};
|
|
34
|
+
updates.push({
|
|
35
|
+
setExpr: input.UpdateExpression ?? '',
|
|
36
|
+
...(input.ConditionExpression !== undefined
|
|
37
|
+
? { conditionExpr: input.ConditionExpression }
|
|
38
|
+
: {}),
|
|
39
|
+
});
|
|
40
|
+
// Honour the floor-at-0 condition the real handler relies on.
|
|
41
|
+
if (
|
|
42
|
+
input.ConditionExpression !== undefined &&
|
|
43
|
+
(state.count === undefined || state.count <= 0)
|
|
44
|
+
) {
|
|
45
|
+
throw Object.assign(new Error('conditional request failed'), {
|
|
46
|
+
name: 'ConditionalCheckFailedException',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// Apply increment / decrement by inspecting the expression.
|
|
50
|
+
const expr = input.UpdateExpression ?? '';
|
|
51
|
+
if (expr.includes('+ :one')) {
|
|
52
|
+
state.count = (state.count ?? 0) + 1;
|
|
53
|
+
} else if (expr.includes('- :one')) {
|
|
54
|
+
state.count = (state.count ?? 0) - 1;
|
|
55
|
+
}
|
|
56
|
+
return { Attributes: { [CONNECTION_COUNT_ATTR]: state.count } };
|
|
57
|
+
}
|
|
58
|
+
throw new Error('unexpected command');
|
|
59
|
+
},
|
|
60
|
+
} as unknown as DynamoDBDocumentClient;
|
|
61
|
+
return {
|
|
62
|
+
dynamo,
|
|
63
|
+
get count() {
|
|
64
|
+
return state.count;
|
|
65
|
+
},
|
|
66
|
+
get updates() {
|
|
67
|
+
return updates;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('incrementConnectionCount', () => {
|
|
73
|
+
it('issues a single UpdateItem on the meta row and returns the new value', async () => {
|
|
74
|
+
const stub = makeStub(4);
|
|
75
|
+
const result = await incrementConnectionCount(stub.dynamo, 'Connections');
|
|
76
|
+
expect(result).toBe(5);
|
|
77
|
+
expect(stub.updates).toHaveLength(1);
|
|
78
|
+
expect(stub.count).toBe(5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('creates the meta row on first use (if_not_exists from 0)', async () => {
|
|
82
|
+
const stub = makeStub(undefined);
|
|
83
|
+
const result = await incrementConnectionCount(stub.dynamo, 'Connections');
|
|
84
|
+
expect(result).toBe(1);
|
|
85
|
+
expect(stub.count).toBe(1);
|
|
86
|
+
// The expression must use if_not_exists so an absent row seeds at 0.
|
|
87
|
+
expect(stub.updates[0]?.setExpr).toContain('if_not_exists');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('targets the sentinel meta row key and the count attribute', async () => {
|
|
91
|
+
const stub = makeStub(0);
|
|
92
|
+
await incrementConnectionCount(stub.dynamo, 'Connections');
|
|
93
|
+
// The stub applied the increment; the contract is the sentinel key +
|
|
94
|
+
// count attr names are used (verified via the ExpressionAttributeNames).
|
|
95
|
+
expect(stub.count).toBe(1);
|
|
96
|
+
expect(CONNECTION_COUNT_META_ID).toContain('__meta:');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('decrementConnectionCount', () => {
|
|
101
|
+
it('decrements the counter by one', async () => {
|
|
102
|
+
const stub = makeStub(3);
|
|
103
|
+
await decrementConnectionCount(stub.dynamo, 'Connections');
|
|
104
|
+
expect(stub.count).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('floors at 0 (condition prevents a negative count)', async () => {
|
|
108
|
+
const stub = makeStub(0);
|
|
109
|
+
await decrementConnectionCount(stub.dynamo, 'Connections');
|
|
110
|
+
expect(stub.count).toBe(0); // unchanged — condition rejected the update
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('is a silent no-op when the meta row is absent', async () => {
|
|
114
|
+
const stub = makeStub(undefined);
|
|
115
|
+
await expect(decrementConnectionCount(stub.dynamo, 'Connections')).resolves.toBeUndefined();
|
|
116
|
+
expect(stub.count).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('never throws (teardown must not fail on counter drift)', async () => {
|
|
120
|
+
const dynamo = {
|
|
121
|
+
send: async () => {
|
|
122
|
+
throw new Error('network exploded');
|
|
123
|
+
},
|
|
124
|
+
} as unknown as DynamoDBDocumentClient;
|
|
125
|
+
await expect(decrementConnectionCount(dynamo, 'Connections')).resolves.toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB-backed integration tests for `DynamoServicesStore`.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the real DynamoDB round-trip that the pure unit suite
|
|
5
|
+
* (`dynamo-services-store-unit.test.ts`) cannot: every services mutator
|
|
6
|
+
* enqueues a write-behind op, `flush()` drains it into the `Services`
|
|
7
|
+
* table, and a fresh `loadDynamoServicesStore` against the same table
|
|
8
|
+
* rehydrates the snapshot. This is the services-state persistence
|
|
9
|
+
* contract for the AWS adapter — a cold start must not lose services state.
|
|
10
|
+
*
|
|
11
|
+
* Gated on DynamoDB Local availability (same `DDB_AVAILABLE` flag as
|
|
12
|
+
* `account-store-dynamo.test.ts`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
16
|
+
import { FakeClock } from '@serverless-ircd/irc-core';
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
18
|
+
import { createDynamoDocumentClient } from '../src/dynamo';
|
|
19
|
+
import { loadDynamoServicesStore } from '../src/dynamo-services-store';
|
|
20
|
+
|
|
21
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
22
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
23
|
+
|
|
24
|
+
const NOW = 1_700_000_000_000;
|
|
25
|
+
|
|
26
|
+
interface Fx {
|
|
27
|
+
tableName: string;
|
|
28
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
29
|
+
cleanup: () => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let fx: Fx | null = null;
|
|
33
|
+
|
|
34
|
+
function requireFx(): Fx {
|
|
35
|
+
if (!fx) throw new Error('test fixture not initialized');
|
|
36
|
+
return fx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function makeFx(): Promise<Fx> {
|
|
40
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
41
|
+
const tableName = `svc-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
42
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
43
|
+
await client.send(
|
|
44
|
+
new CreateTableCommand({
|
|
45
|
+
TableName: tableName,
|
|
46
|
+
AttributeDefinitions: [
|
|
47
|
+
{ AttributeName: 'pk', AttributeType: 'S' },
|
|
48
|
+
{ AttributeName: 'sk', AttributeType: 'S' },
|
|
49
|
+
],
|
|
50
|
+
KeySchema: [
|
|
51
|
+
{ AttributeName: 'pk', KeyType: 'HASH' },
|
|
52
|
+
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
|
53
|
+
],
|
|
54
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
return {
|
|
58
|
+
tableName,
|
|
59
|
+
client,
|
|
60
|
+
cleanup: async () => {
|
|
61
|
+
try {
|
|
62
|
+
await client.send(new DeleteTableCommand({ TableName: tableName }));
|
|
63
|
+
} catch {
|
|
64
|
+
// Idempotent.
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe.skipIf(!available)('DynamoServicesStore — real DynamoDB round-trip', () => {
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
fx = await makeFx();
|
|
73
|
+
});
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
if (fx) await fx.cleanup();
|
|
76
|
+
fx = null;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('persists a NickServ registration and verifies after reload (register→identify→SASL)', async () => {
|
|
80
|
+
const store = await loadDynamoServicesStore(
|
|
81
|
+
requireFx().client,
|
|
82
|
+
requireFx().tableName,
|
|
83
|
+
new FakeClock(NOW),
|
|
84
|
+
);
|
|
85
|
+
expect(store).toBeDefined();
|
|
86
|
+
store?.registerNick('alice', 'hunter2', 'a@example.com');
|
|
87
|
+
await store?.flush();
|
|
88
|
+
|
|
89
|
+
const reloaded = await loadDynamoServicesStore(
|
|
90
|
+
requireFx().client,
|
|
91
|
+
requireFx().tableName,
|
|
92
|
+
new FakeClock(NOW),
|
|
93
|
+
);
|
|
94
|
+
expect(reloaded).toBeDefined();
|
|
95
|
+
expect(reloaded?.isRegisteredNick('Alice')).toBe(true);
|
|
96
|
+
expect(reloaded?.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('channel registration survives a reload (persists across a deploy)', async () => {
|
|
100
|
+
const store = await loadDynamoServicesStore(
|
|
101
|
+
requireFx().client,
|
|
102
|
+
requireFx().tableName,
|
|
103
|
+
new FakeClock(NOW),
|
|
104
|
+
);
|
|
105
|
+
store?.registerChannel('#chan', 'alice');
|
|
106
|
+
store?.setChannelAccess('#chan', 'bob', 5);
|
|
107
|
+
await store?.flush();
|
|
108
|
+
|
|
109
|
+
const reloaded = await loadDynamoServicesStore(
|
|
110
|
+
requireFx().client,
|
|
111
|
+
requireFx().tableName,
|
|
112
|
+
new FakeClock(NOW),
|
|
113
|
+
);
|
|
114
|
+
expect(reloaded?.getChannelFounder('#chan')).toBe('alice');
|
|
115
|
+
expect(reloaded?.getChannelAccess('#chan', 'bob')).toBe(5);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('delivers a queued memo on reconnect after identify', async () => {
|
|
119
|
+
const store = await loadDynamoServicesStore(
|
|
120
|
+
requireFx().client,
|
|
121
|
+
requireFx().tableName,
|
|
122
|
+
new FakeClock(NOW),
|
|
123
|
+
);
|
|
124
|
+
const id = store?.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
|
|
125
|
+
await store?.flush();
|
|
126
|
+
|
|
127
|
+
const reloaded = await loadDynamoServicesStore(
|
|
128
|
+
requireFx().client,
|
|
129
|
+
requireFx().tableName,
|
|
130
|
+
new FakeClock(NOW),
|
|
131
|
+
);
|
|
132
|
+
expect(reloaded).toBeDefined();
|
|
133
|
+
if (!reloaded) throw new Error('reloaded services store failed to load');
|
|
134
|
+
const memos = reloaded.listMemos('alice');
|
|
135
|
+
expect(memos).toHaveLength(1);
|
|
136
|
+
expect(memos[0]?.id).toBe(id);
|
|
137
|
+
expect(memos[0]?.body).toBe('hi');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('drops a channel subtree (channel + access + levels) on dropChannel', async () => {
|
|
141
|
+
const store = await loadDynamoServicesStore(
|
|
142
|
+
requireFx().client,
|
|
143
|
+
requireFx().tableName,
|
|
144
|
+
new FakeClock(NOW),
|
|
145
|
+
);
|
|
146
|
+
store?.registerChannel('#chan', 'alice');
|
|
147
|
+
store?.setChannelAccess('#chan', 'bob', 5);
|
|
148
|
+
store?.setChannelLevel('#chan', 'AUTOOP', 7);
|
|
149
|
+
await store?.flush();
|
|
150
|
+
const mid = await loadDynamoServicesStore(
|
|
151
|
+
requireFx().client,
|
|
152
|
+
requireFx().tableName,
|
|
153
|
+
new FakeClock(NOW),
|
|
154
|
+
);
|
|
155
|
+
mid?.dropChannel('#chan');
|
|
156
|
+
await mid?.flush();
|
|
157
|
+
|
|
158
|
+
const reloaded = await loadDynamoServicesStore(
|
|
159
|
+
requireFx().client,
|
|
160
|
+
requireFx().tableName,
|
|
161
|
+
new FakeClock(NOW),
|
|
162
|
+
);
|
|
163
|
+
expect(reloaded?.getChannelFounder('#chan')).toBeUndefined();
|
|
164
|
+
expect(reloaded?.getChannelAccess('#chan', 'bob')).toBe(0);
|
|
165
|
+
expect(reloaded?.getChannelLevel('#chan', 'AUTOOP')).toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns undefined when the table name is absent (services unbound)', async () => {
|
|
169
|
+
const store = await loadDynamoServicesStore(requireFx().client, undefined, new FakeClock(NOW));
|
|
170
|
+
expect(store).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns an empty store on a fresh table', async () => {
|
|
174
|
+
const store = await loadDynamoServicesStore(
|
|
175
|
+
requireFx().client,
|
|
176
|
+
requireFx().tableName,
|
|
177
|
+
new FakeClock(NOW),
|
|
178
|
+
);
|
|
179
|
+
expect(store).toBeDefined();
|
|
180
|
+
expect(store?.listAkills()).toEqual([]);
|
|
181
|
+
expect(store?.listMemos('alice')).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
});
|