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,394 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect } from '../src/effects';
|
|
3
|
+
import type { Effect as EffectType, RawLine } from '../src/effects';
|
|
4
|
+
import {
|
|
5
|
+
EXCESS_FLOOD_REASON,
|
|
6
|
+
type FloodControlConfig,
|
|
7
|
+
defaultCommandCost,
|
|
8
|
+
wrapWithFloodControl,
|
|
9
|
+
} from '../src/flood-control';
|
|
10
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../src/ports';
|
|
11
|
+
import { type ConnectionState, createConnection } from '../src/state/connection';
|
|
12
|
+
import {
|
|
13
|
+
type Ctx,
|
|
14
|
+
type Reducer,
|
|
15
|
+
type ReducerResult,
|
|
16
|
+
type ServerConfig,
|
|
17
|
+
buildCtx,
|
|
18
|
+
} from '../src/types';
|
|
19
|
+
|
|
20
|
+
const serverConfig: ServerConfig = {
|
|
21
|
+
serverName: 'irc.example.com',
|
|
22
|
+
networkName: 'ExampleNet',
|
|
23
|
+
maxChannelsPerUser: 30,
|
|
24
|
+
maxTargetsPerCommand: 10,
|
|
25
|
+
maxListEntries: 50,
|
|
26
|
+
nickLen: 30,
|
|
27
|
+
channelLen: 50,
|
|
28
|
+
topicLen: 390,
|
|
29
|
+
quitMessage: 'Client Quit',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function makeCtx(state: ConnectionState, clock: FakeClock): Ctx {
|
|
33
|
+
return buildCtx({
|
|
34
|
+
serverConfig,
|
|
35
|
+
clock,
|
|
36
|
+
ids: new SequentialIdFactory(),
|
|
37
|
+
motd: EmptyMotdProvider,
|
|
38
|
+
connection: state,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeState(id = 'c1'): ConnectionState {
|
|
43
|
+
return createConnection({ id, connectedSince: 0 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const L = (text: string): RawLine => ({ text });
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Inner reducer for tests: counts invocations and emits a single Send so the
|
|
50
|
+
* wrapper's pass-through behaviour is observable.
|
|
51
|
+
*/
|
|
52
|
+
function countingReducer(): { reducer: Reducer<ConnectionState>; getCalls: () => number } {
|
|
53
|
+
let calls = 0;
|
|
54
|
+
const reducer: Reducer<ConnectionState> = (state, _msg, ctx): ReducerResult<ConnectionState> => {
|
|
55
|
+
calls += 1;
|
|
56
|
+
return {
|
|
57
|
+
state,
|
|
58
|
+
effects: [Effect.send(ctx.connId, [L('inner-ok')])],
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
return { reducer, getCalls: () => calls };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function privmsg(text: string): {
|
|
65
|
+
command: string;
|
|
66
|
+
params: string[];
|
|
67
|
+
tags: Record<string, string>;
|
|
68
|
+
} {
|
|
69
|
+
return { command: 'PRIVMSG', params: ['#foo', text], tags: {} };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ircMessage(
|
|
73
|
+
command: string,
|
|
74
|
+
params: string[] = [],
|
|
75
|
+
): {
|
|
76
|
+
command: string;
|
|
77
|
+
params: string[];
|
|
78
|
+
tags: Record<string, string>;
|
|
79
|
+
} {
|
|
80
|
+
return { command, params, tags: {} };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const CAP_10: FloodControlConfig = {
|
|
84
|
+
capacity: 10,
|
|
85
|
+
refillRatePerSecond: 1,
|
|
86
|
+
disconnectThreshold: 0,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const CAP_5: FloodControlConfig = {
|
|
90
|
+
capacity: 5,
|
|
91
|
+
refillRatePerSecond: 1,
|
|
92
|
+
disconnectThreshold: 0,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
describe('wrapWithFloodControl — under-limit messages pass through', () => {
|
|
96
|
+
it('invokes the inner reducer for every message when the burst fits the bucket', () => {
|
|
97
|
+
const state = makeState();
|
|
98
|
+
const clock = new FakeClock(1_000);
|
|
99
|
+
const ctx = makeCtx(state, clock);
|
|
100
|
+
const inner = countingReducer();
|
|
101
|
+
const wrapped = wrapWithFloodControl(inner.reducer, CAP_10);
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < 10; i++) {
|
|
104
|
+
const out = wrapped(state, privmsg(`m${i}`), ctx);
|
|
105
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [L('inner-ok')])]);
|
|
106
|
+
}
|
|
107
|
+
expect(inner.getCalls()).toBe(10);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns the inner reducer result untouched on the happy path', () => {
|
|
111
|
+
const state = makeState();
|
|
112
|
+
const clock = new FakeClock(1_000);
|
|
113
|
+
const ctx = makeCtx(state, clock);
|
|
114
|
+
const inner = countingReducer();
|
|
115
|
+
const wrapped = wrapWithFloodControl(inner.reducer, CAP_10);
|
|
116
|
+
|
|
117
|
+
const expectedEffects = [Effect.send('c1', [L('inner-ok')])];
|
|
118
|
+
const out = wrapped(state, privmsg('hello'), ctx);
|
|
119
|
+
expect(out.effects).toEqual(expectedEffects);
|
|
120
|
+
expect(out.state).toBe(state);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('initializes the bucket lazily on the first message', () => {
|
|
124
|
+
const state = makeState();
|
|
125
|
+
const clock = new FakeClock(42_000);
|
|
126
|
+
const ctx = makeCtx(state, clock);
|
|
127
|
+
const inner = countingReducer();
|
|
128
|
+
const wrapped = wrapWithFloodControl(inner.reducer, CAP_10);
|
|
129
|
+
|
|
130
|
+
expect(state.floodBucket).toBeUndefined();
|
|
131
|
+
wrapped(state, privmsg('first'), ctx);
|
|
132
|
+
expect(state.floodBucket).toBeDefined();
|
|
133
|
+
expect(state.floodBucket?.lastRefill).toBe(42_000);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('wrapWithFloodControl — over-limit burst disconnects with "Excess Flood"', () => {
|
|
138
|
+
it('emits Disconnect("Excess Flood") once capacity is exhausted (10 PRIVMSG in 1ms with capacity 5)', () => {
|
|
139
|
+
const state = makeState();
|
|
140
|
+
const clock = new FakeClock(1_000);
|
|
141
|
+
const ctx = makeCtx(state, clock);
|
|
142
|
+
const inner = countingReducer();
|
|
143
|
+
const wrapped = wrapWithFloodControl(inner.reducer, CAP_5);
|
|
144
|
+
|
|
145
|
+
const results: ReducerResult<ConnectionState>[] = [];
|
|
146
|
+
for (let i = 0; i < 10; i++) {
|
|
147
|
+
results.push(wrapped(state, privmsg(`m${i}`), ctx));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// First 5 messages pass through to the inner reducer.
|
|
151
|
+
expect(inner.getCalls()).toBe(5);
|
|
152
|
+
for (let i = 0; i < 5; i++) {
|
|
153
|
+
expect(results[i]?.effects).toEqual<EffectType[]>([Effect.send('c1', [L('inner-ok')])]);
|
|
154
|
+
}
|
|
155
|
+
// 6th message onward: Disconnect with the canonical reason, inner NOT called.
|
|
156
|
+
for (let i = 5; i < 10; i++) {
|
|
157
|
+
expect(results[i]?.effects).toEqual<EffectType[]>([
|
|
158
|
+
Effect.disconnect('c1', EXCESS_FLOOD_REASON),
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('uses the literal "Excess Flood" string per RFC 2812 §6', () => {
|
|
164
|
+
expect(EXCESS_FLOOD_REASON).toBe('Excess Flood');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('does NOT call the inner reducer for the message that triggers the disconnect', () => {
|
|
168
|
+
const state = makeState();
|
|
169
|
+
const clock = new FakeClock(1_000);
|
|
170
|
+
const ctx = makeCtx(state, clock);
|
|
171
|
+
const inner = countingReducer();
|
|
172
|
+
const wrapped = wrapWithFloodControl(inner.reducer, CAP_5);
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < 5; i++) wrapped(state, privmsg(`m${i}`), ctx);
|
|
175
|
+
const callsBefore = inner.getCalls();
|
|
176
|
+
wrapped(state, privmsg('over'), ctx);
|
|
177
|
+
expect(inner.getCalls()).toBe(callsBefore);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('threshold allows bursts that exceed capacity by up to the threshold', () => {
|
|
181
|
+
const state = makeState();
|
|
182
|
+
const clock = new FakeClock(1_000);
|
|
183
|
+
const ctx = makeCtx(state, clock);
|
|
184
|
+
const inner = countingReducer();
|
|
185
|
+
const config: FloodControlConfig = {
|
|
186
|
+
capacity: 10,
|
|
187
|
+
refillRatePerSecond: 1,
|
|
188
|
+
disconnectThreshold: 2,
|
|
189
|
+
};
|
|
190
|
+
const wrapped = wrapWithFloodControl(inner.reducer, config);
|
|
191
|
+
|
|
192
|
+
const results: ReducerResult<ConnectionState>[] = [];
|
|
193
|
+
for (let i = 0; i < 13; i++) {
|
|
194
|
+
results.push(wrapped(state, privmsg(`m${i}`), ctx));
|
|
195
|
+
}
|
|
196
|
+
// Threshold = 2 means 12 messages pass (capacity 10 + 2 slack), 13th kills.
|
|
197
|
+
expect(inner.getCalls()).toBe(12);
|
|
198
|
+
for (let i = 0; i < 12; i++) {
|
|
199
|
+
expect(results[i]?.effects).toEqual<EffectType[]>([Effect.send('c1', [L('inner-ok')])]);
|
|
200
|
+
}
|
|
201
|
+
expect(results[12]?.effects).toEqual<EffectType[]>([
|
|
202
|
+
Effect.disconnect('c1', EXCESS_FLOOD_REASON),
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('wrapWithFloodControl — refill', () => {
|
|
208
|
+
it('refills the bucket as wall-clock time advances, allowing further messages', () => {
|
|
209
|
+
const state = makeState();
|
|
210
|
+
const clock = new FakeClock(1_000);
|
|
211
|
+
const ctx = makeCtx(state, clock);
|
|
212
|
+
const inner = countingReducer();
|
|
213
|
+
const config: FloodControlConfig = {
|
|
214
|
+
capacity: 5,
|
|
215
|
+
refillRatePerSecond: 5,
|
|
216
|
+
disconnectThreshold: 0,
|
|
217
|
+
};
|
|
218
|
+
const wrapped = wrapWithFloodControl(inner.reducer, config);
|
|
219
|
+
|
|
220
|
+
for (let i = 0; i < 5; i++) wrapped(state, privmsg(`m${i}`), ctx);
|
|
221
|
+
expect(inner.getCalls()).toBe(5);
|
|
222
|
+
|
|
223
|
+
clock.advance(1_000);
|
|
224
|
+
const out = wrapped(state, privmsg('after-refill'), ctx);
|
|
225
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [L('inner-ok')])]);
|
|
226
|
+
expect(inner.getCalls()).toBe(6);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('caps refill at the bucket capacity (no overflow)', () => {
|
|
230
|
+
const state = makeState();
|
|
231
|
+
const clock = new FakeClock(1_000);
|
|
232
|
+
const ctx = makeCtx(state, clock);
|
|
233
|
+
const inner = countingReducer();
|
|
234
|
+
const config: FloodControlConfig = {
|
|
235
|
+
capacity: 5,
|
|
236
|
+
refillRatePerSecond: 1_000,
|
|
237
|
+
disconnectThreshold: 0,
|
|
238
|
+
};
|
|
239
|
+
const wrapped = wrapWithFloodControl(inner.reducer, config);
|
|
240
|
+
|
|
241
|
+
wrapped(state, privmsg('first'), ctx);
|
|
242
|
+
expect(state.floodBucket?.tokens).toBe(4);
|
|
243
|
+
|
|
244
|
+
clock.advance(10_000);
|
|
245
|
+
wrapped(state, privmsg('second'), ctx);
|
|
246
|
+
expect(state.floodBucket?.tokens).toBe(4);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('wrapWithFloodControl — determinism', () => {
|
|
251
|
+
it('returns the same decision for the same Clock readings + message stream', () => {
|
|
252
|
+
const run = (): { innerCalls: number; disconnects: number } => {
|
|
253
|
+
const state = makeState();
|
|
254
|
+
const clock = new FakeClock(1_000);
|
|
255
|
+
const ctx = makeCtx(state, clock);
|
|
256
|
+
const inner = countingReducer();
|
|
257
|
+
const wrapped = wrapWithFloodControl(inner.reducer, CAP_5);
|
|
258
|
+
let disconnects = 0;
|
|
259
|
+
for (let i = 0; i < 7; i++) {
|
|
260
|
+
const out = wrapped(state, privmsg(`m${i}`), ctx);
|
|
261
|
+
if (out.effects.some((e) => e.tag === 'Disconnect')) disconnects += 1;
|
|
262
|
+
}
|
|
263
|
+
return { innerCalls: inner.getCalls(), disconnects };
|
|
264
|
+
};
|
|
265
|
+
expect(run()).toEqual(run());
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('wrapWithFloodControl — generic over inner state', () => {
|
|
270
|
+
it('works with a reducer whose state is not ConnectionState (e.g. a counter)', () => {
|
|
271
|
+
interface Counter {
|
|
272
|
+
n: number;
|
|
273
|
+
}
|
|
274
|
+
const inc: Reducer<Counter> = (s) => ({ state: { n: s.n + 1 }, effects: [] });
|
|
275
|
+
|
|
276
|
+
const state: Counter = { n: 0 };
|
|
277
|
+
const conn = makeState();
|
|
278
|
+
const clock = new FakeClock(1_000);
|
|
279
|
+
const ctx = makeCtx(conn, clock);
|
|
280
|
+
const wrapped = wrapWithFloodControl(inc, CAP_10);
|
|
281
|
+
const out = wrapped(state, privmsg('x'), ctx);
|
|
282
|
+
expect(out.state.n).toBe(1);
|
|
283
|
+
expect(out.effects).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('wrapWithFloodControl — per-command cost (commandCost)', () => {
|
|
288
|
+
it('defaultCommandCost returns 0 for the control-plane exempt set and 1 otherwise', () => {
|
|
289
|
+
for (const command of ['PING', 'PONG', 'CAP', 'AUTHENTICATE', 'QUIT']) {
|
|
290
|
+
expect(defaultCommandCost(command)).toBe(0);
|
|
291
|
+
}
|
|
292
|
+
expect(defaultCommandCost('PRIVMSG')).toBe(1);
|
|
293
|
+
expect(defaultCommandCost('JOIN')).toBe(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('delegates a cost-0 PING to the inner reducer even when the bucket is exhausted', () => {
|
|
297
|
+
const state = makeState();
|
|
298
|
+
const clock = new FakeClock(1_000);
|
|
299
|
+
const ctx = makeCtx(state, clock);
|
|
300
|
+
const inner = countingReducer();
|
|
301
|
+
const wrapped = wrapWithFloodControl(inner.reducer, {
|
|
302
|
+
...CAP_5,
|
|
303
|
+
commandCost: defaultCommandCost,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
for (let i = 0; i < 5; i++) wrapped(state, privmsg(`m${i}`), ctx);
|
|
307
|
+
expect(inner.getCalls()).toBe(5);
|
|
308
|
+
|
|
309
|
+
const out = wrapped(state, ircMessage('PING', ['12345']), ctx);
|
|
310
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [L('inner-ok')])]);
|
|
311
|
+
expect(inner.getCalls()).toBe(6);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('a cost-0 command does NOT decrement the bucket', () => {
|
|
315
|
+
const state = makeState();
|
|
316
|
+
const clock = new FakeClock(1_000);
|
|
317
|
+
const ctx = makeCtx(state, clock);
|
|
318
|
+
const inner = countingReducer();
|
|
319
|
+
const wrapped = wrapWithFloodControl(inner.reducer, {
|
|
320
|
+
...CAP_5,
|
|
321
|
+
commandCost: defaultCommandCost,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
for (let i = 0; i < 5; i++) wrapped(state, privmsg(`m${i}`), ctx);
|
|
325
|
+
expect(state.floodBucket?.tokens).toBe(0);
|
|
326
|
+
|
|
327
|
+
wrapped(state, ircMessage('PING', ['12345']), ctx);
|
|
328
|
+
expect(state.floodBucket?.tokens).toBe(0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('a CAP LS burst (10 frames in 1ms with capacity 5) does NOT disconnect when CAP costs 0', () => {
|
|
332
|
+
const state = makeState();
|
|
333
|
+
const clock = new FakeClock(1_000);
|
|
334
|
+
const ctx = makeCtx(state, clock);
|
|
335
|
+
const inner = countingReducer();
|
|
336
|
+
const wrapped = wrapWithFloodControl(inner.reducer, {
|
|
337
|
+
capacity: 5,
|
|
338
|
+
refillRatePerSecond: 1,
|
|
339
|
+
disconnectThreshold: 0,
|
|
340
|
+
commandCost: (c) => (c === 'CAP' ? 0 : 1),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
let disconnects = 0;
|
|
344
|
+
for (let i = 0; i < 10; i++) {
|
|
345
|
+
const out = wrapped(state, ircMessage('CAP', ['LS']), ctx);
|
|
346
|
+
if (out.effects.some((e) => e.tag === 'Disconnect')) disconnects += 1;
|
|
347
|
+
}
|
|
348
|
+
expect(disconnects).toBe(0);
|
|
349
|
+
expect(inner.getCalls()).toBe(10);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('throws at construction when commandCost returns a negative cost', () => {
|
|
353
|
+
expect(() =>
|
|
354
|
+
wrapWithFloodControl(countingReducer().reducer, {
|
|
355
|
+
...CAP_5,
|
|
356
|
+
commandCost: () => -1,
|
|
357
|
+
}),
|
|
358
|
+
).toThrow();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('omitting commandCost keeps the flat-cost behaviour so a PING can still disconnect (regression)', () => {
|
|
362
|
+
const state = makeState();
|
|
363
|
+
const clock = new FakeClock(1_000);
|
|
364
|
+
const ctx = makeCtx(state, clock);
|
|
365
|
+
const inner = countingReducer();
|
|
366
|
+
const wrapped = wrapWithFloodControl(inner.reducer, CAP_5);
|
|
367
|
+
|
|
368
|
+
for (let i = 0; i < 5; i++) wrapped(state, privmsg(`m${i}`), ctx);
|
|
369
|
+
expect(inner.getCalls()).toBe(5);
|
|
370
|
+
|
|
371
|
+
const out = wrapped(state, ircMessage('PING', ['12345']), ctx);
|
|
372
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c1', EXCESS_FLOOD_REASON)]);
|
|
373
|
+
expect(inner.getCalls()).toBe(5);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('a custom cost greater than 1 deducts multiple tokens per message', () => {
|
|
377
|
+
const state = makeState();
|
|
378
|
+
const clock = new FakeClock(1_000);
|
|
379
|
+
const ctx = makeCtx(state, clock);
|
|
380
|
+
const inner = countingReducer();
|
|
381
|
+
const wrapped = wrapWithFloodControl(inner.reducer, {
|
|
382
|
+
capacity: 5,
|
|
383
|
+
refillRatePerSecond: 1,
|
|
384
|
+
disconnectThreshold: 0,
|
|
385
|
+
commandCost: () => 2,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
wrapped(state, privmsg('first'), ctx);
|
|
389
|
+
expect(state.floodBucket?.tokens).toBe(3);
|
|
390
|
+
|
|
391
|
+
wrapped(state, privmsg('second'), ctx);
|
|
392
|
+
expect(state.floodBucket?.tokens).toBe(1);
|
|
393
|
+
});
|
|
394
|
+
});
|