serverless-ircd 0.4.0 → 0.5.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +88 -19
  3. package/apps/aws-stack/README.md +4 -3
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/aws-stack/src/aws-stack.ts +32 -4
  6. package/apps/aws-stack/tests/stack.test.ts +47 -1
  7. package/apps/cf-tcp-container/package.json +1 -1
  8. package/apps/cf-worker/package.json +1 -1
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/server.ts +129 -21
  11. package/apps/local-cli/tests/e2e.test.ts +1 -1
  12. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  13. package/package.json +2 -2
  14. package/packages/aws-adapter/package.json +1 -1
  15. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  16. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  17. package/packages/aws-adapter/src/handlers/default.ts +63 -5
  18. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  19. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  20. package/packages/aws-adapter/src/index.ts +2 -0
  21. package/packages/aws-adapter/src/serialize.ts +11 -1
  22. package/packages/aws-adapter/src/stats.ts +80 -0
  23. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  24. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  25. package/packages/aws-adapter/tests/connect.test.ts +97 -1
  26. package/packages/aws-adapter/tests/handlers.test.ts +148 -0
  27. package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
  28. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  29. package/packages/cf-adapter/package.json +5 -1
  30. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  31. package/packages/cf-adapter/src/channel-do.ts +2 -2
  32. package/packages/cf-adapter/src/connection-do.ts +182 -54
  33. package/packages/cf-adapter/src/env.ts +25 -6
  34. package/packages/cf-adapter/src/index.ts +2 -0
  35. package/packages/cf-adapter/src/registry-do.ts +22 -3
  36. package/packages/cf-adapter/src/sharding.ts +1 -2
  37. package/packages/cf-adapter/src/stats.ts +65 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  41. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  42. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  43. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  44. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  45. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  46. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  47. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  48. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  49. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  50. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  51. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  52. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  53. package/packages/cf-adapter/wrangler.test.toml +7 -0
  54. package/packages/in-memory-runtime/package.json +1 -1
  55. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  56. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  57. package/packages/irc-core/package.json +1 -1
  58. package/packages/irc-core/src/admission.ts +16 -15
  59. package/packages/irc-core/src/caps/capabilities.ts +1 -1
  60. package/packages/irc-core/src/commands/index.ts +8 -0
  61. package/packages/irc-core/src/commands/invite.ts +2 -4
  62. package/packages/irc-core/src/commands/isupport.ts +6 -2
  63. package/packages/irc-core/src/commands/kick.ts +2 -4
  64. package/packages/irc-core/src/commands/kill.ts +127 -0
  65. package/packages/irc-core/src/commands/list.ts +1 -1
  66. package/packages/irc-core/src/commands/lusers.ts +204 -0
  67. package/packages/irc-core/src/commands/mode.ts +4 -8
  68. package/packages/irc-core/src/commands/names.ts +3 -5
  69. package/packages/irc-core/src/commands/part.ts +2 -4
  70. package/packages/irc-core/src/commands/rehash.ts +119 -0
  71. package/packages/irc-core/src/commands/setname.ts +109 -0
  72. package/packages/irc-core/src/commands/stats.ts +152 -0
  73. package/packages/irc-core/src/commands/topic.ts +2 -4
  74. package/packages/irc-core/src/commands/trace.ts +137 -0
  75. package/packages/irc-core/src/commands/wallops.ts +118 -0
  76. package/packages/irc-core/src/config.ts +7 -0
  77. package/packages/irc-core/src/effects.ts +27 -1
  78. package/packages/irc-core/src/index.ts +2 -0
  79. package/packages/irc-core/src/ports.ts +179 -0
  80. package/packages/irc-core/src/protocol/numerics.ts +42 -11
  81. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  82. package/packages/irc-core/src/types.ts +8 -1
  83. package/packages/irc-core/src/ws-framing.ts +132 -0
  84. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  85. package/packages/irc-core/tests/admission.test.ts +18 -0
  86. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  87. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  88. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  89. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  90. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  91. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  92. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  93. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  94. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  95. package/packages/irc-core/tests/effects.test.ts +14 -0
  96. package/packages/irc-core/tests/numerics.test.ts +90 -0
  97. package/packages/irc-core/tests/outbound.test.ts +51 -0
  98. package/packages/irc-core/tests/ports.test.ts +22 -0
  99. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  100. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  101. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  102. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  103. package/packages/irc-server/package.json +1 -1
  104. package/packages/irc-server/src/actor.ts +249 -16
  105. package/packages/irc-server/src/dispatch.ts +1 -0
  106. package/packages/irc-server/src/routing.ts +3 -0
  107. package/packages/irc-server/src/runtime.ts +31 -0
  108. package/packages/irc-server/src/transport.ts +10 -7
  109. package/packages/irc-server/tests/actor.test.ts +1089 -1
  110. package/packages/irc-server/tests/dispatch.test.ts +37 -0
  111. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  112. package/packages/irc-server/tests/routing.test.ts +1 -0
  113. package/packages/irc-server/tests/runtime.test.ts +7 -0
  114. package/packages/irc-test-support/package.json +1 -1
  115. package/packages/irc-test-support/src/scenarios.ts +9 -1
  116. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  117. package/pnpm-workspace.yaml +1 -0
  118. package/tools/ci-hardening/package.json +1 -1
  119. package/tools/package.json +6 -1
  120. package/tools/seed-cf-accounts.ts +4 -1
  121. package/tools/tcp-ws-forwarder/package.json +1 -1
  122. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  123. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  124. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -0,0 +1,171 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildRehashTrailing, rehashLine, rehashReducer } from '../../src/commands/rehash';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import type { IrcMessage } from '../../src/protocol/messages';
