serverless-ircd 0.5.0 → 0.7.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 +40 -3
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +474 -0
- package/README.md +219 -22
- package/apps/aws-stack/README.md +33 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +74 -9
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +2 -2
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/src/origin-allowlist.ts +99 -0
- package/apps/cf-worker/src/worker.ts +89 -9
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +18 -0
- package/apps/cf-worker/tests/fixtures/web-dist/index.html +18 -0
- package/apps/cf-worker/tests/origin-allowlist.test.ts +101 -0
- package/apps/cf-worker/tests/smoke.test.ts +28 -2
- package/apps/cf-worker/tests/ws-origin.test.ts +89 -0
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.test.toml +23 -0
- package/apps/cf-worker/wrangler.toml +59 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +9 -7
- package/apps/local-cli/tests/e2e.test.ts +112 -28
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
- package/apps/web/landing/index.html +217 -0
- package/apps/web/package.json +28 -0
- package/apps/web/scripts/build.mjs +125 -0
- package/apps/web/src/build-env.ts +43 -0
- package/apps/web/src/config-schema.ts +138 -0
- package/apps/web/static/config.json +28 -0
- package/apps/web/static/config.prod.json +28 -0
- package/apps/web/static/config.staging.json +28 -0
- package/apps/web/tests/build-env.test.ts +63 -0
- package/apps/web/tests/build-smoke.test.ts +103 -0
- package/apps/web/tests/config-schema.test.ts +432 -0
- package/apps/web/tests/workspace.test.ts +12 -0
- package/apps/web/tsconfig.json +10 -0
- package/apps/web/upstream/.browserslistrc +3 -0
- package/apps/web/upstream/.editorconfig +9 -0
- package/apps/web/upstream/.eslintignore +3 -0
- package/apps/web/upstream/.eslintrc.js +90 -0
- package/apps/web/upstream/.github/FUNDING.yml +12 -0
- package/apps/web/upstream/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
- package/apps/web/upstream/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/apps/web/upstream/.github/workflows/push.yml +26 -0
- package/apps/web/upstream/.github/workflows/release.yml +33 -0
- package/apps/web/upstream/.github/workflows/release_manual.yml +55 -0
- package/apps/web/upstream/.prettierrc.js +9 -0
- package/apps/web/upstream/.stylelintignore +5 -0
- package/apps/web/upstream/.stylelintrc.js +21 -0
- package/apps/web/upstream/LICENSE +201 -0
- package/apps/web/upstream/README.md +76 -0
- package/apps/web/upstream/babel.config.js +25 -0
- package/apps/web/upstream/crowdin.yml +3 -0
- package/apps/web/upstream/index.html +35 -0
- package/apps/web/upstream/jest.config.js +7 -0
- package/apps/web/upstream/jsdoc.json +22 -0
- package/apps/web/upstream/package.json +80 -0
- package/apps/web/upstream/scripts/pre-commit +43 -0
- package/apps/web/upstream/src/components/App.vue +516 -0
- package/apps/web/upstream/src/components/AppSettings.vue +587 -0
- package/apps/web/upstream/src/components/AutoComplete.vue +284 -0
- package/apps/web/upstream/src/components/Avatar.vue +105 -0
- package/apps/web/upstream/src/components/AwayStatusIndicator.vue +77 -0
- package/apps/web/upstream/src/components/BufferKey.vue +121 -0
- package/apps/web/upstream/src/components/BufferSettings.vue +97 -0
- package/apps/web/upstream/src/components/Captcha.vue +85 -0
- package/apps/web/upstream/src/components/ChannelBanlist.vue +96 -0
- package/apps/web/upstream/src/components/ChannelInfo.vue +159 -0
- package/apps/web/upstream/src/components/ChannelInvitelist.vue +276 -0
- package/apps/web/upstream/src/components/ChannelList.vue +345 -0
- package/apps/web/upstream/src/components/Container.vue +401 -0
- package/apps/web/upstream/src/components/ContainerHeader.vue +522 -0
- package/apps/web/upstream/src/components/ControlInput.vue +1016 -0
- package/apps/web/upstream/src/components/LoadingAnimation.vue +104 -0
- package/apps/web/upstream/src/components/MediaViewer.vue +199 -0
- package/apps/web/upstream/src/components/MessageInfo.vue +192 -0
- package/apps/web/upstream/src/components/MessageList.vue +1028 -0
- package/apps/web/upstream/src/components/MessageListAvatar.vue +20 -0
- package/apps/web/upstream/src/components/MessageListMessageCompact.vue +361 -0
- package/apps/web/upstream/src/components/MessageListMessageInline.vue +257 -0
- package/apps/web/upstream/src/components/MessageListMessageModern.vue +430 -0
- package/apps/web/upstream/src/components/NetworkSettings.vue +528 -0
- package/apps/web/upstream/src/components/Nicklist.vue +309 -0
- package/apps/web/upstream/src/components/NicklistUser.vue +181 -0
- package/apps/web/upstream/src/components/NotConnected.vue +271 -0
- package/apps/web/upstream/src/components/SelfUser.vue +283 -0
- package/apps/web/upstream/src/components/ServerSelector.vue +162 -0
- package/apps/web/upstream/src/components/ServerView.vue +126 -0
- package/apps/web/upstream/src/components/SettingsAdvanced.vue +232 -0
- package/apps/web/upstream/src/components/SettingsAliases.vue +121 -0
- package/apps/web/upstream/src/components/Sidebar.vue +429 -0
- package/apps/web/upstream/src/components/SidebarAboutBuffer.vue +288 -0
- package/apps/web/upstream/src/components/SidebarState.vue +145 -0
- package/apps/web/upstream/src/components/StartupError.vue +35 -0
- package/apps/web/upstream/src/components/StateBrowser.vue +420 -0
- package/apps/web/upstream/src/components/StateBrowserBuffer.vue +124 -0
- package/apps/web/upstream/src/components/StateBrowserNetwork.vue +823 -0
- package/apps/web/upstream/src/components/StateBrowserUsermenu.vue +162 -0
- package/apps/web/upstream/src/components/TypingStatusIndicator.vue +34 -0
- package/apps/web/upstream/src/components/TypingUsersList.vue +56 -0
- package/apps/web/upstream/src/components/UrlEmbed.vue +149 -0
- package/apps/web/upstream/src/components/UserBox.vue +728 -0
- package/apps/web/upstream/src/components/inputtools/Emoji.vue +67 -0
- package/apps/web/upstream/src/components/inputtools/TextStyle.vue +142 -0
- package/apps/web/upstream/src/components/startups/CommonLayout.vue +208 -0
- package/apps/web/upstream/src/components/startups/CustomServer.vue +495 -0
- package/apps/web/upstream/src/components/startups/KiwiBnc.vue +361 -0
- package/apps/web/upstream/src/components/startups/Personal.vue +317 -0
- package/apps/web/upstream/src/components/startups/Welcome.vue +544 -0
- package/apps/web/upstream/src/components/startups/ZncLogin.vue +229 -0
- package/apps/web/upstream/src/components/utils/InputConfirm.vue +68 -0
- package/apps/web/upstream/src/components/utils/InputPrompt.vue +118 -0
- package/apps/web/upstream/src/components/utils/InputText.vue +172 -0
- package/apps/web/upstream/src/components/utils/IrcInput.vue +579 -0
- package/apps/web/upstream/src/components/utils/PluginWrapper.vue +26 -0
- package/apps/web/upstream/src/components/utils/TabbedView.vue +149 -0
- package/apps/web/upstream/src/components/utils/TransitionExpand.vue +97 -0
- package/apps/web/upstream/src/helpers/Colours.js +128 -0
- package/apps/web/upstream/src/helpers/IrcdDiffs.js +26 -0
- package/apps/web/upstream/src/helpers/Md5.js +193 -0
- package/apps/web/upstream/src/helpers/Misc.js +401 -0
- package/apps/web/upstream/src/helpers/TextFormatting.js +237 -0
- package/apps/web/upstream/src/libs/AliasRewriter.js +157 -0
- package/apps/web/upstream/src/libs/AudioManager.js +60 -0
- package/apps/web/upstream/src/libs/BouncerMiddleware.js +247 -0
- package/apps/web/upstream/src/libs/BouncerProvider.js +551 -0
- package/apps/web/upstream/src/libs/ChathistoryMiddleware.js +153 -0
- package/apps/web/upstream/src/libs/ConfigLoader.js +94 -0
- package/apps/web/upstream/src/libs/EmojiProvider.js +46 -0
- package/apps/web/upstream/src/libs/GlobalApi.js +346 -0
- package/apps/web/upstream/src/libs/IPC.js +51 -0
- package/apps/web/upstream/src/libs/InputHandler.js +921 -0
- package/apps/web/upstream/src/libs/IrcClient.js +1506 -0
- package/apps/web/upstream/src/libs/Logger.js +71 -0
- package/apps/web/upstream/src/libs/Message.js +169 -0
- package/apps/web/upstream/src/libs/MessageFormatter.js +364 -0
- package/apps/web/upstream/src/libs/MessageParser.js +249 -0
- package/apps/web/upstream/src/libs/Notifications.js +78 -0
- package/apps/web/upstream/src/libs/ServerConnection.js +270 -0
- package/apps/web/upstream/src/libs/ServerSession.js +102 -0
- package/apps/web/upstream/src/libs/SoundBleep.js +20 -0
- package/apps/web/upstream/src/libs/StatePersistence.js +90 -0
- package/apps/web/upstream/src/libs/ThemeManager.js +128 -0
- package/apps/web/upstream/src/libs/TypingMiddleware.js +105 -0
- package/apps/web/upstream/src/libs/WindowTitle.js +63 -0
- package/apps/web/upstream/src/libs/batchedAdd.js +74 -0
- package/apps/web/upstream/src/libs/bufferTools.js +177 -0
- package/apps/web/upstream/src/libs/polyfill/Element.closest.js +18 -0
- package/apps/web/upstream/src/libs/renderers/Html.js +113 -0
- package/apps/web/upstream/src/libs/settingTools.js +31 -0
- package/apps/web/upstream/src/libs/state/BufferState.js +703 -0
- package/apps/web/upstream/src/libs/state/NetworkState.js +158 -0
- package/apps/web/upstream/src/libs/state/UserState.js +89 -0
- package/apps/web/upstream/src/libs/state/common.js +26 -0
- package/apps/web/upstream/src/libs/state.js +961 -0
- package/apps/web/upstream/src/libs/storage/Local.js +51 -0
- package/apps/web/upstream/src/main.js +566 -0
- package/apps/web/upstream/src/res/autocompleteCommands.js +31 -0
- package/apps/web/upstream/src/res/configTemplates.js +373 -0
- package/apps/web/upstream/src/res/globalStyle.css +277 -0
- package/apps/web/upstream/src/res/kiwiLoadingLogo.png +0 -0
- package/apps/web/upstream/src/res/locales/app.af-ZA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ar-SA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.bg-BG.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.bs-BA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ca-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.cs-CZ.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.da-DK.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.de-DE.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.dev.po +1246 -0
- package/apps/web/upstream/src/res/locales/app.el-GR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.en-US.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-419.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-AR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-EM.po +536 -0
- package/apps/web/upstream/src/res/locales/app.es-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-US.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.eu-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.fi-FI.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.fr-FR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.gl-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.he-IL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.hi-IN.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.hu-HU.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.id-ID.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.it-IT.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ja-JP.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ko-KR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.nl-NL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.no-NO.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.pl-PL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.pt-BR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.pt-PT.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ro-RO.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ru-RU.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sl-SI.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sq-AL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sr-SP.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sv-SE.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.tr-TR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.uk-UA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.vi-VN.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.zh-CN.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.zh-TW.po +1145 -0
- package/apps/web/upstream/src/res/localesList.json +27 -0
- package/apps/web/upstream/src/res/logo.png +0 -0
- package/apps/web/upstream/src/thirdparty/about.html +43 -0
- package/apps/web/upstream/src/thirdparty/index.js +5 -0
- package/apps/web/upstream/src/thirdparty/kiwiirccom.vue +185 -0
- package/apps/web/upstream/static/config.json +28 -0
- package/apps/web/upstream/static/emoticons/smile.png +0 -0
- package/apps/web/upstream/static/favicon.png +0 -0
- package/apps/web/upstream/static/highlight.mp3 +0 -0
- package/apps/web/upstream/static/highlight.ogg +0 -0
- package/apps/web/upstream/static/locales/.gitignore +2 -0
- package/apps/web/upstream/static/plugins/customise.html.example +10 -0
- package/apps/web/upstream/static/themes/coffee/theme.css +145 -0
- package/apps/web/upstream/static/themes/common/base.css +1064 -0
- package/apps/web/upstream/static/themes/dark/theme.css +196 -0
- package/apps/web/upstream/static/themes/default/theme.css +92 -0
- package/apps/web/upstream/static/themes/elite/theme.css +248 -0
- package/apps/web/upstream/static/themes/grayfox/theme.css +143 -0
- package/apps/web/upstream/static/themes/nightswatch/theme.css +367 -0
- package/apps/web/upstream/static/themes/osprey/theme.css +115 -0
- package/apps/web/upstream/static/themes/radioactive/theme.css +1211 -0
- package/apps/web/upstream/static/themes/sky/theme.css +107 -0
- package/apps/web/upstream/tests/unit/BatchAdd.spec.js +174 -0
- package/apps/web/upstream/tests/unit/MessageParser.spec.js +125 -0
- package/apps/web/upstream/tests/unit/Misc.spec.js +24 -0
- package/apps/web/upstream/tests/unit/NetworkState.spec.js +58 -0
- package/apps/web/upstream/tests/unit/StartupError.spec.js +20 -0
- package/apps/web/upstream/vue.config.js +150 -0
- package/apps/web/upstream/yarn.lock +9767 -0
- package/apps/web/vitest.config.ts +13 -0
- package/biome.json +2 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +16 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/docs/WebClientGuide.md +535 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/handlers/default.ts +3 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/serialize.ts +29 -1
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +9 -9
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +90 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/connection-do.ts +3 -0
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/tests/cf-harness.ts +12 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/reports/mutation/mutation.html +342 -0
- package/packages/irc-core/src/caps/capabilities.ts +37 -3
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +34 -0
- package/packages/irc-core/src/commands/isupport.ts +53 -0
- package/packages/irc-core/src/commands/mode.ts +8 -1
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +65 -10
- package/packages/irc-core/src/effects.ts +14 -0
- package/packages/irc-core/src/ports.ts +389 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +15 -0
- package/packages/irc-core/src/protocol/outbound.ts +16 -1
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +112 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/batch.test.ts +15 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/mode.test.ts +72 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +13 -0
- package/packages/irc-core/tests/parser.test.ts +109 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +450 -3
- package/packages/irc-server/src/dispatch.ts +108 -16
- package/packages/irc-server/tests/actor.test.ts +1432 -39
- package/packages/irc-server/tests/dispatch.test.ts +263 -2
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +24 -2
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AccountStore,
|
|
3
|
+
type AwayStore,
|
|
3
4
|
CapturingLogger,
|
|
4
5
|
type ChanName,
|
|
5
6
|
type ChanSnapshot,
|
|
@@ -11,16 +12,21 @@ import {
|
|
|
11
12
|
EmptyMotdProvider,
|
|
12
13
|
FakeClock,
|
|
13
14
|
type FloodControlConfig,
|
|
15
|
+
InMemoryAwayStore,
|
|
14
16
|
InMemoryMessageStore,
|
|
17
|
+
InMemoryMonitorStore,
|
|
15
18
|
InMemoryNickHistoryStore,
|
|
19
|
+
InMemoryReadMarkerStore,
|
|
16
20
|
LogLevel,
|
|
17
21
|
type Logger,
|
|
18
22
|
type MessageStore,
|
|
23
|
+
type MonitorStore,
|
|
19
24
|
type MtlsIdentityProvider,
|
|
20
25
|
type Nick,
|
|
21
26
|
type NickHistoryStore,
|
|
22
27
|
Numerics,
|
|
23
28
|
type RawLine,
|
|
29
|
+
type ReadMarkerStore,
|
|
24
30
|
type SaslPayload,
|
|
25
31
|
type SaslResult,
|
|
26
32
|
SequentialIdFactory,
|
|
@@ -122,14 +128,27 @@ class FakeRuntime implements IrcRuntime {
|
|
|
122
128
|
}
|
|
123
129
|
async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
|
|
124
130
|
this.calls.push({ method: 'reserveNick', args: [nick, conn] });
|
|
131
|
+
const key = nick.toLowerCase();
|
|
132
|
+
const existing = this.nicks.get(key);
|
|
133
|
+
if (existing !== undefined && existing !== conn) {
|
|
134
|
+
return { ok: false };
|
|
135
|
+
}
|
|
136
|
+
this.nicks.set(key, conn);
|
|
125
137
|
return { ok: true };
|
|
126
138
|
}
|
|
127
139
|
async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
|
|
128
140
|
this.calls.push({ method: 'changeNick', args: [conn, oldNick, newNick] });
|
|
141
|
+
const newKey = newNick.toLowerCase();
|
|
142
|
+
const oldKey = oldNick.toLowerCase();
|
|
143
|
+
const owner = this.nicks.get(newKey);
|
|
144
|
+
if (owner !== undefined && owner !== conn) return false;
|
|
145
|
+
this.nicks.set(newKey, conn);
|
|
146
|
+
if (this.nicks.get(oldKey) === conn) this.nicks.delete(oldKey);
|
|
129
147
|
return true;
|
|
130
148
|
}
|
|
131
149
|
async releaseNick(nick: Nick): Promise<void> {
|
|
132
150
|
this.calls.push({ method: 'releaseNick', args: [nick] });
|
|
151
|
+
this.nicks.delete(nick.toLowerCase());
|
|
133
152
|
}
|
|
134
153
|
async applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void> {
|
|
135
154
|
this.calls.push({ method: 'applyChannelDelta', args: [chan, delta] });
|
|
@@ -206,6 +225,10 @@ function makeActor(opts: {
|
|
|
206
225
|
operCreds?: ReadonlyArray<{ user: string; password: string }>;
|
|
207
226
|
transport?: Transport;
|
|
208
227
|
configSource?: string;
|
|
228
|
+
secure?: boolean;
|
|
229
|
+
monitor?: MonitorStore;
|
|
230
|
+
away?: AwayStore;
|
|
231
|
+
readMarkers?: ReadMarkerStore;
|
|
209
232
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
210
233
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
211
234
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -274,6 +297,18 @@ function makeActor(opts: {
|
|
|
274
297
|
if (opts.configSource !== undefined) {
|
|
275
298
|
finalOpts.configSource = opts.configSource;
|
|
276
299
|
}
|
|
300
|
+
if (opts.secure !== undefined) {
|
|
301
|
+
finalOpts.secure = opts.secure;
|
|
302
|
+
}
|
|
303
|
+
if (opts.monitor !== undefined) {
|
|
304
|
+
finalOpts.monitor = opts.monitor;
|
|
305
|
+
}
|
|
306
|
+
if (opts.away !== undefined) {
|
|
307
|
+
finalOpts.away = opts.away;
|
|
308
|
+
}
|
|
309
|
+
if (opts.readMarkers !== undefined) {
|
|
310
|
+
finalOpts.readMarkers = opts.readMarkers;
|
|
311
|
+
}
|
|
277
312
|
const actor = new ConnectionActor(finalOpts);
|
|
278
313
|
return { actor, runtime, conn };
|
|
279
314
|
}
|
|
@@ -283,23 +318,75 @@ void isChannelTarget;
|
|
|
283
318
|
|
|
284
319
|
describe('ConnectionActor — framing', () => {
|
|
285
320
|
it('processes one PRIVMSG in a single text frame into one broadcast', async () => {
|
|
321
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
322
|
+
bobConn.nick = 'bob';
|
|
323
|
+
bobConn.user = 'bob';
|
|
324
|
+
bobConn.host = 'example.org';
|
|
325
|
+
bobConn.registration = 'registered';
|
|
286
326
|
const { actor, runtime } = makeActor({});
|
|
327
|
+
runtime.addConnection(bobConn);
|
|
287
328
|
// Pre-add the connection to the channel so the broadcast excludes the sender.
|
|
288
329
|
await runtime.applyChannelDelta('#foo', {
|
|
289
|
-
memberships: [
|
|
330
|
+
memberships: [
|
|
331
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
332
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
333
|
+
],
|
|
290
334
|
});
|
|
291
335
|
runtime.calls.length = 0;
|
|
292
336
|
|
|
293
337
|
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
294
338
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
339
|
+
// Channel PRIVMSG fans out cap-split (msgid): dispatch resolves each
|
|
340
|
+
// member's caps and sends per-recipient. bob (no msgid cap) gets the bare
|
|
341
|
+
// line; the sender (c1) is excluded.
|
|
342
|
+
const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
343
|
+
expect(toBob).toHaveLength(1);
|
|
344
|
+
expect(toBob[0]?.args[0]).toBe('c2');
|
|
345
|
+
const lines = toBob[0]?.args[1] as RawLine[];
|
|
300
346
|
expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
301
347
|
});
|
|
302
348
|
|
|
349
|
+
it('delivers @msgid=<id> to a msgid-cap peer and the bare line to a non-cap peer', async () => {
|
|
350
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
351
|
+
bobConn.nick = 'bob';
|
|
352
|
+
bobConn.user = 'bob';
|
|
353
|
+
bobConn.host = 'example.org';
|
|
354
|
+
bobConn.registration = 'registered';
|
|
355
|
+
bobConn.caps = new Set<string>(['msgid']);
|
|
356
|
+
const carolConn = createConnection({ id: 'c3', connectedSince: 0 });
|
|
357
|
+
carolConn.nick = 'carol';
|
|
358
|
+
carolConn.user = 'carol';
|
|
359
|
+
carolConn.host = 'example.net';
|
|
360
|
+
carolConn.registration = 'registered';
|
|
361
|
+
const { actor, runtime } = makeActor({});
|
|
362
|
+
runtime.addConnection(bobConn);
|
|
363
|
+
runtime.addConnection(carolConn);
|
|
364
|
+
await runtime.applyChannelDelta('#foo', {
|
|
365
|
+
memberships: [
|
|
366
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
367
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
368
|
+
{ type: 'add', conn: 'c3', nick: 'carol' },
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
runtime.calls.length = 0;
|
|
372
|
+
|
|
373
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hi\r\n');
|
|
374
|
+
|
|
375
|
+
// bob (msgid cap) receives the decorated line; carol (no cap) receives
|
|
376
|
+
// the bare line. The sender (c1) is excluded from both.
|
|
377
|
+
const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
378
|
+
const toCarol = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c3');
|
|
379
|
+
expect(toBob).toHaveLength(1);
|
|
380
|
+
expect(toCarol).toHaveLength(1);
|
|
381
|
+
const bobLine = (toBob[0]?.args[1] as RawLine[])[0]?.text ?? '';
|
|
382
|
+
// bob sees the msgid-tagged line; carol sees the bare line. The msgid
|
|
383
|
+
// value is opaque (an IdFactory nonce), so match the shape not the value.
|
|
384
|
+
expect(bobLine).toMatch(/^@msgid=nonce-\d+ :alice!alice@example\.com PRIVMSG #foo :hi$/);
|
|
385
|
+
expect((toCarol[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
386
|
+
':alice!alice@example.com PRIVMSG #foo :hi',
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
303
390
|
it('tolerates a frame without a trailing CRLF', async () => {
|
|
304
391
|
const { actor, runtime } = makeActor({});
|
|
305
392
|
await actor.receiveTextFrame('PING :tok');
|
|
@@ -339,9 +426,18 @@ describe('ConnectionActor — framing', () => {
|
|
|
339
426
|
|
|
340
427
|
describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
341
428
|
it('two TCP chunks split mid-line produce exactly one PRIVMSG broadcast', async () => {
|
|
429
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
430
|
+
bobConn.nick = 'bob';
|
|
431
|
+
bobConn.user = 'bob';
|
|
432
|
+
bobConn.host = 'example.org';
|
|
433
|
+
bobConn.registration = 'registered';
|
|
342
434
|
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
435
|
+
runtime.addConnection(bobConn);
|
|
343
436
|
await runtime.applyChannelDelta('#foo', {
|
|
344
|
-
memberships: [
|
|
437
|
+
memberships: [
|
|
438
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
439
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
440
|
+
],
|
|
345
441
|
});
|
|
346
442
|
runtime.calls.length = 0;
|
|
347
443
|
|
|
@@ -351,10 +447,10 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
|
351
447
|
expect(runtime.calls).toHaveLength(0);
|
|
352
448
|
await actor.receive('lo\r\n');
|
|
353
449
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
expect(
|
|
357
|
-
const lines =
|
|
450
|
+
// Exactly one fanout to bob (cap-split dispatch sends per-recipient).
|
|
451
|
+
const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
452
|
+
expect(toBob).toHaveLength(1);
|
|
453
|
+
const lines = toBob[0]?.args[1] as RawLine[];
|
|
358
454
|
expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
359
455
|
});
|
|
360
456
|
|
|
@@ -365,8 +461,17 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
|
365
461
|
const wsSetup = makeActor({});
|
|
366
462
|
const tcpSetup = makeActor({ transport: new TcpByteStreamTransport() });
|
|
367
463
|
for (const rt of [wsSetup.runtime, tcpSetup.runtime]) {
|
|
464
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
465
|
+
bobConn.nick = 'bob';
|
|
466
|
+
bobConn.user = 'bob';
|
|
467
|
+
bobConn.host = 'example.org';
|
|
468
|
+
bobConn.registration = 'registered';
|
|
469
|
+
rt.addConnection(bobConn);
|
|
368
470
|
await rt.applyChannelDelta('#foo', {
|
|
369
|
-
memberships: [
|
|
471
|
+
memberships: [
|
|
472
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
473
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
474
|
+
],
|
|
370
475
|
});
|
|
371
476
|
}
|
|
372
477
|
wsSetup.runtime.calls.length = 0;
|
|
@@ -379,18 +484,30 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
|
379
484
|
await tcpSetup.actor.receive(stream.slice(i, i + 4));
|
|
380
485
|
}
|
|
381
486
|
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
487
|
+
// Both transports deliver exactly one fanout to bob with identical bytes.
|
|
488
|
+
const wsSends = wsSetup.runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
489
|
+
const tcpSends = tcpSetup.runtime.calls.filter(
|
|
490
|
+
(c) => c.method === 'send' && c.args[0] === 'c2',
|
|
491
|
+
);
|
|
492
|
+
expect(tcpSends).toHaveLength(wsSends.length);
|
|
493
|
+
expect((tcpSends[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
494
|
+
(wsSends[0]?.args[1] as RawLine[])[0]?.text,
|
|
387
495
|
);
|
|
388
496
|
});
|
|
389
497
|
|
|
390
498
|
it('buffers a partial line across multiple chunks with no loss or duplication', async () => {
|
|
499
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
500
|
+
bobConn.nick = 'bob';
|
|
501
|
+
bobConn.user = 'bob';
|
|
502
|
+
bobConn.host = 'example.org';
|
|
503
|
+
bobConn.registration = 'registered';
|
|
391
504
|
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
505
|
+
runtime.addConnection(bobConn);
|
|
392
506
|
await runtime.applyChannelDelta('#foo', {
|
|
393
|
-
memberships: [
|
|
507
|
+
memberships: [
|
|
508
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
509
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
510
|
+
],
|
|
394
511
|
});
|
|
395
512
|
runtime.calls.length = 0;
|
|
396
513
|
|
|
@@ -400,13 +517,14 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
|
400
517
|
for (const ch of stream) {
|
|
401
518
|
await actor.receive(ch);
|
|
402
519
|
}
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
expect(
|
|
406
|
-
expect((
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
expect((
|
|
520
|
+
const pongs = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
521
|
+
expect(pongs).toHaveLength(2); // PONG :a, PONG :c
|
|
522
|
+
expect((pongs[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :a');
|
|
523
|
+
expect((pongs[1]?.args[1] as RawLine[])[0]?.text).toBe('PONG :c');
|
|
524
|
+
// PRIVMSG fans out to bob exactly once (cap-split per-recipient send).
|
|
525
|
+
const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
526
|
+
expect(toBob).toHaveLength(1); // PRIVMSG #foo :b
|
|
527
|
+
expect((toBob[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
410
528
|
':alice!alice@example.com PRIVMSG #foo :b',
|
|
411
529
|
);
|
|
412
530
|
});
|
|
@@ -531,6 +649,50 @@ describe('ConnectionActor — routing', () => {
|
|
|
531
649
|
});
|
|
532
650
|
});
|
|
533
651
|
|
|
652
|
+
describe('ConnectionActor — secure transport (user mode `S`)', () => {
|
|
653
|
+
it('marks the connection state secure when constructed with secure: true', () => {
|
|
654
|
+
const { conn } = makeActor({ secure: true });
|
|
655
|
+
expect(conn.secure).toBe(true);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('defaults to insecure when secure is omitted', () => {
|
|
659
|
+
const { conn } = makeActor({});
|
|
660
|
+
expect(conn.secure).toBe(false);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('sets user mode `S` at registration when constructed with secure: true', async () => {
|
|
664
|
+
const { actor, conn } = makeActor({ secure: true });
|
|
665
|
+
// Reset to pre-registration so the NICK/USER handshake drives welcome.
|
|
666
|
+
conn.registration = 'pre-registration';
|
|
667
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
668
|
+
delete conn.nick;
|
|
669
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
670
|
+
delete conn.user;
|
|
671
|
+
|
|
672
|
+
await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
|
|
673
|
+
await actor.receiveTextFrame('NICK alice\r\n');
|
|
674
|
+
|
|
675
|
+
expect(conn.registration).toBe('registered');
|
|
676
|
+
expect(conn.userModes.tls).toBe(true);
|
|
677
|
+
expect(conn.secure).toBe(true);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('does not set user mode `S` when constructed without secure', async () => {
|
|
681
|
+
const { actor, conn } = makeActor({});
|
|
682
|
+
conn.registration = 'pre-registration';
|
|
683
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
684
|
+
delete conn.nick;
|
|
685
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
686
|
+
delete conn.user;
|
|
687
|
+
|
|
688
|
+
await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
|
|
689
|
+
await actor.receiveTextFrame('NICK alice\r\n');
|
|
690
|
+
|
|
691
|
+
expect(conn.registration).toBe('registered');
|
|
692
|
+
expect(conn.userModes.tls).toBe(false);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
534
696
|
describe('ConnectionActor — additional routing', () => {
|
|
535
697
|
it('routes PART through routeMultiChannel (one channel per invocation)', async () => {
|
|
536
698
|
const { actor, runtime } = makeActor({});
|
|
@@ -652,7 +814,9 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
652
814
|
});
|
|
653
815
|
runtime.calls.length = 0;
|
|
654
816
|
await actor.receiveTextFrame('NOTICE #foo :note\r\n');
|
|
655
|
-
|
|
817
|
+
// NOTICE channel fanout is cap-split (msgid) so dispatch resolves via
|
|
818
|
+
// getChannelSnapshot rather than the fast-path broadcast.
|
|
819
|
+
expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(1);
|
|
656
820
|
});
|
|
657
821
|
|
|
658
822
|
it('routes TAGMSG to a channel broadcast (never 421) when message-tags cap is present', async () => {
|
|
@@ -713,8 +877,8 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
713
877
|
});
|
|
714
878
|
runtime.calls.length = 0;
|
|
715
879
|
await actor.receiveTextFrame('PRIVMSG #foo,bob :hi\r\n');
|
|
716
|
-
// One
|
|
717
|
-
expect(runtime.calls.filter((c) => c.method === '
|
|
880
|
+
// One channel fanout (cap-split → getChannelSnapshot) + one sendToNick.
|
|
881
|
+
expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(1);
|
|
718
882
|
expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(1);
|
|
719
883
|
});
|
|
720
884
|
|
|
@@ -1136,7 +1300,15 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1136
1300
|
|
|
1137
1301
|
it('routes WALLOPS from an oper through wallopsReducer (broadcastWallops, never 421)', async () => {
|
|
1138
1302
|
const { actor, runtime } = makeActor({
|
|
1139
|
-
conn: {
|
|
1303
|
+
conn: {
|
|
1304
|
+
userModes: {
|
|
1305
|
+
invisible: false,
|
|
1306
|
+
oper: true,
|
|
1307
|
+
wallops: false,
|
|
1308
|
+
serverNotices: false,
|
|
1309
|
+
tls: false,
|
|
1310
|
+
},
|
|
1311
|
+
},
|
|
1140
1312
|
});
|
|
1141
1313
|
runtime.calls.length = 0;
|
|
1142
1314
|
|
|
@@ -1180,7 +1352,15 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1180
1352
|
|
|
1181
1353
|
it('routes WALLOPS with no message through wallopsReducer (412, never 421)', async () => {
|
|
1182
1354
|
const { actor, runtime } = makeActor({
|
|
1183
|
-
conn: {
|
|
1355
|
+
conn: {
|
|
1356
|
+
userModes: {
|
|
1357
|
+
invisible: false,
|
|
1358
|
+
oper: true,
|
|
1359
|
+
wallops: false,
|
|
1360
|
+
serverNotices: false,
|
|
1361
|
+
tls: false,
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1184
1364
|
});
|
|
1185
1365
|
runtime.calls.length = 0;
|
|
1186
1366
|
|
|
@@ -1200,7 +1380,15 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1200
1380
|
|
|
1201
1381
|
it('routes REHASH from an oper through rehashReducer (382, never 421) and re-invokes the loader', async () => {
|
|
1202
1382
|
const { actor, runtime, conn } = makeActor({
|
|
1203
|
-
conn: {
|
|
1383
|
+
conn: {
|
|
1384
|
+
userModes: {
|
|
1385
|
+
invisible: false,
|
|
1386
|
+
oper: true,
|
|
1387
|
+
wallops: false,
|
|
1388
|
+
serverNotices: false,
|
|
1389
|
+
tls: false,
|
|
1390
|
+
},
|
|
1391
|
+
},
|
|
1204
1392
|
configSource: 'KV',
|
|
1205
1393
|
});
|
|
1206
1394
|
runtime.reloadConfigResult = {
|
|
@@ -1261,7 +1449,15 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1261
1449
|
|
|
1262
1450
|
it('keeps the oper connected and retains the prior config when the loader rejects', async () => {
|
|
1263
1451
|
const { actor, runtime } = makeActor({
|
|
1264
|
-
conn: {
|
|
1452
|
+
conn: {
|
|
1453
|
+
userModes: {
|
|
1454
|
+
invisible: false,
|
|
1455
|
+
oper: true,
|
|
1456
|
+
wallops: false,
|
|
1457
|
+
serverNotices: false,
|
|
1458
|
+
tls: false,
|
|
1459
|
+
},
|
|
1460
|
+
},
|
|
1265
1461
|
configSource: 'KV',
|
|
1266
1462
|
});
|
|
1267
1463
|
runtime.reloadConfigError = new Error('store unreachable');
|
|
@@ -1292,7 +1488,15 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1292
1488
|
|
|
1293
1489
|
it('stringifies a non-Error rejection in the graceful 382 suffix', async () => {
|
|
1294
1490
|
const { actor, runtime } = makeActor({
|
|
1295
|
-
conn: {
|
|
1491
|
+
conn: {
|
|
1492
|
+
userModes: {
|
|
1493
|
+
invisible: false,
|
|
1494
|
+
oper: true,
|
|
1495
|
+
wallops: false,
|
|
1496
|
+
serverNotices: false,
|
|
1497
|
+
tls: false,
|
|
1498
|
+
},
|
|
1499
|
+
},
|
|
1296
1500
|
configSource: 'KV',
|
|
1297
1501
|
});
|
|
1298
1502
|
// A non-Error throw exercises the `String(err)` branch of the suffix
|
|
@@ -1317,7 +1521,15 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1317
1521
|
// sets the nick before OPER can succeed) but the actor must still
|
|
1318
1522
|
// format a valid numeric addressed to "*" rather than crash.
|
|
1319
1523
|
const { actor, runtime, conn } = makeActor({
|
|
1320
|
-
conn: {
|
|
1524
|
+
conn: {
|
|
1525
|
+
userModes: {
|
|
1526
|
+
invisible: false,
|
|
1527
|
+
oper: true,
|
|
1528
|
+
wallops: false,
|
|
1529
|
+
serverNotices: false,
|
|
1530
|
+
tls: false,
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1321
1533
|
configSource: 'KV',
|
|
1322
1534
|
});
|
|
1323
1535
|
// Clear the nick post-construction (exactOptionalPropertyTypes forbids
|
|
@@ -1606,10 +1818,11 @@ describe('ConnectionActor — flood control', () => {
|
|
|
1606
1818
|
await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
|
|
1607
1819
|
}
|
|
1608
1820
|
|
|
1609
|
-
// Disabled → every frame reaches the inner reducer and
|
|
1610
|
-
// close path is never invoked.
|
|
1821
|
+
// Disabled → every frame reaches the inner reducer and fans out; the
|
|
1822
|
+
// close path is never invoked. Channel PRIVMSG dispatches cap-split
|
|
1823
|
+
// (msgid) via getChannelSnapshot, once per accepted message.
|
|
1611
1824
|
expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
|
|
1612
|
-
expect(runtime.calls.filter((c) => c.method === '
|
|
1825
|
+
expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(20);
|
|
1613
1826
|
});
|
|
1614
1827
|
|
|
1615
1828
|
it('does not let an interleaved PING advance the bucket (control-plane exemption plumbed through)', async () => {
|
|
@@ -1893,8 +2106,9 @@ describe('ConnectionActor — CHATHISTORY', () => {
|
|
|
1893
2106
|
|
|
1894
2107
|
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
1895
2108
|
|
|
1896
|
-
// Broadcast still happens (the message is delivered live)
|
|
1897
|
-
|
|
2109
|
+
// Broadcast still happens (the message is delivered live). Channel
|
|
2110
|
+
// PRIVMSG fans out cap-split (msgid) via getChannelSnapshot…
|
|
2111
|
+
expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(1);
|
|
1898
2112
|
// …but there is no store to record into.
|
|
1899
2113
|
});
|
|
1900
2114
|
});
|
|
@@ -2974,3 +3188,1182 @@ describe('ConnectionActor — WHOIS channel list', () => {
|
|
|
2974
3188
|
}
|
|
2975
3189
|
});
|
|
2976
3190
|
});
|
|
3191
|
+
|
|
3192
|
+
// ============================================================================
|
|
3193
|
+
// MONITOR (IRCv3) — synchronous reducer + async push hook
|
|
3194
|
+
// ============================================================================
|
|
3195
|
+
|
|
3196
|
+
describe('ConnectionActor — MONITOR (synchronous reducer + async push hook)', () => {
|
|
3197
|
+
/**
|
|
3198
|
+
* Builds two actors (alice on c1, bob on c2) sharing a single
|
|
3199
|
+
* {@link InMemoryMonitorStore} and the same FakeRuntime. Mirrors the
|
|
3200
|
+
* production wiring where every ConnectionActor in a deployment shares
|
|
3201
|
+
* one monitor store.
|
|
3202
|
+
*/
|
|
3203
|
+
function makeMonitorPair(): {
|
|
3204
|
+
alice: ConnectionActor;
|
|
3205
|
+
bob: ConnectionActor;
|
|
3206
|
+
runtime: FakeRuntime;
|
|
3207
|
+
aliceState: ConnectionState;
|
|
3208
|
+
bobState: ConnectionState;
|
|
3209
|
+
monitor: InMemoryMonitorStore;
|
|
3210
|
+
} {
|
|
3211
|
+
const clock = new FakeClock(1_000);
|
|
3212
|
+
const aliceState = createConnection({ id: 'c1', connectedSince: 0 });
|
|
3213
|
+
aliceState.nick = 'alice';
|
|
3214
|
+
aliceState.user = 'alice';
|
|
3215
|
+
aliceState.host = 'example.com';
|
|
3216
|
+
aliceState.realname = 'Alice';
|
|
3217
|
+
aliceState.registration = 'registered';
|
|
3218
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
3219
|
+
bobState.user = 'bob';
|
|
3220
|
+
bobState.host = 'example.com';
|
|
3221
|
+
bobState.realname = 'Bob';
|
|
3222
|
+
// bob is pre-registration so we can drive him through NICK to test the
|
|
3223
|
+
// reserve-time 730 push.
|
|
3224
|
+
const runtime = new FakeRuntime(clock);
|
|
3225
|
+
runtime.addConnection(aliceState);
|
|
3226
|
+
const monitor = new InMemoryMonitorStore();
|
|
3227
|
+
const serverConfig: ServerConfig = {
|
|
3228
|
+
serverName: 'irc.example.com',
|
|
3229
|
+
networkName: 'ExampleNet',
|
|
3230
|
+
maxChannelsPerUser: 30,
|
|
3231
|
+
maxTargetsPerCommand: 10,
|
|
3232
|
+
maxListEntries: 50,
|
|
3233
|
+
nickLen: 30,
|
|
3234
|
+
channelLen: 50,
|
|
3235
|
+
topicLen: 390,
|
|
3236
|
+
quitMessage: 'Client Quit',
|
|
3237
|
+
};
|
|
3238
|
+
const ids = new SequentialIdFactory();
|
|
3239
|
+
type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
|
|
3240
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
|
3241
|
+
const aliceOpts: Mutable<ActorOpts> = {
|
|
3242
|
+
state: aliceState,
|
|
3243
|
+
runtime,
|
|
3244
|
+
channels: runtime,
|
|
3245
|
+
serverConfig,
|
|
3246
|
+
clock,
|
|
3247
|
+
ids,
|
|
3248
|
+
motd: EmptyMotdProvider,
|
|
3249
|
+
monitor,
|
|
3250
|
+
};
|
|
3251
|
+
const bobOpts: Mutable<ActorOpts> = {
|
|
3252
|
+
state: bobState,
|
|
3253
|
+
runtime,
|
|
3254
|
+
channels: runtime,
|
|
3255
|
+
serverConfig,
|
|
3256
|
+
clock,
|
|
3257
|
+
ids,
|
|
3258
|
+
motd: EmptyMotdProvider,
|
|
3259
|
+
monitor,
|
|
3260
|
+
};
|
|
3261
|
+
return {
|
|
3262
|
+
alice: new ConnectionActor(aliceOpts),
|
|
3263
|
+
bob: new ConnectionActor(bobOpts),
|
|
3264
|
+
runtime,
|
|
3265
|
+
aliceState,
|
|
3266
|
+
bobState,
|
|
3267
|
+
monitor,
|
|
3268
|
+
};
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
it('MONITOR + offline nick emits 731 RPL_MONOFFLINE (synchronous path)', async () => {
|
|
3272
|
+
const { alice, runtime } = makeMonitorPair();
|
|
3273
|
+
runtime.calls.length = 0;
|
|
3274
|
+
|
|
3275
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3276
|
+
|
|
3277
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
3278
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3279
|
+
expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
|
|
3280
|
+
});
|
|
3281
|
+
|
|
3282
|
+
it('MONITOR + online nick emits 730 RPL_MONONLINE (synchronous path)', async () => {
|
|
3283
|
+
// Drive bob through the actor's NICK reservation so the dispatch hook
|
|
3284
|
+
// fires monitor.onNickOnline('bob'). Then alice's MONITOR + bob sees
|
|
3285
|
+
// him online via the runtime nick registry (resolveOnlineMap) and
|
|
3286
|
+
// emits 730 synchronously.
|
|
3287
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3288
|
+
runtime.addConnection(bobState);
|
|
3289
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3290
|
+
runtime.calls.length = 0;
|
|
3291
|
+
|
|
3292
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3293
|
+
|
|
3294
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3295
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3296
|
+
expect(lines.some((l) => l.text === ':irc.example.com 730 alice :bob')).toBe(true);
|
|
3297
|
+
});
|
|
3298
|
+
|
|
3299
|
+
it('alice MONITOR + bob (offline) then bob connects → 730 pushed to alice', async () => {
|
|
3300
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3301
|
+
// alice subscribes to bob while bob is offline.
|
|
3302
|
+
runtime.calls.length = 0;
|
|
3303
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3304
|
+
// Sanity: 731 emitted for the offline state.
|
|
3305
|
+
{
|
|
3306
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3307
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3308
|
+
expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// bob now registers — NICK bob reserves the nick. The dispatch hook
|
|
3312
|
+
// fires monitor.onNickOnline('bob'), and bob's actor's post-dispatch
|
|
3313
|
+
// drain emits a Send to alice's connId.
|
|
3314
|
+
runtime.addConnection(bobState);
|
|
3315
|
+
runtime.calls.length = 0;
|
|
3316
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3317
|
+
|
|
3318
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3319
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3320
|
+
expect(lines.some((l) => l.text === ':irc.example.com 730 alice :bob')).toBe(true);
|
|
3321
|
+
});
|
|
3322
|
+
|
|
3323
|
+
it('bob quits after being monitored → 731 pushed to alice', async () => {
|
|
3324
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3325
|
+
// Bring bob online through the actor so the dispatch hook fires
|
|
3326
|
+
// monitor.onNickOnline('bob') on reserveNick.
|
|
3327
|
+
runtime.addConnection(bobState);
|
|
3328
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3329
|
+
runtime.calls.length = 0;
|
|
3330
|
+
// alice subscribes while bob is online.
|
|
3331
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3332
|
+
runtime.calls.length = 0;
|
|
3333
|
+
|
|
3334
|
+
await bob.receiveTextFrame('QUIT :gone\r\n');
|
|
3335
|
+
|
|
3336
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3337
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3338
|
+
expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
|
|
3339
|
+
});
|
|
3340
|
+
|
|
3341
|
+
it('unmatched MONITOR subcommand yields 461 (never 421)', async () => {
|
|
3342
|
+
const { alice, runtime } = makeMonitorPair();
|
|
3343
|
+
runtime.calls.length = 0;
|
|
3344
|
+
|
|
3345
|
+
await alice.receiveTextFrame('MONITOR Z\r\n');
|
|
3346
|
+
|
|
3347
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
3348
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3349
|
+
expect(lines.some((l) => l.text.includes(' 461 '))).toBe(true);
|
|
3350
|
+
for (const line of lines) {
|
|
3351
|
+
expect(line.text).not.toContain(' 421 ');
|
|
3352
|
+
}
|
|
3353
|
+
});
|
|
3354
|
+
|
|
3355
|
+
it('MONITOR L lists the watchlist then 733 terminator', async () => {
|
|
3356
|
+
const { alice, runtime } = makeMonitorPair();
|
|
3357
|
+
await alice.receiveTextFrame('MONITOR + bob,carol\r\n');
|
|
3358
|
+
runtime.calls.length = 0;
|
|
3359
|
+
|
|
3360
|
+
await alice.receiveTextFrame('MONITOR L\r\n');
|
|
3361
|
+
|
|
3362
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3363
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3364
|
+
expect(lines.some((l) => l.text === ':irc.example.com 732 alice :bob')).toBe(true);
|
|
3365
|
+
expect(lines.some((l) => l.text === ':irc.example.com 732 alice :carol')).toBe(true);
|
|
3366
|
+
expect(lines.some((l) => l.text === ':irc.example.com 733 alice :End of MONITOR list')).toBe(
|
|
3367
|
+
true,
|
|
3368
|
+
);
|
|
3369
|
+
});
|
|
3370
|
+
|
|
3371
|
+
it('MONITOR C empties the watchlist silently', async () => {
|
|
3372
|
+
const { alice, runtime, aliceState } = makeMonitorPair();
|
|
3373
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3374
|
+
expect(aliceState.monitorList?.has('bob')).toBe(true);
|
|
3375
|
+
runtime.calls.length = 0;
|
|
3376
|
+
|
|
3377
|
+
await alice.receiveTextFrame('MONITOR C\r\n');
|
|
3378
|
+
|
|
3379
|
+
expect(aliceState.monitorList?.size ?? 0).toBe(0);
|
|
3380
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3381
|
+
expect(sends).toHaveLength(0);
|
|
3382
|
+
});
|
|
3383
|
+
|
|
3384
|
+
it('works without a bound MonitorStore (synchronous path only, no push fanout)', async () => {
|
|
3385
|
+
// No `monitor:` option → the actor still routes the reducer but skips
|
|
3386
|
+
// the post-dispatch push drain. Covers the `this.monitor === undefined`
|
|
3387
|
+
// branches in routeMonitor and drainMonitorPushes.
|
|
3388
|
+
const { actor, runtime } = makeActor({});
|
|
3389
|
+
runtime.calls.length = 0;
|
|
3390
|
+
|
|
3391
|
+
await actor.receiveTextFrame('MONITOR + bob\r\n');
|
|
3392
|
+
|
|
3393
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3394
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3395
|
+
expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
|
|
3396
|
+
});
|
|
3397
|
+
|
|
3398
|
+
it('skips a nick whose connection has torn down between lookupNick and getConnection (race)', async () => {
|
|
3399
|
+
// Covers the `conn?.nick !== undefined` false branch in
|
|
3400
|
+
// resolveOnlineMap: lookupNick resolves but getConnection returns null.
|
|
3401
|
+
const { alice, runtime, bobState } = makeMonitorPair();
|
|
3402
|
+
runtime.addConnection(bobState);
|
|
3403
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3404
|
+
runtime.calls.length = 0;
|
|
3405
|
+
// Drop bob's live connection while leaving the nick map intact so
|
|
3406
|
+
// lookupNick still resolves.
|
|
3407
|
+
runtime.dropConnection('c2');
|
|
3408
|
+
|
|
3409
|
+
await alice.receiveTextFrame('MONITOR S\r\n');
|
|
3410
|
+
|
|
3411
|
+
// 730 absent (bob no longer reachable); 731 present instead.
|
|
3412
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3413
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3414
|
+
expect(lines.some((l) => l.text.includes(' 731 '))).toBe(true);
|
|
3415
|
+
for (const line of lines) {
|
|
3416
|
+
expect(line.text).not.toContain(' 730 ');
|
|
3417
|
+
}
|
|
3418
|
+
});
|
|
3419
|
+
|
|
3420
|
+
it('skips a nick whose live connection has no nick set (defensive)', async () => {
|
|
3421
|
+
// Covers the `conn.nick !== undefined` false branch in resolveOnlineMap:
|
|
3422
|
+
// lookupNick resolves but the connection state at that id has no nick
|
|
3423
|
+
// (should not happen under correct runtime invariants — defensive).
|
|
3424
|
+
const { alice, runtime, bobState } = makeMonitorPair();
|
|
3425
|
+
runtime.addConnection(bobState);
|
|
3426
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3427
|
+
runtime.calls.length = 0;
|
|
3428
|
+
// Manually clear bob's nick while leaving the registry intact.
|
|
3429
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
3430
|
+
delete bobState.nick;
|
|
3431
|
+
|
|
3432
|
+
await alice.receiveTextFrame('MONITOR S\r\n');
|
|
3433
|
+
|
|
3434
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3435
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3436
|
+
for (const line of lines) {
|
|
3437
|
+
expect(line.text).not.toContain(' 730 ');
|
|
3438
|
+
}
|
|
3439
|
+
});
|
|
3440
|
+
|
|
3441
|
+
it('push to a watcher whose live connection has torn down uses * as nick placeholder', async () => {
|
|
3442
|
+
// Covers the `watcher?.nick ?? '*'` `??` branch in drainMonitorPushes:
|
|
3443
|
+
// getConnection returns null → watcherNick falls back to '*'.
|
|
3444
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3445
|
+
runtime.addConnection(bobState);
|
|
3446
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3447
|
+
runtime.calls.length = 0;
|
|
3448
|
+
// Drop alice's live connection so the post-QUIT drain cannot resolve
|
|
3449
|
+
// her nick. The push is still attempted; the wire line uses '*'.
|
|
3450
|
+
runtime.dropConnection('c1');
|
|
3451
|
+
|
|
3452
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3453
|
+
|
|
3454
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3455
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3456
|
+
expect(lines.some((l) => l.text.includes(' 730 * :bob'))).toBe(true);
|
|
3457
|
+
});
|
|
3458
|
+
|
|
3459
|
+
it('reserveNick failure does not fan out a 730 (collision)', async () => {
|
|
3460
|
+
// Two actors racing for the same nick: the second reserve returns
|
|
3461
|
+
// { ok: false } so the dispatch handler must NOT call
|
|
3462
|
+
// monitor.onNickOnline. Covers the `if (result.ok)` false branch.
|
|
3463
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3464
|
+
runtime.addConnection(bobState);
|
|
3465
|
+
// alice subscribes to bob.
|
|
3466
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3467
|
+
runtime.calls.length = 0;
|
|
3468
|
+
|
|
3469
|
+
// Pre-reserve bob under alice's connId so bob's NICK collides.
|
|
3470
|
+
await runtime.reserveNick('bob', 'c1');
|
|
3471
|
+
void bob;
|
|
3472
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3473
|
+
|
|
3474
|
+
// No 730 push should reach alice (the reserve failed).
|
|
3475
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3476
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3477
|
+
for (const line of lines) {
|
|
3478
|
+
expect(line.text).not.toContain(' 730 ');
|
|
3479
|
+
}
|
|
3480
|
+
});
|
|
3481
|
+
|
|
3482
|
+
it('changeNick failure does not fan out push effects', async () => {
|
|
3483
|
+
// Covers the `if (ok)` false branch in changeNickEtc: changeNick
|
|
3484
|
+
// returns false on collision; monitor hooks must NOT fire.
|
|
3485
|
+
const { alice, bob, runtime } = makeMonitorPair();
|
|
3486
|
+
// bob subscribes to alice; alice then tries NICK <occupied>.
|
|
3487
|
+
await bob.receiveTextFrame('MONITOR + alice\r\n');
|
|
3488
|
+
// Seed both nicks: alice and bob both registered.
|
|
3489
|
+
const bobState2 = createConnection({ id: 'c2', connectedSince: 0 });
|
|
3490
|
+
bobState2.nick = 'bob';
|
|
3491
|
+
bobState2.registration = 'registered';
|
|
3492
|
+
runtime.addConnection(bobState2);
|
|
3493
|
+
runtime.calls.length = 0;
|
|
3494
|
+
|
|
3495
|
+
// alice attempts NICK bob — bob is taken, changeNick fails.
|
|
3496
|
+
await alice.receiveTextFrame('NICK bob\r\n');
|
|
3497
|
+
|
|
3498
|
+
// No push to the bob watcher: changeNick failed.
|
|
3499
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
3500
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3501
|
+
for (const line of lines) {
|
|
3502
|
+
expect(line.text).not.toMatch(/ 73[01] /u);
|
|
3503
|
+
}
|
|
3504
|
+
});
|
|
3505
|
+
});
|
|
3506
|
+
|
|
3507
|
+
// ===========================================================================
|
|
3508
|
+
// ConnectionActor — IRCv3 labeled-response
|
|
3509
|
+
//
|
|
3510
|
+
// A client tags an inbound message with `+label=<id>`; the server wraps every
|
|
3511
|
+
// reply caused by that message (addressed back to the requester) in a single
|
|
3512
|
+
// `BATCH +id labeled-response … BATCH -id`. No-reply requests emit an empty
|
|
3513
|
+
// batch so the client can still correlate the label.
|
|
3514
|
+
// ===========================================================================
|
|
3515
|
+
|
|
3516
|
+
describe('ConnectionActor — IRCv3 labeled-response', () => {
|
|
3517
|
+
/** Caps a labeled-response-capable client negotiates (batch is required). */
|
|
3518
|
+
const LABELED_CAPS = new Set<string>(['batch', 'labeled-response']);
|
|
3519
|
+
|
|
3520
|
+
/** echo-message + labeled caps — a client that sees its own echoed replies. */
|
|
3521
|
+
const ECHO_LABELED_CAPS = new Set<string>(['echo-message', 'batch', 'labeled-response']);
|
|
3522
|
+
|
|
3523
|
+
function makeOnlineConn(id: string, nick: string): ConnectionState {
|
|
3524
|
+
const c = createConnection({ id, connectedSince: 0 });
|
|
3525
|
+
c.nick = nick;
|
|
3526
|
+
c.user = nick;
|
|
3527
|
+
c.host = `${nick}.org`;
|
|
3528
|
+
c.realname = nick;
|
|
3529
|
+
c.registration = 'registered';
|
|
3530
|
+
return c;
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
it('wraps the echo of a labeled PRIVMSG in a BATCH +label labeled-response frame', async () => {
|
|
3534
|
+
const { actor, runtime, conn } = makeActor({ conn: { caps: ECHO_LABELED_CAPS } });
|
|
3535
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3536
|
+
// Put alice on #foo so the channel PRIVMSG is accepted.
|
|
3537
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3538
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3539
|
+
});
|
|
3540
|
+
runtime.calls.length = 0;
|
|
3541
|
+
void conn;
|
|
3542
|
+
|
|
3543
|
+
await actor.receiveTextFrame('@+label=foo PRIVMSG #foo :hi\r\n');
|
|
3544
|
+
|
|
3545
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3546
|
+
expect(toAlice).toHaveLength(1);
|
|
3547
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3548
|
+
expect(lines.map((l) => l.text)).toEqual([
|
|
3549
|
+
'BATCH +foo labeled-response',
|
|
3550
|
+
':alice!alice@example.com PRIVMSG #foo :hi',
|
|
3551
|
+
'BATCH -foo',
|
|
3552
|
+
]);
|
|
3553
|
+
});
|
|
3554
|
+
|
|
3555
|
+
it('emits an empty labeled batch when the labeled request produces no reply', async () => {
|
|
3556
|
+
// NOTICE (no echo-message) is fire-and-forget for the sender: only a
|
|
3557
|
+
// broadcast to peers, no Send back to the requester.
|
|
3558
|
+
const { actor, runtime } = makeActor({ conn: { caps: LABELED_CAPS } });
|
|
3559
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3560
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3561
|
+
memberships: [
|
|
3562
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
3563
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
3564
|
+
],
|
|
3565
|
+
});
|
|
3566
|
+
runtime.calls.length = 0;
|
|
3567
|
+
|
|
3568
|
+
await actor.receiveTextFrame('@+label=empty NOTICE #foo :hi\r\n');
|
|
3569
|
+
|
|
3570
|
+
// The requester receives ONLY the empty labeled batch (start + end).
|
|
3571
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3572
|
+
expect(toAlice).toHaveLength(1);
|
|
3573
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3574
|
+
expect(lines.map((l) => l.text)).toEqual(['BATCH +empty labeled-response', 'BATCH -empty']);
|
|
3575
|
+
});
|
|
3576
|
+
|
|
3577
|
+
it('accumulates every reply to the requester under a single labeled batch', async () => {
|
|
3578
|
+
// Multi-target PRIVMSG with echo-message produces one echo Send-to-self
|
|
3579
|
+
// per target; all echoes must land inside ONE labeled batch.
|
|
3580
|
+
const { actor, runtime } = makeActor({ conn: { caps: ECHO_LABELED_CAPS } });
|
|
3581
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3582
|
+
runtime.addConnection(makeOnlineConn('c3', 'carol'));
|
|
3583
|
+
runtime.calls.length = 0;
|
|
3584
|
+
|
|
3585
|
+
await actor.receiveTextFrame('@+label=m PRIVMSG bob,carol :hi\r\n');
|
|
3586
|
+
|
|
3587
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3588
|
+
expect(toAlice).toHaveLength(1);
|
|
3589
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3590
|
+
expect(lines.map((l) => l.text)).toEqual([
|
|
3591
|
+
'BATCH +m labeled-response',
|
|
3592
|
+
':alice!alice@example.com PRIVMSG bob :hi',
|
|
3593
|
+
':alice!alice@example.com PRIVMSG carol :hi',
|
|
3594
|
+
'BATCH -m',
|
|
3595
|
+
]);
|
|
3596
|
+
});
|
|
3597
|
+
|
|
3598
|
+
it('wraps an error numeric (403) in the labeled batch', async () => {
|
|
3599
|
+
// JOIN <invalid-name> (no # prefix) yields 403 ERR_NOSUCHCHANNEL, which
|
|
3600
|
+
// must still be wrapped so the requester can correlate the failure.
|
|
3601
|
+
const { actor, runtime } = makeActor({ conn: { caps: LABELED_CAPS } });
|
|
3602
|
+
runtime.calls.length = 0;
|
|
3603
|
+
|
|
3604
|
+
await actor.receiveTextFrame('@+label=e JOIN badname\r\n');
|
|
3605
|
+
|
|
3606
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3607
|
+
expect(toAlice).toHaveLength(1);
|
|
3608
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3609
|
+
expect(lines.map((l) => l.text)).toEqual([
|
|
3610
|
+
'BATCH +e labeled-response',
|
|
3611
|
+
':irc.example.com 403 alice badname :No such channel',
|
|
3612
|
+
'BATCH -e',
|
|
3613
|
+
]);
|
|
3614
|
+
});
|
|
3615
|
+
|
|
3616
|
+
it('does NOT wrap when the labeled-response cap is absent', async () => {
|
|
3617
|
+
const { actor, runtime } = makeActor({
|
|
3618
|
+
conn: { caps: new Set<string>(['echo-message', 'batch']) },
|
|
3619
|
+
});
|
|
3620
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3621
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3622
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3623
|
+
});
|
|
3624
|
+
runtime.calls.length = 0;
|
|
3625
|
+
|
|
3626
|
+
await actor.receiveTextFrame('@+label=foo PRIVMSG #foo :hi\r\n');
|
|
3627
|
+
|
|
3628
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3629
|
+
expect(toAlice).toHaveLength(1);
|
|
3630
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3631
|
+
// No batch markers — the echo is delivered bare.
|
|
3632
|
+
expect(lines.map((l) => l.text)).toEqual([':alice!alice@example.com PRIVMSG #foo :hi']);
|
|
3633
|
+
});
|
|
3634
|
+
|
|
3635
|
+
it('does NOT wrap when the batch cap is absent (labeled-response requires batch)', async () => {
|
|
3636
|
+
const { actor, runtime } = makeActor({
|
|
3637
|
+
conn: { caps: new Set<string>(['echo-message', 'labeled-response']) },
|
|
3638
|
+
});
|
|
3639
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3640
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3641
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3642
|
+
});
|
|
3643
|
+
runtime.calls.length = 0;
|
|
3644
|
+
|
|
3645
|
+
await actor.receiveTextFrame('@+label=foo PRIVMSG #foo :hi\r\n');
|
|
3646
|
+
|
|
3647
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3648
|
+
expect(toAlice).toHaveLength(1);
|
|
3649
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3650
|
+
expect(lines.map((l) => l.text)).toEqual([':alice!alice@example.com PRIVMSG #foo :hi']);
|
|
3651
|
+
});
|
|
3652
|
+
|
|
3653
|
+
it('does NOT wrap an unlabeled PRIVMSG even when both caps are negotiated', async () => {
|
|
3654
|
+
const { actor, runtime } = makeActor({ conn: { caps: ECHO_LABELED_CAPS } });
|
|
3655
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3656
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3657
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3658
|
+
});
|
|
3659
|
+
runtime.calls.length = 0;
|
|
3660
|
+
|
|
3661
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hi\r\n');
|
|
3662
|
+
|
|
3663
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3664
|
+
expect(toAlice).toHaveLength(1);
|
|
3665
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3666
|
+
expect(lines.map((l) => l.text)).toEqual([':alice!alice@example.com PRIVMSG #foo :hi']);
|
|
3667
|
+
});
|
|
3668
|
+
});
|
|
3669
|
+
|
|
3670
|
+
// ---------------------------------------------------------------------------
|
|
3671
|
+
// draft/multiline BATCH (multi-line batching)
|
|
3672
|
+
// ---------------------------------------------------------------------------
|
|
3673
|
+
|
|
3674
|
+
/**
|
|
3675
|
+
* Builds a channel with the supplied members and returns per-connection
|
|
3676
|
+
* capture filters. Mirrors the setup used by the framing / msgid tests.
|
|
3677
|
+
*/
|
|
3678
|
+
async function setupMultilineChannel(
|
|
3679
|
+
runtime: FakeRuntime,
|
|
3680
|
+
members: Array<{ conn: ConnId; nick: string; caps?: ReadonlyArray<string> }>,
|
|
3681
|
+
): Promise<void> {
|
|
3682
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3683
|
+
memberships: members.map((m) => ({ type: 'add' as const, conn: m.conn, nick: m.nick })),
|
|
3684
|
+
});
|
|
3685
|
+
for (const m of members) {
|
|
3686
|
+
const conn = createConnection({ id: m.conn, connectedSince: 0 });
|
|
3687
|
+
conn.nick = m.nick;
|
|
3688
|
+
conn.user = m.nick;
|
|
3689
|
+
conn.host = `${m.nick}.example`;
|
|
3690
|
+
conn.registration = 'registered';
|
|
3691
|
+
if (m.caps !== undefined) conn.caps = new Set<string>(m.caps);
|
|
3692
|
+
runtime.addConnection(conn);
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
function sentTo(runtime: FakeRuntime, connId: ConnId): RawLine[] {
|
|
3697
|
+
return runtime.calls
|
|
3698
|
+
.filter((c) => c.method === 'send' && c.args[0] === connId)
|
|
3699
|
+
.flatMap((c) => c.args[1] as RawLine[]);
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
describe('ConnectionActor — draft/multiline BATCH', () => {
|
|
3703
|
+
it('strips the +draft/multiline tag for a non-message-tags legacy peer', async () => {
|
|
3704
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3705
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3706
|
+
await setupMultilineChannel(runtime, [
|
|
3707
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3708
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3709
|
+
]);
|
|
3710
|
+
runtime.calls.length = 0;
|
|
3711
|
+
|
|
3712
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3713
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
3714
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
3715
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3716
|
+
|
|
3717
|
+
// Bob has no caps → bare joined PRIVMSG, no `@+draft/multiline` prefix.
|
|
3718
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
3719
|
+
expect(
|
|
3720
|
+
bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :line one\nline two'),
|
|
3721
|
+
).toBe(true);
|
|
3722
|
+
expect(bobLines.some((l) => l.text.startsWith('@+draft/multiline'))).toBe(false);
|
|
3723
|
+
});
|
|
3724
|
+
|
|
3725
|
+
it('preserves the +draft/multiline tag for a message-tags-only peer', async () => {
|
|
3726
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3727
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3728
|
+
await setupMultilineChannel(runtime, [
|
|
3729
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3730
|
+
{ conn: 'c4', nick: 'dave', caps: ['message-tags'] },
|
|
3731
|
+
]);
|
|
3732
|
+
runtime.calls.length = 0;
|
|
3733
|
+
|
|
3734
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3735
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
3736
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
3737
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3738
|
+
|
|
3739
|
+
// Dave has message-tags but NOT draft/multiline → tagged joined PRIVMSG.
|
|
3740
|
+
const daveLines = sentTo(runtime, 'c4');
|
|
3741
|
+
expect(
|
|
3742
|
+
daveLines.some(
|
|
3743
|
+
(l) =>
|
|
3744
|
+
l.text === '@+draft/multiline :alice!alice@example.com PRIVMSG #foo :line one\nline two',
|
|
3745
|
+
),
|
|
3746
|
+
).toBe(true);
|
|
3747
|
+
});
|
|
3748
|
+
|
|
3749
|
+
it('relays a 2-line batch to a multiline-capable peer as a BATCH frame', async () => {
|
|
3750
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3751
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3752
|
+
await setupMultilineChannel(runtime, [
|
|
3753
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3754
|
+
{ conn: 'c3', nick: 'carol', caps: ['batch', 'draft/multiline'] },
|
|
3755
|
+
]);
|
|
3756
|
+
runtime.calls.length = 0;
|
|
3757
|
+
|
|
3758
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3759
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
3760
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
3761
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3762
|
+
|
|
3763
|
+
// Multiline-capable carol receives the replayed BATCH framing. The batch
|
|
3764
|
+
// ref id is allocated from the shared id factory (offset by per-frame
|
|
3765
|
+
// trace ids), so match the shape rather than the exact id.
|
|
3766
|
+
const carolLines = sentTo(runtime, 'c3');
|
|
3767
|
+
const joined = carolLines.map((l) => l.text).join('|');
|
|
3768
|
+
expect(joined).toMatch(/BATCH \+batch-\d+ draft\/multiline #foo/);
|
|
3769
|
+
expect(joined).toContain(':alice!alice@example.com PRIVMSG #foo :line one');
|
|
3770
|
+
expect(joined).toContain(':alice!alice@example.com PRIVMSG #foo :line two');
|
|
3771
|
+
expect(joined).toMatch(/BATCH -batch-\d+/);
|
|
3772
|
+
// And she does NOT receive the legacy joined form.
|
|
3773
|
+
expect(carolLines.some((l) => l.text.startsWith('@+draft/multiline'))).toBe(false);
|
|
3774
|
+
});
|
|
3775
|
+
|
|
3776
|
+
it('rejects an over-budget batch with FAIL and relays nothing', async () => {
|
|
3777
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3778
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
3779
|
+
await setupMultilineChannel(runtime, [
|
|
3780
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3781
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3782
|
+
]);
|
|
3783
|
+
runtime.calls.length = 0;
|
|
3784
|
+
|
|
3785
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3786
|
+
await actor.receiveTextFrame('PRIVMSG #foo :aaaaaaaaaa\r\n');
|
|
3787
|
+
await actor.receiveTextFrame('PRIVMSG #foo :bbbbbbbbbb\r\n');
|
|
3788
|
+
// Two 10-char lines => joined 10 + 1 + 10 = 21 bytes. The actor uses the
|
|
3789
|
+
// default 4096 budget; force a tiny budget via a config-override actor.
|
|
3790
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3791
|
+
|
|
3792
|
+
// With the default budget this is accepted; assert no FAIL on this path.
|
|
3793
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes('FAIL BATCH'))).toBe(false);
|
|
3794
|
+
});
|
|
3795
|
+
|
|
3796
|
+
it('rejects an over-budget batch with FAIL when the configured budget is small', async () => {
|
|
3797
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3798
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
3799
|
+
// Override the actor's config to advertise a tiny multiline budget.
|
|
3800
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
3801
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
3802
|
+
multilineMaxBytes: 10,
|
|
3803
|
+
};
|
|
3804
|
+
await setupMultilineChannel(runtime, [
|
|
3805
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3806
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3807
|
+
]);
|
|
3808
|
+
runtime.calls.length = 0;
|
|
3809
|
+
|
|
3810
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3811
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
3812
|
+
await actor.receiveTextFrame('PRIVMSG #foo :world\r\n');
|
|
3813
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3814
|
+
|
|
3815
|
+
// Sender receives the FAIL; no broadcast to bob.
|
|
3816
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3817
|
+
expect(
|
|
3818
|
+
aliceLines.some(
|
|
3819
|
+
(l) =>
|
|
3820
|
+
l.text ===
|
|
3821
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES #foo :Multi-line batch exceeds the 10 byte limit',
|
|
3822
|
+
),
|
|
3823
|
+
).toBe(true);
|
|
3824
|
+
expect(runtime.calls.some((c) => c.method === 'broadcast')).toBe(false);
|
|
3825
|
+
});
|
|
3826
|
+
|
|
3827
|
+
it('ignores a BATCH open from a client without the draft/multiline cap (421)', async () => {
|
|
3828
|
+
const { actor, runtime } = makeActor({});
|
|
3829
|
+
// Sender has batch but NOT draft/multiline.
|
|
3830
|
+
await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice', caps: ['batch'] }]);
|
|
3831
|
+
runtime.calls.length = 0;
|
|
3832
|
+
|
|
3833
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3834
|
+
|
|
3835
|
+
// Falls back to 421 ERR_UNKNOWNCOMMAND for an unsupported batch open.
|
|
3836
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3837
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3838
|
+
});
|
|
3839
|
+
|
|
3840
|
+
it('ignores inner PRIVMSG while no multiline batch is open (normal delivery)', async () => {
|
|
3841
|
+
const { actor, runtime } = makeActor({});
|
|
3842
|
+
await setupMultilineChannel(runtime, [
|
|
3843
|
+
{ conn: 'c1', nick: 'alice' },
|
|
3844
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3845
|
+
]);
|
|
3846
|
+
runtime.calls.length = 0;
|
|
3847
|
+
|
|
3848
|
+
// A standalone PRIVMSG (no open batch) is delivered as a normal channel
|
|
3849
|
+
// message, not swallowed by the multiline accumulator.
|
|
3850
|
+
await actor.receiveTextFrame('PRIVMSG #foo :plain\r\n');
|
|
3851
|
+
|
|
3852
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
3853
|
+
expect(bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :plain')).toBe(
|
|
3854
|
+
true,
|
|
3855
|
+
);
|
|
3856
|
+
});
|
|
3857
|
+
|
|
3858
|
+
it('falls back to 421 when closing a batch with an unknown id', async () => {
|
|
3859
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3860
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3861
|
+
await setupMultilineChannel(runtime, [
|
|
3862
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3863
|
+
]);
|
|
3864
|
+
runtime.calls.length = 0;
|
|
3865
|
+
|
|
3866
|
+
// No batch is open; `BATCH -nope` hits the unknown-id path.
|
|
3867
|
+
await actor.receiveTextFrame('BATCH -nope\r\n');
|
|
3868
|
+
|
|
3869
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3870
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3871
|
+
});
|
|
3872
|
+
|
|
3873
|
+
it('aborts the batch with FAIL when an inner line targets a different channel', async () => {
|
|
3874
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3875
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3876
|
+
await setupMultilineChannel(runtime, [
|
|
3877
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3878
|
+
]);
|
|
3879
|
+
runtime.calls.length = 0;
|
|
3880
|
+
|
|
3881
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3882
|
+
await actor.receiveTextFrame('PRIVMSG #bar :wrong target\r\n');
|
|
3883
|
+
|
|
3884
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3885
|
+
expect(
|
|
3886
|
+
aliceLines.some((l) => l.text.includes('FAIL BATCH MULTILINE_INVALID_TARGET #foo')),
|
|
3887
|
+
).toBe(true);
|
|
3888
|
+
});
|
|
3889
|
+
|
|
3890
|
+
it('aborts the batch with FAIL when inner lines mix PRIVMSG and NOTICE', async () => {
|
|
3891
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3892
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3893
|
+
await setupMultilineChannel(runtime, [
|
|
3894
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3895
|
+
]);
|
|
3896
|
+
runtime.calls.length = 0;
|
|
3897
|
+
|
|
3898
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3899
|
+
await actor.receiveTextFrame('PRIVMSG #foo :first\r\n');
|
|
3900
|
+
await actor.receiveTextFrame('NOTICE #foo :mixed\r\n');
|
|
3901
|
+
|
|
3902
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3903
|
+
expect(
|
|
3904
|
+
aliceLines.some((l) => l.text.includes('FAIL BATCH MULTILINE_MIXED_COMMANDS #foo')),
|
|
3905
|
+
).toBe(true);
|
|
3906
|
+
});
|
|
3907
|
+
|
|
3908
|
+
it('falls back to 421 for a BATCH frame with a malformed marker', async () => {
|
|
3909
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3910
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3911
|
+
runtime.calls.length = 0;
|
|
3912
|
+
|
|
3913
|
+
// Neither `+id` nor `-id` — the marker is a bare word.
|
|
3914
|
+
await actor.receiveTextFrame('BATCH bogus\r\n');
|
|
3915
|
+
|
|
3916
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3917
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3918
|
+
});
|
|
3919
|
+
|
|
3920
|
+
it('falls back to 421 for a BATCH open with the wrong batch type', async () => {
|
|
3921
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3922
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3923
|
+
runtime.calls.length = 0;
|
|
3924
|
+
|
|
3925
|
+
await actor.receiveTextFrame('BATCH +m1 netsplit :#foo\r\n');
|
|
3926
|
+
|
|
3927
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3928
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3929
|
+
});
|
|
3930
|
+
|
|
3931
|
+
it('falls back to 421 when opening a second batch before the first closes', async () => {
|
|
3932
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3933
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3934
|
+
await setupMultilineChannel(runtime, [
|
|
3935
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3936
|
+
]);
|
|
3937
|
+
runtime.calls.length = 0;
|
|
3938
|
+
|
|
3939
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3940
|
+
// Re-open without closing the first.
|
|
3941
|
+
await actor.receiveTextFrame('BATCH +m2 draft/multiline :#foo\r\n');
|
|
3942
|
+
|
|
3943
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3944
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3945
|
+
});
|
|
3946
|
+
|
|
3947
|
+
it('falls back to 421 for a BATCH frame with no params', async () => {
|
|
3948
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3949
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3950
|
+
runtime.calls.length = 0;
|
|
3951
|
+
|
|
3952
|
+
await actor.receiveTextFrame('BATCH\r\n');
|
|
3953
|
+
|
|
3954
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3955
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3956
|
+
});
|
|
3957
|
+
|
|
3958
|
+
it('accumulates an inner PRIVMSG with no trailing text as empty', async () => {
|
|
3959
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3960
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3961
|
+
await setupMultilineChannel(runtime, [
|
|
3962
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3963
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3964
|
+
]);
|
|
3965
|
+
runtime.calls.length = 0;
|
|
3966
|
+
|
|
3967
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3968
|
+
// Inner PRIVMSG with no trailing parameter — exercises the `?? ''` fallback.
|
|
3969
|
+
await actor.receiveTextFrame('PRIVMSG #foo\r\n');
|
|
3970
|
+
await actor.receiveTextFrame('PRIVMSG #foo :real text\r\n');
|
|
3971
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3972
|
+
|
|
3973
|
+
// The batch still finalizes; bob receives the joined content (empty + real).
|
|
3974
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
3975
|
+
expect(bobLines.some((l) => l.text.includes('PRIVMSG #foo :'))).toBe(true);
|
|
3976
|
+
});
|
|
3977
|
+
|
|
3978
|
+
it('treats accumulateMultilineLine as a no-op when no batch is open (defensive guard)', () => {
|
|
3979
|
+
const { actor } = makeActor({});
|
|
3980
|
+
// route() only dispatches PRIVMSG/NOTICE here while a batch is open; the
|
|
3981
|
+
// method still guards defensively so a future caller cannot crash. Pin
|
|
3982
|
+
// that no-op contract by invoking the private method with no batch open.
|
|
3983
|
+
const result = (
|
|
3984
|
+
actor as unknown as {
|
|
3985
|
+
accumulateMultilineLine: (msg: { command: string; params: string[] }) => unknown[];
|
|
3986
|
+
}
|
|
3987
|
+
).accumulateMultilineLine({ command: 'PRIVMSG', params: ['#foo', 'hi'] });
|
|
3988
|
+
expect(result).toEqual([]);
|
|
3989
|
+
});
|
|
3990
|
+
});
|
|
3991
|
+
|
|
3992
|
+
// ---------------------------------------------------------------------------
|
|
3993
|
+
// draft/multiline BATCH — nick-target routing
|
|
3994
|
+
//
|
|
3995
|
+
// A batch whose target is a nick (not a channel) must be delivered to that
|
|
3996
|
+
// nick only. The pure reducer is channel-focused (Broadcast); nick-target
|
|
3997
|
+
// delivery requires actor-side per-recipient cap resolution.
|
|
3998
|
+
// ---------------------------------------------------------------------------
|
|
3999
|
+
|
|
4000
|
+
describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-marker)', () => {
|
|
4001
|
+
it('persists an AWAY reason to the bound AwayStore for an identified connection', async () => {
|
|
4002
|
+
const away = new InMemoryAwayStore();
|
|
4003
|
+
const { actor, conn } = makeActor({ away, conn: { account: 'alice' } });
|
|
4004
|
+
|
|
4005
|
+
await actor.receiveTextFrame('AWAY :gone fishing\r\n');
|
|
4006
|
+
|
|
4007
|
+
expect(conn.away).toBe('gone fishing');
|
|
4008
|
+
expect(away.get('alice')).toBe('gone fishing');
|
|
4009
|
+
});
|
|
4010
|
+
|
|
4011
|
+
it('does not persist AWAY to the store when the connection is unidentified', async () => {
|
|
4012
|
+
const away = new InMemoryAwayStore();
|
|
4013
|
+
const { actor, conn } = makeActor({ away });
|
|
4014
|
+
|
|
4015
|
+
await actor.receiveTextFrame('AWAY :gone fishing\r\n');
|
|
4016
|
+
|
|
4017
|
+
// In-memory session state still tracks the reason; the persisted store
|
|
4018
|
+
// stays empty because the connection has no account.
|
|
4019
|
+
expect(conn.away).toBe('gone fishing');
|
|
4020
|
+
expect(away.get('alice')).toBeUndefined();
|
|
4021
|
+
});
|
|
4022
|
+
|
|
4023
|
+
it('persists a +draft/read-marker TAGMSG to the bound ReadMarkerStore for an identified member', async () => {
|
|
4024
|
+
const readMarkers = new InMemoryReadMarkerStore();
|
|
4025
|
+
const { actor, runtime, conn } = makeActor({ readMarkers });
|
|
4026
|
+
conn.account = 'alice';
|
|
4027
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4028
|
+
await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice' }]);
|
|
4029
|
+
runtime.calls.length = 0;
|
|
4030
|
+
|
|
4031
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
|
|
4032
|
+
|
|
4033
|
+
expect(readMarkers.get('alice', '#foo')).toBe('msg-42');
|
|
4034
|
+
});
|
|
4035
|
+
});
|
|
4036
|
+
|
|
4037
|
+
describe('ConnectionActor — draft/multiline BATCH to a nick target', () => {
|
|
4038
|
+
function addRecipient(
|
|
4039
|
+
runtime: FakeRuntime,
|
|
4040
|
+
conn: ConnId,
|
|
4041
|
+
nick: string,
|
|
4042
|
+
caps?: ReadonlyArray<string>,
|
|
4043
|
+
): ConnectionState {
|
|
4044
|
+
const state = createConnection({ id: conn, connectedSince: 0 });
|
|
4045
|
+
state.nick = nick;
|
|
4046
|
+
state.user = nick;
|
|
4047
|
+
state.host = `${nick}.example`;
|
|
4048
|
+
state.registration = 'registered';
|
|
4049
|
+
if (caps !== undefined) state.caps = new Set<string>(caps);
|
|
4050
|
+
runtime.addConnection(state);
|
|
4051
|
+
return state;
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
it('delivers a nick-target batch to a multiline-capable recipient as a BATCH frame', async () => {
|
|
4055
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4056
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4057
|
+
addRecipient(runtime, 'c2', 'bob', ['batch', 'draft/multiline']);
|
|
4058
|
+
runtime.calls.length = 0;
|
|
4059
|
+
|
|
4060
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4061
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4062
|
+
await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
|
|
4063
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4064
|
+
|
|
4065
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4066
|
+
const joined = bobLines.map((l) => l.text).join('|');
|
|
4067
|
+
expect(joined).toMatch(/BATCH \+batch-\d+ draft\/multiline bob/);
|
|
4068
|
+
expect(joined).toContain(':alice!alice@example.com PRIVMSG bob :line one');
|
|
4069
|
+
expect(joined).toContain(':alice!alice@example.com PRIVMSG bob :line two');
|
|
4070
|
+
expect(joined).toMatch(/BATCH -batch-\d+/);
|
|
4071
|
+
});
|
|
4072
|
+
|
|
4073
|
+
it('delivers a nick-target batch to a legacy recipient as a single joined PRIVMSG', async () => {
|
|
4074
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4075
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4076
|
+
addRecipient(runtime, 'c2', 'bob');
|
|
4077
|
+
runtime.calls.length = 0;
|
|
4078
|
+
|
|
4079
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4080
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4081
|
+
await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
|
|
4082
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4083
|
+
|
|
4084
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4085
|
+
expect(
|
|
4086
|
+
bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG bob :line one\nline two'),
|
|
4087
|
+
).toBe(true);
|
|
4088
|
+
});
|
|
4089
|
+
|
|
4090
|
+
it('delivers a nick-target batch to a message-tags recipient as a tagged joined PRIVMSG', async () => {
|
|
4091
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4092
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4093
|
+
addRecipient(runtime, 'c2', 'bob', ['message-tags']);
|
|
4094
|
+
runtime.calls.length = 0;
|
|
4095
|
+
|
|
4096
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4097
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4098
|
+
await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
|
|
4099
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4100
|
+
|
|
4101
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4102
|
+
expect(
|
|
4103
|
+
bobLines.some(
|
|
4104
|
+
(l) =>
|
|
4105
|
+
l.text === '@+draft/multiline :alice!alice@example.com PRIVMSG bob :line one\nline two',
|
|
4106
|
+
),
|
|
4107
|
+
).toBe(true);
|
|
4108
|
+
});
|
|
4109
|
+
|
|
4110
|
+
it('does not broadcast a nick-target batch to other connections', async () => {
|
|
4111
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4112
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4113
|
+
addRecipient(runtime, 'c2', 'bob', ['batch', 'draft/multiline']);
|
|
4114
|
+
addRecipient(runtime, 'c3', 'carol', ['batch', 'draft/multiline']);
|
|
4115
|
+
runtime.calls.length = 0;
|
|
4116
|
+
|
|
4117
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4118
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4119
|
+
await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
|
|
4120
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4121
|
+
|
|
4122
|
+
// Carol is NOT the nick target and must not receive the batch.
|
|
4123
|
+
const carolLines = sentTo(runtime, 'c3');
|
|
4124
|
+
expect(carolLines).toHaveLength(0);
|
|
4125
|
+
});
|
|
4126
|
+
|
|
4127
|
+
it('surfaces 401 ERR_NOSUCHNICK for a nick-target PRIVMSG when the recipient is offline', async () => {
|
|
4128
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4129
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4130
|
+
// bob is NOT registered → lookupNick returns null.
|
|
4131
|
+
runtime.calls.length = 0;
|
|
4132
|
+
|
|
4133
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4134
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4135
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4136
|
+
|
|
4137
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4138
|
+
expect(aliceLines.some((l) => l.text.includes(' 401 '))).toBe(true);
|
|
4139
|
+
expect(aliceLines.some((l) => l.text.includes('bob'))).toBe(true);
|
|
4140
|
+
});
|
|
4141
|
+
|
|
4142
|
+
it('omits 401 for a nick-target NOTICE when the recipient is offline', async () => {
|
|
4143
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4144
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4145
|
+
runtime.calls.length = 0;
|
|
4146
|
+
|
|
4147
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4148
|
+
await actor.receiveTextFrame('NOTICE bob :quiet one\r\n');
|
|
4149
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4150
|
+
|
|
4151
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4152
|
+
expect(aliceLines.some((l) => l.text.includes(' 401 '))).toBe(false);
|
|
4153
|
+
});
|
|
4154
|
+
|
|
4155
|
+
it('delivers nothing for an empty nick-target batch', async () => {
|
|
4156
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4157
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4158
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4159
|
+
bobConn.nick = 'bob';
|
|
4160
|
+
bobConn.user = 'bob';
|
|
4161
|
+
bobConn.host = 'bob.example';
|
|
4162
|
+
bobConn.registration = 'registered';
|
|
4163
|
+
bobConn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4164
|
+
runtime.addConnection(bobConn);
|
|
4165
|
+
runtime.calls.length = 0;
|
|
4166
|
+
|
|
4167
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4168
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4169
|
+
|
|
4170
|
+
expect(sentTo(runtime, 'c2')).toHaveLength(0);
|
|
4171
|
+
});
|
|
4172
|
+
|
|
4173
|
+
it('rejects an over-budget nick-target batch with FAIL', async () => {
|
|
4174
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4175
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
4176
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4177
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4178
|
+
multilineMaxBytes: 5,
|
|
4179
|
+
};
|
|
4180
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4181
|
+
bobConn.nick = 'bob';
|
|
4182
|
+
bobConn.user = 'bob';
|
|
4183
|
+
bobConn.host = 'bob.example';
|
|
4184
|
+
bobConn.registration = 'registered';
|
|
4185
|
+
runtime.addConnection(bobConn);
|
|
4186
|
+
runtime.calls.length = 0;
|
|
4187
|
+
|
|
4188
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4189
|
+
await actor.receiveTextFrame('PRIVMSG bob :hello world\r\n');
|
|
4190
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4191
|
+
|
|
4192
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4193
|
+
expect(
|
|
4194
|
+
aliceLines.some(
|
|
4195
|
+
(l) =>
|
|
4196
|
+
l.text ===
|
|
4197
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES bob :Multi-line batch exceeds the 5 byte limit',
|
|
4198
|
+
),
|
|
4199
|
+
).toBe(true);
|
|
4200
|
+
expect(sentTo(runtime, 'c2')).toHaveLength(0);
|
|
4201
|
+
});
|
|
4202
|
+
|
|
4203
|
+
it('surfaces 401 when the recipient connection is gone mid-flight', async () => {
|
|
4204
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4205
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4206
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4207
|
+
bobConn.nick = 'bob';
|
|
4208
|
+
bobConn.user = 'bob';
|
|
4209
|
+
bobConn.host = 'bob.example';
|
|
4210
|
+
bobConn.registration = 'registered';
|
|
4211
|
+
runtime.addConnection(bobConn);
|
|
4212
|
+
// Drop the connection AFTER nick registration so lookupNick resolves but
|
|
4213
|
+
// getConnection returns null (the "gone between the two awaits" race).
|
|
4214
|
+
runtime.dropConnection('c2');
|
|
4215
|
+
runtime.calls.length = 0;
|
|
4216
|
+
|
|
4217
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4218
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4219
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4220
|
+
|
|
4221
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4222
|
+
expect(aliceLines.some((l) => l.text.includes(' 401 '))).toBe(true);
|
|
4223
|
+
});
|
|
4224
|
+
|
|
4225
|
+
it('uses "?" and "*" fallbacks when the sender has no nick', async () => {
|
|
4226
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4227
|
+
// A connection that negotiated caps but has no NICK set — exercises the
|
|
4228
|
+
// hostmask `?? '?'` and numeric `?? '*'` fallbacks. `delete` is required
|
|
4229
|
+
// because `exactOptionalPropertyTypes` forbids `= undefined`.
|
|
4230
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
4231
|
+
delete conn.nick;
|
|
4232
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
4233
|
+
delete conn.user;
|
|
4234
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
4235
|
+
delete conn.host;
|
|
4236
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4237
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4238
|
+
bobConn.nick = 'bob';
|
|
4239
|
+
bobConn.user = 'bob';
|
|
4240
|
+
bobConn.host = 'bob.example';
|
|
4241
|
+
bobConn.registration = 'registered';
|
|
4242
|
+
bobConn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4243
|
+
runtime.addConnection(bobConn);
|
|
4244
|
+
runtime.calls.length = 0;
|
|
4245
|
+
|
|
4246
|
+
// Online recipient → hostmask falls back to "?".
|
|
4247
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4248
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4249
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4250
|
+
|
|
4251
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4252
|
+
expect(bobLines.some((l) => l.text.startsWith(':? PRIVMSG bob :line one'))).toBe(true);
|
|
4253
|
+
|
|
4254
|
+
// Offline recipient → numeric falls back to "*".
|
|
4255
|
+
runtime.calls.length = 0;
|
|
4256
|
+
await actor.receiveTextFrame('BATCH +m2 draft/multiline :carol\r\n');
|
|
4257
|
+
await actor.receiveTextFrame('PRIVMSG carol :no recipient\r\n');
|
|
4258
|
+
await actor.receiveTextFrame('BATCH -m2\r\n');
|
|
4259
|
+
|
|
4260
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4261
|
+
expect(aliceLines.some((l) => l.text.includes(' 401 * carol'))).toBe(true);
|
|
4262
|
+
});
|
|
4263
|
+
});
|
|
4264
|
+
|
|
4265
|
+
// ---------------------------------------------------------------------------
|
|
4266
|
+
// draft/multiline BATCH — chathistory recording
|
|
4267
|
+
//
|
|
4268
|
+
// A finalized multi-line batch must be recorded once into the bound
|
|
4269
|
+
// MessageStore so CHATHISTORY AFTER/BEFORE/LATEST return exactly one entry
|
|
4270
|
+
// per batch, carrying the joined body, the assigned msgid, and the
|
|
4271
|
+
// +draft/multiline client tag.
|
|
4272
|
+
// ---------------------------------------------------------------------------
|
|
4273
|
+
|
|
4274
|
+
describe('ConnectionActor — draft/multiline chathistory recording', () => {
|
|
4275
|
+
it('records the joined channel batch into the MessageStore', async () => {
|
|
4276
|
+
const store = new InMemoryMessageStore();
|
|
4277
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4278
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4279
|
+
await setupMultilineChannel(runtime, [
|
|
4280
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4281
|
+
]);
|
|
4282
|
+
runtime.calls.length = 0;
|
|
4283
|
+
|
|
4284
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4285
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
4286
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
4287
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4288
|
+
|
|
4289
|
+
const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
|
|
4290
|
+
expect(recorded).toHaveLength(1);
|
|
4291
|
+
expect(recorded[0]?.text).toBe('line one\nline two');
|
|
4292
|
+
expect(recorded[0]?.command).toBe('PRIVMSG');
|
|
4293
|
+
expect(recorded[0]?.nick).toBe('alice');
|
|
4294
|
+
expect(recorded[0]?.msgid).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
4295
|
+
});
|
|
4296
|
+
|
|
4297
|
+
it('CHATHISTORY replay carries the +draft/multiline tag on the joined body', async () => {
|
|
4298
|
+
const store = new InMemoryMessageStore();
|
|
4299
|
+
const chathistoryCaps = new Set<string>([
|
|
4300
|
+
'batch',
|
|
4301
|
+
'draft/multiline',
|
|
4302
|
+
'draft/chathistory',
|
|
4303
|
+
'server-time',
|
|
4304
|
+
'message-tags',
|
|
4305
|
+
]);
|
|
4306
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4307
|
+
conn.caps = chathistoryCaps;
|
|
4308
|
+
await setupMultilineChannel(runtime, [
|
|
4309
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4310
|
+
]);
|
|
4311
|
+
runtime.calls.length = 0;
|
|
4312
|
+
|
|
4313
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4314
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
4315
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
4316
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4317
|
+
runtime.calls.length = 0;
|
|
4318
|
+
|
|
4319
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 1\r\n');
|
|
4320
|
+
|
|
4321
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4322
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
4323
|
+
const replayLine = lines.find((l) => l.text.includes('PRIVMSG #foo'));
|
|
4324
|
+
expect(replayLine?.text).toContain('+draft/multiline');
|
|
4325
|
+
expect(replayLine?.text).toContain('line one\nline two');
|
|
4326
|
+
});
|
|
4327
|
+
|
|
4328
|
+
it('records the joined nick-target batch into the MessageStore', async () => {
|
|
4329
|
+
const store = new InMemoryMessageStore();
|
|
4330
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4331
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4332
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4333
|
+
bobConn.nick = 'bob';
|
|
4334
|
+
bobConn.user = 'bob';
|
|
4335
|
+
bobConn.host = 'bob.example';
|
|
4336
|
+
bobConn.registration = 'registered';
|
|
4337
|
+
bobConn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4338
|
+
runtime.addConnection(bobConn);
|
|
4339
|
+
runtime.calls.length = 0;
|
|
4340
|
+
|
|
4341
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4342
|
+
await actor.receiveTextFrame('PRIVMSG bob :pm one\r\n');
|
|
4343
|
+
await actor.receiveTextFrame('PRIVMSG bob :pm two\r\n');
|
|
4344
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4345
|
+
|
|
4346
|
+
const recorded = store.query({ chan: 'bob', direction: 'latest', limit: 10 });
|
|
4347
|
+
expect(recorded).toHaveLength(1);
|
|
4348
|
+
expect(recorded[0]?.text).toBe('pm one\npm two');
|
|
4349
|
+
});
|
|
4350
|
+
|
|
4351
|
+
it('does not record when no MessageStore is bound', async () => {
|
|
4352
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4353
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4354
|
+
await setupMultilineChannel(runtime, [
|
|
4355
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4356
|
+
]);
|
|
4357
|
+
runtime.calls.length = 0;
|
|
4358
|
+
|
|
4359
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4360
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
4361
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
4362
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4363
|
+
|
|
4364
|
+
// No crash, no store — the delivery still works (broadcast happened).
|
|
4365
|
+
expect(runtime.calls.some((c) => c.method === 'broadcast')).toBe(false);
|
|
4366
|
+
// Cap-split path emits a send to bob via getChannelSnapshot…
|
|
4367
|
+
expect(runtime.calls.some((c) => c.method === 'getChannelSnapshot')).toBe(true);
|
|
4368
|
+
});
|
|
4369
|
+
});
|