serverless-ircd 0.3.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 (223) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ adminReducer,
4
+ infoReducer,
5
+ timeReducer,
6
+ versionReducer,
7
+ } from '../../src/commands/server-info';
8
+ import { DEFAULT_SERVER_VERSION } from '../../src/config';
9
+ import { Effect } from '../../src/effects';
10
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
11
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
12
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
13
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
14
+
15
+ const baseServerConfig: ServerConfig = {
16
+ serverName: 'irc.example.com',
17
+ networkName: 'ExampleNet',
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(state: ConnectionState, clockMs = 5_000): Ctx {
28
+ return buildCtx({
29
+ serverConfig: baseServerConfig,
30
+ clock: new FakeClock(clockMs),
31
+ ids: new SequentialIdFactory(),
32
+ motd: EmptyMotdProvider,
33
+ connection: state,
34
+ });
35
+ }
36
+
37
+ function makeState(): ConnectionState {
38
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
39
+ s.nick = 'alice';
40
+ s.user = 'alice';
41
+ s.host = 'example.com';
42
+ s.realname = 'Alice';
43
+ s.registration = 'registered';
44
+ return s;
45
+ }
46
+
47
+ const L = (text: string): RawLine => ({ text });
48
+
49
+ const version = (server?: string) =>
50
+ ({ command: 'VERSION', params: server === undefined ? [] : [server], tags: {} }) as const;
51
+
52
+ const time = (server?: string) =>
53
+ ({ command: 'TIME', params: server === undefined ? [] : [server], tags: {} }) as const;
54
+
55
+ const admin = (server?: string) =>
56
+ ({ command: 'ADMIN', params: server === undefined ? [] : [server], tags: {} }) as const;
57
+
58
+ const info = (server?: string) =>
59
+ ({ command: 'INFO', params: server === undefined ? [] : [server], tags: {} }) as const;
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // VERSION
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe('versionReducer', () => {
66
+ it('emits 351 RPL_VERSION with the server version and name', () => {
67
+ const state = makeState();
68
+ const ctx = makeCtx(state);
69
+
70
+ const out = versionReducer(state, version(), ctx);
71
+
72
+ expect(out.effects).toEqual<EffectType[]>([
73
+ Effect.send('c1', [
74
+ L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
75
+ ]),
76
+ ]);
77
+ });
78
+
79
+ it('updates lastSeen to ctx.clock.now()', () => {
80
+ const state = makeState();
81
+ const ctx = makeCtx(state, 9_999);
82
+
83
+ versionReducer(state, version(), ctx);
84
+
85
+ expect(state.lastSeen).toBe(9_999);
86
+ });
87
+
88
+ it('uses * as nick placeholder for unregistered connections', () => {
89
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
90
+ const ctx = makeCtx(state);
91
+
92
+ const out = versionReducer(state, version(), ctx);
93
+
94
+ expect(out.effects[0]).toEqual<EffectType>(
95
+ Effect.send('c1', [
96
+ L(`:irc.example.com 351 * ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
97
+ ]),
98
+ );
99
+ });
100
+
101
+ it('ignores the optional server target parameter (single-server deployment)', () => {
102
+ const state = makeState();
103
+ const ctx = makeCtx(state);
104
+
105
+ const out = versionReducer(state, version('other.server'), ctx);
106
+
107
+ expect(out.effects[0]).toEqual<EffectType>(
108
+ Effect.send('c1', [
109
+ L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
110
+ ]),
111
+ );
112
+ });
113
+
114
+ it('returns the same state reference', () => {
115
+ const state = makeState();
116
+ const ctx = makeCtx(state);
117
+
118
+ const out = versionReducer(state, version(), ctx);
119
+
120
+ expect(out.state).toBe(state);
121
+ });
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // TIME
126
+ // ---------------------------------------------------------------------------
127
+
128
+ describe('timeReducer', () => {
129
+ it('emits 391 RPL_TIME with a human-readable timestamp from the clock', () => {
130
+ const state = makeState();
131
+ const ctx = makeCtx(state, 1_700_000_000_000);
132
+
133
+ const out = timeReducer(state, time(), ctx);
134
+
135
+ expect(out.effects).toEqual<EffectType[]>([
136
+ Effect.send('c1', [
137
+ L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),
138
+ ]),
139
+ ]);
140
+ });
141
+
142
+ it('updates lastSeen to ctx.clock.now()', () => {
143
+ const state = makeState();
144
+ const ctx = makeCtx(state, 42_000);
145
+
146
+ timeReducer(state, time(), ctx);
147
+
148
+ expect(state.lastSeen).toBe(42_000);
149
+ });
150
+
151
+ it('uses * as nick placeholder for unregistered connections', () => {
152
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
153
+ const ctx = makeCtx(state, 1_700_000_000_000);
154
+
155
+ const out = timeReducer(state, time(), ctx);
156
+
157
+ expect(out.effects[0]).toEqual<EffectType>(
158
+ Effect.send('c1', [L(':irc.example.com 391 * irc.example.com :2023-11-14T22:13:20.000Z')]),
159
+ );
160
+ });
161
+
162
+ it('ignores the optional server target parameter', () => {
163
+ const state = makeState();
164
+ const ctx = makeCtx(state, 1_700_000_000_000);
165
+
166
+ const out = timeReducer(state, time('elsewhere'), ctx);
167
+
168
+ expect(out.effects[0]).toEqual<EffectType>(
169
+ Effect.send('c1', [
170
+ L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),
171
+ ]),
172
+ );
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // ADMIN
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe('adminReducer', () => {
181
+ it('emits 256 RPL_ADMINME with the server name', () => {
182
+ const state = makeState();
183
+ const ctx = makeCtx(state);
184
+
185
+ const out = adminReducer(state, admin(), ctx);
186
+
187
+ expect(out.effects).toEqual<EffectType[]>([
188
+ Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),
189
+ ]);
190
+ });
191
+
192
+ it('updates lastSeen to ctx.clock.now()', () => {
193
+ const state = makeState();
194
+ const ctx = makeCtx(state, 7_777);
195
+
196
+ adminReducer(state, admin(), ctx);
197
+
198
+ expect(state.lastSeen).toBe(7_777);
199
+ });
200
+
201
+ it('uses * as nick placeholder for unregistered connections', () => {
202
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
203
+ const ctx = makeCtx(state);
204
+
205
+ const out = adminReducer(state, admin(), ctx);
206
+
207
+ expect(out.effects[0]).toEqual<EffectType>(
208
+ Effect.send('c1', [L(':irc.example.com 256 * irc.example.com :Administrative info')]),
209
+ );
210
+ });
211
+
212
+ it('ignores the optional server target parameter', () => {
213
+ const state = makeState();
214
+ const ctx = makeCtx(state);
215
+
216
+ const out = adminReducer(state, admin('foo'), ctx);
217
+
218
+ expect(out.effects[0]).toEqual<EffectType>(
219
+ Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),
220
+ );
221
+ });
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // INFO
226
+ // ---------------------------------------------------------------------------
227
+
228
+ describe('infoReducer', () => {
229
+ it('emits 371 RPL_INFO lines followed by 374 RPL_ENDOFINFO', () => {
230
+ const state = makeState();
231
+ const ctx = makeCtx(state);
232
+
233
+ const out = infoReducer(state, info(), ctx);
234
+
235
+ const lines = (out.effects[0] as { lines: RawLine[] }).lines;
236
+ expect(lines[0]).toEqual(
237
+ L(`:irc.example.com 371 alice :ServerlessIRCd - version ${DEFAULT_SERVER_VERSION}`),
238
+ );
239
+ expect(lines[1]).toEqual(L(':irc.example.com 371 alice :Running on network ExampleNet'));
240
+ expect(lines[lines.length - 1]).toEqual(L(':irc.example.com 374 alice :End of INFO list'));
241
+ });
242
+
243
+ it('includes at least one 371 line and the 374 terminator', () => {
244
+ const state = makeState();
245
+ const ctx = makeCtx(state);
246
+
247
+ const out = infoReducer(state, info(), ctx);
248
+
249
+ const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);
250
+ const rpl371Count = texts.filter((t) => / 371 /.test(t)).length;
251
+ const rpl374Count = texts.filter((t) => / 374 /.test(t)).length;
252
+ expect(rpl371Count).toBeGreaterThanOrEqual(1);
253
+ expect(rpl374Count).toBe(1);
254
+ });
255
+
256
+ it('updates lastSeen to ctx.clock.now()', () => {
257
+ const state = makeState();
258
+ const ctx = makeCtx(state, 3_333);
259
+
260
+ infoReducer(state, info(), ctx);
261
+
262
+ expect(state.lastSeen).toBe(3_333);
263
+ });
264
+
265
+ it('uses * as nick placeholder for unregistered connections', () => {
266
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
267
+ const ctx = makeCtx(state);
268
+
269
+ const out = infoReducer(state, info(), ctx);
270
+
271
+ const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);
272
+ expect(texts.every((t) => t.includes(' * '))).toBe(true);
273
+ });
274
+ });
@@ -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
+ });