diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07af5560f..6321f21f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: Test run: | go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt - go test $(cat /tmp/go-packages.txt) + go test -shuffle=on -count=1 $(cat /tmp/go-packages.txt) codegen: runs-on: ubuntu-latest @@ -69,6 +69,39 @@ jobs: - name: Run govulncheck run: govulncheck ./... + # Race + shuffle hygiene gate: data races and order-dependent tests fail the build. + race: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Stub internal/web/dist for go:embed + run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep + - name: Race + shuffle + run: | + go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt + go test -race -shuffle=on -count=1 $(cat /tmp/go-packages.txt) + + # Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the + # generated corpus plus 30s of exploration; a crash here is a real input-handling bug. + fuzz-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Stub internal/web/dist for go:embed + run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep + - name: Fuzz critical parsers (smoke) + run: | + go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/ + go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/ + frontend: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 000000000..e1efa5267 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,62 @@ +name: Mutation testing + +# Mutation testing (gremlins) is the objective check for "fake" tests: it mutates the +# source and a surviving (LIVED) mutant means no test caught the change. It is SLOW, so it +# runs nightly / on demand and scoped per package — never per-commit. It is informational: +# no thresholds are set, so it reports survivors as artifacts without failing the build. +on: + schedule: + - cron: "0 3 * * *" # 03:00 UTC daily + workflow_dispatch: + +permissions: + contents: read + +jobs: + gremlins: + runs-on: ubuntu-latest + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - name: sub + path: ./internal/sub/ + exclude: "" + - name: runtime + path: ./internal/web/runtime/ + exclude: "" + - name: link + path: ./internal/util/link/ + exclude: "" + - name: database + path: ./internal/database/ + exclude: 'dump_sqlite\.go' + - name: service + path: ./internal/web/service/ + exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go' + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Stub internal/web/dist for go:embed + run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep + - name: Install gremlins + run: go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0 + - name: Run gremlins on ${{ matrix.name }} + run: | + OUT="mutation-${{ matrix.name }}.json" + if [ -n "${{ matrix.exclude }}" ]; then + gremlins unleash -E '${{ matrix.exclude }}' -o "$OUT" ${{ matrix.path }} + else + gremlins unleash -o "$OUT" ${{ matrix.path }} + fi + - name: Upload mutation report + if: always() + uses: actions/upload-artifact@v4 + with: + name: mutation-${{ matrix.name }} + path: mutation-${{ matrix.name }}.json + if-no-files-found: ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3ae9cd62..44e1e36dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -236,6 +236,49 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R | `internal/config/` | Environment-variable helpers, paths, defaults | | `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) | +## Testing + +Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`. + +### Go conventions + +- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers. +- **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test. +- **Real dependencies over mocks.** Get a throwaway DB with `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `t.Cleanup(func() { _ = database.CloseDB() })` (Windows-safe), and use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template. + +### Running + +| Goal | Command | +|------|---------| +| Standard run | `go test ./...` | +| Hygiene — data races + order-dependence | `go test -race -shuffle=on -count=1 ./...` (`-race` needs the C compiler from Prerequisites) | +| Coverage gaps | `go test -coverprofile=cov.out .//... && go tool cover -func=cov.out` | +| Fuzz a parser briefly | `go test -run '^$' -fuzz 'FuzzName$' -fuzztime=30s .//...` | + +Frontend: `cd frontend && npm run test` (vitest), or `npm run test -- --coverage`. + +### Property and fuzz tests + +Input-heavy or pure logic (link builders, parsers, decoders) is also covered by **property tests** (`pgregory.net/rapid`) and **native fuzz targets** (`go test -fuzz`). A fuzz target's **seed corpus** (its inline `f.Add` cases plus any `testdata/fuzz` entries) runs as ordinary subtests under a plain `go test` — no `-fuzz` flag needed — so CI's normal test job exercises the seeds; the time-boxed *fuzzing* exploration (`-fuzz=...`) runs separately as the `fuzz-smoke` job. + +### Mutation testing (optional, manual) + +[gremlins](https://github.com/go-gremlins/gremlins) checks whether tests actually fail when the code is mutated — a surviving (`LIVED`) mutant means a weak test. It is **slow**, so run it **scoped per package**, never repo-wide or per-commit: + +```bash +go install github.com/go-gremlins/gremlins/cmd/gremlins@latest +gremlins unleash ./internal/sub/ +gremlins unleash -E 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go' ./internal/web/service/ +``` + +Treat each survivor as one of: a weak test (strengthen it), dead code (remove it), or an equivalent mutant (unkillable — leave it). Don't write a test purely to kill a mutant if it doesn't reflect real behavior. + +CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml` — scoped per package, results uploaded as artifacts. It is **informational**, not a gate (no thresholds), so check the reports when hardening a suite rather than waiting for a red build. + +### CI + +`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green. + ## Sending a pull request 1. Branch off `main` (e.g. `feat/short-description`). diff --git a/frontend/.gitignore b/frontend/.gitignore index e5dc08543..7874edd7b 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -2,3 +2,4 @@ node_modules/ .vite/ *.log *.tsbuildinfo +coverage/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0e14e6c02..4ada132b9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,7 @@ "@types/react-dom": "^19.2.3", "@types/swagger-ui-react": "^5.18.0", "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.6.0", @@ -456,6 +457,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3364,6 +3375,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.8", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", @@ -3647,6 +3689,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4941,6 +5002,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -5067,6 +5138,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -5320,6 +5398,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-file-download": { "version": "0.4.12", "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", @@ -5832,6 +5949,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7033,6 +7191,19 @@ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/swagger-client": { "version": "3.37.4", "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.37.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 386021559..31d46a774 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "@types/react-dom": "^19.2.3", "@types/swagger-ui-react": "^5.18.0", "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.6.0", @@ -73,4 +74,4 @@ "core-js-pure": false, "tree-sitter-json": false } -} \ No newline at end of file +} diff --git a/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap b/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap deleted file mode 100644 index 6f5326857..000000000 --- a/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap +++ /dev/null @@ -1,161 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`InboundFormModal > field structure is stable for every protocol > http 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; - -exports[`InboundFormModal > field structure is stable for every protocol > hysteria 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; - -exports[`InboundFormModal > field structure is stable for every protocol > mixed 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; - -exports[`InboundFormModal > field structure is stable for every protocol > shadowsocks 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; - -exports[`InboundFormModal > field structure is stable for every protocol > trojan 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; - -exports[`InboundFormModal > field structure is stable for every protocol > tun 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; - -exports[`InboundFormModal > field structure is stable for every protocol > tunnel 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; - -exports[`InboundFormModal > field structure is stable for every protocol > vless 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; - -exports[`InboundFormModal > field structure is stable for every protocol > vmess 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; - -exports[`InboundFormModal > field structure is stable for every protocol > wireguard 1`] = ` -[ - "Enabled", - "Remark", - "Protocol", - "Address", - "Share address strategy", - "Subscription sort order", - "Port", - "Total Flow", - "Traffic Reset", - "Duration", - "Enabled", -] -`; diff --git a/frontend/src/test/__snapshots__/outbound-form-modal.test.tsx.snap b/frontend/src/test/__snapshots__/outbound-form-modal.test.tsx.snap deleted file mode 100644 index 6b555f649..000000000 --- a/frontend/src/test/__snapshots__/outbound-form-modal.test.tsx.snap +++ /dev/null @@ -1,141 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`OutboundFormModal > field structure is stable for every protocol > blackhole 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; - -exports[`OutboundFormModal > field structure is stable for every protocol > dns 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; - -exports[`OutboundFormModal > field structure is stable for every protocol > freedom 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; - -exports[`OutboundFormModal > field structure is stable for every protocol > hysteria 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; - -exports[`OutboundFormModal > field structure is stable for every protocol > shadowsocks 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; - -exports[`OutboundFormModal > field structure is stable for every protocol > socks 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; - -exports[`OutboundFormModal > field structure is stable for every protocol > trojan 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; - -exports[`OutboundFormModal > field structure is stable for every protocol > vless 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; - -exports[`OutboundFormModal > field structure is stable for every protocol > vmess 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; - -exports[`OutboundFormModal > field structure is stable for every protocol > wireguard 1`] = ` -[ - "Protocol", - "Tag", - "Send Through", - "Address", - "Port", - "ID", - "Encryption", - "Reverse tag", - "Mux", -] -`; diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index 1b15cfaff..31a687d89 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -289,8 +289,18 @@ describe('subSortIndex', () => { }); it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => { - expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false); - expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false); + // Reject for the RIGHT reason: the issue must be about subSortIndex, not some + // unrelated field — otherwise a schema that rejects everything would pass. + const nonInt = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }); + expect(nonInt.success).toBe(false); + if (!nonInt.success) expect(nonInt.error.issues[0]?.path).toContain('subSortIndex'); + + const belowMin = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }); + expect(belowMin.success).toBe(false); + if (!belowMin.success) expect(belowMin.error.issues[0]?.path).toContain('subSortIndex'); + + // A valid integer >= 1 must pass (guards against a mutant rejecting all values). + expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 5 }).success).toBe(true); expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1); }); }); diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 8738595e4..9e41ac7aa 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/frontend/src/test/inbound-form-modal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { screen } from '@testing-library/react'; +import { screen, act } from '@testing-library/react'; import InboundFormModal from '@/pages/inbounds/form/InboundFormModal'; import { DBInbound } from '@/models/dbinbound'; @@ -31,15 +31,30 @@ describe('InboundFormModal', () => { expect(fieldLabels().length).toBeGreaterThan(0); }); - it('field structure is stable for every protocol', () => { + it('field structure differs per protocol (not a vacuous snapshot loop)', async () => { renderModal(); const protocols = listSelectOptions('protocol'); expect(protocols.length).toBeGreaterThan(3); + + const labelsByProto: Record = {}; for (const proto of protocols) { chooseSelectOption('protocol', proto); - expect(fieldLabels()).toMatchSnapshot(proto); + // Flush antd Form.useWatch('protocol') before reading — without it every iteration + // sees the same pre-update DOM and the loop asserts nothing (the original bug here). + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + labelsByProto[proto] = fieldLabels(); } - }); + + // The loop must actually exercise protocol-specific rendering: distinct protocols + // must yield distinct field sets (a vacuous loop makes them all identical). + const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|'))); + expect(distinctShapes.size).toBeGreaterThan(1); + + // Spot-check a protocol-distinguishing field that must appear after the switch. + if (labelsByProto.shadowsocks) { + expect(labelsByProto.shadowsocks).toContain('Encryption method'); + } + }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners it('preserves custom share address strategy when editing a local inbound', async () => { renderWithProviders( diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index a469003d2..6c45a1e85 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { act } from '@testing-library/react'; import OutboundFormModal from '@/pages/xray/outbounds/OutboundFormModal'; import { @@ -27,13 +28,30 @@ describe('OutboundFormModal', () => { expect(fieldLabels().length).toBeGreaterThan(0); }); - it('field structure is stable for every protocol', () => { + it('field structure differs per protocol (not a vacuous snapshot loop)', async () => { renderModal(null); const protocols = listSelectOptions('protocol'); expect(protocols.length).toBeGreaterThan(3); + + const labelsByProto: Record = {}; for (const proto of protocols) { chooseSelectOption('protocol', proto); - expect(fieldLabels()).toMatchSnapshot(proto); + // Flush antd Form.useWatch('protocol') so protocol-specific fields render before + // reading; otherwise every iteration sees the same default (vless) DOM. + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + labelsByProto[proto] = fieldLabels(); } - }); + + // Distinct protocols must yield distinct field sets (a vacuous loop is all-identical). + const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|'))); + expect(distinctShapes.size).toBeGreaterThan(1); + + // vless carries an Encryption field; wireguard does not — proves real protocol switching. + if (labelsByProto.vless) { + expect(labelsByProto.vless).toContain('Encryption'); + } + if (labelsByProto.wireguard) { + expect(labelsByProto.wireguard).not.toContain('Encryption'); + } + }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners }); diff --git a/frontend/src/test/protocols.test.ts b/frontend/src/test/protocols.test.ts index 45a8f6a5a..1d4486533 100644 --- a/frontend/src/test/protocols.test.ts +++ b/frontend/src/test/protocols.test.ts @@ -27,3 +27,35 @@ describe('InboundSettingsSchema fixtures', () => { }); } }); + +// The fixture tests above pin coerced values only via regenerable snapshots. These +// assert the load-bearing transforms directly, so a broken coercion fails independently +// of the snapshot baseline. +describe('InboundSettingsSchema coercions', () => { + it('vmess: defaults alterId to 0 and coerces a string tgId to a number', () => { + const parsed = InboundSettingsSchema.parse({ + protocol: 'vmess', + settings: { clients: [{ id: 'u1', email: 'a@b.c', tgId: '12345' }] }, + }); + if (parsed.protocol !== 'vmess') throw new Error('discriminator narrowed to the wrong protocol'); + const client = parsed.settings.clients[0]; + expect(client.alterId).toBe(0); // .default(0) injected for omitted field + expect(client.tgId).toBe(12345); // string -> number transform + }); + + it('vmess: a non-numeric tgId coerces to 0', () => { + const parsed = InboundSettingsSchema.parse({ + protocol: 'vmess', + settings: { clients: [{ id: 'u1', email: 'a@b.c', tgId: 'not-a-number' }] }, + }); + if (parsed.protocol !== 'vmess') throw new Error('wrong protocol'); + expect(parsed.settings.clients[0].tgId).toBe(0); // Number(v) || 0 + }); + + it('vless: defaults decryption and encryption to "none"', () => { + const parsed = InboundSettingsSchema.parse({ protocol: 'vless', settings: { clients: [] } }); + if (parsed.protocol !== 'vless') throw new Error('wrong protocol'); + expect(parsed.settings.decryption).toBe('none'); + expect(parsed.settings.encryption).toBe('none'); + }); +}); diff --git a/go.mod b/go.mod index 7793f8309..d43c567be 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 + pgregory.net/rapid v1.3.0 ) require ( diff --git a/go.sum b/go.sum index d586f5ebd..98f06bd18 100644 --- a/go.sum +++ b/go.sum @@ -305,3 +305,5 @@ gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TI gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc= +pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk= diff --git a/internal/config/config_mutation_test.go b/internal/config/config_mutation_test.go new file mode 100644 index 000000000..29ed5ce5b --- /dev/null +++ b/internal/config/config_mutation_test.go @@ -0,0 +1,75 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// copyFile is the workhorse invoked by init()'s Windows-only DB migration +// (config.go:214), the branch guarded by the platform check on config.go:196. +// The init() guard itself cannot be re-driven from an in-process test (init runs +// once at package load, the OS check is a compile-time constant, and the old-DB +// source path is hardcoded to a system location), so these tests pin down the +// migration payload's contract instead. + +func TestCopyFileCopiesContents(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.db") + dst := filepath.Join(dir, "dst.db") + + want := []byte("3x-ui sqlite payload\x00\x01\x02") + if err := os.WriteFile(src, want, 0o600); err != nil { + t.Fatalf("write src: %v", err) + } + + if err := copyFile(src, dst); err != nil { + t.Fatalf("copyFile returned error: %v", err) + } + + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read dst: %v", err) + } + if string(got) != string(want) { + t.Errorf("dst contents = %q, want %q", got, want) + } +} + +func TestCopyFileMissingSourceReturnsError(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "does-not-exist.db") + dst := filepath.Join(dir, "dst.db") + + if err := copyFile(src, dst); err == nil { + t.Fatal("copyFile with missing source returned nil error, want error") + } + if _, err := os.Stat(dst); !os.IsNotExist(err) { + t.Errorf("dst should not be created when source is missing, stat err = %v", err) + } +} + +func TestCopyFileOverwritesDestination(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.db") + dst := filepath.Join(dir, "dst.db") + + if err := os.WriteFile(src, []byte("new"), 0o600); err != nil { + t.Fatalf("write src: %v", err) + } + if err := os.WriteFile(dst, []byte("stale-and-longer"), 0o600); err != nil { + t.Fatalf("write dst: %v", err) + } + + if err := copyFile(src, dst); err != nil { + t.Fatalf("copyFile returned error: %v", err) + } + + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read dst: %v", err) + } + if string(got) != "new" { + t.Errorf("dst contents = %q, want %q (truncated overwrite)", got, "new") + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index afb2cf3c8..215c597cd 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "sync" "time" "github.com/mhsanaei/3x-ui/v3/internal/config" @@ -31,8 +32,10 @@ var ( logger *logging.Logger fileRotate *lumberjack.Logger // nil when file backend disabled - // logBuffer maintains recent log entries in memory for web UI retrieval - logBuffer []struct { + // logBuffer maintains recent log entries in memory for web UI retrieval; + // logBufferMu guards it — written from many goroutines, read by the web UI. + logBufferMu sync.Mutex + logBuffer []struct { time string level logging.Level log string @@ -193,6 +196,8 @@ func Errorf(format string, args ...any) { // addToBuffer adds a log entry to the in-memory ring buffer for web UI retrieval. func addToBuffer(level string, newLog string) { t := time.Now() + logBufferMu.Lock() + defer logBufferMu.Unlock() if len(logBuffer) >= maxLogBufferSize { logBuffer = logBuffer[1:] } @@ -214,9 +219,21 @@ func GetLogs(c int, level string) []string { var output []string logLevel, _ := logging.LogLevel(level) - for i := len(logBuffer) - 1; i >= 0 && len(output) <= c; i-- { - if logBuffer[i].level <= logLevel { - output = append(output, fmt.Sprintf("%s %s - %s", logBuffer[i].time, logBuffer[i].level, logBuffer[i].log)) + // Snapshot (copy) under the lock, then filter/format unlocked: a UI log fetch + // must not block addToBuffer — and thus all logging — for the formatting loop. + // A copy (not a reslice) is required, since addToBuffer can append in place. + logBufferMu.Lock() + snapshot := make([]struct { + time string + level logging.Level + log string + }, len(logBuffer)) + copy(snapshot, logBuffer) + logBufferMu.Unlock() + + for i := len(snapshot) - 1; i >= 0 && len(output) < c; i-- { + if snapshot[i].level <= logLevel { + output = append(output, fmt.Sprintf("%s %s - %s", snapshot[i].time, snapshot[i].level, snapshot[i].log)) } } return output diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 000000000..b989517ee --- /dev/null +++ b/internal/logger/logger_test.go @@ -0,0 +1,30 @@ +package logger + +import ( + "fmt" + "testing" +) + +// TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract. +// The loop condition must cap output at c (ERROR entries are queried at "debug" +// level so the level filter passes all of them, isolating the count). +func TestGetLogs_ReturnsAtMostC(t *testing.T) { + logBufferMu.Lock() + logBuffer = nil + logBufferMu.Unlock() + for i := 0; i < 5; i++ { + addToBuffer("ERROR", fmt.Sprintf("m%d", i)) + } + + cases := []struct{ c, want int }{ + {0, 0}, + {2, 2}, + {5, 5}, + {10, 5}, // capped at what's available + } + for _, tc := range cases { + if got := GetLogs(tc.c, "debug"); len(got) != tc.want { + t.Errorf("GetLogs(%d) returned %d entries, want %d", tc.c, len(got), tc.want) + } + } +} diff --git a/internal/mtproto/manager_mutation_test.go b/internal/mtproto/manager_mutation_test.go new file mode 100644 index 000000000..1a82a042b --- /dev/null +++ b/internal/mtproto/manager_mutation_test.go @@ -0,0 +1,99 @@ +package mtproto + +import ( + "testing" +) + +// TestParseMetricLineBraceBoundary pins the contract of the brace-position +// guard in parseMetricLine (manager.go:425 -> `if end < brace`). +// +// Once a '{' is found at index `brace`, the matching '}' must appear AFTER it. +// A '}' that precedes the '{', or a '{' with no closing '}' at all +// (strings.IndexByte returns -1, which is < brace), is a malformed line and +// must yield an error rather than slicing past the brace. +func TestParseMetricLineBraceBoundary(t *testing.T) { + t.Run("closing brace before opening brace is malformed", func(t *testing.T) { + // '}' at index 8 comes before '{' at index 16: end < brace must hold, + // so this is rejected. Mutating `<` to `>`/`>=` would accept it. + _, _, _, err := parseMetricLine(`mtg_x_a}_b{direction="x"} 5`) + if err == nil { + t.Fatal("expected error for '}' appearing before '{'") + } + }) + + t.Run("opening brace with no closing brace is malformed", func(t *testing.T) { + // No '}' at all -> end == -1, which is < brace. Must error. + // If the guard were dropped/inverted the code would slice line[brace+1:-1] + // and panic; asserting a clean error keeps that contract. + _, _, _, err := parseMetricLine(`mtg_traffic{direction="x" 5`) + if err == nil { + t.Fatal("expected error for '{' without a closing '}'") + } + }) + + t.Run("well-formed braces are accepted", func(t *testing.T) { + // '{' at index 11, '}' at index 25: end > brace, so the guard must NOT + // fire and parsing must succeed. Guards against a mutant that always errors. + name, labels, val, err := parseMetricLine(`mtg_traffic{direction="up"} 42`) + if err != nil { + t.Fatalf("well-formed line should parse: %v", err) + } + if name != "mtg_traffic" { + t.Fatalf("name=%q", name) + } + if labels["direction"] != "up" { + t.Fatalf("labels=%v", labels) + } + if val != 42 { + t.Fatalf("val=%v", val) + } + }) +} + +// TestParseMetricLineLabelEqualsBoundary pins the contract of the '=' guard in +// the per-label loop (manager.go:430 -> `if eq < 0`). +// +// - eq < 0 (no '=' in the segment): the segment is skipped, no label added. +// - eq == 0 (segment begins with '='): the key is empty but the pair is STILL +// parsed, producing labels[""] = value. The boundary is `< 0`, not `<= 0`. +func TestParseMetricLineLabelEqualsBoundary(t *testing.T) { + t.Run("label segment without '=' is skipped, not fatal", func(t *testing.T) { + // "novalue" has no '=' (eq == -1) and must be skipped. A real key=val + // segment in the same line must still be parsed. Mutating `< 0` to `> 0` + // would take kv[:eq] with eq=-1 and panic; mutating away the skip would + // also corrupt parsing. + name, labels, val, err := parseMetricLine(`mtg_traffic{novalue,direction="down"} 9`) + if err != nil { + t.Fatalf("line with a value-less label should still parse: %v", err) + } + if name != "mtg_traffic" { + t.Fatalf("name=%q", name) + } + if _, present := labels["novalue"]; present { + t.Fatalf("value-less segment must not create a label: %v", labels) + } + if labels["direction"] != "down" { + t.Fatalf("real label must still be parsed: %v", labels) + } + if val != 9 { + t.Fatalf("val=%v", val) + } + }) + + t.Run("label segment beginning with '=' is parsed as empty key", func(t *testing.T) { + // "=onlyvalue": eq == 0. Since the guard is `< 0`, this is NOT skipped: + // it yields labels[""] = "onlyvalue". A mutant changing `< 0` to `<= 0` + // would skip it, losing the empty-key entry. + _, labels, _, err := parseMetricLine(`mtg_traffic{=onlyvalue} 1`) + if err != nil { + t.Fatalf("segment with empty key should still parse: %v", err) + } + v, present := labels[""] + if !present { + t.Fatalf("eq==0 segment must produce an empty-key label: %v", labels) + } + if v != "onlyvalue" { + t.Fatalf("empty-key label value=%q", v) + } + }) +} diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index 070cfaaf5..450aea0c1 100644 --- a/internal/sub/clash_service_test.go +++ b/internal/sub/clash_service_test.go @@ -41,6 +41,64 @@ func TestEnsureUniqueProxyNames(t *testing.T) { } } +// TestBuildProxy_VLESSRealityFieldsForClash locks the reality field mapping in +// applySecurity (clash_service.go ~488): a regression that drops servername, +// public-key, short-id, or client-fingerprint would hand mihomo a broken reality +// proxy. The existing clash tests don't assert any of these. +func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) { + svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}} + inbound := &model.Inbound{Listen: "203.0.113.1", Port: 443, Protocol: model.VLESS, Remark: "r", Settings: `{"encryption":"none"}`} + client := model.Client{ID: "11111111-2222-4333-8444-555555555555"} + stream := map[string]any{ + "network": "tcp", + "security": "reality", + "tcpSettings": map[string]any{"header": map[string]any{"type": "none"}}, + "realitySettings": map[string]any{"serverName": "reality.example.com", "publicKey": "PBKvalue", "shortId": "ab12", "fingerprint": "chrome"}, + } + + proxy := svc.buildProxy(inbound, client, stream, "") + if proxy == nil { + t.Fatal("buildProxy returned nil for a valid reality stream") + } + if proxy["tls"] != true { + t.Fatalf("tls = %v, want true", proxy["tls"]) + } + if proxy["servername"] != "reality.example.com" { + t.Fatalf("servername = %v, want reality.example.com", proxy["servername"]) + } + if proxy["client-fingerprint"] != "chrome" { + t.Fatalf("client-fingerprint = %v, want chrome", proxy["client-fingerprint"]) + } + opts, _ := proxy["reality-opts"].(map[string]any) + if opts == nil { + t.Fatal("reality-opts missing") + } + if opts["public-key"] != "PBKvalue" { + t.Fatalf("public-key = %v, want PBKvalue", opts["public-key"]) + } + if opts["short-id"] != "ab12" { + t.Fatalf("short-id = %v, want ab12", opts["short-id"]) + } +} + +// TestApplyTransport_TCPHeader pins the tcp-header validation (clash_service.go ~359): +// plain tcp and a "none" header are representable in clash; a non-none obfs header is +// not, so applyTransport must reject it (returning false drops it from the YAML). +func TestApplyTransport_TCPHeader(t *testing.T) { + svc := &SubClashService{} + if !svc.applyTransport(map[string]any{}, "tcp", map[string]any{}) { + t.Fatal("plain tcp must be buildable") + } + noneStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}}} + if !svc.applyTransport(map[string]any{}, "tcp", noneStream) { + t.Fatal("tcp + header type none must be buildable") + } + httpStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "http"}}} + if svc.applyTransport(map[string]any{}, "tcp", httpStream) { + t.Fatal("tcp + non-none (http) header is not representable in clash and must be rejected") + } +} + func TestApplyTransport_XHTTP(t *testing.T) { svc := &SubClashService{} proxy := map[string]any{} @@ -228,4 +286,18 @@ func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) { if _, ok := proxy["encryption"]; ok { t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy) } + // The rest of the proxy must still be well-formed — otherwise a mutant that + // drops encryption *and* corrupts a core field passes the absence check alone. + if proxy["type"] != "vless" { + t.Fatalf("type = %v, want vless", proxy["type"]) + } + if proxy["server"] != "203.0.113.1" { + t.Fatalf("server = %v, want 203.0.113.1", proxy["server"]) + } + if proxy["port"] != 443 { + t.Fatalf("port = %v, want 443", proxy["port"]) + } + if proxy["uuid"] != client.ID { + t.Fatalf("uuid = %v, want %v", proxy["uuid"], client.ID) + } } diff --git a/internal/sub/mutation_audit_test.go b/internal/sub/mutation_audit_test.go new file mode 100644 index 000000000..9dd8cb478 --- /dev/null +++ b/internal/sub/mutation_audit_test.go @@ -0,0 +1,484 @@ +package sub + +import ( + "encoding/base64" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// initMutDB spins up a real temp SQLite DB for tests that exercise DB-backed +// query helpers, mirroring the house pattern in service_sharelink/dedup tests. +func initMutDB(t *testing.T) { + t.Helper() + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) +} + +// --- json_service.go:40 — rules are merged into routing only when non-empty --- + +func TestSubJsonService_CustomRulesPrepended(t *testing.T) { + rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]` + svc := NewSubJsonService("", rules, "", nil) + + routing, ok := svc.configJson["routing"].(map[string]any) + if !ok { + t.Fatalf("routing missing: %#v", svc.configJson["routing"]) + } + got, _ := routing["rules"].([]any) + // default.json ships exactly 1 rule; the custom rule must be prepended. + if len(got) != 2 { + t.Fatalf("rules len = %d, want 2 (custom prepended to default)", len(got)) + } + first, _ := got[0].(map[string]any) + if domains, _ := first["domain"].([]any); len(domains) != 1 || domains[0] != "geosite:ads" { + t.Fatalf("custom rule must come first, got %#v", got[0]) + } +} + +func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) { + svc := NewSubJsonService("", "", "", nil) + routing, _ := svc.configJson["routing"].(map[string]any) + got, _ := routing["rules"].([]any) + if len(got) != 1 { + t.Fatalf("rules len = %d, want 1 (no custom rules → default untouched)", len(got)) + } +} + +// --- json_service.go:331,356,408 — mux is attached only when configured --- + +func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) { + const mux = `{"enabled":true,"concurrency":8}` + client := model.Client{ID: "uuid-1", Password: "p4ss"} + + cases := []struct { + name string + raw []byte + wantMux bool + protocol model.Protocol + }{ + {"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), true, model.VMESS}, + {"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), true, model.VLESS}, + {"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), true, model.Trojan}, + {"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), false, model.VMESS}, + {"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), false, model.VLESS}, + {"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), false, model.Trojan}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var ob map[string]any + if err := json.Unmarshal(tc.raw, &ob); err != nil { + t.Fatalf("unmarshal outbound: %v", err) + } + m, has := ob["mux"] + if tc.wantMux { + if !has { + t.Fatalf("mux must be set when configured, outbound = %#v", ob) + } + mm, _ := m.(map[string]any) + if mm["enabled"] != true || mm["concurrency"] != float64(8) { + t.Fatalf("mux payload wrong: %#v", m) + } + } else if has { + t.Fatalf("mux must be omitted when empty, outbound = %#v", ob) + } + }) + } +} + +// --- json_service.go:268 — a non-empty finalMask that merges to nothing must +// not add the finalmask key (the `len(merged) > 0` guard). --- + +func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) { + // finalMask is non-empty (passes the len(fm)==0 early return) but its only + // key is an empty tcp slice, which mergeFinalMask drops → merged is empty, + // so applyGlobalFinalMask (json_service.go:268) must NOT set finalmask. + svc := NewSubJsonService("", "", `{"tcp":[]}`, nil) + stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`) + if _, ok := stream["finalmask"]; ok { + t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"]) + } + + // Sanity: a finalMask that DOES merge to something still gets set, so the + // guard is the only distinguishing factor. + svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil) + stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`) + if _, ok := stream2["finalmask"]; !ok { + t.Fatal("non-empty finalMask must be set") + } +} + +// --- json_service.go:494 — an empty extra tcp slice must not clobber the base --- + +func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) { + base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}} + extra := map[string]any{"tcp": []any{}} // empty → must be ignored + merged := mergeFinalMask(base, extra) + tcp, _ := merged["tcp"].([]any) + if len(tcp) != 1 { + t.Fatalf("tcp len = %d, want 1 (empty extra must not drop or append)", len(tcp)) + } + if first, _ := tcp[0].(map[string]any); first["type"] != "keep" { + t.Fatalf("base tcp mask lost: %#v", tcp) + } + // Sanity: a non-empty extra DOES append, so the guard is the only thing + // distinguishing the two paths. + extra2 := map[string]any{"tcp": []any{map[string]any{"type": "add"}}} + merged2 := mergeFinalMask(base, extra2) + if tcp2, _ := merged2["tcp"].([]any); len(tcp2) != 2 { + t.Fatalf("non-empty extra must append: len = %d, want 2", len(tcp2)) + } +} + +// --- service.go:69-77 — configuredPublicHost priority: subDomain > webDomain > "" --- + +func TestConfiguredPublicHost_Priority(t *testing.T) { + initMutDB(t) + db := database.GetDB() + set := func(key, val string) { + if err := db.Save(&model.Setting{Key: key, Value: val}).Error; err != nil { + t.Fatalf("save %s: %v", key, err) + } + } + + s := &SubService{} + + // Both empty → "". + if got := s.configuredPublicHost(); got != "" { + t.Fatalf("no domains configured: got %q, want empty", got) + } + + // Only webDomain → webDomain wins (exercises the second branch, service.go:73). + set("webDomain", "web.example.com") + if got := s.configuredPublicHost(); got != "web.example.com" { + t.Fatalf("webDomain fallback: got %q, want web.example.com", got) + } + + // subDomain set → subDomain takes precedence over webDomain (service.go:70). + set("subDomain", "sub.example.com") + if got := s.configuredPublicHost(); got != "sub.example.com" { + t.Fatalf("subDomain priority: got %q, want sub.example.com", got) + } +} + +// --- service.go:248 — AggregateTrafficByEmails tracks the MAX LastOnline --- + +func TestAggregateTrafficByEmails_LastOnlineIsMax(t *testing.T) { + initMutDB(t) + db := database.GetDB() + + rows := []xray.ClientTraffic{ + {Email: "a@x", Up: 10, Down: 20, LastOnline: 100}, + {Email: "b@x", Up: 1, Down: 2, LastOnline: 500}, // the max + {Email: "c@x", Up: 3, Down: 4, LastOnline: 300}, + } + for i := range rows { + if err := db.Create(&rows[i]).Error; err != nil { + t.Fatalf("seed traffic: %v", err) + } + } + + s := &SubService{} + agg, lastOnline := s.AggregateTrafficByEmails([]string{"a@x", "b@x", "c@x"}) + if lastOnline != 500 { + t.Fatalf("lastOnline = %d, want 500 (max across rows)", lastOnline) + } + // Up/Down must still sum so a mutant can't pass by zeroing everything. + if agg.Up != 14 || agg.Down != 26 { + t.Fatalf("agg up/down = %d/%d, want 14/26", agg.Up, agg.Down) + } +} + +// --- service.go:329 — projectThroughFallbackMaster returns false for nil --- + +func TestProjectThroughFallbackMaster_Nil(t *testing.T) { + s := &SubService{} + if s.projectThroughFallbackMaster(nil) { + t.Fatal("nil inbound must yield false (no projection, no DB hit)") + } +} + +// --- service.go:555 — empty client flow must not emit a flow param even when allowed --- + +func TestGenVlessLink_NoFlowWhenClientFlowEmpty(t *testing.T) { + // tcp+reality is a flow-allowed combo; with an empty client flow the + // len(...)>0 guard (service.go:555) must keep `flow` out of the link. + stream := `{ + "network":"tcp","security":"reality", + "tcpSettings":{"header":{"type":"none"}}, + "realitySettings":{"serverNames":["r.example.com"],"shortIds":["ab"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}} + }` + inbound := &model.Inbound{ + Listen: "203.0.113.1", + Port: 443, + Protocol: model.VLESS, + Remark: "noflow", + Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"encryption":"none"}`, + StreamSettings: stream, + } + s := &SubService{remarkModel: "-ieo"} + if link := s.genVlessLink(inbound, "user"); strings.Contains(link, "flow=") { + t.Fatalf("empty client flow must not produce a flow param, got %q", link) + } +} + +// --- service.go:906-913 — applyPathAndHostParams host source --- + +func TestApplyPathAndHostParams(t *testing.T) { + // Direct host wins (service.go:908 true branch). + params := map[string]string{} + applyPathAndHostParams(map[string]any{"path": "/p", "host": "direct.example.com"}, params) + if params["path"] != "/p" { + t.Fatalf("path = %q, want /p", params["path"]) + } + if params["host"] != "direct.example.com" { + t.Fatalf("direct host = %q, want direct.example.com", params["host"]) + } + + // No direct host → fall back to headers.Host (service.go:908 false branch). + params = map[string]string{} + applyPathAndHostParams(map[string]any{ + "path": "/p", + "headers": map[string]any{"Host": "via-header.example.com"}, + }, params) + if params["host"] != "via-header.example.com" { + t.Fatalf("header host fallback = %q, want via-header.example.com", params["host"]) + } + + // Empty-string host must NOT shadow the header fallback (len(host) > 0 guard). + params = map[string]string{} + applyPathAndHostParams(map[string]any{ + "path": "/p", + "host": "", + "headers": map[string]any{"Host": "via-header.example.com"}, + }, params) + if params["host"] != "via-header.example.com" { + t.Fatalf("empty host must defer to headers, got %q", params["host"]) + } +} + +// --- external_config.go:39,42,55,58 — getClientExternalLinksBySubId --- + +func TestGetClientExternalLinksBySubId(t *testing.T) { + initMutDB(t) + db := database.GetDB() + s := &SubService{} + + // No client rows for the subId → nil, no error (service.go path :42). + out, err := s.getClientExternalLinksBySubId("missing") + if err != nil { + t.Fatalf("missing subId err = %v, want nil", err) + } + if out != nil { + t.Fatalf("missing subId = %#v, want nil", out) + } + + // A client with NO external-link rows → nil (the rows-empty guard :58). + bare := &model.ClientRecord{Email: "bare@x", SubID: "sub-bare", UUID: "u", Enable: true} + if err := db.Create(bare).Error; err != nil { + t.Fatalf("seed bare client: %v", err) + } + out, err = s.getClientExternalLinksBySubId("sub-bare") + if err != nil { + t.Fatalf("bare subId err = %v", err) + } + if out != nil { + t.Fatalf("client with no links = %#v, want nil", out) + } + + // A client with two link rows: ordering by sort_index and email/enable + // attribution from the owning client (the loop copies rec.Email/rec.Enable). + rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true} + if err := db.Create(rec).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://b", Remark: "second", SortIndex: 5}).Error; err != nil { + t.Fatalf("seed link b: %v", err) + } + if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil { + t.Fatalf("seed link a: %v", err) + } + + out, err = s.getClientExternalLinksBySubId("sub-ok") + if err != nil { + t.Fatalf("ok subId err = %v", err) + } + if len(out) != 2 { + t.Fatalf("entries = %d, want 2", len(out)) + } + // sort_index ASC: the SortIndex=1 row comes first. + if out[0].Value != "trojan://a" || out[1].Value != "trojan://b" { + t.Fatalf("ordering wrong: %#v", out) + } + // Email + Enable must be copied from the owning client, not the link row + // (which carries neither field). The enabled owner → Enable true. + if out[0].Email != "owner@x" || out[0].Enable != true { + t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable) + } + + // A DISABLED client must produce entries with Enable=false, proving the + // value is read from the client row (Enable has a gorm default:true, so + // flip it with a raw UPDATE that bypasses the default). + dis := &model.ClientRecord{Email: "off@x", SubID: "sub-off", UUID: "u3", Enable: true} + if err := db.Create(dis).Error; err != nil { + t.Fatalf("seed disabled client: %v", err) + } + if err := db.Model(&model.ClientRecord{}).Where("id = ?", dis.Id).Update("enable", false).Error; err != nil { + t.Fatalf("disable client: %v", err) + } + if err := db.Create(&model.ClientExternalLink{ClientId: dis.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://c", SortIndex: 1}).Error; err != nil { + t.Fatalf("seed link c: %v", err) + } + offOut, err := s.getClientExternalLinksBySubId("sub-off") + if err != nil { + t.Fatalf("off subId err = %v", err) + } + if len(offOut) != 1 { + t.Fatalf("disabled client entries = %d, want 1", len(offOut)) + } + if offOut[0].Email != "off@x" || offOut[0].Enable != false { + t.Fatalf("disabled attribution wrong: email=%q enable=%v", offOut[0].Email, offOut[0].Enable) + } +} + +// --- external_config.go:102 — applyRemarkToLink appends a fragment when none exists --- + +func TestApplyRemarkToLink_NoFragmentAppends(t *testing.T) { + link := "trojan://pw@1.2.3.4:8443?security=tls" + out := applyRemarkToLink(link, "DE-Node") + if out != link+"#DE-Node" { + t.Fatalf("no-fragment link must get the remark appended, got %q", out) + } +} + +// --- external_config.go:111 — applyVmessRemark falls back to RawURLEncoding --- + +func TestApplyVmessRemark_RawURLEncodingFallback(t *testing.T) { + // The "aa?" ps forces a URL-safe char (_) in the RawURL encoding, so + // base64.StdEncoding.DecodeString fails and the RawURLEncoding fallback + // path (external_config.go:111) must take over. (ps is overwritten below, + // so its value is irrelevant to the assertions.) + payload := map[string]any{"v": "2", "ps": "aa?", "add": "1.2.3.4", "port": "443", "id": "uuid"} + b, _ := json.Marshal(payload) + link := "vmess://" + base64.RawURLEncoding.EncodeToString(b) + // Guard the premise: this link must NOT be std-decodable, else the fallback + // branch is never reached and the test is meaningless. + if _, err := base64.StdEncoding.DecodeString(padBase64Sub(strings.TrimPrefix(link, "vmess://"))); err == nil { + t.Fatal("test premise broken: link is std-base64 decodable, fallback not exercised") + } + + out := applyRemarkToLink(link, "NL-Node") + if out == link { + t.Fatalf("raw-url-encoded vmess remark was not applied (fallback decode broken): %q", out) + } + // The result re-encodes with StdEncoding; decode and verify ps + credentials. + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(out, "vmess://")) + if err != nil { + t.Fatalf("decode out: %v", err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["ps"] != "NL-Node" { + t.Fatalf("ps = %v, want NL-Node", got["ps"]) + } + if got["id"] != "uuid" { + t.Fatalf("credentials lost via fallback path: %#v", got) + } +} + +// --- external_config.go:130 — padBase64Sub pads to a multiple of 4 --- + +func TestPadBase64Sub(t *testing.T) { + cases := map[string]string{ + "": "", + "a": "a===", + "ab": "ab==", + "abc": "abc=", + "abcd": "abcd", + } + for in, want := range cases { + if got := padBase64Sub(in); got != want { + t.Fatalf("padBase64Sub(%q) = %q, want %q", in, got, want) + } + if len(padBase64Sub(in))%4 != 0 { + t.Fatalf("padBase64Sub(%q) length not a multiple of 4", in) + } + } +} + +// --- external_subscription.go:122 — base64 body decode strips embedded whitespace --- + +func TestDecodeSubscriptionBody_StripsWhitespaceInBase64(t *testing.T) { + plain := "vless://uuid@a.com:443#one\ntrojan://pw@b.com:8443#two\n" + encoded := base64.StdEncoding.EncodeToString([]byte(plain)) + // Inject whitespace into the base64 token; tryDecodeBase64Body must strip it + // (external_subscription.go:122) so decoding still succeeds. + half := len(encoded) / 2 + dirty := encoded[:half] + "\n \t" + encoded[half:] + + links := decodeSubscriptionBody([]byte(dirty)) + if len(links) != 2 || links[0] != "vless://uuid@a.com:443#one" || links[1] != "trojan://pw@b.com:8443#two" { + t.Fatalf("whitespace-laden base64 body decoded wrong: %#v", links) + } +} + +// --- clash_service.go:123 — duplicate proxy names disambiguate as base-N --- + +func TestEnsureUniqueProxyNames_SuffixSequence(t *testing.T) { + proxies := []map[string]any{ + {"name": "node"}, + {"name": "node"}, + {"name": "node"}, + } + ensureUniqueProxyNames(proxies) + if proxies[0]["name"] != "node" { + t.Fatalf("first occurrence must keep base name, got %v", proxies[0]["name"]) + } + if proxies[1]["name"] != "node-2" { + t.Fatalf("second duplicate = %v, want node-2", proxies[1]["name"]) + } + if proxies[2]["name"] != "node-3" { + t.Fatalf("third duplicate = %v, want node-3", proxies[2]["name"]) + } +} + +// --- clash_service.go:422,447 — empty transport opts must NOT add the *-opts key --- + +func TestApplyTransport_EmptyOptsOmitted(t *testing.T) { + svc := &SubClashService{} + + // httpupgrade with no path/host → opts empty → no http-upgrade-opts key (clash:422). + huProxy := map[string]any{} + if !svc.applyTransport(huProxy, "httpupgrade", map[string]any{"httpupgradeSettings": map[string]any{}}) { + t.Fatal("httpupgrade must still be buildable") + } + if huProxy["network"] != "httpupgrade" { + t.Fatalf("network = %v, want httpupgrade", huProxy["network"]) + } + if _, ok := huProxy["http-upgrade-opts"]; ok { + t.Fatalf("empty opts must not set http-upgrade-opts: %#v", huProxy["http-upgrade-opts"]) + } + + // xhttp with no path/host/mode → opts empty → no xhttp-opts key (clash:447). + xhProxy := map[string]any{} + if !svc.applyTransport(xhProxy, "xhttp", map[string]any{"xhttpSettings": map[string]any{}}) { + t.Fatal("xhttp must still be buildable") + } + if xhProxy["network"] != "xhttp" { + t.Fatalf("network = %v, want xhttp", xhProxy["network"]) + } + if _, ok := xhProxy["xhttp-opts"]; ok { + t.Fatalf("empty opts must not set xhttp-opts: %#v", xhProxy["xhttp-opts"]) + } +} diff --git a/internal/sub/service_dedup_test.go b/internal/sub/service_dedup_test.go index 17f5b79a4..f94e2ad0c 100644 --- a/internal/sub/service_dedup_test.go +++ b/internal/sub/service_dedup_test.go @@ -3,6 +3,7 @@ package sub import ( "fmt" "path/filepath" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -62,4 +63,35 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) { if len(emails) != 1 { t.Fatalf("emails = %d, want 1, got %v", len(emails), emails) } + // Identity, not just count: the single surviving link must be for this client. + if !strings.Contains(links[0], uuid) { + t.Fatalf("surviving link must carry the client uuid %q, got %q", uuid, links[0]) + } +} + +// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count: +// the two entries differ only by email case, so dropping strings.ToLower (or keying on +// another field) yields two clients. The byte-identical dupes above can't catch that. +func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) { + const subId = "s1" + const uuid = "11111111-2222-4333-8444-555555555555" + ib := &model.Inbound{ + Protocol: model.VLESS, + Settings: `{"clients":[ + {"id":"` + uuid + `","email":"Dup@Example.com","subId":"` + subId + `","enable":true}, + {"id":"` + uuid + `","email":"dup@example.com","subId":"` + subId + `","enable":true} + ]}`, + } + s := &SubService{} + got := s.matchingClients(ib, subId) + if len(got) != 1 { + t.Fatalf("case-differing duplicate emails must dedup to 1 client, got %d", len(got)) + } + if got[0].Email != "Dup@Example.com" { + t.Fatalf("first occurrence must be kept, got %q", got[0].Email) + } + // A wrong subId must still be excluded (guards the subId filter at service.go:127). + if other := s.matchingClients(ib, "nope"); len(other) != 0 { + t.Fatalf("non-matching subId must yield 0 clients, got %d", len(other)) + } } diff --git a/internal/sub/service_property_test.go b/internal/sub/service_property_test.go new file mode 100644 index 000000000..c8995daf8 --- /dev/null +++ b/internal/sub/service_property_test.go @@ -0,0 +1,89 @@ +package sub + +import ( + "net" + "net/url" + "strconv" + "strings" + "testing" + + "pgregory.net/rapid" +) + +// TestProp_JoinHostPort_Bracketing asserts the RFC-3986 authority contract for any +// host/port: SplitHostPort must recover the (un-bracketed) host and the exact port, +// and an IPv6 literal is bracketed exactly once regardless of input brackets. +func TestProp_JoinHostPort_Bracketing(t *testing.T) { + hosts := []string{ + "1.2.3.4", "example.com", "sub.host.test", + "2001:db8::1", "[2001:db8::1]", "::1", "[::1]", "fe80::1%eth0", + } + rapid.Check(t, func(t *rapid.T) { + host := rapid.SampledFrom(hosts).Draw(t, "host") + port := rapid.IntRange(0, 65535).Draw(t, "port") + + out := joinHostPort(host, port) + + gotHost, gotPort, err := net.SplitHostPort(out) + if err != nil { + t.Fatalf("SplitHostPort(%q) failed: %v", out, err) + } + wantHost := strings.Trim(host, "[]") + if gotHost != wantHost { + t.Fatalf("host round-trip: joinHostPort(%q,%d)=%q -> host %q, want %q", host, port, out, gotHost, wantHost) + } + if gotPort != strconv.Itoa(port) { + t.Fatalf("port round-trip: got %q, want %d (out=%q)", gotPort, port, out) + } + // An IPv6 literal (contains a colon in the host) must be bracketed once. + if strings.Contains(wantHost, ":") { + if strings.Count(out, "[") != 1 || strings.Count(out, "]") != 1 { + t.Fatalf("IPv6 host not bracketed exactly once: %q", out) + } + } + }) +} + +// TestProp_EncodeUserinfo_RoundTrip asserts encodeUserinfo produces a userinfo token +// that net/url parses back to the original password for ANY input — the contract that +// trojan/ss links rely on. A field-mapping mutant that mangles escaping breaks this. +func TestProp_EncodeUserinfo_RoundTrip(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + pw := rapid.String().Draw(t, "pw") + raw := "trojan://" + encodeUserinfo(pw) + "@example.com:443" + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("url.Parse(%q) failed for pw=%q: %v", raw, pw, err) + } + if got := u.User.Username(); got != pw { + t.Fatalf("userinfo round-trip mismatch: pw=%q got=%q", pw, got) + } + }) +} + +// TestProp_SplitLinkLines_Invariants asserts splitLinkLines never emits empty or +// untrimmed lines, and that re-splitting its own joined output is a fixed point. +func TestProp_SplitLinkLines_Invariants(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + raw := rapid.String().Draw(t, "raw") + out := splitLinkLines(raw) + for i, line := range out { + if line == "" { + t.Fatalf("splitLinkLines emitted an empty line at %d for %q", i, raw) + } + if line != strings.TrimSpace(line) { + t.Fatalf("splitLinkLines emitted an untrimmed line %q", line) + } + } + rejoined := splitLinkLines(strings.Join(out, "\n")) + if len(rejoined) != len(out) { + t.Fatalf("not a fixed point: %d -> %d lines", len(out), len(rejoined)) + } + for i := range out { + if rejoined[i] != out[i] { + t.Fatalf("fixed-point mismatch at %d: %q vs %q", i, out[i], rejoined[i]) + } + } + }) +} + diff --git a/internal/sub/service_sharelink_test.go b/internal/sub/service_sharelink_test.go new file mode 100644 index 000000000..c062418f4 --- /dev/null +++ b/internal/sub/service_sharelink_test.go @@ -0,0 +1,89 @@ +package sub + +import ( + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// shareLinkInbound builds a VLESS inbound with one client and the given stream +// settings, mirroring flowTestInbound but without forcing a flow. +func shareLinkInbound(streamSettings string) *model.Inbound { + return &model.Inbound{ + Listen: "203.0.113.1", + Port: 443, + Protocol: model.VLESS, + Remark: "sharelink", + Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"decryption":"none","encryption":"none"}`, + StreamSettings: streamSettings, + } +} + +// TestGenVlessLink_TLSParamsMapped locks every field that applyShareTLSParams +// (service.go:1029) writes into a TLS share link. Without these assertions a mutant +// that drops `sni`, swaps a key, or skips `pcs`/`alpn`/`fp` survives the whole suite — +// the existing flow tests only check `flow=`. +func TestGenVlessLink_TLSParamsMapped(t *testing.T) { + stream := `{ + "network":"tcp","security":"tls", + "tcpSettings":{"header":{"type":"none"}}, + "tlsSettings":{ + "serverName":"sni.example.com", + "alpn":["h2","http/1.1"], + "settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["YWJj"]} + } + }` + s := &SubService{remarkModel: "-ieo"} + link := s.genVlessLink(shareLinkInbound(stream), "user") + + // url.Values.Encode() percent-encodes values: "," -> %2C, "/" -> %2F. + wants := []string{ + "security=tls", + "sni=sni.example.com", + "fp=chrome", + "alpn=h2%2Chttp%2F1.1", + "pcs=YWJj", + } + for _, w := range wants { + if !strings.Contains(link, w) { + t.Fatalf("TLS link missing %q\n got: %s", w, link) + } + } +} + +// TestGenVlessLink_RealityParamsMapped locks the reality field mapping +// (applyShareRealityParams, service.go:1147). serverNames/shortIds are single-element +// so random.Num is deterministic (index 0); spx is random so it is asserted by prefix. +// Distinct pbk/sid values catch a pbk<->sid swap mutant. +func TestGenVlessLink_RealityParamsMapped(t *testing.T) { + stream := `{ + "network":"tcp","security":"reality", + "tcpSettings":{"header":{"type":"none"}}, + "realitySettings":{ + "serverNames":["reality.example.com"], + "shortIds":["ab12cd"], + "settings":{"publicKey":"PBKvalue","fingerprint":"firefox"} + } + }` + s := &SubService{remarkModel: "-ieo"} + link := s.genVlessLink(shareLinkInbound(stream), "user") + + wants := []string{ + "security=reality", + "sni=reality.example.com", + "pbk=PBKvalue", + "sid=ab12cd", + "fp=firefox", + "spx=%2F", // "/" + random.Seq(15), percent-encoded leading slash + } + for _, w := range wants { + if !strings.Contains(link, w) { + t.Fatalf("reality link missing %q\n got: %s", w, link) + } + } + // A pbk<->sid swap must not silently pass: pbk must not carry the shortId. + if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") { + t.Fatalf("reality pbk/sid mapping crossed: %s", link) + } +} diff --git a/internal/util/common/format_mutation_test.go b/internal/util/common/format_mutation_test.go new file mode 100644 index 000000000..01a8ec368 --- /dev/null +++ b/internal/util/common/format_mutation_test.go @@ -0,0 +1,60 @@ +package common + +import "testing" + +// TestFormatTraffic_UnitBoundaries pins the exact switch point in the loop +// condition `size >= 1024`: a unit must roll over at exactly 1024 (not 1023, +// not 1025), and a value one byte short must stay in the lower unit. This kills +// CONDITIONALS_BOUNDARY (>= -> >) and ARITHMETIC_BASE on the 1024 comparison. +func TestFormatTraffic_UnitBoundaries(t *testing.T) { + cases := []struct { + name string + bytes int64 + want string + }{ + // Just below the first boundary: must NOT roll over to KB. + {"one_below_kb", 1023, "1023.00B"}, + // Exactly at the boundary: must roll over to KB. + {"exactly_kb", 1024, "1.00KB"}, + // Just above: stays in KB. + {"one_above_kb", 1025, "1.00KB"}, + // Just below the MB boundary: stays in KB (proves division divisor 1024). + {"one_below_mb", 1024*1024 - 1, "1024.00KB"}, + // Exactly at the MB boundary: rolls over to MB. + {"exactly_mb", 1024 * 1024, "1.00MB"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := FormatTraffic(c.bytes); got != c.want { + t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want) + } + }) + } +} + +// TestFormatTraffic_ClampsAtPB pins the upper bound guard +// `unitIndex < len(units)-1`: huge values must clamp at PB instead of indexing +// past the units slice. A mutated bound (< -> <= via CONDITIONALS_BOUNDARY, or +// len(units)-1 -> len(units)+1 via INVERT_NEGATIVES/ARITHMETIC_BASE) would run +// one extra iteration and panic with index-out-of-range, so the assertion that +// these return a normal "PB" string kills those mutants. +func TestFormatTraffic_ClampsAtPB(t *testing.T) { + const pb = int64(1024 * 1024 * 1024 * 1024 * 1024) + cases := []struct { + name string + bytes int64 + want string + }{ + // Stays at PB even though size is still >= 1024 at the PB level. + {"1024_pb", 1024 * pb, "1024.00PB"}, + // Max int64 must not overflow the units slice. + {"max_int64", 9223372036854775807, "8192.00PB"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := FormatTraffic(c.bytes); got != c.want { + t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want) + } + }) + } +} diff --git a/internal/util/link/outbound_fuzz_test.go b/internal/util/link/outbound_fuzz_test.go new file mode 100644 index 000000000..25e703dd4 --- /dev/null +++ b/internal/util/link/outbound_fuzz_test.go @@ -0,0 +1,28 @@ +package link + +import "testing" + +// FuzzParseLink asserts the parser never panics and upholds its (result, error) contract +// — exactly one non-nil. It base64-decodes and type-asserts attacker-controllable JSON, +// the classic panic source. +func FuzzParseLink(f *testing.F) { + seeds := []string{ + "", + "not-a-link", + "vmess://eyJ2IjoiMiIsInBzIjoidCIsImFkZCI6ImEuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwibmV0IjoidGNwIn0=", + "vless://11111111-2222-4333-8444-555555555555@a.com:443?type=tcp&security=none#x", + "trojan://pass@a.com:443?security=tls#x", + "ss://YWVzLTI1Ni1nY206cGFzcw==@a.com:8388#x", + "hysteria2://pass@a.com:443?sni=a.com#x", + "wireguard://cGsdkey@a.com:51820?publickey=pub#x", + } + for _, s := range seeds { + f.Add(s) + } + f.Fuzz(func(t *testing.T, s string) { + res, err := ParseLink(s) + if (res == nil) == (err == nil) { + t.Fatalf("ParseLink(%q): exactly one of (result, error) must be non-nil; got res=%v err=%v", s, res, err) + } + }) +} diff --git a/internal/util/link/outbound_helpers_test.go b/internal/util/link/outbound_helpers_test.go new file mode 100644 index 000000000..c6a02356b --- /dev/null +++ b/internal/util/link/outbound_helpers_test.go @@ -0,0 +1,201 @@ +package link + +import ( + "encoding/base64" + "net/url" + "reflect" + "testing" +) + +func TestDefaultPort(t *testing.T) { + cases := []struct { + in string + def int + want int + }{ + {"", 443, 443}, + {"8080", 443, 8080}, + {"0", 443, 443}, // non-positive falls back + {"-1", 443, 443}, // negative falls back + {"abc", 443, 443}, // unparseable falls back + {"65535", 443, 65535}, + } + for _, c := range cases { + if got := defaultPort(c.in, c.def); got != c.want { + t.Errorf("defaultPort(%q,%d) = %d, want %d", c.in, c.def, got, c.want) + } + } +} + +func TestFirstNonEmptyAndParam(t *testing.T) { + if got := firstNonEmpty("a", "b"); got != "a" { + t.Errorf("firstNonEmpty(a,b) = %q, want a", got) + } + if got := firstNonEmpty("", "b"); got != "b" { + t.Errorf("firstNonEmpty(,b) = %q, want b", got) + } + p := url.Values{"x": {""}, "y": {"hit"}, "z": {"z"}} + if got := firstParam(p, "x", "y", "z"); got != "hit" { + t.Errorf("firstParam = %q, want hit (first non-empty)", got) + } + if got := firstParam(p, "x"); got != "" { + t.Errorf("firstParam(only empty) = %q, want empty", got) + } +} + +func TestSplitComma(t *testing.T) { + if got := splitComma(""); got != nil { + t.Errorf("splitComma(empty) = %v, want nil", got) + } + if got := splitComma("a, ,b ,, c"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) { + t.Errorf("splitComma trim/skip = %v, want [a b c]", got) + } + if got := splitCommaOrDefault("", []string{"d"}); !reflect.DeepEqual(got, []string{"d"}) { + t.Errorf("splitCommaOrDefault(empty) = %v, want [d]", got) + } + if got := splitCommaOrDefault("x,y", []string{"d"}); !reflect.DeepEqual(got, []string{"x", "y"}) { + t.Errorf("splitCommaOrDefault(x,y) = %v, want [x y]", got) + } +} + +func TestPadAndBase64DecodeFlexible(t *testing.T) { + if got := padBase64("abc"); got != "abc=" { + t.Errorf("padBase64(abc) = %q, want abc=", got) + } + if got := padBase64("abcd"); got != "abcd" { + t.Errorf("padBase64(abcd) = %q, want unchanged", got) + } + std := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secret")) + if got, err := base64DecodeFlexible(std); err != nil || got != "aes-256-gcm:secret" { + t.Errorf("base64DecodeFlexible(std) = (%q,%v), want (aes-256-gcm:secret,nil)", got, err) + } + rawURL := base64.RawURLEncoding.EncodeToString([]byte("m:p")) + if got, err := base64DecodeFlexible(rawURL); err != nil || got != "m:p" { + t.Errorf("base64DecodeFlexible(rawurl) = (%q,%v), want (m:p,nil)", got, err) + } + if _, err := base64DecodeFlexible("!!!not!!!"); err == nil { + t.Error("base64DecodeFlexible(garbage) should error") + } +} + +func TestDecodeHash(t *testing.T) { + if got := decodeHash(""); got != "" { + t.Errorf("decodeHash(empty) = %q, want empty", got) + } + if got := decodeHash("a%20b"); got != "a b" { + t.Errorf("decodeHash(a%%20b) = %q, want 'a b'", got) + } + if got := decodeHash("plain"); got != "plain" { + t.Errorf("decodeHash(plain) = %q, want plain", got) + } +} + +func TestCanonicalQuery_SortsKeys(t *testing.T) { + // unsorted input must come out key-sorted for a stable identity + got := canonicalQuery(url.Values{"c": {"3"}, "a": {"1"}, "b": {"2"}}) + if got != "a=1&b=2&c=3" { + t.Fatalf("canonicalQuery = %q, want a=1&b=2&c=3", got) + } +} + +// stream navigates res.Outbound["streamSettings"][key] as a map. +func streamSub(t *testing.T, res *ParseResult, key string) map[string]any { + t.Helper() + ss, _ := res.Outbound["streamSettings"].(map[string]any) + m, ok := ss[key].(map[string]any) + if !ok { + t.Fatalf("streamSettings.%s missing/not a map: %#v", key, ss) + } + return m +} + +func TestParse_RealitySecurityMapped(t *testing.T) { + res, err := ParseLink("vless://uuid@h.com:443?type=tcp&security=reality&pbk=PBK&sid=SID&sni=SNI&fp=firefox&spx=%2Fspx&pqv=PQV") + if err != nil { + t.Fatalf("parse: %v", err) + } + re := streamSub(t, res, "realitySettings") + for k, want := range map[string]string{"publicKey": "PBK", "shortId": "SID", "serverName": "SNI", "fingerprint": "firefox", "spiderX": "/spx", "mldsa65Verify": "PQV"} { + if re[k] != want { + t.Errorf("realitySettings[%q] = %v, want %q", k, re[k], want) + } + } +} + +func TestParse_TLSSecurityMapped(t *testing.T) { + res, err := ParseLink("trojan://pw@h.com:443?type=tcp&security=tls&sni=SNI&fp=chrome&alpn=h2,http/1.1&ech=ECH&pcs=PCS") + if err != nil { + t.Fatalf("parse: %v", err) + } + tls := streamSub(t, res, "tlsSettings") + if tls["serverName"] != "SNI" || tls["fingerprint"] != "chrome" || tls["echConfigList"] != "ECH" || tls["pinnedPeerCertSha256"] != "PCS" { + t.Errorf("tlsSettings fields = %#v", tls) + } + if alpn, _ := tls["alpn"].([]string); !reflect.DeepEqual(alpn, []string{"h2", "http/1.1"}) { + t.Errorf("alpn = %#v, want [h2 http/1.1]", tls["alpn"]) + } +} + +func TestParse_WSAndGRPCTransport(t *testing.T) { + ws, err := ParseLink("vless://uuid@h.com:443?type=ws&host=H&path=%2Fwspath") + if err != nil { + t.Fatalf("parse ws: %v", err) + } + wss := streamSub(t, ws, "wsSettings") + if wss["host"] != "H" || wss["path"] != "/wspath" { + t.Errorf("wsSettings = %#v, want host=H path=/wspath", wss) + } + + grpc, err := ParseLink("vless://uuid@h.com:443?type=grpc&serviceName=svc&authority=auth&mode=multi") + if err != nil { + t.Fatalf("parse grpc: %v", err) + } + gs := streamSub(t, grpc, "grpcSettings") + if gs["serviceName"] != "svc" || gs["authority"] != "auth" || gs["multiMode"] != true { + t.Errorf("grpcSettings = %#v, want serviceName=svc authority=auth multiMode=true", gs) + } +} + +func TestParse_TCPHTTPHeader(t *testing.T) { + res, err := ParseLink("vless://uuid@h.com:443?type=tcp&headerType=http&host=ex.com&path=%2F") + if err != nil { + t.Fatalf("parse: %v", err) + } + tcp := streamSub(t, res, "tcpSettings") + header, _ := tcp["header"].(map[string]any) + if header["type"] != "http" { + t.Errorf("tcp header type = %v, want http", header["type"]) + } +} + +func TestParseVless_CoreFields(t *testing.T) { + res, err := ParseLink("vless://the-uuid@9.9.9.9:8443?type=tcp&security=none&flow=xtls-rprx-vision#tag1") + if err != nil { + t.Fatalf("parse: %v", err) + } + st, _ := res.Outbound["settings"].(map[string]any) + if st["address"] != "9.9.9.9" || st["port"] != 8443 || st["id"] != "the-uuid" || st["flow"] != "xtls-rprx-vision" { + t.Errorf("vless settings = %#v", st) + } +} + +func TestParseTrojanAndSS_CoreFields(t *testing.T) { + tr, err := ParseLink("trojan://secret@t.com:443?type=tcp&security=tls#tj") + if err != nil { + t.Fatalf("parse trojan: %v", err) + } + srv := tr.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any) + if srv["address"] != "t.com" || srv["port"] != 443 || srv["password"] != "secret" { + t.Errorf("trojan server = %#v", srv) + } + + ssLink := "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:sspass")) + "@s.com:8388#ss1" + ss, err := ParseLink(ssLink) + if err != nil { + t.Fatalf("parse ss: %v", err) + } + ssrv := ss.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any) + if ssrv["address"] != "s.com" || ssrv["port"] != 8388 || ssrv["password"] != "sspass" || ssrv["method"] != "aes-256-gcm" { + t.Errorf("ss server = %#v", ssrv) + } +} diff --git a/internal/util/netproxy/netproxy_test.go b/internal/util/netproxy/netproxy_test.go index 830a3105c..f1faf56a7 100644 --- a/internal/util/netproxy/netproxy_test.go +++ b/internal/util/netproxy/netproxy_test.go @@ -2,6 +2,8 @@ package netproxy import ( "net/http" + "net/http/httptest" + "reflect" "testing" "time" ) @@ -22,6 +24,10 @@ func TestNewHTTPClient(t *testing.T) { {name: "unsupported scheme errors", proxyURL: "ftp://127.0.0.1:21", wantErr: true}, } + // baseTransport clones http.DefaultTransport, whose Proxy and DialContext are already + // non-nil — so "!= nil" can't prove our proxy/dialer was applied. Check the real values. + defaultDialPtr := reflect.ValueOf(http.DefaultTransport.(*http.Transport).DialContext).Pointer() + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client, err := NewHTTPClient(tc.proxyURL, 5*time.Second) @@ -37,16 +43,36 @@ func TestNewHTTPClient(t *testing.T) { if client.Timeout != 5*time.Second { t.Errorf("timeout = %v, want 5s", client.Timeout) } + // Empty proxyURL → a plain direct client with no custom transport. + if tc.proxyURL == "" { + if client.Transport != nil { + t.Errorf("empty proxy must yield a direct client (nil Transport), got %T", client.Transport) + } + return + } + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("transport is %T, want *http.Transport", client.Transport) + } if tc.wantProxy { - transport, ok := client.Transport.(*http.Transport) - if !ok || transport.Proxy == nil { - t.Errorf("expected transport with Proxy set for %q", tc.proxyURL) + // Prove the CONFIGURED proxy is applied: transport.Proxy(req) must + // return our URL, not the cloned default's ProxyFromEnvironment. + req := httptest.NewRequest(http.MethodGet, "https://example.com", nil) + u, perr := transport.Proxy(req) + if perr != nil { + t.Fatalf("transport.Proxy returned error: %v", perr) + } + if u == nil || u.String() != tc.proxyURL { + t.Errorf("transport.Proxy(req) = %v, want %q (configured proxy not applied)", u, tc.proxyURL) } } if tc.wantDial { - transport, ok := client.Transport.(*http.Transport) - if !ok || transport.DialContext == nil { - t.Errorf("expected transport with DialContext set for %q", tc.proxyURL) + if transport.DialContext == nil { + t.Fatal("DialContext is nil") + } + // Must be the socks5 dialer, not the cloned default DialContext. + if reflect.ValueOf(transport.DialContext).Pointer() == defaultDialPtr { + t.Error("DialContext is still the default; socks5 dialer was not applied") } } }) diff --git a/internal/util/netsafe/netsafe_mutation_test.go b/internal/util/netsafe/netsafe_mutation_test.go new file mode 100644 index 000000000..297f868fc --- /dev/null +++ b/internal/util/netsafe/netsafe_mutation_test.go @@ -0,0 +1,102 @@ +package netsafe + +import ( + "context" + "strings" + "testing" +) + +// TestSSRFGuardedDialContext_LiteralIPSkipsResolver pins the netsafe.go:37 +// decision (`if ip := net.ParseIP(host); ip != nil`). The string "fe80::1%eth0" +// is rejected by net.ParseIP (returns nil) but accepted by the resolver, which +// yields the link-local address fe80::1. With the branch intact, ParseIP returns +// nil so the host falls through to LookupIPAddr, resolves to fe80::1, and is +// blocked by IsBlockedIP -> the error mentions the resolved blocked address. +// If the condition is flipped to `ip == nil`, the nil-IP literal path is taken +// instead: ips = [{IP: nil}], IsBlockedIP(nil) is false, the guard never fires +// and the error would never say "blocked private/internal address fe80::1". +func TestSSRFGuardedDialContext_LiteralIPSkipsResolver(t *testing.T) { + _, err := SSRFGuardedDialContext(context.Background(), "tcp", "[fe80::1%eth0]:80") + if err == nil { + t.Fatal("expected error for link-local host with zone suffix") + } + if !strings.Contains(err.Error(), "blocked private/internal address fe80::1") { + t.Fatalf("expected guard to block resolved link-local fe80::1, got: %v", err) + } +} + +// TestSSRFGuardedDialContext_LiteralPrivateIPv6Blocked complements the above by +// confirming that a valid IP literal (parsed by the line 37 branch) is still run +// through IsBlockedIP and rejected with the literal in the message. +func TestSSRFGuardedDialContext_LiteralPrivateIPv6Blocked(t *testing.T) { + _, err := SSRFGuardedDialContext(context.Background(), "tcp", "[::1]:80") + if err == nil { + t.Fatal("expected dial to ::1 to be blocked") + } + if !strings.Contains(err.Error(), "blocked private/internal address ::1") { + t.Fatalf("expected '::1' literal in blocked error, got: %v", err) + } +} + +// TestNormalizeHost_LengthBoundary pins the netsafe.go:76 length check +// (`len(addr) > 253`). A valid-pattern hostname of exactly 253 chars must be +// accepted (kills `>` -> `>=` / off-by-one mutations of the bound), while the +// same hostname at 254 chars must be rejected. +func TestNormalizeHost_LengthBoundary(t *testing.T) { + label := strings.Repeat("a", 61) + base := label + "." + label + "." + label + "." // 186 chars, valid pattern + h253 := base + strings.Repeat("a", 253-len(base)) + if len(h253) != 253 { + t.Fatalf("test setup: expected 253-char host, got %d", len(h253)) + } + h254 := h253 + "a" + + got, err := NormalizeHost(h253) + if err != nil { + t.Fatalf("NormalizeHost(253-char valid host) returned error: %v", err) + } + if got != h253 { + t.Fatalf("NormalizeHost(253-char host) = %q, want unchanged input", got) + } + + if _, err := NormalizeHost(h254); err == nil { + t.Fatal("NormalizeHost(254-char host) expected error, got nil") + } +} + +// TestNormalizeHost_PatternClauseIndependentOfLength pins the OR in line 76: +// a short hostname (well under the 253 limit) that violates the pattern must +// still be rejected. If `||` were mutated to `&&`, this short-but-invalid host +// would slip through because the length clause is false. +func TestNormalizeHost_PatternClauseIndependentOfLength(t *testing.T) { + cases := []string{ + "under_score.example.com", + "bad host", + "exa$mple.com", + "-leadingdash.com", + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + if len(in) > 253 { + t.Fatalf("test setup: %q should be short to isolate the pattern clause", in) + } + if _, err := NormalizeHost(in); err == nil { + t.Fatalf("NormalizeHost(%q) expected error for invalid pattern, got nil", in) + } + }) + } +} + +// TestNormalizeHost_ValidShortHostAccepted ensures a short valid-pattern host is +// accepted, so a mutation dropping the `!` on the pattern match (rejecting valid +// hosts) is caught alongside the rejection cases above. +func TestNormalizeHost_ValidShortHostAccepted(t *testing.T) { + const in = "node-1.example.com" + got, err := NormalizeHost(in) + if err != nil { + t.Fatalf("NormalizeHost(%q) returned error: %v", in, err) + } + if got != in { + t.Fatalf("NormalizeHost(%q) = %q, want %q", in, got, in) + } +} diff --git a/internal/web/middleware/security_test.go b/internal/web/middleware/security_test.go index 02d7b098b..f9fa19186 100644 --- a/internal/web/middleware/security_test.go +++ b/internal/web/middleware/security_test.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/web/session" @@ -80,7 +81,9 @@ func TestSecurityHeadersMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(SecurityHeadersMiddleware(true)) + var capturedNonce string router.GET("/", func(c *gin.Context) { + capturedNonce = c.GetString("csp_nonce") c.String(http.StatusOK, "ok") }) @@ -101,6 +104,33 @@ func TestSecurityHeadersMiddleware(t *testing.T) { if got := headers.Get("Strict-Transport-Security"); got == "" { t.Fatal("Strict-Transport-Security should be set for direct HTTPS") } + + // CSP is the highest-value header here: assert it stays nonce-bound with its hardening + // directives, so weakening it (unsafe-inline, dropped frame-ancestors, broken nonce) fails. + csp := headers.Get("Content-Security-Policy") + if csp == "" { + t.Fatal("Content-Security-Policy header must be set") + } + if capturedNonce == "" { + t.Fatal("csp_nonce context value must be set (the injected inline script reads it)") + } + if want := "script-src 'self' 'nonce-" + capturedNonce + "'"; !strings.Contains(csp, want) { + t.Fatalf("CSP script-src must be bound to the per-request nonce %q; got %q", want, csp) + } + for _, directive := range []string{"object-src 'none'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'"} { + if !strings.Contains(csp, directive) { + t.Errorf("CSP missing hardening directive %q; got %q", directive, csp) + } + } + // script-src must NOT allow 'unsafe-inline' (it would defeat the nonce). Check the + // script-src directive in isolation, since style-src legitimately uses unsafe-inline. + scriptDir := csp[strings.Index(csp, "script-src"):] + if i := strings.Index(scriptDir, ";"); i >= 0 { + scriptDir = scriptDir[:i] + } + if strings.Contains(scriptDir, "unsafe-inline") { + t.Errorf("CSP script-src must not allow 'unsafe-inline': %q", scriptDir) + } } func TestSecurityHeadersMiddlewareSkipsHSTSWithoutDirectHTTPS(t *testing.T) { diff --git a/internal/web/middleware/validate_mutation_test.go b/internal/web/middleware/validate_mutation_test.go new file mode 100644 index 000000000..f2d80a9e7 --- /dev/null +++ b/internal/web/middleware/validate_mutation_test.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +// The accept side of validate.go:45 — `c.ShouldBindWith(&dst, binding.JSON)` must SUCCEED +// for a well-formed JSON body and decode it into the destination struct. If the conditional +// is flipped (err != nil -> err == nil) or the bind call is dropped, a valid body would be +// rejected or the fields would come back zero-valued; both fail these assertions. +func TestBindJSONAndValidate_ValidJSONDecodesAndPasses(t *testing.T) { + var got *sampleBody + r := newRouter(func(c *gin.Context) { + var ok bool + got, ok = BindJSONAndValidate[sampleBody](c) + if !ok { + t.Fatalf("expected ok=true for valid JSON; got false") + } + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/submit", + strings.NewReader(`{"port":443,"protocol":"vless","tag":"inbound-443"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + if got == nil { + t.Fatal("expected decoded struct; got nil") + } + if got.Port != 443 || got.Protocol != "vless" || got.Tag != "inbound-443" { + t.Fatalf("decoded JSON mismatch: %+v", got) + } +} + +// The reject side of validate.go:45 — a malformed JSON body must be caught by the bind +// conditional, returning (nil,false) with a parse-error Message and NO validator Issues. +// If the conditional is flipped so malformed input bypasses the bind check, control falls +// through to validate.Struct on a zero-valued struct, which would instead emit validator +// Issues (e.g. rule="required"/"gte"). Asserting empty Issues + non-empty Message pins the +// distinct parse-failure path that line 45 owns. +func TestBindJSONAndValidate_MalformedJSONRejectedWithoutValidatorIssues(t *testing.T) { + r := newRouter(func(c *gin.Context) { + if _, ok := BindJSONAndValidate[sampleBody](c); ok { + t.Fatal("expected ok=false on malformed JSON; got true") + } + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/submit", + strings.NewReader(`{"port":}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + msg := decodeMsg(t, rec.Body.String()) + if msg.Success { + t.Fatal("expected Success=false on malformed JSON") + } + payload, err := payloadFromObj(msg.Obj) + if err != nil { + t.Fatalf("payload extraction: %v", err) + } + if len(payload.Issues) != 0 { + t.Fatalf("expected empty Issues for a JSON parse error (not validator output); got %+v", payload.Issues) + } + if payload.Message == "" { + t.Fatal("expected non-empty Message describing the JSON parse error") + } +} + +// BindJSONAndValidateInto shares the same line-45-style bind conditional (line 57). Cover its +// accept side: a valid JSON body must bind onto the caller-supplied destination and pass, +// overwriting any pre-populated field. A flipped/dropped bind check leaves the destination +// untouched (or returns false), which these assertions catch. +func TestBindJSONAndValidateInto_ValidJSONBindsOntoDestination(t *testing.T) { + dst := &sampleBody{Tag: "preset"} + r := newRouter(func(c *gin.Context) { + if !BindJSONAndValidateInto(c, dst) { + t.Fatal("expected ok=true for valid JSON; got false") + } + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/submit", + strings.NewReader(`{"port":8443,"protocol":"trojan","tag":"inbound-8443"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + if dst.Port != 8443 || dst.Protocol != "trojan" { + t.Fatalf("expected JSON to bind onto destination; got %+v", dst) + } + if dst.Tag != "inbound-8443" { + t.Fatalf("expected payload Tag to overwrite preset; got %q", dst.Tag) + } +} diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index 95432af54..998e39ff5 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -87,9 +87,6 @@ func (r *Remote) baseURL() (string, error) { return "", fmt.Errorf("invalid node port %d", r.node.Port) } bp := r.node.BasePath - if bp == "" { - bp = "/" - } if !strings.HasSuffix(bp, "/") { bp += "/" } diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go index 04b05b880..0f5c76767 100644 --- a/internal/web/runtime/remote_test.go +++ b/internal/web/runtime/remote_test.go @@ -1,12 +1,20 @@ package runtime import ( + "context" "encoding/json" + "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) +type stubEgress struct{ url string } + +func (s stubEgress) NodeEgressProxyURL(int) string { return s.url } + // cacheGetTag must resolve a remote inbound id even when the n- prefix // sits on only one side: the node may store the bare tag while the central // panel pushes the prefixed form, or vice versa. Without this a mismatch makes @@ -50,6 +58,115 @@ func TestWireInboundIncludesShareAddressFields(t *testing.T) { } } +func TestRemoteHTTPClientEgressProxy(t *testing.T) { + // OutboundTag + a resolver → a dedicated proxy client (not the shared default). + withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"}) + c, err := withTag.httpClient() + if err != nil { + t.Fatalf("httpClient: %v", err) + } + if c == defaultNodeHTTPClient { + t.Fatal("OutboundTag + resolver must produce a dedicated egress client, not the shared default") + } + // No OutboundTag → no egress proxy → shared default client (verify mode). + noTag := NewRemote(&model.Node{Id: 2, Scheme: "https", TlsVerifyMode: "verify"}, stubEgress{url: "socks5://127.0.0.1:1080"}) + c2, err := noTag.httpClient() + if err != nil { + t.Fatalf("httpClient: %v", err) + } + if c2 != defaultNodeHTTPClient { + t.Fatal("no OutboundTag must use the shared default client") + } +} + +func TestRemoteDoSetsContentType(t *testing.T) { + var gotCT string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCT = r.Header.Get("Content-Type") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + })) + defer srv.Close() + + r := NewRemote(nodeForServer(t, srv, "skip", ""), nil) + if _, err := r.do(context.Background(), http.MethodPost, "x", url.Values{"a": {"b"}}); err != nil { + t.Fatalf("do: %v", err) + } + if gotCT != "application/x-www-form-urlencoded" { + t.Fatalf("Content-Type = %q, want application/x-www-form-urlencoded", gotCT) + } +} + +func TestRemoteBaseURL(t *testing.T) { + cases := []struct { + name string + scheme string + port int + bp string + want string + wantErr bool + }{ + {"https default path", "https", 443, "", "https://example.com:443/", false}, + {"http custom path gets trailing slash", "http", 8080, "/panel", "http://example.com:8080/panel/", false}, + {"empty scheme defaults to https", "", 2096, "/", "https://example.com:2096/", false}, + {"invalid scheme defaults to https", "ftp", 2096, "/", "https://example.com:2096/", false}, + {"port zero rejected", "https", 0, "/", "", true}, + {"port above range rejected", "https", 65536, "/", "", true}, + {"negative port rejected", "https", -1, "/", "", true}, + {"max port accepted", "https", 65535, "/", "https://example.com:65535/", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := NewRemote(&model.Node{Address: "example.com", Scheme: c.scheme, Port: c.port, BasePath: c.bp}, nil) + got, err := r.baseURL() + if c.wantErr { + if err == nil { + t.Fatalf("expected error for scheme=%q port=%d", c.scheme, c.port) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != c.want { + t.Fatalf("baseURL = %q, want %q", got, c.want) + } + }) + } +} + +func TestIsNonEmptySlice(t *testing.T) { + cases := []struct { + name string + in any + want bool + }{ + {"non-empty slice", []any{1}, true}, + {"empty slice", []any{}, false}, + {"nil slice", []any(nil), false}, + {"not a slice", "x", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := isNonEmptySlice(c.in); got != c.want { + t.Fatalf("isNonEmptySlice(%#v) = %v, want %v", c.in, got, c.want) + } + }) + } +} + +func TestWireInboundTrafficReset(t *testing.T) { + with := wireInbound(&model.Inbound{TrafficReset: "daily"}) + if got := with.Get("trafficReset"); got != "daily" { + t.Fatalf("trafficReset = %q, want daily", got) + } + // Empty TrafficReset must be omitted entirely, not sent as an empty field. + without := wireInbound(&model.Inbound{}) + if without.Has("trafficReset") { + t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset")) + } +} + func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) { values := wireInbound(&model.Inbound{}) diff --git a/internal/web/runtime/tls_client_property_test.go b/internal/web/runtime/tls_client_property_test.go new file mode 100644 index 000000000..b859c5b11 --- /dev/null +++ b/internal/web/runtime/tls_client_property_test.go @@ -0,0 +1,73 @@ +package runtime + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" + "testing" + + "pgregory.net/rapid" +) + +func insertColons(h string) string { + var b strings.Builder + for i := 0; i < len(h); i += 2 { + if i > 0 { + b.WriteByte(':') + } + b.WriteString(h[i : i+2]) + } + return b.String() +} + +// TestProp_DecodeCertPin_FormatAgnostic asserts that for ANY 32-byte pin, every +// accepted encoding (hex lower/upper, openssl colon-hex, base64 std/raw/url) decodes +// back to the same bytes. Generalizes the fixed-input TestDecodeCertPin so a mutant +// that breaks one decoding path is caught across the whole input space. +func TestProp_DecodeCertPin_FormatAgnostic(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + raw := rapid.SliceOfN(rapid.Byte(), sha256.Size, sha256.Size).Draw(t, "raw") + hx := hex.EncodeToString(raw) + forms := []string{ + hx, + strings.ToUpper(hx), + insertColons(hx), + base64.StdEncoding.EncodeToString(raw), + base64.RawStdEncoding.EncodeToString(raw), + base64.URLEncoding.EncodeToString(raw), + base64.RawURLEncoding.EncodeToString(raw), + } + for _, f := range forms { + got, err := DecodeCertPin(f) + if err != nil { + t.Fatalf("DecodeCertPin(%q) errored: %v", f, err) + } + if !bytes.Equal(got, raw) { + t.Fatalf("DecodeCertPin(%q) = %x, want %x", f, got, raw) + } + } + }) +} + +// FuzzDecodeCertPin asserts the security-load-bearing decoder never panics, never +// returns a non-32-byte slice with a nil error, and never returns bytes alongside an +// error. Seeded from the known-good/known-bad cases. +func FuzzDecodeCertPin(f *testing.F) { + seed := sha256.Sum256([]byte("seed")) + f.Add(hex.EncodeToString(seed[:])) + f.Add(base64.StdEncoding.EncodeToString(seed[:])) + f.Add(insertColons(hex.EncodeToString(seed[:]))) + f.Add("") + f.Add("not-a-pin") + f.Fuzz(func(t *testing.T, s string) { + got, err := DecodeCertPin(s) + if err == nil && len(got) != sha256.Size { + t.Fatalf("DecodeCertPin(%q): nil error but %d bytes, want %d", s, len(got), sha256.Size) + } + if err != nil && got != nil { + t.Fatalf("DecodeCertPin(%q): error %v but returned bytes %x", s, err, got) + } + }) +} diff --git a/internal/web/runtime/tls_client_test.go b/internal/web/runtime/tls_client_test.go index 3f4e55610..1cf1f96c0 100644 --- a/internal/web/runtime/tls_client_test.go +++ b/internal/web/runtime/tls_client_test.go @@ -116,8 +116,67 @@ func TestHTTPClientForNodeVerifyShared(t *testing.T) { } func TestHTTPClientForNodePinInvalid(t *testing.T) { - if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}, ""); err == nil { - t.Fatal("expected error for invalid pin") + // pin mode must fail closed, and with a specific error per cause — not merely + // "some error" (which a bug anywhere in the build path would also satisfy). + cases := []struct { + name string + pin string + wantErr string + }{ + {"garbage pin", "not-a-pin", "must be a SHA-256 hash"}, + {"empty pin", "", "certificate pin is empty"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "") + if err == nil { + t.Fatalf("expected error for pin %q", c.pin) + } + if !strings.Contains(err.Error(), c.wantErr) { + t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr) + } + }) + } +} + +// TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch +// (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's +// transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg` +// line). Dropping it would silently disable certificate pinning whenever a proxy is set. +func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) { + pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size)) + n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin} + + c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080") + if err != nil { + t.Fatalf("HTTPClientForNode: %v", err) + } + if c == defaultNodeHTTPClient { + t.Fatal("proxy client must not be the shared default client") + } + tr, ok := c.Transport.(*http.Transport) + if !ok { + t.Fatalf("transport is %T, want *http.Transport", c.Transport) + } + if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil { + t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped") + } +} + +// TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch +// (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is, +// using system-CA verification and NOT a pin VerifyConnection. +func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) { + n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"} + c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080") + if err != nil { + t.Fatalf("HTTPClientForNode: %v", err) + } + if c == defaultNodeHTTPClient { + t.Fatal("proxy client must not be the shared default client") + } + if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil { + t.Fatal("verify mode must not install a pin VerifyConnection") } } diff --git a/internal/web/service/inbound_migration.go b/internal/web/service/inbound_migration.go index e2258c2bc..23bd67f7d 100644 --- a/internal/web/service/inbound_migration.go +++ b/internal/web/service/inbound_migration.go @@ -244,7 +244,7 @@ func (s *InboundService) MigrationRequirements() { SET tag = REPLACE(tag, '0.0.0.0:', '') WHERE position('0.0.0.0:' in tag) > 0;` } - err = tx.Raw(tagCleanup).Error + err = tx.Exec(tagCleanup).Error if err != nil { return } diff --git a/internal/web/service/inbound_migration_test.go b/internal/web/service/inbound_migration_test.go index a2a4c8f64..2f7b8fc33 100644 --- a/internal/web/service/inbound_migration_test.go +++ b/internal/web/service/inbound_migration_test.go @@ -90,6 +90,45 @@ func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t * } } +// TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that +// strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the +// externalProxy detection query returns rows and the cleanup is reached (it early-returns +// at len(externalProxy)==0 otherwise). The cleanup must use tx.Exec, not tx.Raw, which +// only builds a non-SELECT statement without running it. +func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + legacy := &model.Inbound{ + UserId: 1, + Tag: "inbound-0.0.0.0:30002", + Enable: true, + Port: 30002, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`, + } + if err := db.Create(legacy).Error; err != nil { + t.Fatalf("create legacy inbound: %v", err) + } + + svc := InboundService{} + svc.MigrationRequirements() + + var got model.Inbound + if err := db.First(&got, legacy.Id).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + if got.Tag != "inbound-30002" { + t.Fatalf("legacy 0.0.0.0: tag not stripped: got %q, want %q", got.Tag, "inbound-30002") + } +} + func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) { setupConflictDB(t) db := database.GetDB() diff --git a/internal/xray/mutation_audit_test.go b/internal/xray/mutation_audit_test.go new file mode 100644 index 000000000..fdc748507 --- /dev/null +++ b/internal/xray/mutation_audit_test.go @@ -0,0 +1,345 @@ +package xray + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" +) + +// --------------------------------------------------------------------------- +// hot_diff.go mutation audits +// --------------------------------------------------------------------------- + +// TestDiffOutbounds_EmptyOutboundsNoPanic pins hot_diff.go:154 — the +// `len(oldOut) > 0` guard that protects the oldOut[0]/newOut[0] index. With no +// outbounds on either side the first-outbound identity check must be SKIPPED +// (an empty hot diff), never executed; a mutated guard (`>= 0`) would index a +// nil slice and panic. +func TestDiffOutbounds_EmptyOutboundsNoPanic(t *testing.T) { + oldCfg := makeHotConfig() + oldCfg.OutboundConfigs = nil + newCfg := makeHotConfig() + newCfg.OutboundConfigs = nil + + diff, ok := ComputeHotDiff(oldCfg, newCfg) + if !ok { + t.Fatal("identical empty-outbound configs must be hot-appliable") + } + if len(diff.RemovedOutboundTags) != 0 || len(diff.AddedOutbounds) != 0 { + t.Fatalf("no outbounds on either side must yield no outbound ops, got %+v", diff) + } +} + +// TestDiffOutbounds_SingleFirstOutboundChangeNeedsRestart pins the other side +// of the hot_diff.go:154 boundary. With exactly ONE outbound, changing its +// content touches the default (first) handler, which has no replace API — it +// must force a restart. A mutated guard (`> 1`) would skip the first-outbound +// check at this length and wrongly classify the change as hot-appliable. +func TestDiffOutbounds_SingleFirstOutboundChangeNeedsRestart(t *testing.T) { + oldCfg := makeHotConfig() + oldCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"}]`) + newCfg := makeHotConfig() + newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"UseIP"},"tag":"direct"}]`) + + if _, ok := ComputeHotDiff(oldCfg, newCfg); ok { + t.Fatal("changing the only (default) outbound must force a restart") + } +} + +// TestRoutingWithoutReloadable_EmptyInput pins hot_diff.go:219 — the +// `len(raw) > 0` guard that skips JSON decoding of empty input. Empty input +// must canonicalize to the empty object `{}` with ok=true (no rules/balancers +// to strip). A mutated guard (`>= 0`) would feed an empty reader to the JSON +// decoder, get io.EOF, and wrongly return ok=false. +func TestRoutingWithoutReloadable_EmptyInput(t *testing.T) { + out, ok := routingWithoutReloadable([]byte{}) + if !ok { + t.Fatal("empty routing input must canonicalize successfully") + } + if string(out) != "{}" { + t.Fatalf("empty routing input must canonicalize to {}, got %q", out) + } + + // nil input behaves the same as empty. + out, ok = routingWithoutReloadable(nil) + if !ok || string(out) != "{}" { + t.Fatalf("nil routing input must canonicalize to {}, ok=%v out=%q", ok, out) + } +} + +// TestRoutingWithoutReloadable_StripsRulesAndBalancers complements the guard +// test: with real content the reloadable keys (rules, balancers) are removed +// and only the restart-only remainder is returned. This pins that a routing +// change limited to rules/balancers leaves an identical remainder. +func TestRoutingWithoutReloadable_StripsRulesAndBalancers(t *testing.T) { + a, ok := routingWithoutReloadable([]byte(`{"domainStrategy":"AsIs","rules":[{"x":1}],"balancers":[{"y":2}]}`)) + if !ok { + t.Fatal("valid routing input must parse") + } + b, ok := routingWithoutReloadable([]byte(`{"domainStrategy":"AsIs","rules":[],"balancers":[]}`)) + if !ok { + t.Fatal("valid routing input must parse") + } + if string(a) != string(b) { + t.Fatalf("rules/balancers must be stripped: %q != %q", a, b) + } + if string(a) != `{"domainStrategy":"AsIs"}` { + t.Fatalf("remainder must keep only restart-only keys, got %q", a) + } +} + +// TestApiTagFromConfig pins hot_diff.go:357 — the three-part guard +// `len(api) > 0 && Unmarshal == nil && parsed.Tag != ""`. Each conjunct must +// hold for a custom tag to be honored; otherwise the default "api" is used. +func TestApiTagFromConfig(t *testing.T) { + cases := []struct { + name string + api string + want string + }{ + {"empty input falls back to api", "", "api"}, + {"explicit tag honored", `{"tag":"my-api"}`, "my-api"}, + {"empty tag falls back to api", `{"tag":""}`, "api"}, + {"missing tag falls back to api", `{"services":["StatsService"]}`, "api"}, + {"unparsable falls back to api", `{not-json`, "api"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := apiTagFromConfig(json_util.RawMessage(tc.api)) + if got != tc.want { + t.Fatalf("apiTagFromConfig(%q) = %q, want %q", tc.api, got, tc.want) + } + }) + } +} + +// TestApiTagDrivesInboundRestartGuard ties hot_diff.go:357 to its consumer: +// the api tag resolved from the api section is the tag whose inbound change +// forces a restart. With a custom api.tag, changing that inbound must NOT be +// hot-appliable (it carries the gRPC server the panel talks through). +func TestApiTagDrivesInboundRestartGuard(t *testing.T) { + oldCfg := makeHotConfig() + oldCfg.API = json_util.RawMessage(`{"services":["HandlerService"],"tag":"custom-api"}`) + oldCfg.InboundConfigs[0].Tag = "custom-api" + newCfg := makeHotConfig() + newCfg.API = json_util.RawMessage(`{"services":["HandlerService"],"tag":"custom-api"}`) + newCfg.InboundConfigs[0].Tag = "custom-api" + newCfg.InboundConfigs[0].Port = 62790 // change the custom-api inbound + + if _, ok := ComputeHotDiff(oldCfg, newCfg); ok { + t.Fatal("changing the inbound named by a custom api.tag must force a restart") + } +} + +// --------------------------------------------------------------------------- +// process.go mutation audits (pure-logic, cross-platform) +// --------------------------------------------------------------------------- + +// TestIsRunning_ExitedProcessWithClosedDone pins process.go:240 — the +// `if p.done != nil` guard that decides whether to consult the done channel. +// When the process has exited (done closed) but ProcessState has not yet been +// observed, IsRunning must report false via the closed-channel select. A +// mutated guard (`== nil`) would skip the select and wrongly report true. +func TestIsRunning_ExitedProcessWithClosedDone(t *testing.T) { + p := newProcess(nil) + p.cmd = &exec.Cmd{Process: &os.Process{}} + done := make(chan struct{}) + close(done) + p.done = done + + if p.IsRunning() { + t.Fatal("a process whose done channel is closed must report not running") + } +} + +// TestIsRunning_LiveProcessWithOpenDone is the complementary case: an open +// done channel and no ProcessState means the process is alive, so IsRunning +// must report true (the select's default branch is taken). +func TestIsRunning_LiveProcessWithOpenDone(t *testing.T) { + p := newProcess(nil) + p.cmd = &exec.Cmd{Process: &os.Process{}} + p.done = make(chan struct{}) // open + + if !p.IsRunning() { + t.Fatal("a process with an open done channel and live cmd must report running") + } +} + +// TestGetResult pins process.go:260 — the +// `if len(lastLine) == 0 && exitErr != nil` choice between the captured log +// line and the exit error string. +func TestGetResult(t *testing.T) { + cases := []struct { + name string + lastLine string + exitErr error + want string + }{ + {"no line, has error -> error string", "", errProcessTest("boom"), "boom"}, + {"has line -> line wins over error", "last log", errProcessTest("boom"), "last log"}, + {"no line, no error -> empty", "", nil, ""}, + {"has line, no error -> line", "last log", nil, "last log"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := newProcess(nil) + p.logWriter.lastLine = tc.lastLine + p.exitErr = tc.exitErr + if got := p.GetResult(); got != tc.want { + t.Fatalf("GetResult() = %q, want %q", got, tc.want) + } + }) + } +} + +type errProcessTest string + +func (e errProcessTest) Error() string { return string(e) } + +// TestRefreshLocalOnline_GraceBoundaryEmails pins the exact `<` boundary at +// process.go:407: an email idle for EXACTLY graceMs must be aged out (the +// window is half-open, age < grace). A mutated comparison (`<=`) would keep it. +func TestRefreshLocalOnline_GraceBoundaryEmails(t *testing.T) { + p := newOnlineTestProcess() + const grace = int64(20000) + + p.RefreshLocalOnline([]string{"edge"}, nil, 0, grace) + // now-ts == grace exactly: age is not strictly < grace, so it must drop. + p.RefreshLocalOnline(nil, nil, grace, grace) + for _, e := range p.GetLocalOnlineClients() { + if e == "edge" { + t.Fatalf("email idle exactly graceMs must age out (half-open window), got online %v", p.GetLocalOnlineClients()) + } + } + + // One millisecond inside the window must still be online. + p2 := newOnlineTestProcess() + p2.RefreshLocalOnline([]string{"edge"}, nil, 0, grace) + p2.RefreshLocalOnline(nil, nil, grace-1, grace) + if !containsString(p2.GetLocalOnlineClients(), "edge") { + t.Fatalf("email idle graceMs-1 must still be online, got %v", p2.GetLocalOnlineClients()) + } +} + +// TestRefreshLocalOnline_GraceBoundaryInbounds pins the same `<` boundary at +// process.go:423 for inbound tags. +func TestRefreshLocalOnline_GraceBoundaryInbounds(t *testing.T) { + p := newOnlineTestProcess() + const grace = int64(20000) + + p.RefreshLocalOnline(nil, []string{"in-edge"}, 0, grace) + p.RefreshLocalOnline(nil, nil, grace, grace) + for _, tag := range p.GetLocalActiveInbounds() { + if tag == "in-edge" { + t.Fatalf("inbound idle exactly graceMs must age out, got active %v", p.GetLocalActiveInbounds()) + } + } + + p2 := newOnlineTestProcess() + p2.RefreshLocalOnline(nil, []string{"in-edge"}, 0, grace) + p2.RefreshLocalOnline(nil, nil, grace-1, grace) + if !containsString(p2.GetLocalActiveInbounds(), "in-edge") { + t.Fatalf("inbound idle graceMs-1 must still be active, got %v", p2.GetLocalActiveInbounds()) + } +} + +func containsString(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// process.go mutation audits (require a real child process; re-invoke the +// test binary so they run cross-platform, no signals needed) +// --------------------------------------------------------------------------- + +// TestWaitForCommand_CrashExitRecordsError pins process.go:554 — the +// `if err == nil || intentionalStop` guard. A process that exits with a +// NON-zero code on its own (not an intentional Stop) is a crash and its error +// MUST be recorded. A mutated guard that negates the err check (`err != nil`) +// would early-return and drop the error. +func TestWaitForCommand_CrashExitRecordsError(t *testing.T) { + t.Setenv("XUI_LOG_FOLDER", t.TempDir()) + cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "crash-exit") + cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1") + + p := newProcess(nil) + if err := p.startCommand(cmd); err != nil { + t.Fatalf("startCommand: %v", err) + } + // We never call Stop -> intentionalStop stays false; the child exits 2. + if err := p.waitForExit(5 * time.Second); err != nil { + t.Fatalf("child did not exit: %v", err) + } + if p.GetErr() == nil { + t.Fatal("a non-intentional non-zero exit must record an error") + } +} + +// TestStop_RemovesTempConfigFile pins process.go:579 — the +// `if p.configPath != ""` guard that removes the per-run temp config file on +// Stop (so test runs never disturb the main config.json). A mutated guard +// (`== ""`) would skip the removal and leak the temp file. +func TestStop_RemovesTempConfigFile(t *testing.T) { + t.Setenv("XUI_LOG_FOLDER", t.TempDir()) + + tmpCfg := filepath.Join(t.TempDir(), "test-config.json") + if err := os.WriteFile(tmpCfg, []byte("{}"), 0o644); err != nil { + t.Fatalf("write temp config: %v", err) + } + + cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "block") + cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1") + + p := newProcess(nil) + p.configPath = tmpCfg + if err := p.startCommand(cmd); err != nil { + t.Fatalf("startCommand: %v", err) + } + t.Cleanup(func() { + if p.IsRunning() { + p.intentionalStop.Store(true) + _ = p.cmd.Process.Kill() + _ = p.waitForExit(2 * time.Second) + } + }) + + if !p.IsRunning() { + t.Fatal("helper process must be running before Stop") + } + if err := p.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if _, err := os.Stat(tmpCfg); !os.IsNotExist(err) { + t.Fatalf("temp config file must be removed on Stop, stat err=%v", err) + } +} + +// TestMutationAuditHelper is the re-invoked child for the process tests above. +// It is inert unless XRAY_MUT_HELPER=1 is set. +func TestMutationAuditHelper(t *testing.T) { + if os.Getenv("XRAY_MUT_HELPER") != "1" { + return + } + mode := "" + for i, arg := range os.Args { + if arg == "--" && i+1 < len(os.Args) { + mode = os.Args[i+1] + break + } + } + switch mode { + case "crash-exit": + os.Exit(2) + case "block": + select {} + } +}