7
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
8
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
9
+
10
+ const baseServerConfig: ServerConfig = {
11
+ serverName: 'irc.example.com',
12
+ networkName: 'ExampleNet',
13
+ maxChannelsPerUser: 30,
14
+ maxTargetsPerCommand: 10,
15
+ maxListEntries: 50,
16
+ nickLen: 30,
17
+ channelLen: 50,
18
+ topicLen: 390,
19
+ quitMessage: 'Client Quit',
20
+ };
21
+
22
+ function makeCtx(state: ConnectionState, config: ServerConfig = baseServerConfig): Ctx {
23
+ return buildCtx({
24
+ serverConfig: config,
25
+ clock: new FakeClock(5_000),
26
+ ids: new SequentialIdFactory(),
27
+ motd: EmptyMotdProvider,
28
+ connection: state,
29
+ });
30
+ }
31
+
32
+ function makeState(oper = false): ConnectionState {
33
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
34
+ s.nick = 'alice';
35
+ s.user = 'alice';
36
+ s.host = 'example.com';
37
+ s.realname = 'Alice';
38
+ s.registration = 'registered';
39
+ s.userModes.oper = oper;
40
+ return s;
41
+ }
42
+
43
+ const L = (text: string): RawLine => ({ text });
44
+
45
+ const rehash = (): IrcMessage => ({ command: 'REHASH', params: [], tags: {} });
46
+
47
+ describe('rehashReducer — oper gating', () => {
48
+ it('emits 382 RPL_REHASHING and signals reload when the caller is oper', () => {
49
+ const state = makeState(true);
50
+ const ctx = makeCtx(state);
51
+
52
+ const out = rehashReducer(state, rehash(), ctx, 'KV');
53
+
54
+ expect(out.shouldReload).toBe(true);
55
+ expect(out.effects).toEqual<EffectType[]>([
56
+ Effect.send('c1', [L(':irc.example.com 382 alice :Rehashing from KV')]),
57
+ ]);
58
+ });
59
+
60
+ it('emits 481 ERR_NOPRIVILEGES and does not signal reload for a non-oper', () => {
61
+ const state = makeState(false);
62
+ const ctx = makeCtx(state);
63
+
64
+ const out = rehashReducer(state, rehash(), ctx, 'KV');
65
+
66
+ expect(out.shouldReload).toBe(false);
67
+ expect(out.effects).toEqual<EffectType[]>([
68
+ Effect.send('c1', [
69
+ L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
70
+ ]),
71
+ ]);
72
+ });
73
+
74
+ it('accepts a lower-case command token', () => {
75
+ const state = makeState(true);
76
+ const ctx = makeCtx(state);
77
+
78
+ const out = rehashReducer(state, { command: 'rehash', params: [], tags: {} }, ctx, 'KV');
79
+ expect(out.shouldReload).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe('rehashReducer — state handling', () => {
84
+ it('returns the same state reference (mutation permitted, no copy)', () => {
85
+ const state = makeState(true);
86
+ const ctx = makeCtx(state);
87
+
88
+ const out = rehashReducer(state, rehash(), ctx, 'KV');
89
+
90
+ expect(out.state).toBe(state);
91
+ });
92
+
93
+ it('updates connection lastSeen to ctx.clock.now()', () => {
94
+ const state = makeState(true);
95
+ expect(state.lastSeen).toBe(0);
96
+ const ctx = makeCtx(state);
97
+
98
+ rehashReducer(state, rehash(), ctx, 'KV');
99
+
100
+ expect(state.lastSeen).toBe(5_000);
101
+ });
102
+
103
+ it('also updates lastSeen for the non-oper rejection path', () => {
104
+ const state = makeState(false);
105
+ const ctx = makeCtx(state);
106
+
107
+ rehashReducer(state, rehash(), ctx, 'KV');
108
+
109
+ expect(state.lastSeen).toBe(5_000);
110
+ });
111
+
112
+ it('uses "*" as the nick in 382 when the connection has no nick', () => {
113
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
114
+ state.userModes.oper = true;
115
+ const ctx = makeCtx(state);
116
+
117
+ const out = rehashReducer(state, rehash(), ctx, 'KV');
118
+
119
+ expect(out.effects).toEqual<EffectType[]>([
120
+ Effect.send('c1', [L(':irc.example.com 382 * :Rehashing from KV')]),
121
+ ]);
122
+ });
123
+
124
+ it('uses "*" as the nick in 481 when an unregistered non-oper has no nick', () => {
125
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
126
+ const ctx = makeCtx(state);
127
+
128
+ const out = rehashReducer(state, rehash(), ctx, 'KV');
129
+
130
+ expect(out.effects).toEqual<EffectType[]>([
131
+ Effect.send('c1', [
132
+ L(":irc.example.com 481 * :Permission Denied - You're not an IRC operator"),
133
+ ]),
134
+ ]);
135
+ });
136
+
137
+ it('reflects the configured source label in the 382 trailing text', () => {
138
+ const state = makeState(true);
139
+ const ctx = makeCtx(state);
140
+
141
+ const out = rehashReducer(state, rehash(), ctx, 'Secrets Manager');
142
+
143
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain(
144
+ ':Rehashing from Secrets Manager',
145
+ );
146
+ });
147
+ });
148
+
149
+ describe('rehash formatting helpers', () => {
150
+ it('buildRehashTrailing composes the success phrase from the source label', () => {
151
+ expect(buildRehashTrailing('config file')).toBe('Rehashing from config file');
152
+ });
153
+
154
+ it('buildRehashTrailing appends the error suffix when supplied', () => {
155
+ expect(buildRehashTrailing('KV', 'store unreachable')).toBe(
156
+ 'Rehashing from KV failed: store unreachable',
157
+ );
158
+ });
159
+
160
+ it('rehashLine renders the full :server 382 nick :trailing wire line', () => {
161
+ expect(rehashLine('irc.example.com', 'alice', 'KV')).toEqual(
162
+ L(':irc.example.com 382 alice :Rehashing from KV'),
163
+ );
164
+ });
165
+
166
+ it('rehashLine renders the error-suffixed line for the failure path', () => {
167
+ expect(rehashLine('irc.example.com', 'alice', 'KV', 'boom')).toEqual(
168
+ L(':irc.example.com 382 alice :Rehashing from KV failed: boom'),
169
+ );
170
+ });
171
+ });
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { setnameReducer } from '../../src/commands/setname';
3
+ import { whoisReducer } from '../../src/commands/whois';
4
+ import { Effect } from '../../src/effects';
5
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
6
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
7
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
8
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
9
+
10
+ const serverConfig: ServerConfig = {
11
+ serverName: 'irc.example.com',
12
+ networkName: 'ExampleNet',
13
+ maxChannelsPerUser: 30,
14
+ maxTargetsPerCommand: 10,
15
+ maxListEntries: 50,
16
+ nickLen: 30,
17
+ channelLen: 50,
18
+ topicLen: 390,
19
+ quitMessage: 'Client Quit',
20
+ };
21
+
22
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
23
+ return buildCtx({
24
+ serverConfig,
25
+ clock,
26
+ ids: new SequentialIdFactory(),
27
+ motd: EmptyMotdProvider,
28
+ connection: conn,
29
+ });
30
+ }
31
+
32
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
33
+ const s = createConnection({ id, connectedSince: 0 });
34
+ s.nick = nick;
35
+ s.user = nick;
36
+ s.host = 'example.com';
37
+ s.realname = 'Alice';
38
+ s.registration = 'registered';
39
+ return s;
40
+ }
41
+
42
+ const L = (text: string): RawLine => ({ text });
43
+
44
+ // ============================================================================
45
+ // setnameReducer — happy path
46
+ // ============================================================================
47
+
48
+ describe('setnameReducer — happy path', () => {
49
+ it('updates state.realname from SETNAME :<realname>', () => {
50
+ const conn = makeConn();
51
+ const ctx = makeCtx(conn);
52
+
53
+ const out = setnameReducer(conn, { command: 'SETNAME', params: ['New Name'], tags: {} }, ctx);
54
+
55
+ expect(conn.realname).toBe('New Name');
56
+ expect(out.effects).toEqual<EffectType[]>([]);
57
+ });
58
+
59
+ it('emits no broadcast (silent update)', () => {
60
+ const conn = makeConn();
61
+ conn.joinedChannels.add('#foo');
62
+ const ctx = makeCtx(conn);
63
+
64
+ const out = setnameReducer(conn, { command: 'SETNAME', params: ['New Name'], tags: {} }, ctx);
65
+
66
+ const broadcasts = out.effects.filter((e) => e.tag === 'Broadcast');
67
+ expect(broadcasts).toHaveLength(0);
68
+ });
69
+
70
+ it('overwrites a previously stored realname', () => {
71
+ const conn = makeConn();
72
+ const ctx = makeCtx(conn);
73
+
74
+ setnameReducer(conn, { command: 'SETNAME', params: ['Second'], tags: {} }, ctx);
75
+ setnameReducer(conn, { command: 'SETNAME', params: ['Third'], tags: {} }, ctx);
76
+
77
+ expect(conn.realname).toBe('Third');
78
+ });
79
+
80
+ it('stamps lastSeen with ctx.clock.now()', () => {
81
+ const conn = makeConn();
82
+ const clock = new FakeClock(9_900);
83
+ const ctx = makeCtx(conn, clock);
84
+
85
+ setnameReducer(conn, { command: 'SETNAME', params: ['X'], tags: {} }, ctx);
86
+
87
+ expect(conn.lastSeen).toBe(9_900);
88
+ });
89
+ });
90
+
91
+ // ============================================================================
92
+ // setnameReducer — WHOIS reflects the new realname
93
+ // ============================================================================
94
+
95
+ describe('setnameReducer — WHOIS reflects the new realname', () => {
96
+ it('a subsequent WHOIS returns the updated realname in 311 RPL_WHOISUSER', () => {
97
+ const conn = makeConn();
98
+ const ctx = makeCtx(conn);
99
+
100
+ setnameReducer(conn, { command: 'SETNAME', params: ['Alice Wonderland'], tags: {} }, ctx);
101
+
102
+ const out = whoisReducer(conn, [], { command: 'WHOIS', params: ['alice'] }, ctx);
103
+ const firstBatch = out.effects[0];
104
+ expect(firstBatch?.tag).toBe('Send');
105
+ if (firstBatch?.tag === 'Send') {
106
+ expect(firstBatch.lines[0]?.text).toBe(
107
+ ':irc.example.com 311 alice alice alice example.com * :Alice Wonderland',
108
+ );
109
+ }
110
+ });
111
+ });
112
+
113
+ // ============================================================================
114
+ // setnameReducer — validation
115
+ // ============================================================================
116
+
117
+ describe('setnameReducer — validation', () => {
118
+ it('emits 461 ERR_NEEDMOREPARAMS when no realname param is given', () => {
119
+ const conn = makeConn();
120
+ const ctx = makeCtx(conn);
121
+
122
+ const out = setnameReducer(conn, { command: 'SETNAME', params: [], tags: {} }, ctx);
123
+
124
+ expect(out.effects).toEqual<EffectType[]>([
125
+ Effect.send('c1', [L(':irc.example.com 461 alice SETNAME :Not enough parameters')]),
126
+ ]);
127
+ expect(conn.realname).toBe('Alice');
128
+ });
129
+
130
+ it('emits 461 ERR_NEEDMOREPARAMS when the realname param is empty', () => {
131
+ const conn = makeConn();
132
+ const ctx = makeCtx(conn);
133
+
134
+ const out = setnameReducer(conn, { command: 'SETNAME', params: [''], tags: {} }, ctx);
135
+
136
+ expect(out.effects).toEqual<EffectType[]>([
137
+ Effect.send('c1', [L(':irc.example.com 461 alice SETNAME :Not enough parameters')]),
138
+ ]);
139
+ expect(conn.realname).toBe('Alice');
140
+ });
141
+
142
+ it('emits 451 ERR_NOTREGISTERED when the connection is not registered', () => {
143
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
144
+ conn.nick = 'alice';
145
+ const ctx = makeCtx(conn);
146
+
147
+ const out = setnameReducer(conn, { command: 'SETNAME', params: ['New Name'], tags: {} }, ctx);
148
+
149
+ expect(out.effects).toEqual<EffectType[]>([
150
+ Effect.send('c1', [L(':irc.example.com 451 alice :You have not registered')]),
151
+ ]);
152
+ expect(conn.realname).toBeUndefined();
153
+ });
154
+
155
+ it('uses * in the numeric reply when the connection has no nick', () => {
156
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
157
+ const ctx = makeCtx(conn);
158
+
159
+ const out = setnameReducer(conn, { command: 'SETNAME', params: ['New Name'], tags: {} }, ctx);
160
+
161
+ expect(out.effects).toEqual<EffectType[]>([
162
+ Effect.send('c1', [L(':irc.example.com 451 * :You have not registered')]),
163
+ ]);
164
+ });
165
+ });
166
+
167
+ // ============================================================================
168
+ // setnameReducer — length cap
169
+ // ============================================================================
170
+
171
+ describe('setnameReducer — length cap', () => {
172
+ it('rejects an over-cap realname with 432 and leaves state.realname unchanged', () => {
173
+ const conn = makeConn();
174
+ const cap = 5;
175
+ const ctx: Ctx = buildCtx({
176
+ serverConfig: { ...serverConfig, realnameLen: cap },
177
+ clock: new FakeClock(1_000),
178
+ ids: new SequentialIdFactory(),
179
+ motd: EmptyMotdProvider,
180
+ connection: conn,
181
+ });
182
+
183
+ const out = setnameReducer(
184
+ conn,
185
+ { command: 'SETNAME', params: ['Way Too Long'], tags: {} },
186
+ ctx,
187
+ );
188
+
189
+ expect(out.effects).toEqual<EffectType[]>([
190
+ Effect.send('c1', [L(':irc.example.com 432 alice :Erroneous realname')]),
191
+ ]);
192
+ expect(conn.realname).toBe('Alice');
193
+ });
194
+
195
+ it('accepts a realname exactly at the cap', () => {
196
+ const conn = makeConn();
197
+ const cap = 5;
198
+ const ctx: Ctx = buildCtx({
199
+ serverConfig: { ...serverConfig, realnameLen: cap },
200
+ clock: new FakeClock(1_000),
201
+ ids: new SequentialIdFactory(),
202
+ motd: EmptyMotdProvider,
203
+ connection: conn,
204
+ });
205
+
206
+ setnameReducer(conn, { command: 'SETNAME', params: ['seven'], tags: {} }, ctx);
207
+
208
+ expect(conn.realname).toBe('seven');
209
+ });
210
+
211
+ it('applies the default cap when ServerConfig.realnameLen is unset', () => {
212
+ const conn = makeConn();
213
+ const ctx = makeCtx(conn);
214
+
215
+ const atDefault = 'x'.repeat(50);
216
+ setnameReducer(conn, { command: 'SETNAME', params: [atDefault], tags: {} }, ctx);
217
+ expect(conn.realname).toBe(atDefault);
218
+
219
+ conn.realname = 'Alice';
220
+ const overDefault = 'x'.repeat(51);
221
+ const out = setnameReducer(conn, { command: 'SETNAME', params: [overDefault], tags: {} }, ctx);
222
+ expect(out.effects[0]?.tag).toBe('Send');
223
+ expect(conn.realname).toBe('Alice');
224
+ });
225
+ });
@@ -0,0 +1,294 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { statsReducer } from '../../src/commands/stats';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ SequentialIdFactory,
9
+ type ServerStatsSnapshot,
10
+ } from '../../src/ports';
11
+ import { createConnection } from '../../src/state/connection';
12
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
13
+
14
+ const serverConfig: ServerConfig = {
15
+ serverName: 'irc.example.com',
16
+ networkName: 'ExampleNet',
17
+ serverVersion: 'serverless-ircd-0.4.0',
18
+ maxChannelsPerUser: 30,
19
+ maxTargetsPerCommand: 10,
20
+ maxListEntries: 50,
21
+ nickLen: 30,
22
+ channelLen: 50,
23
+ topicLen: 390,
24
+ quitMessage: 'Client Quit',
25
+ };
26
+
27
+ function makeCtx(connId = 'c1', nick = 'alice', clock = new FakeClock(5_000)): Ctx {
28
+ const conn = createConnection({ id: connId, connectedSince: 0 });
29
+ conn.nick = nick;
30
+ conn.user = nick;
31
+ conn.host = 'example.com';
32
+ conn.realname = 'Alice';
33
+ conn.registration = 'registered';
34
+ return buildCtx({
35
+ serverConfig,
36
+ clock,
37
+ ids: new SequentialIdFactory(),
38
+ motd: EmptyMotdProvider,
39
+ connection: conn,
40
+ });
41
+ }
42
+
43
+ const L = (text: string): RawLine => ({ text });
44
+
45
+ const stats = (...params: string[]) =>
46
+ ({
47
+ command: 'STATS',
48
+ params,
49
+ tags: {},
50
+ }) as const;
51
+
52
+ const snap = (uptimeStartedAt = 1_000): ServerStatsSnapshot => ({
53
+ users: 0,
54
+ invisible: 0,
55
+ opers: 0,
56
+ unknownConnections: 0,
57
+ channels: 0,
58
+ servers: 1,
59
+ localConns: 0,
60
+ globalConns: 0,
61
+ maxLocalConns: 0,
62
+ maxGlobalConns: 0,
63
+ uptimeStartedAt,
64
+ });
65
+
66
+ // ============================================================================
67
+ // statsReducer — STATS u (uptime)
68
+ // ============================================================================
69
+
70
+ describe('statsReducer — STATS u (uptime)', () => {
71
+ it('emits 242 RPL_STATSUPTIME with a formatted uptime line', () => {
72
+ const ctx = makeCtx();
73
+ // uptimeStartedAt=0, clock=5_000 → 5 seconds uptime.
74
+ const s = snap(0);
75
+
76
+ const out = statsReducer(s, stats('u'), ctx);
77
+
78
+ expect(out.effects).toContainEqual<EffectType>(
79
+ Effect.send('c1', [L(':irc.example.com 242 alice :Server Up 0 days, 0:00:05')]),
80
+ );
81
+ });
82
+
83
+ it('formats days/hours/minutes/seconds from the uptime delta', () => {
84
+ const ctx = makeCtx(
85
+ 'c1',
86
+ 'alice',
87
+ new FakeClock(1 * 86_400_000 + 2 * 3_600_000 + 3 * 60_000 + 4_000),
88
+ );
89
+ const s = snap(0);
90
+
91
+ const out = statsReducer(s, stats('u'), ctx);
92
+
93
+ expect(out.effects).toContainEqual<EffectType>(
94
+ Effect.send('c1', [L(':irc.example.com 242 alice :Server Up 1 days, 2:03:04')]),
95
+ );
96
+ });
97
+
98
+ it('terminates the STATS u reply with 219 RPL_ENDOFSTATS carrying the query letter', () => {
99
+ const ctx = makeCtx();
100
+
101
+ const out = statsReducer(snap(0), stats('u'), ctx);
102
+
103
+ expect(out.effects).toContainEqual<EffectType>(
104
+ Effect.send('c1', [L(':irc.example.com 219 alice u :End of /STATS report')]),
105
+ );
106
+ });
107
+
108
+ it('updates the requester lastSeen to ctx.clock.now()', () => {
109
+ const ctx = makeCtx();
110
+ expect(ctx.connection.lastSeen).toBe(0);
111
+
112
+ statsReducer(snap(0), stats('u'), ctx);
113
+
114
+ expect(ctx.connection.lastSeen).toBe(5_000);
115
+ });
116
+ });
117
+
118
+ // ============================================================================
119
+ // statsReducer — STATS l (link info)
120
+ // ============================================================================
121
+
122
+ describe('statsReducer — STATS l (link info)', () => {
123
+ it('emits 211 RPL_STATSLINKINFO for the local server (single-hop, serverless)', () => {
124
+ const ctx = makeCtx();
125
+ const s = snap(0);
126
+
127
+ const out = statsReducer(s, stats('l'), ctx);
128
+
129
+ // Format: <sendq> <nick|server> <send-bytes> <send-msgs> <rc-bytes>
130
+ // <rc-msgs> <time-open> <time-since-last> <who> <desc>
131
+ // Single-server: one line for the bound server, hops=0.
132
+ expect(out.effects[0]).toEqual<EffectType>(
133
+ Effect.send('c1', [
134
+ L(
135
+ `:irc.example.com 211 alice irc.example.com 0 0 0 0 0 0 :${ctx.serverConfig.serverVersion ?? ''}`,
136
+ ),
137
+ ]),
138
+ );
139
+ });
140
+
141
+ it('terminates the STATS l reply with 219 RPL_ENDOFSTATS', () => {
142
+ const ctx = makeCtx();
143
+
144
+ const out = statsReducer(snap(0), stats('l'), ctx);
145
+
146
+ const last = out.effects[out.effects.length - 1];
147
+ expect(last).toEqual<EffectType>(
148
+ Effect.send('c1', [L(':irc.example.com 219 alice l :End of /STATS report')]),
149
+ );
150
+ });
151
+ });
152
+
153
+ // ============================================================================
154
+ // statsReducer — unknown query letter
155
+ // ============================================================================
156
+
157
+ describe('statsReducer — unknown query letter', () => {
158
+ it('emits only the 219 terminator (charybdis-style) for an unknown letter', () => {
159
+ const ctx = makeCtx();
160
+
161
+ const out = statsReducer(snap(0), stats('z'), ctx);
162
+
163
+ // Charybdis: unknown STATS letters produce no body lines, just 219.
164
+ expect(out.effects).toEqual<EffectType[]>([
165
+ Effect.send('c1', [L(':irc.example.com 219 alice z :End of /STATS report')]),
166
+ ]);
167
+ });
168
+
169
+ it('passes the unknown query letter through to the 219 terminator', () => {
170
+ const ctx = makeCtx();
171
+
172
+ const out = statsReducer(snap(0), stats('Q'), ctx);
173
+
174
+ // Letter is normalised to lowercase in the terminator.
175
+ expect(out.effects).toEqual<EffectType[]>([
176
+ Effect.send('c1', [L(':irc.example.com 219 alice q :End of /STATS report')]),
177
+ ]);
178
+ });
179
+ });
180
+
181
+ // ============================================================================
182
+ // statsReducer — no query letter
183
+ // ============================================================================
184
+
185
+ describe('statsReducer — no query letter', () => {
186
+ it('emits only 219 with an empty query letter when STATS is invoked without args', () => {
187
+ const ctx = makeCtx();
188
+
189
+ const out = statsReducer(snap(0), stats(), ctx);
190
+
191
+ expect(out.effects).toEqual<EffectType[]>([
192
+ Effect.send('c1', [L(':irc.example.com 219 alice :End of /STATS report')]),
193
+ ]);
194
+ });
195
+ });
196
+
197
+ // ============================================================================
198
+ // statsReducer — remote server target
199
+ // ============================================================================
200
+
201
+ describe('statsReducer — remote server target', () => {
202
+ it('emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target', () => {
203
+ const ctx = makeCtx();
204
+
205
+ const out = statsReducer(snap(0), stats('u', 'remote.example.org'), ctx, 'remote.example.org');
206
+
207
+ expect(out.effects).toEqual<EffectType[]>([
208
+ Effect.send('c1', [L(':irc.example.com 402 alice remote.example.org :No such server')]),
209
+ ]);
210
+ });
211
+
212
+ it('uses "*" as the nick in 402 when the requester has none', () => {
213
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
214
+ conn.registration = 'registered';
215
+ const ctx = buildCtx({
216
+ serverConfig,
217
+ clock: new FakeClock(5_000),
218
+ ids: new SequentialIdFactory(),
219
+ motd: EmptyMotdProvider,
220
+ connection: conn,
221
+ });
222
+
223
+ const out = statsReducer(snap(0), stats('u', 'remote.example.org'), ctx, 'remote.example.org');
224
+
225
+ expect(out.effects).toEqual<EffectType[]>([
226
+ Effect.send('c1', [L(':irc.example.com 402 * remote.example.org :No such server')]),
227
+ ]);
228
+ });
229
+ });
230
+
231
+ // ============================================================================
232
+ // statsReducer — wire-shape details
233
+ // ============================================================================
234
+
235
+ describe('statsReducer — "*" nick fallback', () => {
236
+ it('uses "*" as the nick when the requester has none', () => {
237
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
238
+ conn.registration = 'registered';
239
+ const ctx = buildCtx({
240
+ serverConfig,
241
+ clock: new FakeClock(5_000),
242
+ ids: new SequentialIdFactory(),
243
+ motd: EmptyMotdProvider,
244
+ connection: conn,
245
+ });
246
+
247
+ const out = statsReducer(snap(0), stats('u'), ctx);
248
+
249
+ expect(out.effects[0]).toEqual<EffectType>(
250
+ Effect.send('c1', [L(':irc.example.com 242 * :Server Up 0 days, 0:00:05')]),
251
+ );
252
+ });
253
+
254
+ it('uses "*" as the requester in STATS l when the requester has no nick', () => {
255
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
256
+ conn.registration = 'registered';
257
+ const ctx = buildCtx({
258
+ serverConfig,
259
+ clock: new FakeClock(5_000),
260
+ ids: new SequentialIdFactory(),
261
+ motd: EmptyMotdProvider,
262
+ connection: conn,
263
+ });
264
+
265
+ const out = statsReducer(snap(0), stats('l'), ctx);
266
+
267
+ expect(out.effects[0]).toEqual<EffectType>(
268
+ Effect.send('c1', [
269
+ L(':irc.example.com 211 * irc.example.com 0 0 0 0 0 0 :serverless-ircd-0.4.0'),
270
+ ]),
271
+ );
272
+ });
273
+
274
+ it('omits the trailing version when serverConfig.serverVersion is undefined', () => {
275
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
276
+ conn.registration = 'registered';
277
+ const { serverVersion: _omit, ...configNoVersion } = serverConfig;
278
+ void _omit;
279
+ const ctx = buildCtx({
280
+ serverConfig: configNoVersion,
281
+ clock: new FakeClock(5_000),
282
+ ids: new SequentialIdFactory(),
283
+ motd: EmptyMotdProvider,
284
+ connection: conn,
285
+ });
286
+
287
+ const out = statsReducer(snap(0), stats('l'), ctx);
288
+
289
+ // Trailing colon is empty when no serverVersion is configured.
290
+ expect(out.effects[0]).toEqual<EffectType>(
291
+ Effect.send('c1', [L(':irc.example.com 211 * irc.example.com 0 0 0 0 0 0 :')]),
292
+ );
293
+ });
294
+ });