serverless-ircd 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@serverless-ircd/ci-hardening",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "CI hardening helpers: coverage-gate config validator + mutation-testing driver for irc-core",
|
|
6
|
+
"license": "BSD-3-Clause",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.build.json",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.test.json --noEmit",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"coverage": "vitest run --coverage",
|
|
16
|
+
"start": "node --enable-source-maps ./dist/main.js",
|
|
17
|
+
"clean": "rimraf dist coverage .tsbuildinfo .turbo"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"js-yaml": "^4.1.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/js-yaml": "^4.0.9",
|
|
24
|
+
"@types/node": "^26.1.1",
|
|
25
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
26
|
+
"rimraf": "^6.0.0",
|
|
27
|
+
"typescript": "^5.6.0",
|
|
28
|
+
"vitest": "^4.1.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { load } from 'js-yaml';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimum mutation score (in percent) required for the Stryker
|
|
6
|
+
* spot-checks on `irc-core/protocol` and `irc-core/commands`.
|
|
7
|
+
*
|
|
8
|
+
* Exported so the test-suite and any future dashboards can
|
|
9
|
+
* import the canonical value instead of re-hard-coding it.
|
|
10
|
+
*/
|
|
11
|
+
export const MUTATION_SCORE_THRESHOLD = 80;
|
|
12
|
+
|
|
13
|
+
export interface CoverageThresholds {
|
|
14
|
+
lines: number;
|
|
15
|
+
functions: number;
|
|
16
|
+
branches: number;
|
|
17
|
+
statements: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface StrykerThresholds {
|
|
21
|
+
high?: number;
|
|
22
|
+
low?: number;
|
|
23
|
+
break?: number | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface StrykerConfig {
|
|
27
|
+
mutate?: unknown;
|
|
28
|
+
thresholds?: StrykerThresholds;
|
|
29
|
+
coverageAnalysis?: string;
|
|
30
|
+
testRunner?: string;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface WorkflowJob {
|
|
35
|
+
steps: Array<{ name?: string; run?: string; [key: string]: unknown }>;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ParsedWorkflow {
|
|
40
|
+
jobs?: Record<string, WorkflowJob>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract the `coverage.thresholds` block from a vitest config file.
|
|
45
|
+
*
|
|
46
|
+
* Vitest configs are plain TypeScript modules; rather than pulling in
|
|
47
|
+
* the full TS toolchain to evaluate them, we parse the source as text
|
|
48
|
+
* and pull out the `thresholds: { ... }` object. This deliberately
|
|
49
|
+
* fails loud (returns NaN-bearing values) if the config shape drifts,
|
|
50
|
+
* so the test-suite notices immediately.
|
|
51
|
+
*
|
|
52
|
+
* @param configPath absolute path to a vitest.config.ts file.
|
|
53
|
+
*/
|
|
54
|
+
export function readCoverageThresholds(configPath: string): CoverageThresholds {
|
|
55
|
+
const src = readFileSync(configPath, 'utf8');
|
|
56
|
+
const match = src.match(/thresholds:\s*\{([^}]*)\}/);
|
|
57
|
+
if (match === null || match[1] === undefined) {
|
|
58
|
+
throw new Error(`No coverage.thresholds block found in ${configPath}`);
|
|
59
|
+
}
|
|
60
|
+
const block = match[1];
|
|
61
|
+
const grab = (key: string): number => {
|
|
62
|
+
const m = block.match(new RegExp(`${key}:\\s*(\\d+)`));
|
|
63
|
+
if (m === null || m[1] === undefined) {
|
|
64
|
+
throw new Error(`No ${key} threshold found in ${configPath}`);
|
|
65
|
+
}
|
|
66
|
+
return Number.parseInt(m[1], 10);
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
lines: grab('lines'),
|
|
70
|
+
functions: grab('functions'),
|
|
71
|
+
branches: grab('branches'),
|
|
72
|
+
statements: grab('statements'),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Load and validate a Stryker JSON config file.
|
|
78
|
+
*
|
|
79
|
+
* @param configPath absolute path to a `stryker.*.conf.json` file.
|
|
80
|
+
*/
|
|
81
|
+
export function readStrykerConfig(configPath: string): StrykerConfig {
|
|
82
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
83
|
+
const parsed = JSON.parse(raw) as StrykerConfig;
|
|
84
|
+
if (!Array.isArray(parsed.mutate)) {
|
|
85
|
+
throw new Error(`Stryker config at ${configPath} is missing mutate[]`);
|
|
86
|
+
}
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse a GitHub Actions YAML file and return the named job.
|
|
92
|
+
*
|
|
93
|
+
* @param yamlText raw YAML source of a workflow file.
|
|
94
|
+
* @param jobName key under `jobs:`.
|
|
95
|
+
*/
|
|
96
|
+
export function readWorkflowJob(yamlText: string, jobName: string): WorkflowJob {
|
|
97
|
+
const parsed = load(yamlText) as ParsedWorkflow;
|
|
98
|
+
const job = parsed.jobs?.[jobName];
|
|
99
|
+
if (job === undefined) {
|
|
100
|
+
throw new Error(`Workflow has no job named "${jobName}"`);
|
|
101
|
+
}
|
|
102
|
+
return job;
|
|
103
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Fixture: vitest config with only some threshold keys present.
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
environment: 'node',
|
|
7
|
+
coverage: {
|
|
8
|
+
provider: 'v8',
|
|
9
|
+
thresholds: {
|
|
10
|
+
functions: 100,
|
|
11
|
+
branches: 100,
|
|
12
|
+
statements: 100,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
MUTATION_SCORE_THRESHOLD,
|
|
6
|
+
readCoverageThresholds,
|
|
7
|
+
readStrykerConfig,
|
|
8
|
+
readWorkflowJob,
|
|
9
|
+
} from '../src/validate.js';
|
|
10
|
+
|
|
11
|
+
const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..');
|
|
12
|
+
|
|
13
|
+
function repoPath(rel: string): string {
|
|
14
|
+
return resolve(REPO_ROOT, rel);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readText(rel: string): string {
|
|
18
|
+
return readFileSync(repoPath(rel), 'utf8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('coverage gate', () => {
|
|
22
|
+
it('enforces 100% line/function/branch/statement coverage on irc-core', () => {
|
|
23
|
+
const thresholds = readCoverageThresholds(repoPath('packages/irc-core/vitest.config.ts'));
|
|
24
|
+
expect(thresholds).toEqual({
|
|
25
|
+
lines: 100,
|
|
26
|
+
functions: 100,
|
|
27
|
+
branches: 100,
|
|
28
|
+
statements: 100,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('enforces ≥90% coverage on cf-adapter', () => {
|
|
33
|
+
const thresholds = readCoverageThresholds(repoPath('packages/cf-adapter/vitest.config.ts'));
|
|
34
|
+
expect(thresholds.lines).toBeGreaterThanOrEqual(90);
|
|
35
|
+
expect(thresholds.functions).toBeGreaterThanOrEqual(90);
|
|
36
|
+
expect(thresholds.branches).toBeGreaterThanOrEqual(90);
|
|
37
|
+
expect(thresholds.statements).toBeGreaterThanOrEqual(90);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('enforces ≥90% coverage on irc-server', () => {
|
|
41
|
+
const thresholds = readCoverageThresholds(repoPath('packages/irc-server/vitest.config.ts'));
|
|
42
|
+
expect(thresholds.lines).toBeGreaterThanOrEqual(90);
|
|
43
|
+
expect(thresholds.functions).toBeGreaterThanOrEqual(90);
|
|
44
|
+
expect(thresholds.branches).toBeGreaterThanOrEqual(90);
|
|
45
|
+
expect(thresholds.statements).toBeGreaterThanOrEqual(90);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('enforces ≥90% coverage on in-memory-runtime', () => {
|
|
49
|
+
const thresholds = readCoverageThresholds(
|
|
50
|
+
repoPath('packages/in-memory-runtime/vitest.config.ts'),
|
|
51
|
+
);
|
|
52
|
+
expect(thresholds.lines).toBeGreaterThanOrEqual(90);
|
|
53
|
+
expect(thresholds.functions).toBeGreaterThanOrEqual(90);
|
|
54
|
+
expect(thresholds.branches).toBeGreaterThanOrEqual(90);
|
|
55
|
+
expect(thresholds.statements).toBeGreaterThanOrEqual(90);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('contract tests on every PR', () => {
|
|
60
|
+
it('runs the integration contract suite on PRs via ci.yml', () => {
|
|
61
|
+
const ci = readText('.github/workflows/ci.yml');
|
|
62
|
+
// Pull request trigger must be present so contract suite runs on PRs.
|
|
63
|
+
expect(ci).toMatch(/pull_request:/);
|
|
64
|
+
// The integration contract suite must be invoked. It ships as
|
|
65
|
+
// packages/cf-adapter/tests/integration (the parametrized harness
|
|
66
|
+
// that runs against every runtime) and is exercised by the
|
|
67
|
+
// workspace-wide coverage task. The gate step must explicitly
|
|
68
|
+
// reference the integration suite so it cannot be silently dropped.
|
|
69
|
+
expect(ci).toMatch(/integration/);
|
|
70
|
+
// Coverage must run on PRs (not only on main pushes).
|
|
71
|
+
const job = readWorkflowJob(ci, 'verify');
|
|
72
|
+
const runSteps = job.steps
|
|
73
|
+
.filter((s: { run?: unknown }) => typeof s.run === 'string')
|
|
74
|
+
.map((s: { run?: string }) => s.run ?? '');
|
|
75
|
+
const runsCoverage = runSteps.some((r) => r.includes('pnpm coverage'));
|
|
76
|
+
expect(runsCoverage).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('has an explicit coverage-gate step that fails the build on regressions', () => {
|
|
80
|
+
const ci = readText('.github/workflows/ci.yml');
|
|
81
|
+
// The gate step name must make the failure mode obvious in the
|
|
82
|
+
// GitHub Actions UI.
|
|
83
|
+
expect(ci).toMatch(/coverage gate/i);
|
|
84
|
+
// The gate step must surface an explicit failure marker rather than
|
|
85
|
+
// relying on vitest's implicit threshold exit code alone.
|
|
86
|
+
expect(ci).toMatch(/coverage-thresholds/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('mutation testing spot-check', () => {
|
|
91
|
+
it('exports a documented mutation-score threshold of ≥80%', () => {
|
|
92
|
+
expect(MUTATION_SCORE_THRESHOLD).toBeGreaterThanOrEqual(80);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('ships a Stryker config that targets irc-core/protocol', () => {
|
|
96
|
+
const config = readStrykerConfig(repoPath('packages/irc-core/stryker.protocol.conf.json'));
|
|
97
|
+
expect(config.mutate).toEqual(
|
|
98
|
+
expect.arrayContaining([expect.stringMatching(/protocol\/[^/]+\.ts$/)]),
|
|
99
|
+
);
|
|
100
|
+
expect(config.thresholds?.high).toBeGreaterThanOrEqual(MUTATION_SCORE_THRESHOLD);
|
|
101
|
+
expect(config.thresholds?.break).toBe(MUTATION_SCORE_THRESHOLD);
|
|
102
|
+
// The config must disable Stryker's own coverage runner and reuse
|
|
103
|
+
// vitest's so the per-package coverage gate stays the source of
|
|
104
|
+
// truth for line/function coverage.
|
|
105
|
+
expect(config.coverageAnalysis).toBe('off');
|
|
106
|
+
expect(config.testRunner).toBe('vitest');
|
|
107
|
+
// Under pnpm's symlinked node_modules the Stryker child process
|
|
108
|
+
// cannot auto-discover the vitest plugin, so it must be listed
|
|
109
|
+
// explicitly.
|
|
110
|
+
expect(config.plugins).toEqual(['@stryker-mutator/vitest-runner']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('ships a Stryker config that targets irc-core/commands', () => {
|
|
114
|
+
const config = readStrykerConfig(repoPath('packages/irc-core/stryker.commands.conf.json'));
|
|
115
|
+
expect(config.mutate).toEqual(
|
|
116
|
+
expect.arrayContaining([expect.stringMatching(/commands\/[^/]+\.ts$/)]),
|
|
117
|
+
);
|
|
118
|
+
expect(config.thresholds?.high).toBeGreaterThanOrEqual(MUTATION_SCORE_THRESHOLD);
|
|
119
|
+
expect(config.thresholds?.break).toBe(MUTATION_SCORE_THRESHOLD);
|
|
120
|
+
expect(config.coverageAnalysis).toBe('off');
|
|
121
|
+
expect(config.testRunner).toBe('vitest');
|
|
122
|
+
expect(config.plugins).toEqual(['@stryker-mutator/vitest-runner']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('exposes a workspace-level mutation script that runs both spot-checks', () => {
|
|
126
|
+
const root = JSON.parse(readText('package.json')) as { scripts: Record<string, string> };
|
|
127
|
+
// Root scripts delegate to the irc-core package's stryker scripts.
|
|
128
|
+
expect(root.scripts['mutation:protocol']).toMatch(
|
|
129
|
+
/--filter @serverless-ircd\/irc-core mutation:protocol/,
|
|
130
|
+
);
|
|
131
|
+
expect(root.scripts['mutation:commands']).toMatch(
|
|
132
|
+
/--filter @serverless-ircd\/irc-core mutation:commands/,
|
|
133
|
+
);
|
|
134
|
+
expect(root.scripts.mutation).toMatch(/mutation:protocol/);
|
|
135
|
+
expect(root.scripts.mutation).toMatch(/mutation:commands/);
|
|
136
|
+
|
|
137
|
+
// The irc-core package must actually invoke stryker.
|
|
138
|
+
const ircCore = JSON.parse(readText('packages/irc-core/package.json')) as {
|
|
139
|
+
scripts: Record<string, string>;
|
|
140
|
+
};
|
|
141
|
+
expect(ircCore.scripts['mutation:protocol']).toMatch(
|
|
142
|
+
/stryker run stryker\.protocol\.conf\.json/,
|
|
143
|
+
);
|
|
144
|
+
expect(ircCore.scripts['mutation:commands']).toMatch(
|
|
145
|
+
/stryker run stryker\.commands\.conf\.json/,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('runs mutation spot-checks in CI on every PR', () => {
|
|
150
|
+
const ci = readText('.github/workflows/ci.yml');
|
|
151
|
+
expect(ci).toMatch(/mutation/i);
|
|
152
|
+
expect(ci).toMatch(/stryker/);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('validate.ts error paths', () => {
|
|
157
|
+
it('readCoverageThresholds throws when no thresholds block exists', () => {
|
|
158
|
+
const tmpPath = repoPath('tools/ci-hardening/tests/__no_thresholds__.txt');
|
|
159
|
+
expect(() => readCoverageThresholds(tmpPath)).toThrow(/no thresholds block|No coverage/i);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('readCoverageThresholds throws when a threshold key is missing', () => {
|
|
163
|
+
const tmpPath = repoPath('tools/ci-hardening/tests/__partial_thresholds__.txt');
|
|
164
|
+
expect(() => readCoverageThresholds(tmpPath)).toThrow(/No lines threshold/i);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('readStrykerConfig throws when mutate is missing', () => {
|
|
168
|
+
const tmpPath = repoPath('tools/ci-hardening/tests/__stryker_bad__.json');
|
|
169
|
+
expect(() => readStrykerConfig(tmpPath)).toThrow(/missing mutate/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('readWorkflowJob throws when the job does not exist', () => {
|
|
173
|
+
expect(() => readWorkflowJob('jobs:\n other: {}\n', 'verify')).toThrow(
|
|
174
|
+
/no job named "verify"/,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"composite": true,
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"tsBuildInfoFile": "./.tsbuildinfo",
|
|
8
|
+
"types": ["node"]
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*.ts"],
|
|
11
|
+
"exclude": ["dist", "tests", "**/*.test.ts"]
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'node',
|
|
6
|
+
include: ['tests/**/*.test.ts'],
|
|
7
|
+
coverage: {
|
|
8
|
+
provider: 'v8',
|
|
9
|
+
all: false,
|
|
10
|
+
include: ['src/**/*.ts'],
|
|
11
|
+
exclude: ['src/index.ts'],
|
|
12
|
+
reporter: ['text', 'html', 'json-summary'],
|
|
13
|
+
thresholds: {
|
|
14
|
+
lines: 100,
|
|
15
|
+
functions: 100,
|
|
16
|
+
branches: 100,
|
|
17
|
+
statements: 100,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/tcp-ws-forwarder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { type Socket, createServer } from 'node:net';
|
|
30
30
|
import { type RawData, WebSocket } from 'ws';
|
|
31
31
|
import { LineScanner } from './line-scanner.js';
|
|
32
|
+
import { ConsoleLogger, LogLevel, type Logger } from './logger.js';
|
|
32
33
|
|
|
33
34
|
/** Options accepted by {@link startForwarder}. */
|
|
34
35
|
export interface StartForwarderOptions {
|
|
@@ -41,6 +42,12 @@ export interface StartForwarderOptions {
|
|
|
41
42
|
* `ws:` or `wss:` scheme; otherwise `startForwarder` throws synchronously.
|
|
42
43
|
*/
|
|
43
44
|
targetUrl: string;
|
|
45
|
+
/**
|
|
46
|
+
* Minimum log severity to emit. `debug` traces every relayed line/frame;
|
|
47
|
+
* `info` (default) logs connection lifecycle; `warn`/`error` quiet the
|
|
48
|
+
* bridge to faults only. Mirrors the irc-core `LogLevel` ordering.
|
|
49
|
+
*/
|
|
50
|
+
logLevel?: LogLevel;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/** A running forwarder. Call {@link Forwarder.close} to shut it down. */
|
|
@@ -87,6 +94,8 @@ export function startForwarder(opts: StartForwarderOptions): Promise<Forwarder>
|
|
|
87
94
|
const target = parseTargetUrl(opts.targetUrl);
|
|
88
95
|
const listenHost = opts.listenHost ?? '127.0.0.1';
|
|
89
96
|
const targetHref = target.href;
|
|
97
|
+
const logger: Logger = new ConsoleLogger(undefined, opts.logLevel ?? LogLevel.Info);
|
|
98
|
+
let nextConnId = 1;
|
|
90
99
|
|
|
91
100
|
const bridges = new Map<Socket, () => void>();
|
|
92
101
|
const sockets = new Set<Socket>();
|
|
@@ -94,7 +103,7 @@ export function startForwarder(opts: StartForwarderOptions): Promise<Forwarder>
|
|
|
94
103
|
|
|
95
104
|
const server = createServer((tcp) => {
|
|
96
105
|
sockets.add(tcp);
|
|
97
|
-
bridges.set(tcp, bridgeConnection(tcp, targetHref, bridges, sockets));
|
|
106
|
+
bridges.set(tcp, bridgeConnection(tcp, targetHref, bridges, sockets, logger, nextConnId++));
|
|
98
107
|
});
|
|
99
108
|
|
|
100
109
|
const close = (): Promise<void> => {
|
|
@@ -132,6 +141,9 @@ export function startForwarder(opts: StartForwarderOptions): Promise<Forwarder>
|
|
|
132
141
|
});
|
|
133
142
|
}
|
|
134
143
|
|
|
144
|
+
/** Why a bridge tore down. Surfaces in the `bridge.closed` log record. */
|
|
145
|
+
type CloseReason = 'shutdown' | 'tcp-close' | 'tcp-error' | 'ws-close' | 'ws-error';
|
|
146
|
+
|
|
135
147
|
/**
|
|
136
148
|
* Wires one TCP socket to one fresh WebSocket. Returns a teardown closure
|
|
137
149
|
* that closes both ends idempotently; the caller registers it in `bridges`
|
|
@@ -142,12 +154,17 @@ function bridgeConnection(
|
|
|
142
154
|
targetHref: string,
|
|
143
155
|
bridges: Map<Socket, () => void>,
|
|
144
156
|
sockets: Set<Socket>,
|
|
157
|
+
logger: Logger,
|
|
158
|
+
connId: number,
|
|
145
159
|
): () => void {
|
|
146
160
|
const ws = new WebSocket(targetHref);
|
|
147
161
|
const outgoing = new LineScanner();
|
|
148
162
|
const pending: string[] = [];
|
|
149
163
|
let wsReady = false;
|
|
150
164
|
let bridgeClosed = false;
|
|
165
|
+
const remote = `${tcp.remoteAddress ?? '?'}:${tcp.remotePort ?? '?'}`;
|
|
166
|
+
|
|
167
|
+
logger.info('bridge.accepted', { conn: connId, remote });
|
|
151
168
|
|
|
152
169
|
const sendLine = (line: string): void => {
|
|
153
170
|
if (wsReady) ws.send(line);
|
|
@@ -156,28 +173,35 @@ function bridgeConnection(
|
|
|
156
173
|
|
|
157
174
|
tcp.on('data', (chunk: Buffer) => {
|
|
158
175
|
for (const line of outgoing.push(chunk)) {
|
|
159
|
-
if (line.length > 0)
|
|
176
|
+
if (line.length > 0) {
|
|
177
|
+
logger.debug('tcp.line', { conn: connId, line });
|
|
178
|
+
sendLine(line);
|
|
179
|
+
}
|
|
160
180
|
}
|
|
161
181
|
});
|
|
162
182
|
|
|
163
183
|
ws.on('open', () => {
|
|
164
184
|
wsReady = true;
|
|
185
|
+
const flushed = pending.length;
|
|
165
186
|
for (const line of pending) ws.send(line);
|
|
166
187
|
pending.length = 0;
|
|
188
|
+
logger.debug('ws.open', { conn: connId, flushed });
|
|
167
189
|
});
|
|
168
190
|
|
|
169
191
|
ws.on('message', (data: RawData) => {
|
|
170
192
|
const text = toBuffer(data).toString('utf8');
|
|
193
|
+
logger.debug('ws.frame', { conn: connId, frame: text });
|
|
171
194
|
for (const line of splitFrameLines(text)) {
|
|
172
195
|
if (line.length > 0) tcp.write(`${line}\r\n`);
|
|
173
196
|
}
|
|
174
197
|
});
|
|
175
198
|
|
|
176
|
-
const teardown = (): void => {
|
|
199
|
+
const teardown = (reason: CloseReason): void => {
|
|
177
200
|
if (bridgeClosed) return;
|
|
178
201
|
bridgeClosed = true;
|
|
179
202
|
bridges.delete(tcp);
|
|
180
203
|
sockets.delete(tcp);
|
|
204
|
+
logger.info('bridge.closed', { conn: connId, reason });
|
|
181
205
|
// ws.close() does not throw: on a CONNECTING socket it aborts the
|
|
182
206
|
// handshake and emits an 'error' event, which our handler suppresses
|
|
183
207
|
// via the `bridgeClosed` guard set above.
|
|
@@ -185,25 +209,25 @@ function bridgeConnection(
|
|
|
185
209
|
tcp.destroy();
|
|
186
210
|
};
|
|
187
211
|
|
|
188
|
-
tcp.on('close', teardown);
|
|
212
|
+
tcp.on('close', () => teardown('tcp-close'));
|
|
189
213
|
tcp.on('error', (err) => {
|
|
190
214
|
// Errors that arrive after we already tore this bridge down (e.g. an
|
|
191
215
|
// RST echoing our own `destroy()`) are expected — don't log them.
|
|
192
216
|
if (bridgeClosed) return;
|
|
193
|
-
|
|
194
|
-
teardown();
|
|
217
|
+
logger.error('forwarder tcp socket error', { conn: connId, err });
|
|
218
|
+
teardown('tcp-error');
|
|
195
219
|
});
|
|
196
|
-
ws.on('close', teardown);
|
|
220
|
+
ws.on('close', () => teardown('ws-close'));
|
|
197
221
|
ws.on('error', (err) => {
|
|
198
222
|
// Same suppression as above; also covers the benign "closed before the
|
|
199
223
|
// connection was established" event ws emits when we close() a socket
|
|
200
224
|
// that was still CONNECTING at shutdown time.
|
|
201
225
|
if (bridgeClosed) return;
|
|
202
|
-
|
|
203
|
-
teardown();
|
|
226
|
+
logger.error('forwarder upstream ws error', { conn: connId, err });
|
|
227
|
+
teardown('ws-error');
|
|
204
228
|
});
|
|
205
229
|
|
|
206
|
-
return teardown;
|
|
230
|
+
return () => teardown('shutdown');
|
|
207
231
|
}
|
|
208
232
|
|
|
209
233
|
/**
|
|
@@ -229,16 +253,3 @@ export function toBuffer(data: RawData): Buffer {
|
|
|
229
253
|
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
230
254
|
return Buffer.from(data as Uint8Array);
|
|
231
255
|
}
|
|
232
|
-
|
|
233
|
-
function logError(msg: string, err: unknown): void {
|
|
234
|
-
const payload = {
|
|
235
|
-
ts: new Date().toISOString(),
|
|
236
|
-
level: 'error',
|
|
237
|
-
msg,
|
|
238
|
-
err:
|
|
239
|
-
err instanceof Error
|
|
240
|
-
? { name: err.name, message: err.message, stack: err.stack }
|
|
241
|
-
: String(err),
|
|
242
|
-
};
|
|
243
|
-
console.error(JSON.stringify(payload));
|
|
244
|
-
}
|