mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d388a39c | ||
|
|
259225ac61 | ||
|
|
cb5953bbb8 | ||
|
|
0ccb42932c | ||
|
|
b1bea96fc8 |
@@ -1,20 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "Server/db/queries/**"
|
||||
- "Server/migrations/**"
|
||||
- "Server/sqlc.yaml"
|
||||
---
|
||||
|
||||
# Schema and query edits
|
||||
|
||||
These files are the sqlc source of truth. Invoke the `db-change` skill before
|
||||
changing anything here.
|
||||
|
||||
`Server/db/dbgen/` is generated from them and is denied to Edit/Write in
|
||||
`.claude/settings.json`. After a change, regenerate and stage the result:
|
||||
|
||||
```
|
||||
cd Server && sqlc generate
|
||||
```
|
||||
|
||||
The pre-commit hook and CI (`make sqlc-verify`) both fail on drift.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "Server/api/router.go"
|
||||
- "Server/config/config.go"
|
||||
- "Server/migrations/**"
|
||||
---
|
||||
|
||||
# Generated documentation blocks
|
||||
|
||||
The `gendocs:*` blocks in `docs/api.md`, `docs/schema.md` and
|
||||
`docs/server-configuration.md` are generated from these files. Never edit
|
||||
inside a `gendocs:*` block by hand. After changing routes, config fields or
|
||||
migrations, regenerate and stage the docs:
|
||||
|
||||
```
|
||||
cd Server && go run -tags otel,wazero ./cmd/gendocs
|
||||
```
|
||||
|
||||
CI (`make docs-verify`) fails on drift.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "protocol/schema.json"
|
||||
- "Server/cmd/genprotocol/**"
|
||||
---
|
||||
|
||||
# Protocol message types
|
||||
|
||||
`protocol/schema.json` is the source of truth for the WebSocket message types.
|
||||
Invoke the `protocol-change` skill before changing it.
|
||||
|
||||
`Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated
|
||||
from it and are denied to Edit/Write in `.claude/settings.json`. After a change,
|
||||
regenerate and stage both files:
|
||||
|
||||
```
|
||||
cd Server && go run ./cmd/genprotocol
|
||||
```
|
||||
|
||||
The pre-commit hook and CI (`make protocol-verify`) both fail on drift.
|
||||
+1
-38
@@ -1,38 +1 @@
|
||||
{
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Edit(Server/db/dbgen/**)",
|
||||
"Write(Server/db/dbgen/**)",
|
||||
"Edit(Server/ws/message_types.go)",
|
||||
"Write(Server/ws/message_types.go)",
|
||||
"Edit(Client/src/lib/protocolTypes.ts)",
|
||||
"Write(Client/src/lib/protocolTypes.ts)",
|
||||
"Edit(Client/src/generated/**)",
|
||||
"Write(Client/src/generated/**)",
|
||||
"Read(**/.env)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PROJECT_DIR}/scripts/claude-hook.mjs\" session-start"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PROJECT_DIR}/scripts/claude-hook.mjs\" pre-bash"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
||||
@@ -60,26 +60,6 @@ if printf '%s\n' "$staged" | grep -qE '^(protocol/schema\.json|Server/cmd/genpro
|
||||
fi
|
||||
fi
|
||||
|
||||
# Any api/ or admin/ Go file, the migrations, the config or the generator
|
||||
# changed -> the regenerated docs index blocks must be part of the same commit.
|
||||
# The route trigger is deliberately the whole of api/ and admin/: routes are
|
||||
# registered in router.go, in the *_handler.go files, in client_update.go's
|
||||
# MountClientUpdateRoute, and in the admin package's own mux — naming files
|
||||
# individually is how this goes stale.
|
||||
#
|
||||
# Inlined like the two blocks above, and for the same reason: make is not on
|
||||
# PATH on a stock Windows box. -tags otel,wazero is the build the route index
|
||||
# is generated from; the tool refuses to run without it.
|
||||
if printf '%s\n' "$staged" | grep -qE '^Server/(api|admin)/.*\.go$|^Server/(migrations/|config/config\.go|cmd/gendocs/)'; then
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
(cd Server && go run -tags otel,wazero ./cmd/gendocs \
|
||||
&& git diff --exit-code ../docs/api.md ../docs/schema.md ../docs/server-configuration.md) \
|
||||
|| fail "generated docs blocks are stale, or a config key is undocumented — if gendocs named keys above, document them in docs/server-configuration.md; otherwise run 'go run -tags otel,wazero ./cmd/gendocs' in Server/ and stage the result"
|
||||
else
|
||||
printf 'pre-commit: WARNING: go not installed; skipping the generated-docs check. CI will run it.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Findings ledger changed -> it must still be valid. Unlike the two blocks
|
||||
# above there is nothing to diff: FINDINGS.md is not tracked (RL-07), so a
|
||||
# stale rendering cannot be committed. --check is the whole gate here, and it
|
||||
|
||||
@@ -22,16 +22,10 @@ version: 2
|
||||
# reason — see docs/contributing.md#dependency-policy for the measured decision
|
||||
# against adopting npm workspaces.
|
||||
|
||||
# Every block targets `dev`, not the default branch. `dev` is the integration
|
||||
# branch and the only branch that takes PRs; `main` carries releases. Without
|
||||
# this, Dependabot opens against `main`, and retargeting by hand does not stick
|
||||
# — `@dependabot rebase` recreates the PR against the configured target.
|
||||
|
||||
updates:
|
||||
# Go server dependencies
|
||||
- package-ecosystem: gomod
|
||||
directory: /Server
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -55,7 +49,6 @@ updates:
|
||||
# together — not a standalone merge.
|
||||
- package-ecosystem: docker
|
||||
directory: /Server
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -76,7 +69,6 @@ updates:
|
||||
# Tauri client npm dependencies
|
||||
- package-ecosystem: npm
|
||||
directory: /Client
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -97,7 +89,6 @@ updates:
|
||||
# Root tooling npm dependencies (changelogen, prettier)
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -118,7 +109,6 @@ updates:
|
||||
# tools/mcp-introspect npm dependencies (local dev MCP server)
|
||||
- package-ecosystem: npm
|
||||
directory: /tools/mcp-introspect
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -139,7 +129,6 @@ updates:
|
||||
# Tauri Rust/Cargo dependencies
|
||||
- package-ecosystem: cargo
|
||||
directory: /Client/src-tauri
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -172,7 +161,6 @@ updates:
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
|
||||
@@ -69,13 +69,6 @@ jobs:
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: make protocol-verify
|
||||
|
||||
# The route, table and config-key index blocks in docs/ must never drift
|
||||
# from the mounted router, the migrated schema and config.Config's koanf
|
||||
# tags. Same one-leg rule as the two checks above.
|
||||
- name: Verify generated docs (make docs-verify)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: make docs-verify
|
||||
|
||||
- name: Run tests with race detection and coverage
|
||||
run: go test -race -timeout 20m ./... -coverprofile=coverage.out -cover
|
||||
|
||||
@@ -84,28 +77,16 @@ jobs:
|
||||
|
||||
# Tag-gated tests (DC-06 / T-2026-07-25-16). The build-tag matrix above
|
||||
# only COMPILES the otel/wazero variants; the tests behind those tags
|
||||
# (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go,
|
||||
# api/recoverer_otel_test.go — the OC-0346 panic-log test, which this
|
||||
# step never executed until ./api/... was added) ran nowhere until this
|
||||
# step. Scoped to the packages that carry tagged files — every other
|
||||
# package is tag-invariant and already covered by the race run above.
|
||||
# One leg is enough; no -race (the runtime under the tag is the concern,
|
||||
# not new concurrency).
|
||||
# (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go) ran
|
||||
# nowhere until this step. Scoped to the two packages that carry tagged
|
||||
# files — every other package is tag-invariant and already covered by the
|
||||
# race run above. One leg is enough; no -race (the runtime under the tag
|
||||
# is the concern, not new concurrency).
|
||||
- name: Run tag-gated tests (-tags wazero, -tags otel)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
go test -tags wazero -count=1 ./plugin/...
|
||||
go test -tags otel -count=1 ./telemetry/... ./api/...
|
||||
|
||||
# Coverage ratchet (B3-6 item 1). Reads the profile the race step wrote,
|
||||
# but placed after the other test steps so a floor miss does not hide
|
||||
# their results. Linux leg only: the profile is not the same on both legs
|
||||
# — OS-tagged files swap in and out and several tests skip on Windows —
|
||||
# so the floors are pinned to one leg and the figure stays deterministic.
|
||||
# Ratchet rule in Server/CLAUDE.md.
|
||||
- name: Check coverage floor
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: bash scripts/coverage-floor.sh coverage.out
|
||||
go test -tags otel -count=1 ./telemetry/...
|
||||
|
||||
- name: Upload Go coverage
|
||||
if: always()
|
||||
@@ -212,17 +193,10 @@ jobs:
|
||||
- name: Ledger schema is valid
|
||||
run: node .superpowers/render-ledger.mjs --check
|
||||
|
||||
# release.yml writes protocol_epoch into the signed server-update
|
||||
# manifest with exactly this command, and release.yml only runs at tag
|
||||
# time — so the read is proven here, on every pull request.
|
||||
- name: protocol_epoch is readable the way release.yml reads it
|
||||
run: test "$(jq -e '.protocol_epoch' protocol/schema.json)" -ge 1
|
||||
|
||||
# R-09 / RL-16. The release gate itself is only invoked for real at tag
|
||||
# time, which is the wrong place to find a bug in it — so its decision
|
||||
# logic is exercised here, on every pull request, against fixtures. Same
|
||||
# reason Server/scripts/docker-smoke.sh is called from all three
|
||||
# workflows (ci.yml, release.yml, nightly-docker-smoke.yml).
|
||||
# reason Server/scripts/docker-smoke.sh is called from both workflows.
|
||||
# This also parses the required-check list out of
|
||||
# b0-dev-branch-protection.sh, so a change to that list's shape fails here
|
||||
# rather than silently weakening the gate.
|
||||
@@ -535,13 +509,10 @@ jobs:
|
||||
|
||||
# Image build is verification only, so it is skipped on dev to keep day-to-day
|
||||
# work on the fast check suite. Runs for main pushes and PRs targeting main.
|
||||
#
|
||||
# Keep in sync with nightly-docker-smoke.yml's nightly-docker-smoke job.
|
||||
server-docker-build:
|
||||
name: Server Docker Build (verify)
|
||||
if: github.ref_name == 'main' || github.base_ref == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@1f291e1cfe0f5fc21db2aef19af844591600ade7 # v1
|
||||
uses: anthropics/claude-code-action@24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# Nightly Docker boot smoke against dev (B3-6 item 8). dev is not a push
|
||||
# trigger in ci.yml, so an image regression on dev is otherwise only caught
|
||||
# once a dev -> main PR opens.
|
||||
#
|
||||
# Its own file rather than a schedule on ci.yml, which is what the plan first
|
||||
# proposed: a scheduled run attaches its check runs to the DEFAULT branch's
|
||||
# tip, so the jobs that would have to be skipped to scope the nightly to the
|
||||
# smoke would land on main's tip as `skipped` under names that are required
|
||||
# contexts. scripts/verify-gate-evidence.mjs:45-61 keeps the latest attempt
|
||||
# per name and does not count `skipped` as success, so release.yml's
|
||||
# gate-evidence job would then refuse to tag that commit. The job name below
|
||||
# matches no required context, and ci.yml is left alone.
|
||||
#
|
||||
# A schedule only ever runs from the default branch: this file does nothing
|
||||
# until it reaches main, and the first nightly follows the next release merge.
|
||||
name: Nightly Docker Smoke
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: nightly-docker-smoke-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Keep in sync with ci.yml's server-docker-build job.
|
||||
nightly-docker-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
# This file is read from the default branch on a schedule; dev is the
|
||||
# branch the nightly exists to smoke.
|
||||
ref: dev
|
||||
|
||||
# So every run's log states what was smoked, rather than leaving it to be
|
||||
# re-derived from the trigger.
|
||||
- name: Print checked-out revision
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
echo "event=$EVENT ref=$REF"
|
||||
git rev-parse HEAD
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build image (no push)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: Server/
|
||||
push: false
|
||||
load: true
|
||||
tags: owncord-smoke:candidate
|
||||
build-args: VERSION=ci
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Same script release.yml and ci.yml run.
|
||||
- name: Boot-smoke Docker image
|
||||
run: bash Server/scripts/docker-smoke.sh owncord-smoke:candidate
|
||||
@@ -498,10 +498,6 @@ jobs:
|
||||
release-server-docker,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
# The `release` environment carries a required reviewer; naming it here
|
||||
# is what makes that approval gate fire before anything is signed or
|
||||
# published. Without this line the environment exists but never applies.
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -574,13 +570,8 @@ jobs:
|
||||
run: |
|
||||
WIN_HASH=$(sha256sum windows/chatserver.exe | awk '{print $1}')
|
||||
LINUX_HASH=$(sha256sum linux/chatserver-linux-amd64.tar.gz | awk '{print $1}')
|
||||
# protocol_epoch is read from the schema, never typed here, so the
|
||||
# manifest cannot drift from the constants the binaries were built with.
|
||||
# The server's client-update endpoint withholds any release whose epoch
|
||||
# is newer than its own (Server/api/client_update.go).
|
||||
EPOCH=$(jq -e '.protocol_epoch' protocol/schema.json)
|
||||
printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}],"protocol_epoch":%s}' \
|
||||
"$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" "$EPOCH" > windows/server-update-manifest.json
|
||||
printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}]}' \
|
||||
"$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" > windows/server-update-manifest.json
|
||||
|
||||
- name: Sign server update assets
|
||||
working-directory: Client
|
||||
@@ -610,46 +601,12 @@ jobs:
|
||||
minisign -Vm "$f" -x "$RUNNER_TEMP/asset.minisig" -p "$RUNNER_TEMP/server_update.pub"
|
||||
done
|
||||
|
||||
# The release body is the curated section for THIS tag, never the whole
|
||||
# file. v1.2.0-alpha.4 published all 795 lines of CHANGELOG.md — every
|
||||
# past release, plus the "How to write an entry" style guide aimed at
|
||||
# contributors — because `--notes-file CHANGELOG.md` hands GitHub the
|
||||
# entire file and nothing ever narrowed it.
|
||||
#
|
||||
# The `changelogen --output CHANGELOG.md` step that used to run here is
|
||||
# gone. It ran after the tag existed, so its from-tag and to-tag were the
|
||||
# same commit: it appended an empty `## <tag>...<tag>` heading whose
|
||||
# compare link pointed at itself, and no step consumed the result.
|
||||
# `npm run changelog` still exists for drafting an entry locally, which
|
||||
# is the point in time where generating one is useful.
|
||||
#
|
||||
# Fail closed. Empty notes on a public download page are worse than a
|
||||
# failed run: the run can be re-run once the entry is written, but a
|
||||
# published release with no description has already been fetched.
|
||||
- name: Extract this tag's release notes
|
||||
- name: Install root dependencies (changelogen)
|
||||
run: npm ci
|
||||
|
||||
- name: Generate changelog
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
DOC_BASE: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}
|
||||
run: |
|
||||
# Headings carry an optional title ("## v1.2.0-alpha.1 — Discord
|
||||
# feature parity"), so match the tag as a whole word, not a prefix:
|
||||
# a bare prefix match would let `v1.2.0` swallow `v1.2.0-alpha.4`.
|
||||
awk -v tag="$TAG" '
|
||||
$0 == "## " tag || index($0, "## " tag " ") == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found
|
||||
' CHANGELOG.md > release-notes.md
|
||||
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "::error::CHANGELOG.md has no '## $TAG' section. Write the entry, then re-run this release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Relative links resolve against the repository, not against a
|
||||
# release page, so every one of them 404s for a release reader.
|
||||
sed -i "s#](docs/#]($DOC_BASE/docs/#g" release-notes.md
|
||||
echo "Release notes: $(wc -l < release-notes.md) lines, $(wc -c < release-notes.md) bytes"
|
||||
run: npx changelogen --output CHANGELOG.md
|
||||
|
||||
# Sole publish target. This repo is public, so its own Releases page both
|
||||
# satisfies AGPL source availability (via the owncord-src snapshot below)
|
||||
@@ -663,5 +620,5 @@ jobs:
|
||||
mapfile -t assets < <(find windows linux -type f)
|
||||
assets+=(checksums.sha256 owncord-src-*.tar.gz)
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--notes-file release-notes.md \
|
||||
--notes-file CHANGELOG.md \
|
||||
"${assets[@]}"
|
||||
|
||||
@@ -9,7 +9,6 @@ Server/.env
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
!.claude/workflows/
|
||||
!.claude/rules/
|
||||
!.claude/settings.json
|
||||
CLAUDE.local.md
|
||||
.mcp.json
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
# Generated, verified by `git diff --exit-code` after regeneration.
|
||||
Server/db/dbgen/
|
||||
Client/src/lib/protocolTypes.ts
|
||||
# Frozen wire records, written by `go test ./ws -run TestEpoch1Fixtures -update`.
|
||||
# Unlike the two above, drift is caught by that test's own frame comparison, not
|
||||
# by `git diff --exit-code`.
|
||||
protocol/fixtures/
|
||||
|
||||
|
||||
# Dated point-in-time snapshots. scripts/check-doc-counts.mjs already treats
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"nextId": 380,
|
||||
"nextId": 349,
|
||||
"findings": [
|
||||
{
|
||||
"id": "OC-0001",
|
||||
@@ -7335,19 +7335,13 @@
|
||||
"why": "The VOICE_LEAVE handler has payload.channel_id in hand (it uses it two lines above for shouldTeardownSession) but calls handleParticipantLeft(payload.user_id) with no channel. E2EEManager.handleParticipantLeft then unconditionally deletes that user from _peerPublicKeys/_peerOfferEpochs, clears their verification badge, retires their key, and re-runs the key-holder election against the client's OWN voice channel — even though the leave was for a different channel entirely. voice_leave is broadcast to channelReadAudience(thatChannel), i.e. every client with READ_MESSAGES on it, not just the room's participants.",
|
||||
"repro": "I am in voice channel A and hold the room key; I also have READ_MESSAGES on voice channel B. Peer P (in B) switches to A. Server order: voice_leave(B,P) and voice_state(A,P) are enqueued on the buffered h.broadcast queue by P's voice_join; P's voice_token is sent directly, so P connects and its voice_e2ee_announce is relayed to me via pubsub.Publish. With the broadcast goroutine backed up, my socket sees: (1) announce(P,K) -> I verify P, store K, offer P the current room key; (2) voice_leave(B,P) -> handleParticipantLeft(P) with no channel filter: hadPeerKey=true, P deleted from _peerPublicKeys, clearPeerVerification(P) wipes the verified badge, and because A's roster does not list P yet, retirePeerKey(P,K) retires P's LIVE key; then the `wasKeyHolder && hadPeerKey` branch rotates the room key excluding P; (3) voice_state(A,P) -> P appears in my voice widget. Result: P is visibly in my call but holds a superseded key — nothing I send decrypts for them and nothing they send decrypts for me. Nothing heals it: mid-call peers never re-announce, my 5-minute rotation iterates _peerPublicKeys (P is gone), and any later replay of P's stored key (e.g. sendVoicePeerKeys on my WS reconnect, hub.go:664-666) is rejected by the retirement guard at livekitE2EE.ts:782. Passing payload.channel_id and ignoring leaves for other channels fixes it; the ready-resync call site at dispatcher.ts:374-384 already scopes by channel.",
|
||||
"evidence": "dispatcher.ts:1071-1079:\n const shouldTeardownSession =\n isSelf && voiceStore.getState().currentChannelId === payload.channel_id;\n void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {\n void handleParticipantLeft(payload.user_id); // <- payload.channel_id dropped\n\nlivekitE2EE.ts:1246-1253 (no channel parameter; acts on this._channelId):\n async handleParticipantLeft(userId: number): Promise<void> {\n const departingKey = this._peerPublicKeys.get(userId);\n const hadPeerKey = departingKey !== undefined;\n this._peerPublicKeys.delete(userId);\n this._peerOfferEpochs.delete(userId);\n clearPeerVerification(userId);\n const channelId = this._channelId ?? this.deps.getCurrentChannelId();\n\nAudience proof, hub_broadcast.go:126-143: broadcastVoiceEventWithLeaver resolves h.channelReadAudience(ctx, channelID) — everyone with READ on the channel, regardless of voice membership.\n\nReordering proof (already documented in-tree): voice_leave goes through the async hub queue (hub_broadcast.go:160-168 `h.broadcast <- bm`), while voice_e2ee_announce is published straight into the recipient's send queue from the announcer's read pump (voice_e2ee.go:270-272 `h.pubsub.Publish(VoiceTopic(channelID), ...)`) — the same hazard livekitE2EE.ts:1268-1272 cites for OC-0213.",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "voice-e2ee",
|
||||
"suggestedFix": "Scope the E2EE notification to this client's own voice channel, mirroring removeVoiceUser and the shouldTeardownSession comparison already computed in the same handler. In dispatcher.ts, reuse the pre-leaveVoiceChannel store read: `const sameChannel = voiceStore.getState().currentChannelId === payload.channel_id;` (the value shouldTeardownSession already derives at :1071-1072), then at :1077 call it only when it matches — `if (sameChannel) void handleParticipantLeft(payload.user_id);` — leaving `if (shouldTeardownSession) void leaveVoice(false);` untouched. This keeps the one-argument call shape that dispatcher.test.ts:2728 asserts, and needs no change in livekitE2EE.ts. (Threading payload.channel_id into handleParticipantLeft and early-returning on mismatch against `this._channelId ?? this.deps.getCurrentChannelId()` is the alternative single-guard form, but it breaks that arity assertion and would require updating it to toHaveBeenCalledWith(7, 3).) The other caller, the ready-resync at dispatcher.ts:374-384, is already channel-scoped and unaffected.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-28",
|
||||
"fix": {
|
||||
"commit": "a231108f",
|
||||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0312",
|
||||
@@ -7409,19 +7403,13 @@
|
||||
"why": "`payload.timestamp` is the raw SQLite `datetime('now')` string (\"2026-08-22 12:00:01\" — UTC, no zone designator; Server/migrations/001_initial_schema.sql:81, passed through verbatim by service/message_crud.go:79). `Date.parse` treats it as LOCAL time, so the parsed epoch is off by the viewer's UTC offset. The codebase already has `parseTimestamp()` (components/message-list/formatting.ts:25-33) that exists solely to append the missing \"Z\"; this comparison bypasses it. The bias cancels once `serverClockSkewMs` has been sampled (line 765 uses the same biased parse), but it is 0 until the first accepted live message — so the very first reconnect of a session is decided by the viewer's timezone instead of by the timestamp.",
|
||||
"repro": "Cold-skew case (serverClockSkewMs still 0 — no chat_message received since login, i.e. a quiet channel).\nEast of UTC, e.g. viewer at UTC+2: socket blips and reconnects at wall time H; 1 s later a peer posts a genuinely LIVE message. Date.parse(ts) = T - 2h, so `T - 2h < H - 0` is true → isReplayFrame = true → notifyIncomingMessage is skipped (no desktop notification, no sound, no taskbar flash). Worse, line 700-702 computes `isMention = ... && !(mentions_here && isReplayFrame)`, so a live `@here` that names the viewer raises no mention badge at all — and the reconnect tier sends no follow-up `ready` to correct it (OC-0271), so the badge is lost permanently.\nWest of UTC, e.g. viewer at UTC-5: Date.parse(ts) = T + 5h, so the test is false for every frame → the entire replayed burst is classified live and fires one desktop notification + sound per already-seen message, which is exactly what the gate was added to prevent.\nNot caught by tests: every timestamp in tests/unit/dispatcher.test.ts (lines 515, 541, 597, 622, 672, 779, 810…) is an ISO `Z` string, a form the server never emits.",
|
||||
"evidence": "685: const isReplayFrame =\n686: lastReconnectHandshakeAt !== null &&\n687: Date.now() - lastReconnectHandshakeAt < REPLAY_GATE_WINDOW_MS &&\n688: Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;\n...\n765: serverClockSkewMs = Date.now() - Date.parse(payload.timestamp);\n\n(the helper that exists for exactly this, formatting.ts:31-34:)\n const date = !raw.endsWith(\"Z\") && !raw.includes(\"+\") && !/T\\d{2}:\\d{2}:\\d{2}[+-]/.test(raw)\n ? new Date(raw.replace(\" \", \"T\") + \"Z\") : new Date(raw);",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "ordering-boundary",
|
||||
"suggestedFix": "Route both parses through the existing UTC-normalizing helper instead of bare Date.parse — e.g. import { parseTimestamp } from \"@components/message-list/formatting\" (or lift it into @lib) and use `parseTimestamp(payload.timestamp).getTime()` at dispatcher.ts:688 and :765. One shared helper at both sites, no per-caller guards.",
|
||||
"confidence": "high",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-28",
|
||||
"fix": {
|
||||
"commit": "a231108f",
|
||||
"test": "Client/tests/unit/dispatcher.test.ts",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0316",
|
||||
@@ -7432,19 +7420,13 @@
|
||||
"why": "registerNow's resume-time E2EE resync (OC-0276) only pushes other participants' stored ECDH public keys *to* the resuming client. The room key itself travels the other way, as a targeted unsequenced voice_e2ee_offer, and one sent while the socket was down is dropped outright. Nothing on either side re-runs the exchange after the resume: the server never re-offers, and the client's only re-announce paths (setupKeyExchange, reannounceForReconnect) are both driven by the LiveKit room, not by the WebSocket, so a pure WS blip leaves a non-key-holder holding the pre-rotation key with no signal and no retry.",
|
||||
"repro": "Users A (lower user id, key holder) and B are in a voice call; both LiveKit sessions are healthy. B's WebSocket drops (WiFi blip / proxy restart) but its LiveKit room stays up — nothing tears voice down on a socket drop alone (dispatcher.ts READY comment, livekitSession.ts). The server has not yet observed B's TCP close, so B's old *Client is still in h.clients. While B is offline a third participant C leaves (or A's 5-minute KEY_ROTATION_INTERVAL_MS timer fires, livekitE2EE.ts:109): A rotates the room key and sends a voice_e2ee_offer for B. sendToUserIfInVoiceChannel queues it onto B's dead client and it is lost. B reconnects with last_seq > 0; handleReconnect replays the sequenced voice_state/voice_leave frames, registerNow transfers B's voice state and calls sendVoicePeerKeys — so B's roster and peer-key map are correct — but B's keyProvider still holds the pre-rotation key. From that moment A and B cannot decrypt each other's frames: both hear silence while VoiceWidget still shows \"Secured\", and the only recovery is A's next 5-minute periodic rotation.",
|
||||
"evidence": "Server/ws/hub.go:664-666 (registerNow tail):\n if voiceChID := c.getVoiceChID(); voiceChID != 0 {\n h.sendVoicePeerKeys(c, voiceChID)\n }\nsendVoicePeerKeys (Server/ws/voice_e2ee.go:344-350) only sends buildVoiceE2EEAnnounce(uid, pubKey, sig) for every *other* participant — no room-key material.\n\nThe offer path drops silently while the socket is down (Server/ws/voice_e2ee.go:239-259):\n target, ok := h.clients[targetUserID]\n if !ok { slog.Debug(\"e2ee: key offer dropped, target not connected\", ...); return }\n ...\n target.sendMsg(msg)\n(and while the dead old *Client is still registered, sendMsg queues into a send buffer that registerNow's old.closeSend() then discards).\n\nClient side, the only two re-announce entry points are LiveKit-driven:\n Client/src/lib/livekitE2EE.ts:159 setupKeyExchange <- called only from livekitSession.ts:1103 (connectAndSetup)\n Client/src/lib/livekitE2EE.ts:346 reannounceForReconnect <- called only from livekitSession.ts:564 (attemptAutoReconnect)\nNeither is reachable from a WS resume: dispatcher.ts's AUTH_OK handler (lines 270-290) does exactly setAuth() + one channel_focus send, and the READY handler's E2EE work (OC-0201, dispatcher.ts:360-384) runs only on the full-resync tier, which a successful replay resume never takes.\n\nreannounceForReconnect's own comment states the assumption that is unmet here: \"the key holder will send a fresh offer if the key was rotated during our absence\" (livekitE2EE.ts:343-344) — true only because that path re-announces; the WS-resume path does not.",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "flow-reconnect",
|
||||
"suggestedFix": "One server-side addition at registerNow's resync call site (Server/ws/hub.go:664-666) — do not put it inside sendVoicePeerKeys, since voice_join.go:531 shares that function and the joiner's own announce already comes from its client there:\n\n if voiceChID := c.getVoiceChID(); voiceChID != 0 {\n h.sendVoicePeerKeys(c, voiceChID)\n // Re-relay THIS client's own stored key back onto VoiceTopic so the\n // key holder's duplicate-announce branch re-wraps the CURRENT room\n // key for us — a rotation offer sent while this socket was down was\n // dropped and no replay tier can recover it.\n if key, sig := c.getE2EEPubKey(); key != \"\" {\n h.sendToVoiceChannelExcept(voiceChID, c.userID,\n buildVoiceE2EEAnnounce(c.userID, key, sig))\n }\n }\n\nThis needs no client change: handleAnnounceInner's dedup branch (livekitE2EE.ts:~812, \"duplicate announce — will re-send offer if key holder\") deliberately falls through to the wrap-and-offer branch on an identical key, so the holder re-offers the live room key. The announce is not blocked by _retiredPeerKeys (that set holds only keys a peer has been moved OFF of, never the live one) and re-runs verifyPeerAnnounce exactly as reannounceForReconnect's announce already does. Guard it on c.lastSeq > 0 if you want it strictly on the resume path.",
|
||||
"confidence": "high",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-28",
|
||||
"fix": {
|
||||
"commit": "cd4cc850",
|
||||
"test": "Server/ws/oc_0316_voice_e2ee_resume_rotation_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0317",
|
||||
@@ -7455,19 +7437,13 @@
|
||||
"why": "The replay branch suppresses the unread/mention increment but still writes `lastMessageId: messageId` unconditionally. When the redelivered id is *lower* than the stored watermark, the watermark is rolled backwards, so the very next frame in the same replay burst no longer looks like a replay and is counted as new. Its sibling `updateDmLastMessagePreview` (lines 186-190) documents this exact hazard (OC-0301) and returns `prev` instead — `updateDmLastMessage` never got the same treatment.",
|
||||
"repro": "DM channel 5. Two messages (ids 101 then 102) are delivered by the server in the registerNow→buildReady window, so `ready` lands with unreadCount=2 / lastMessageId=102, and both frames are then drained from the queue as `chat_message` (dispatcher.ts:740 calls updateDmLastMessage for each, since the DM is neither own-message nor active).\n1. frame 101: isReplay = (101 <= 102) = true → unreadCount stays 2, but lastMessageId is overwritten with 101.\n2. frame 102: isReplay = (102 <= 101) = false → unreadCount = 3, and mentionCount = +1 if the message mentioned the reader.\nThe DM sidebar badge shows 3 unread (and a phantom mention) for 2 messages, and it survives until the next full `ready`. Nothing in tests/unit/dm-store.test.ts asserts lastMessageId after a stale call, so the behavior is not locked.",
|
||||
"evidence": "const isReplay = updated.lastMessageId !== null && messageId <= updated.lastMessageId;\nreturn { channels: [ { ...updated,\n lastMessageId: messageId, // <-- regresses the watermark on a replay\n lastMessage: content,\n lastMessageAt: timestamp,\n unreadCount: isReplay ? updated.unreadCount : updated.unreadCount + 1,\n mentionCount: isMention && !isReplay ? updated.mentionCount + 1 : updated.mentionCount,\n}, ...rest ] };",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "hotspot-client-tauri-client-src-components",
|
||||
"suggestedFix": "Mirror the sibling: in updateDmLastMessage's setState, replace the isReplay ternaries with an early `if (isReplay) return prev;` right after the isReplay computation (dm.store.ts:149). One guard in the shared function; the equal-id case is the same message ready already previewed, so nothing visible is lost.",
|
||||
"confidence": "high",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-28",
|
||||
"fix": {
|
||||
"commit": "7c159c11",
|
||||
"test": "Client/tests/unit/dm-store.test.ts",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0318",
|
||||
@@ -7478,19 +7454,13 @@
|
||||
"why": "Two sources of truth for one plugin directory, read with opposite precedence by the two paths that consume it. `installZipStagedManifest` reads *only* `plugin.json` from the staged zip, and that is the manifest that is validated, shown to the admin, persisted to `plugins.manifest_json`, and used to activate the instance. `scanPluginDirectory` — the path that runs on every server start — prefers `plugin.toml` and only falls back to `plugin.json` when the TOML file is absent. A zip may contain both files (installZipExtract rejects only symlinks and path escapes, not extra regular files), so the manifest that governs the plugin after a restart is one that was never examined at install time. The manifest is the per-plugin ACL (manifest.go:64-67, errors.go:18-22: \"the manifest — not the guest module — is the authority ... so an admin can see the full command surface before enabling the plugin\"), so this defeats exactly the review it exists for.",
|
||||
"repro": "Server built with `-tags wazero` (the build where plugins actually execute and where TOML is parsed). Upload a zip through POST /api/v1/admin/plugins/install containing plugin.json with `\"permissions\": [\"commands\"]`, `\"commands\": [{\"name\":\"hello\"}]`, `\"entrypoint\":\"hello.wasm\"` — plus a plugin.toml at the same root declaring `permissions = [\"commands\",\"http\",\"storage\",\"ui\"]`, extra `[[commands]]` entries, and `entrypoint = \"other.wasm\"`. Install succeeds; installZipStagedManifest parses only the JSON, so the admin list, the stored manifest_json, and the immediately-activated instance all show the narrow JSON surface. Restart the server: LoadAll → scanPluginDirectory (loader.go:64) picks plugin.toml, installFromDisk upserts *that* manifest, and activateAll brings the plugin up with the broader capability set, the undeclared-at-review commands, and a different .wasm entrypoint — with no new admin action and no log line noting that the effective manifest changed. The same mechanism bites non-maliciously: an author who ships both files and later edits only plugin.json sees the stale TOML silently win after every restart while the freshly installed process used the JSON.",
|
||||
"evidence": "registry.go:421-427 (install path)\n\tmanifestPath := filepath.Join(stageAbs, \"plugin.json\")\n\traw, err := os.ReadFile(manifestPath)\n\tif err != nil { return nil, fmt.Errorf(\"plugin zip: missing plugin.json at root: %w\", err) }\n\tmanifest, err := ParseManifest(raw)\n\nloader.go:63-86 (load path)\n\t// Prefer plugin.toml (wazero build) over plugin.json.\n\tmanifest, ok, tomlErr := tryLoadPluginTOML(pluginDir)\n\t...\n\tif !ok { /* only now read plugin.json */ }\n\nregistry.go:286-296 — the staged tree (including any plugin.toml) is promoted verbatim into finalDir and registered with the JSON manifest.\n`grep -rn \"plugin.toml\" Server/ --include=*.go` matches only manifest_toml.go and the loader comment: nothing in the install path ever looks at it.",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "explore-2",
|
||||
"suggestedFix": "Make both paths resolve the manifest through one function instead of guarding each caller. Extract loader.go:62-86's precedence into a shared helper and call it from the install path too:\n\n\t// loader.go\n\tfunc loadManifestFromDir(dir string) (*Manifest, error) {\n\t\tif m, ok, err := tryLoadPluginTOML(dir); err != nil {\n\t\t\treturn nil, err\n\t\t} else if ok {\n\t\t\treturn m, nil\n\t\t}\n\t\traw, err := os.ReadFile(filepath.Join(dir, \"plugin.json\"))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ParseManifest(raw)\n\t}\n\nThen replace registry.go:422-427 with `manifest, err := loadManifestFromDir(stageAbs)` (keeping the existing \"missing plugin.json at root\" wrapping for os.IsNotExist) and have scanPluginDirectory call the same helper. The manifest the admin's install validates is then byte-for-byte the one the next restart loads, in both build tags. If keeping JSON-only at install is preferred, the equally small alternative is to reject the ambiguity at the single install site — after extraction, `if _, err := os.Stat(filepath.Join(stageAbs, \"plugin.toml\")); err == nil { return nil, fmt.Errorf(\"plugin zip: must not contain both plugin.json and plugin.toml\") }` — but the shared-helper version also fixes the plain on-disk case where an author edits only one of the two files.",
|
||||
"confidence": "high",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-28",
|
||||
"fix": {
|
||||
"commit": "bbbaeed4",
|
||||
"test": "Server/plugin/registry_test.go, Server/plugin/registry_zip_toml_wazero_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0319",
|
||||
@@ -7552,19 +7522,13 @@
|
||||
"why": "The DNS-name branch uses `[\\w.-]+`, and JS `\\w` is `[A-Za-z0-9_]` — so a host containing `_` passes. Both `http_proxy::validate_remote_host` and `livekit_proxy::validate_remote_host` allow only `is_ascii_alphanumeric() || '.' | '-' | ':' | '[' | ']'` and reject `_`. Since every REST call routes through `ensureHttpProxy` (api.ts:88), an underscore host is accepted by the Add Server modal and by `api.setConfig`, then fails 100% of REST traffic. The file's own header comment and ServerPanel.ts:310-313 both state the invariant that this validator mirrors the Rust one (\"an address accepted here is also accepted by the actual connection path, and vice versa\").",
|
||||
"repro": "Connect page -> \"Add Server\" -> address `my_server.lan:8443`. ServerPanel.ts:314 `isValidHost(addr)` returns true (JS `\\w` matches `_`), so the profile is saved. The connect page then health-checks it: `api.getHealth(\"my_server.lan:8443\")` -> `ensureHttpProxy(host)` -> `invoke(\"start_http_proxy\", {remoteHost})` -> http_proxy.rs:101 `validate_remote_host` -> Err(\"remote_host contains unexpected characters\"). Every REST call fails identically, so login is impossible and the profile shows permanently unreachable; `start_livekit_proxy` rejects the same host, so voice is dead too. The WS proxy has no charset check, so `wss://my_server.lan/api/v1/ws` would have connected — the client accepts an address that only one of its three transports can use.",
|
||||
"evidence": "hostValidation.ts:33 return /^[\\w.-]+(:\\d+)?$/.test(host); // \\w includes '_'\n\nhttp_proxy.rs:83-88\n if !remote_host\n .chars()\n .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))\n {\n return Err(\"remote_host contains unexpected characters\".into());\n }\n\nlivekit_proxy.rs:110-116 (identical charset, same rejection)\n\napi.ts:87-89\n async function baseUrl(): Promise<string> {\n return `${await ensureHttpProxy(config.host)}/api/v1`;\n }\n\ncommands.rs tests pin the Rust side as deliberate:\n (\"underscore\", \"chat_example.com\".into()), // expected to be rejected\n\ntests/unit/host-validation.test.ts has no underscore case, so nothing locks the TS behavior.",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "tauri-rust",
|
||||
"suggestedFix": "One character-class change in the shared validator, hostValidation.ts:33: replace `\\w` with an explicit ASCII class so the DNS branch matches the Rust charset — `return /^[A-Za-z0-9.-]+(:\\d+)?$/.test(host);`. Add an underscore rejection case to tests/unit/host-validation.test.ts mirroring commands.rs:364 so the two validators stay pinned together.",
|
||||
"confidence": "high",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-28",
|
||||
"fix": {
|
||||
"commit": "e95c57a4",
|
||||
"test": "Client/tests/unit/host-validation.test.ts",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0323",
|
||||
@@ -7660,19 +7624,13 @@
|
||||
"why": "incrementUnread/incrementMention bump unconditionally. `Channel.lastMessageId` is declared (line 30) and filled from `ready`'s `last_message_id` (line 100), but no call site anywhere in the client reads it — the identical registerNow->buildReady double-delivery window that OC-0242 fixed for DMs (dm.store.ts) is unguarded for server channels.",
|
||||
"repro": "A message is broadcast into channel #general between registerNow (Server/ws/serve.go:853, which subscribes the socket) and buildReady (serve.go:884) on a fresh connect or a full resync. The server counts it in read_states.unread_count, so `ready` carries unread_count = 1 and last_message_id = <that id>; `ready` is written straight to the connection by handshakeWrite while the broadcast waits in the client's send queue. setChannels applies unreadCount = 1, then writePump drains the queued chat_message and dispatcher.ts:711 calls incrementUnread -> the sidebar shows 2 unread for 1 message, and an @mention in it shows a mention count of 2. dm.store.ts:148 guards this exact case for DMs with `messageId <= updated.lastMessageId`; the channel path has no equivalent.",
|
||||
"evidence": "channels.store.ts:346-363\n export function incrementUnread(channelId: number, evenIfActive = false): void {\n channelsStore.setState((prev) => {\n if (prev.activeChannelId === channelId && !evenIfActive) return prev;\n const existing = prev.channels.get(channelId);\n if (existing === undefined) return prev;\n const updated: Channel = { ...existing, unreadCount: existing.unreadCount + 1 };\n ...\n\nchannels.store.ts:30 / :100 — the watermark is stored and never consulted\n readonly lastMessageId: number | null;\n lastMessageId: ch.last_message_id ?? null,\n\n`grep -rn lastMessageId` over src/ shows the only readers are dm.store.ts and SidebarDmHelpers.ts — nothing reads Channel.lastMessageId.\n\ncaller: dispatcher.ts:706-715\n if ((payload.channel_id !== activeId || isDetached) && !isOwnMessage) {\n incrementUnread(payload.channel_id, isDetached);\n if (isMention) incrementMention(payload.channel_id, isDetached);\n }",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "hotspot-client-tauri-client-src-lib",
|
||||
"suggestedFix": "Mirror the DM shape in the one shared store function rather than at the call site: give channels.store a single guarded entry point, e.g. `noteChannelMessage(channelId, messageId, isMention, evenIfActive)`, whose setState computes `const isReplay = existing.lastMessageId !== null && messageId <= existing.lastMessageId;` and writes `unreadCount: isReplay ? existing.unreadCount : existing.unreadCount + 1`, `mentionCount: isMention && !isReplay ? existing.mentionCount + 1 : existing.mentionCount`, and `lastMessageId: Math.max(messageId, existing.lastMessageId ?? 0)` — both counters behind ONE watermark read, exactly as OC-0242 required for updateDmLastMessage (a guard split across the two functions cannot work: the first call would already have advanced the watermark). Then replace the pair at dispatcher.ts:710/713 with the single call; it is the only production caller of incrementUnread/incrementMention, so the existing exports can stay for the tests.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-28",
|
||||
"fix": {
|
||||
"commit": "3e74c968",
|
||||
"test": "Client/tests/unit/channels.store.test.ts, Client/tests/unit/dispatcher.test.ts",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0329",
|
||||
@@ -7819,19 +7777,13 @@
|
||||
"why": "The cold-tier query is `ORDER BY seq ASC LIMIT n`, so when the range exceeds the cap it is the newest rows that are discarded. The main replay path 220 lines above (reconnectSelectReplay, serve.go:422 and serve.go:442) explicitly detects both of that query's failure modes — `len(persisted) >= coldCap` (cap hit, newest dropped) and a retention-pruned prefix (oldest-seq probe) — and forces a full ready. liveVoiceEventsSince calls the identical db.GetEventsSinceForChannels with the identical cap and has neither guard, so a truncated window is handed to the client as if it were the complete voice history for that room. Worse, the cap is spent on UNFILTERED rows: the query is `channel_id = 0 OR channel_id IN (chID)`, so global broadcasts and the DM's ordinary chat messages consume the budget, and the voice_state/voice_leave filter at serve.go:655 only runs on whatever survived. A peer's voice_leave that falls in the dropped tail is never delivered and never re-sent (the client tracks only max(seq)), so the resumed client renders a participant who has left; symmetrically, a dropped voice_state hides a peer who is really in the call, which also starves that peer of the E2EE announce/offer exchange keyed on the roster.",
|
||||
"repro": "Config: event_persistence.enabled = true, event_persistence.replay_cold_limit = 50 (a legal value; ConfigureReplay accepts any positive int, Server/ws/hub.go:751). Alice and Bob are both in a voice call inside a 1:1 DM that Alice has since closed, so the DM id is outside Alice's allowedChannelIDs (computeAllowedChannels sources DM ids from dm_open_state) and handleReconnect takes the liveVoiceChID supplement branch at serve.go:286. Alice's socket drops. While she is offline: (1) Bob posts 60 messages into that DM — each is a persisted event on that channel_id — and then (2) Bob leaves voice, emitting voice_leave. Alice's readable channels stay quiet, so the main cold-tier replay at serve.go:417 returns well under 50 rows and succeeds (tier \"db\"), and the ring buffer no longer covers her last_seq. liveVoiceEventsSince then runs GetEventsSinceForChannels(lastSeq, [dmID], 50), which returns the OLDEST 50 rows — the first 50 chat messages — and drops the remaining 10 rows including Bob's voice_leave. Alice's client resumes with Bob still listed in the voice roster and never receives a correction; the same window would equally have swallowed a voice_state for a peer who joined late, leaving that peer invisible to her for the rest of the call.",
|
||||
"evidence": "// Server/ws/serve.go:637-649 (liveVoiceEventsSince)\nif buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil {\n\traw = buf\n} else if esp := h.eventStore.Load(); esp != nil {\n\tes := *esp\n\tpersisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, h.maxColdReplayLimit())\n\tif err != nil {\n\t\treturn nil\n\t}\n\traw = make([][]byte, 0, len(persisted))\n\tfor _, p := range persisted {\n\t\traw = append(raw, p.Payload)\n\t}\n}\n// no `len(persisted) >= coldCap` check, no oldest-seq retention probe — compare\n// Server/ws/serve.go:422-453, which has both for the same query:\n// case len(persisted) >= coldCap: \"...the NEWEST events were dropped...forcing full ready\"\n// case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: \"retention pruning left a gap...forcing full ready\"\n//\n// Server/db/event_queries.go:136-144 — the cap is applied before any type filter:\n// WHERE seq > ? AND (channel_id = 0 OR channel_id IN (...)) ORDER BY seq ASC LIMIT ?\n// Server/ws/serve.go:653-659 — voice filtering happens only on the truncated result.",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "explore-1",
|
||||
"suggestedFix": "Stop spending the cap on non-voice rows and stop replaying a truncated window. Smallest change: give this call its own store method that applies the type filter in SQL — `WHERE seq > ? AND channel_id = ? AND event_type IN ('voice_state','voice_leave') ORDER BY seq ASC LIMIT ?` — so chat and global broadcasts can no longer evict voice events from the budget, and in liveVoiceEventsSince add the sibling's cap check: `if len(persisted) >= cap { slog.Warn(\"live voice supplement hit the row cap, skipping truncated window\"); return nil }`. Returning nil is the correct degradation here (a full ready is no longer available — registerNow already ran at serve.go:268 before the supplement at serve.go:287), and it restores the documented best-effort miss instead of installing a join whose matching leave was discarded. Do not simply raise the limit: that leaves the same silent-truncation hole one order of magnitude further out.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-28",
|
||||
"fix": {
|
||||
"commit": "7aeab0ed",
|
||||
"test": "Server/ws/reconnect_voice_supplement_coldtier_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0338",
|
||||
@@ -7842,19 +7794,13 @@
|
||||
"why": "`toml.Decode` resolves a TOML key to a struct field via the `toml` struct tag, or, when absent, the Go field name matched with `strings.EqualFold`. `Manifest`/`Resources` declare only `json` tags, so `max_memory_mb` and `cpu_budget_ms` never match `MaxMemoryMB` / `CPUBudgetMs` (underscores break EqualFold) and are left undecoded with no error. Every other manifest key happens to be a single word (`name`, `version`, `entrypoint`, `permissions`, `commands`, `ui`, `asset`, …) and case-folds fine, which is why the breakage is invisible — only the two snake_case resource keys are silently discarded, and `Validate()` only checks `>= 0`, so zero passes.",
|
||||
"repro": "Build with `-tags wazero` (the only build where plugin.toml is parsed at all — manifest_nottoml.go:9 stubs it out). Ship `plugins/foo/plugin.toml`:\n\n name = \"foo\"\n version = \"1.0.0\"\n entrypoint = \"foo.wasm\"\n permissions = [\"commands\"]\n [[commands]]\n name = \"foo\"\n [resources]\n cpu_budget_ms = 2000\n max_memory_mb = 128\n\nscanPluginDirectory (loader.go:64) loads it via tryLoadPluginTOML; `Manifest.Resources` is `{0, 0}`. Invoke `/foo`: sandbox_wazero.go:317 falls through to `r.cfg.CPUBudgetMs` (config default 100), so a command the author budgeted 2000 ms for is killed at 100 ms with \"command exceeded CPU budget of 100ms\". The byte-identical plugin.json (`\"resources\": {\"cpu_budget_ms\": 2000}`) behaves correctly, so the same plugin works as JSON and misbehaves as TOML. installFromDisk then serializes the zeroed Resources back into `plugins.manifest_json` (loader.go:132-138, registry.go:192-196), so the admin plugin list also reports a budget the author never wrote. No test covers TOML decoding (`grep -rn toml Server/plugin/*_test.go` is empty), so nothing locks this in as intended.",
|
||||
"evidence": "manifest_toml.go:31-38\n\tvar m Manifest\n\tif _, err := toml.Decode(string(raw), &m); err != nil { ... }\n\tif err := m.Validate(); err != nil { ... }\n\nmanifest.go:77-80\ntype Resources struct {\n\tMaxMemoryMB int `json:\"max_memory_mb\"`\n\tCPUBudgetMs int `json:\"cpu_budget_ms\"`\n}\n\ntoml@v1.6.0/decode.go:311-318 — `if ff.name == key { ... }` else `if f == nil && strings.EqualFold(ff.name, key) { f = ff }`\ntoml@v1.6.0/type_fields.go:108-113 — `name := opts.name; if name == \"\" { name = sf.Name }`, where `opts` comes from `tag.Get(\"toml\")` (encode.go:647-648).\n\nConsumer: sandbox_wazero.go:317-323\n\tbudgetMs := inst.Manifest.Resources.CPUBudgetMs\n\tif budgetMs <= 0 { budgetMs = r.cfg.CPUBudgetMs }\n\tif budgetMs <= 0 { budgetMs = 100 }",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "explore-2",
|
||||
"suggestedFix": "Add toml tags to the two snake_case fields in Server/plugin/manifest.go:77-80:\n\ttype Resources struct {\n\t\tMaxMemoryMB int `json:\"max_memory_mb\" toml:\"max_memory_mb\"`\n\t\tCPUBudgetMs int `json:\"cpu_budget_ms\" toml:\"cpu_budget_ms\"`\n\t}\nThat is the minimal fix and is safe for the JSON path (encoding/json ignores the toml tag). Optionally harden the shared decode site instead of every future field: in tryLoadPluginTOML (manifest_toml.go:31) keep the MetaData and reject leftovers — `md, err := toml.Decode(...)`; `if u := md.Undecoded(); len(u) > 0 { return nil, false, fmt.Errorf(\"plugin.toml: unknown keys %v\", u) }` — which turns any future tag/name mismatch or manifest typo into a loud load error rather than a silent zero.",
|
||||
"confidence": "high",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-28",
|
||||
"fix": {
|
||||
"commit": "073e8799",
|
||||
"test": "Server/plugin/manifest_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0339",
|
||||
@@ -7967,19 +7913,13 @@
|
||||
"why": "`adminAuthMiddleware` already resolved the principal's `*db.Role` via `auth.ResolveTokenHash` and stored it in the request context under `adminRoleKey` (line 90). `ownerOnlyMiddleware` ignores that value, issues a second `GetRoleByID` on the request context, and collapses `err != nil` into the same 403 \"role not found\" it uses for a genuinely missing role. This is the exact fail-closed-as-authorization-denial collapse that the perimeter branch 60 lines above was explicitly fixed for (it now answers 503 SERVICE_UNAVAILABLE and logs, precisely so a DB outage is not reported as a bad credential), and that `api/middleware.go:117` was fixed for. The function's own doc comment (lines 120-121) claims it \"reads the user from context ... rather than re-authenticating, avoiding redundant DB queries\" — the redundant query it claims to avoid is the one that introduces the fault.",
|
||||
"repro": "Owner is signed into the admin panel. Any transient read failure on the `roles` lookup (SQLITE_BUSY / \"database is locked\" while a scheduled backup's `VACUUM INTO` runs, a disk I/O error, or a context deadline on the reader pool) hits `GetRoleByID` during a request to one of the nine owner-only routes registered in Server/admin/api.go:148-176 — `GET /admin/api/updates`, `POST /admin/api/updates/apply`, `POST /admin/api/backup`, `GET /admin/api/backups`, `DELETE /admin/api/backups/{name}`, `POST /admin/api/backups/{name}/restore`, `GET|POST /admin/api/tokens`, `DELETE /admin/api/tokens/{id}`. The perimeter middleware immediately before it already succeeded and put the correct, non-nil Owner role in the context, so the request is fully authenticated and authorized. The Owner nevertheless receives HTTP 403 `FORBIDDEN {\"code\":\"FORBIDDEN\",\"message\":\"role not found\"}` — the admin panel renders a permission-denied error telling the server Owner they lack the Owner role — and nothing is logged, unlike the perimeter path which logs the underlying error. Using the already-resolved `adminRoleKey` value (or mirroring the perimeter's 503 + slog on `err != nil`) makes the outcome correct.",
|
||||
"evidence": "// middleware.go:89-93 (perimeter already stores the role)\nctx := context.WithValue(r.Context(), adminUserKey, user)\nctx = context.WithValue(ctx, adminRoleKey, role)\n\n// middleware.go:59-69 (perimeter, after the OC fix: DB error != bad token)\ndefault:\n slog.ErrorContext(r.Context(), \"admin: token resolution failed\", \"error\", err)\n writeErr(w, http.StatusServiceUnavailable, \"SERVICE_UNAVAILABLE\", \"authentication service temporarily unavailable\")\n\n// middleware.go:130-134 (ownerOnlyMiddleware, unfixed sibling)\nrole, err := database.GetRoleByID(r.Context(), user.RoleID)\nif err != nil || role == nil {\n writeErr(w, http.StatusForbidden, \"FORBIDDEN\", \"role not found\")\n return\n}",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "explore-1",
|
||||
"suggestedFix": "Split the two outcomes in ownerOnlyMiddleware rather than switching to the context role. Reading adminRoleKey would be the cleaner design but it breaks both TestOwnerOnlyMiddleware_RoleNotFound and TestOwnerOnlyMiddleware_OwnerPassesThrough, which inject only adminUserKey. Smallest change that preserves every locked behavior, at Server/admin/middleware.go:130-134:\n\n role, err := database.GetRoleByID(r.Context(), user.RoleID)\n if err != nil {\n slog.ErrorContext(r.Context(), \"admin: owner role lookup failed\", \"error\", err)\n writeErr(w, http.StatusServiceUnavailable, \"SERVICE_UNAVAILABLE\", \"authorization service temporarily unavailable\")\n return\n }\n if role == nil {\n writeErr(w, http.StatusForbidden, \"FORBIDDEN\", \"role not found\")\n return\n }\n\nrole==nil still yields 403 (test at middleware_and_spawn_test.go:211 unaffected), the owner path still yields 200, and the DB fault now matches the perimeter's 503 + slog contract.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-30",
|
||||
"fix": {
|
||||
"commit": "fb1afb8a",
|
||||
"test": "Server/admin/middleware_and_spawn_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0346",
|
||||
@@ -7990,19 +7930,13 @@
|
||||
"why": "`recoverer` is registered at router.go:286, two slots ahead of `telemetry.HTTPMiddleware()` at router.go:291, and it snapshots `telemetry.TraceIDFromContext(r.Context())` *before* calling `next.ServeHTTP`. At that moment no span exists in the request context (otelhttp is downstream), so `TraceIDFromContext` returns \"\" on every request and the `trace_id` attribute the recovery closure promises is always dropped by the `if traceID != \"\"` guard at line 666. The panic record — the one log line where trace correlation matters most — is the only one that silently loses it, while in-handler logs via logctx.go:38 get it correctly because they run inside the span.",
|
||||
"repro": "Build with `-tags otel`, set telemetry.enabled=true and exporter=\"otlp\" (or \"prometheus\"), then issue a request to any REST route whose handler panics (e.g. force a nil deref in a handler). The recovered-panic slog record contains method/path/panic/stack/req_id but never a trace_id attribute, even though otelhttp created a live span for that exact request and the trace is exported. Moving `r.Use(telemetry.HTTPMiddleware())` above `r.Use(recoverer)` (or reading the trace ID inside the deferred closure instead of before dispatch) makes the same request log the real trace ID.",
|
||||
"evidence": "router.go:286-291\n\tr.Use(recoverer) // slog-routing panic recovery ...\n\tr.Use(requestLogger)\n\tr.Use(telemetry.HTTPMiddleware())\n\nrouter.go:646-667\n\t// Capture correlation IDs before dispatch ... while the\n\t// panic log still carries req_id/trace_id.\n\treqID := middleware.GetReqID(r.Context())\n\ttraceID := telemetry.TraceIDFromContext(r.Context()) // <- no span yet: always \"\"\n\tdefer func() {\n\t\tif rec := recover(); rec != nil {\n\t\t\t...\n\t\t\tif traceID != \"\" {\n\t\t\t\tattrs = append(attrs, \"trace_id\", traceID)\n\t\t\t}\n\t\t\tslog.Error(\"http handler panic recovered\", attrs...)\n\nBuild-tag dependency: telemetry_otel.go's TraceIDFromContext is the only implementation that can ever return non-empty (telemetry_default.go:26 hardcodes \"\"), and it reads trace.SpanContextFromContext(ctx), which is populated by otelhttp.NewHandler in (*otelProvider).HTTPMiddleware — mounted after recoverer.",
|
||||
"status": "fixed",
|
||||
"status": "open",
|
||||
"found": "2026-08-22",
|
||||
"hunt": "general-2026-08-22-b",
|
||||
"lens": "explore-2",
|
||||
"suggestedFix": "In routerMiddleware (Server/api/router.go:278-292) move `r.Use(telemetry.HTTPMiddleware())` above `r.Use(recoverer)`. With otelhttp outermost, recoverer's r.Context() already carries the live span, so the existing line 650 capture yields the real trace ID, and recoverer still recovers handler panics because it remains outside every route handler. This is one line in the shared stack rather than a change in recoverer, and it keeps the deferred closure free of context calls (the contextcheck constraint the comment cites). It does not disturb the ordering the file comment calls a security property — request-id binding, security headers and the body cap keep their relative positions.",
|
||||
"confidence": "high",
|
||||
"finder": "opus",
|
||||
"fixed": "2026-08-30",
|
||||
"fix": {
|
||||
"commit": "775eba50",
|
||||
"test": "Server/api/recoverer_otel_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0347",
|
||||
@@ -8037,556 +7971,6 @@
|
||||
"suggestedFix": "Tighten the single shared selector rather than its caller — in members.store.ts getOnlineMembers, change the predicate to `if (member.status !== \"offline\" && member.status !== \"invisible\")`, matching MemberList's isAwayStatus.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0349",
|
||||
"title": "voice_join relays the joiner’s own voice_state through the asynchronous hub queue while the rest of the join burst is written directly, so its position on the joiner’s socket is unordered",
|
||||
"file": "Server/ws/voice_join.go",
|
||||
"line": 498,
|
||||
"severity": "low",
|
||||
"why": "voiceJoinComplete writes voice_token (voice_join.go:445), each existing participant’s voice_state (:523), the peers’ voice_e2ee_announce relays (sendVoicePeerKeys, :531) and voice_config (:546) straight into the joiner’s send queue with c.sendMsg, but the joiner’s OWN voice_state goes through h.broadcastVoiceEvent (:498), which hub_broadcast.go:95-168 enqueues on the buffered h.broadcast channel for the broadcast goroutine to fan out later. The joiner is part of that audience (channelReadAudience), so nothing orders its own sequenced voice_state against the four direct frames: it can land before voice_token, between the existing participants’ states, or after voice_config, depending on how backed up the broadcast goroutine is. The B2-1 fixture capture could not pin the join burst and had to document the order as unspecified (docs/protocol.md:927-929) and exclude that one frame from the epoch-1 transcript’s ordered comparison (protocol_epoch1_contract_test.go:69-75, 636-648). A client that treats the reply as an ordered burst — e.g. takes its own voice_state as the signal that the roster before it is complete, or that the join finished before voice_config — reads a state that is right most of the time and wrong under load.",
|
||||
"repro": "cd Server && go test -tags deadlock ./ws -run TestEpoch1Fixtures -count=30 at 1fe3df79 with the voice-join journey’s own-voice_state exclusion removed (protocol_epoch1_contract_test.go:636-648): roughly 1 run in 30 records the joiner’s own voice_state after the existing participants’ states or after voice_config instead of directly after voice_token; the default build reorders less often but is not immune. Equivalently, keep the broadcast goroutine busy (a burst of chat_send into another channel from a second client) while a client sends voice_join and watch the joiner’s own voice_state trail voice_config on its socket.",
|
||||
"evidence": "Server/ws/voice_join.go:445 c.sendMsg(buildVoiceToken(channelID, token, \"/livekit\", h.livekit.URL(), isKeyHolder)) // direct\nServer/ws/voice_join.go:498 h.broadcastVoiceEvent(ctx, channelID, buildVoiceState(*state)) // hub queue, joiner in audience\nServer/ws/voice_join.go:523 c.sendMsg(buildVoiceState(vs)) // direct, per existing participant\nServer/ws/voice_join.go:531 h.sendVoicePeerKeys(c, channelID) // direct\nServer/ws/voice_join.go:546 c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, maxUsers)) // direct\nServer/ws/hub_broadcast.go:95-168 broadcastVoiceEvent -> h.broadcast <- bm (buffered channel; deliverBroadcast fans out on the hub goroutine)\ndocs/protocol.md:927-929 \"Items 1, 3 and 4 are written directly and keep that relative order; item 2 travels through the hub’s broadcast queue, so its position relative to the other three on the joiner’s own socket is not guaranteed.\"\nServer/ws/protocol_epoch1_contract_test.go:69-75, 636-648 the epoch-1 transcript deliberately records the joiner’s own voice_state out of the ordered comparison because under -tags deadlock it was observed arriving after the direct frames.",
|
||||
"status": "open",
|
||||
"found": "2026-08-28",
|
||||
"hunt": "b2-1-fixture-capture-2026-08-28",
|
||||
"lens": "ordering-boundary",
|
||||
"suggestedFix": "Write the joiner’s own voice_state to the joiner directly (c.sendMsg, in program order at :498, so the whole burst on the joiner’s socket is one goroutine’s program order) and broadcast it to everyone except the joiner — broadcastVoiceEventWithLeaver already carries an exclude-user path in hub_broadcast.go, so the fix is one call-site change plus a helper, not a new fan-out. Check the seq contract first: deliverBroadcast is where the sequenced copy is stamped and appended to the replay buffer, so the joiner’s direct copy must carry the same seq (or be documented as the unsequenced form like the relayed existing states) rather than double-stamping. Behaviour change within epoch 1 (same frame set, deterministic position): regenerate the epoch-1 fixture in the same PR (`go test ./ws -run TestEpoch1Fixtures -update`), drop the contract test’s exclusion so the order is asserted, and tighten docs/protocol.md:927-929.",
|
||||
"confidence": "high",
|
||||
"finder": "fable"
|
||||
},
|
||||
{
|
||||
"id": "OC-0350",
|
||||
"title": "Admin panel login has no 2FA branch — any admin with TOTP enabled is permanently locked out of /admin",
|
||||
"file": "Server/admin/static/index.html",
|
||||
"line": 659,
|
||||
"severity": "high",
|
||||
"why": "POST /api/v1/auth/login answers a TOTP-enabled account with HTTP 200 and `{\"partial_token\":…,\"requires_2fa\":true}` and NO `token`. The admin panel's sign-in handler only checks `res.ok`, then assigns `d.token` (undefined) and proceeds. There is no `requires_2fa` branch and no call to `/auth/verify-totp` anywhere in the file, so the panel can never complete a 2FA login.",
|
||||
"repro": "1. Owner account enables 2FA from the desktop client (POST /users/me/totp/enable + confirm).\n2. Open https://server/admin and sign in with the correct username + password.\n3. Server returns 200 `{\"partial_token\":\"…\",\"requires_2fa\":true}`. The handler sets `state.token = undefined` and writes the literal string \"undefined\" into localStorage['admin_token'].\n4. `enterApp()` → `api('GET','/me')` sends `Authorization: Bearer undefined` → adminAuthMiddleware 401 → `handleSessionExpired()` clears the token and shows the login overlay with \"Your session expired — sign in again.\"\n5. Every retry repeats this. The admin panel is unreachable for that account, with no error explaining why.\n\nAggravating case: docs/security.md:41 advertises the `require_2fa` server setting, and Server/admin/handlers_settings.go:148 refuses to enable it until *every* user has TOTP enrolled — so following the documented hardening path locks every principal out of the admin panel at once.",
|
||||
"evidence": "Server/admin/static/index.html:650-661\n document.getElementById('loginBtn').onclick=async()=>{\n ...\n try{const r=await fetch('/api/v1/auth/login',{...});const d=await r.json();if(!r.ok)throw new Error(d.message||'Login failed');\n state.token=d.token;localStorage.setItem('admin_token',state.token);await enterApp();\n }catch(e){err.textContent=e.message}\n\nServer/api/auth_handler.go:363-377\n if user.TOTPSecret != nil {\n partialToken, err := partialStore.Issue(...)\n ...\n writeJSON(w, http.StatusOK, authSuccessResponse{\n PartialToken: partialToken,\n Requires2FA: true,\n })\n return\n }\n\nServer/api/auth_handler.go:67-72 Token is `json:\"token,omitempty\"` — omitted entirely on the 2FA branch.\n\n`grep -n \"verify-totp|partial_token|requires_2fa\" Server/admin/static/index.html` → no matches.",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "hotspot-server-admin-static",
|
||||
"suggestedFix": "Add the missing 2FA branch at the single shared login handler (Server/admin/static/index.html:659). After parsing `d`, before assigning the token: if `d.requires_2fa && d.partial_token`, show a code prompt and complete the login with `POST /api/v1/auth/verify-totp` sending `Authorization: Bearer <d.partial_token>` and body `{code}` (the contract handleVerifyTOTP expects, totp_handler.go:41-66), then use the `token` from that 200 response. As a one-line stopgap for the same spot, guard `if(!d.token) throw new Error(d.requires_2fa?'This account has two-factor authentication enabled; the admin panel cannot complete 2FA sign-in yet.':'Login failed')` so the panel stops writing the literal string \"undefined\" into localStorage and gives a truthful error instead of a false \"session expired\".",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0351",
|
||||
"title": "voice_join tears the user out of their current call before the destination's capacity is ever checked, so clicking a full voice channel ends the call for nothing",
|
||||
"file": "Server/ws/voice_join.go",
|
||||
"line": 222,
|
||||
"severity": "medium",
|
||||
"why": "handleVoiceJoin's pre-flight (voiceJoinPrecheck, lines 84-172) refuses every condition that would make the join bounce — rate limit, bad channel_id, CONNECT_VOICE, missing channel, wrong channel type, DM block, archived, nil user, LiveKit down — but it never checks voice_max_users. voiceJoinLeaveCurrent then unconditionally runs the destructive half of the switch (h.handleVoiceLeave at line 223: DB row deleted, voice_leave broadcast to the whole READ audience, LiveKit participant removed, key-holder re-elected), and only afterwards does voiceJoinPersist (line 269) run the atomic capacity check and answer CHANNEL_FULL. The user is already out of their old channel when the refusal is sent. The sibling server-driven path knows this is wrong and guards it: handleVoiceModMoveV2 runs an explicit advisory CountChannelVoiceUsers pre-flight (Server/ws/voice_moderation.go:426-437) with the comment \"this one keeps the common case from dropping the target into a channel that is already full\", and the whole handler is introduced as \"the pre-flight: they refuse a move the re-join would only bounce, so the target is never dropped from voice for nothing.\" The self-switch path has no equivalent.",
|
||||
"repro": "Voice channel B has voice_max_users = 2 and two participants. User U is live in voice channel A. U clicks B (the client deliberately never pre-blocks the click — Client/src/lib/dispatcher.ts:1291-1296: \"The server owns the limits ... the client never pre-blocks the click\"), sending voice_join{channel_id: B}. voiceJoinPrecheck passes (B exists, is a voice channel, is not archived, U holds CONNECT_VOICE). voiceJoinLeaveCurrent then calls handleVoiceLeave: U's voice_states row for A is deleted, voice_leave(A, U) is broadcast to everyone who can see A, U's LiveKit participant in A is removed, and A's E2EE key holder is re-elected. voiceJoinPersist then returns db.ErrChannelFull and the server replies CHANNEL_FULL. Client-side, dispatcher.ts:1287-1291 sees voiceStatus === \"joining\" and calls leaveVoice(true). End state: U is in no voice channel at all, their call in A is over, and rejoining A can now itself fail if A filled up meanwhile. Deterministic — no race required. Neither TestVoice_Join_ChannelFull (Server/ws/voice_handlers_test.go:996) nor TestHandleVoiceJoin_ChannelFull (Server/ws/coverage_voice_test.go:395) covers the switch case: in both, the refused joiner starts with no current voice channel, so the destructive leave never runs.",
|
||||
"evidence": "// voice_join.go:221-223 — destructive leave, no capacity pre-flight above it\n\t// If user is already in a different voice channel, leave it first.\n\tif currentChID > 0 {\n\t\th.handleVoiceLeave(ctx, c)\n\n// voice_join.go:266-273 — the capacity check runs only AFTER that leave\n\t// Check channel capacity and persist to DB atomically.\n\tmaxUsers := ch.VoiceMaxUsers\n\tif maxUsers > 0 {\n\t\tif err := h.db.JoinVoiceChannelIfCapacity(ctx, c.userID, channelID, maxUsers); err != nil {\n\t\t\tif errors.Is(err, db.ErrChannelFull) {\n\t\t\t\tc.sendMsg(buildErrorMsg(ErrCodeChannelFull, \"voice channel is full\"))\n\t\t\t\treturn nil, false\n\n// voice_moderation.go:426-437 — the sibling path DOES pre-check\n\tif dest.VoiceMaxUsers > 0 {\n\t\tcount, cErr := d.DB.CountChannelVoiceUsers(ctx, c.ToChannelID())\n\t\t...\n\t\tif count >= dest.VoiceMaxUsers {\n\t\t\treturn Result{Error: ClientError{Code: ErrCodeChannelFull, Message: \"voice channel is full\"}}\n\t\t}\n\t}",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "ws-hub",
|
||||
"suggestedFix": "Add the same advisory capacity pre-flight the sibling path already has, in voiceJoinPrecheck where `ch` is already in hand — after the Archived check and before any state mutation — scoped to the switch case so the same-channel re-join keeps answering ALREADY_JOINED: `if cur := c.getVoiceChID(); cur > 0 && cur != channelID && ch.VoiceMaxUsers > 0 { count, cErr := h.db.CountChannelVoiceUsers(ctx, channelID); if cErr != nil { c.sendMsg(buildErrorMsg(ErrCodeInternal, \"failed to check channel capacity\")); return 0, nil, false }; if count >= ch.VoiceMaxUsers { c.sendMsg(buildErrorMsg(ErrCodeChannelFull, \"voice channel is full\")); return 0, nil, false } }`. CountChannelVoiceUsers already exists (Server/db/voice_queries.go:329). This is advisory only — the atomic JoinVoiceChannelIfCapacity check in voiceJoinPersist stays as the authority for the racing case, exactly as in handleVoiceModMoveV2 — and the joiner's own row lives on the old channel, so it cannot be miscounted against the destination.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0352",
|
||||
"title": "A key holder's reconnect re-announce is fire-and-forget with no confirmation retry, so one dropped WS frame permanently breaks room-key delivery for every participant",
|
||||
"file": "Client/src/lib/livekitE2EE.ts",
|
||||
"line": 401,
|
||||
"severity": "medium",
|
||||
"why": "reannounceForReconnect() mints a brand-new ephemeral ECDH keypair on every LiveKit auto-reconnect attempt and publishes it with a single unacknowledged `getWs()?.send(...)` (line 384). WsClient.sendRaw silently drops the frame when the WS proxy is not open (ws.ts:603-609 — `if (tauriInvoke === null || !proxyOpen) { log.warn(\"Cannot send, WebSocket not open\"); ... return; }`), and nothing queues or retries it. The one bounded retry that exists — the RECONNECT_CONFIRM_MS timer — is armed only for non-key-holders (`if (!this._isKeyHolder && roomKeyAtReconnect !== null)`), so a key holder whose announce is dropped has zero recovery: no retry, no log, no signal. Its published ECDH key is now permanently the pre-reconnect (dead) one, while it wraps every future room key under the new private half. No other code path ever re-announces mid-call: setupKeyExchange only runs on a fresh join, and the server's OC-0316 resume relay (Server/ws/hub.go:672) re-publishes the client's *stale stored* key, cementing the divergence.",
|
||||
"repro": "1) Client H is the elected key holder (lowest uid) in a voice channel with peers P1, P2. 2) The user's network drops. livekit-client gives up on the SFU signal socket and the session enters attemptAutoReconnect (livekitSession.ts:498); ws.ts is meanwhile in exponential backoff (getReconnectDelay, ws.ts:206 — 1s/2s/4s/8s/16s/30s, capped at 30s), so after a ~60s outage its next retry is ~30s away. 3) Network returns. attemptAutoReconnect's 3s-delayed attempt calls `await this._e2ee.reannounceForReconnect()` (livekitSession.ts:564) BEFORE `newRoom.connect()`: a fresh pair K_new is generated, `this._ecdhKeyPair = pair` is published locally, and the announce carrying K_new is passed to `getWs()?.send(...)` while `proxyOpen === false` → dropped. 4) `newRoom.connect()` succeeds (the SFU is reachable again), so the room is live with K_new. Because `this._isKeyHolder` is true, the block at line 401 never arms the confirm timer, so the announce is never repeated. 5) The WS resumes ~30s later; the server transfers and re-relays H's OLD stored key K_old to VoiceTopic (hub.go:672, `if key, sig := c.getE2EEPubKey(); key != \"\"`). P1/P2 see a duplicate announce for K_old and keep K_old in _peerPublicKeys. 6) H's next rotation (5-minute timer, or any voice_leave) calls distributeRoomKey → wrapRoomKey(K_new.privateKey, peerPub, ...). P1/P2 unwrap with unwrapRoomKey(theirPriv, K_old.publicKey, ...): the two ECDH shared secrets differ, AES-GCM authentication fails, handleOfferInner's catch logs \"failed to handle offer\" and returns. Every peer stays on the superseded room key while H encrypts with the new one — the whole call is deaf and mute in both directions, with the UI still showing \"🔒 Secured\". 7) It never heals: a *new* joiner is handed H's stale K_old by sendVoicePeerKeys (Server/ws/voice_e2ee.go:329), so its setupKeyExchange also cannot unwrap H's offer and it is ejected with e2ee_timeout after 15s. The existing test at Client/tests/unit/livekit-e2ee.test.ts:1196 deliberately stands the manager down to a non-holder (`await mgr.handleOffer(PEER_ID, \"enc\", \"iv\"); // stands us down — now a non-holder`) before asserting the retry, so the holder case is neither covered nor intended behaviour.",
|
||||
"evidence": "livekitE2EE.ts:384 this.deps.getWs()?.send({ type: \"voice_e2ee_announce\", payload: reconnectAnnounce });\nlivekitE2EE.ts:394-395 // periodic rotation (OC-0007). Holders don't need this: their own key\\n // IS the current one.\nlivekitE2EE.ts:401 if (!this._isKeyHolder && roomKeyAtReconnect !== null) {\nws.ts:603-609 if (tauriInvoke === null || !proxyOpen) { log.warn(\"Cannot send, WebSocket not open\"); queueMicrotask(() => notifySendFailure(id, \"OFFLINE\")); return; }",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "voice-e2ee",
|
||||
"suggestedFix": "One guard in reannounceForReconnect (Client/src/lib/livekitE2EE.ts, around :368): do not adopt the freshly generated pair when the announce cannot actually be delivered. Widen the E2EE ws dep to expose the connection state (WsClient already has getState(), ws.ts:755) and, when it is not \"connected\", skip the keypair swap entirely — keep the existing `_ecdhKeyPair`, still `keyProvider.setKey(...)` the retained room key, and return. Losing per-reconnect forward secrecy on that one attempt is strictly better than publishing a key half nobody holds; the published key and the local private half then can never diverge, for holders and non-holders alike. (Equivalent, slightly larger: re-send the stored `reconnectAnnounce` from an ws.onStateChange(\"connected\") hook until it is accepted.)",
|
||||
"confidence": "medium",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0353",
|
||||
"title": "LiveKit proxy rewrites Origin to a non-canonical \"https://host:443\", which the server's own origin allowlist can never match behind the documented reverse proxy — every remote voice join 403s",
|
||||
"file": "Client/src-tauri/src/livekit_proxy.rs",
|
||||
"line": 136,
|
||||
"severity": "medium",
|
||||
"why": "rewrite_proxy_headers replaces the webview's Origin with `https://` + remote_host, and remote_host always carries an explicit port (livekitSession.ts's ensureLiveKitProxy appends `:443` when the profile host has none). The server gates every `/livekit/*` request — including the signal WS upgrade — with isOriginAllowed, which accepts only (a) an absent Origin, (b) an Origin whose host string EqualFolds r.Host, (c) the three fixed `tauri.localhost` origins, or (d) an exact allowed_origins entry. The synthesized `https://chat.example.com:443` is not a canonical origin serialization (RFC 6454 omits the default port), so it matches none of the first-party entries, and it only matches r.Host when nothing in front of the Go server normalizes the Host header. The project's own documented nginx recipe uses `proxy_set_header Host $host`, and nginx's `$host` strips the port — so r.Host becomes `chat.example.com` while Origin stays `https://chat.example.com:443`.",
|
||||
"repro": "Deploy the server behind the documented nginx snippet (docs/deployment.md:285-303) on chat.example.com with the default `server.allowed_origins: []`. Save the profile as host \"chat.example.com\" in the desktop client and join a voice channel from a machine that is not the server. ensureLiveKitProxy computes remote_host=\"chat.example.com:443\"; the Rust proxy sends `Host: chat.example.com:443` and `Origin: https://chat.example.com:443`; nginx forwards `Host: chat.example.com`; isOriginAllowed compares u.Host \"chat.example.com:443\" against r.Host \"chat.example.com\", falls through the first-party list and the empty allowlist, and returns false. The LiveKit signal WS gets 403 FORBIDDEN and voice never connects, while the chat WebSocket keeps working (ws_proxy uses tokio-tungstenite, which sends no Origin header at all, so it takes the `origin == \"\"` early-accept). Note livekit_proxy.rs:598's unit test pins the rewrite's shape but nothing tests that the resulting origin is one the server accepts.",
|
||||
"evidence": "Client/src-tauri/src/livekit_proxy.rs:135-137\n } else if lower.starts_with(\"origin:\") {\n modified.push_str(\"Origin: https://\");\n modified.push_str(remote_host);\n\nClient/src/lib/livekitSession.ts:766 (ensureLiveKitProxy)\n hostWithPort = this.serverHost.includes(\":\") ? this.serverHost : `${this.serverHost}:443`;\n\nServer/api/livekit_proxy.go:147-149 (isOriginAllowed)\n\tif u, err := url.Parse(origin); err == nil && u.Host != \"\" && strings.EqualFold(u.Host, r.Host) {\n\t\treturn true\n\t}\n\nServer/api/livekit_proxy.go:76-84 — the check gates every /livekit/* request, WS upgrades included.\n\ndocs/deployment.md:297\n proxy_set_header Host $host;\n\nCorroborating: Server/api/livekit_proxy_test.go:127 calls the Rust layer the client's \"Origin-stripping Rust proxy\" — the server-side allowance was written against a premise (Origin removed) that livekit_proxy.rs does not implement (Origin rewritten).",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "tauri-rust",
|
||||
"suggestedFix": "Normalize the scheme-default port on both sides of the same-origin comparison in the shared server-side guard, Server/api/livekit_proxy.go:145 — e.g. compare `stripDefaultPort(u.Scheme, u.Host)` against `stripDefaultPort(schemeOf(r), r.Host)`, dropping only \":443\" for https and \":80\" for http. One guard in isOriginAllowed covers both the port-stripping and port-preserving proxies, and keeps TestIsOriginAllowed_SameHostDifferentPortDenied (:9999 vs :8443) failing as it should. Fixing it client-side by dropping :443 from the Origin alone would break the direct-on-443 case, where the rewritten Host still carries the port.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0354",
|
||||
"title": "authStore.user.totp_enabled is never populated from the server, so 2FA can never be disabled from Settings and every auth_ok wipes it",
|
||||
"file": "Client/src/components/settings/AccountTab.ts",
|
||||
"line": 786,
|
||||
"severity": "medium",
|
||||
"why": "The 2FA panel's entire state is derived from `authStore.getState().user?.totp_enabled`, but nothing on any production code path ever sets that field from the server. Its only two writers are the local post-action calls in MainPage (`updateUser({ totp_enabled: true })` at :559 and `false` at :570), and the dispatcher's AUTH_OK handler replaces `authStore.user` wholesale on *every* auth_ok — fresh connect and every WS resume alike — with the `auth_ok` payload user, which `buildAuthOK` (Server/ws/serve_ready.go:27-51) does not include `totp_enabled` in. So the flag is undefined at login and is reset to undefined by the first reconnect after an in-session enable.",
|
||||
"repro": "Enable 2FA on an account, then restart the desktop app (or just let the WebSocket drop and resume once). Open Settings → Account → Two-Factor Authentication. The badge reads \"Disabled\" and `buildTotpEnrollForm` is rendered; `buildTotpDisableView` is unreachable, so there is no way to turn 2FA off from the UI. Clicking Enable posts /users/me/totp/enable and the server answers 409 \"disable 2FA before re-enabling\" — an instruction the UI makes impossible to follow. The badge also actively lies about the state of a security control. Within a single session the same wipe happens on reconnect: enable 2FA (panel flips to \"Enabled\"), drop the socket, resume — the next render reads undefined again.",
|
||||
"evidence": "AccountTab.ts:786 `const enabled = authStore.getState().user?.totp_enabled === true;`\nAccountTab.ts:802-806 `if (enabled) { contentArea.appendChild(buildTotpDisableView(...)); } else { contentArea.appendChild(buildTotpEnrollForm(...)); }`\ndispatcher.ts:276 `setAuth(authStore.getState().token ?? \"\", payload.user, payload.server_name, payload.motd);`\nauth.store.ts:57-65 setAuth does `authStore.setState(() => ({ token, user, serverName, motd, isAuthenticated: true }))` — a wholesale replace, no merge.\nServer/ws/serve_ready.go:29-46 auth_ok payload user = {id, username, avatar, role, display_name, about, custom_status, status} — no totp_enabled.\nClient/src/lib/types.ts:823-828 `export interface AuthResponse { token?, partial_token?, requires_2fa }` — the login response's real `totp_enabled` (Server/api/auth_handler.go:62 and :721) is not even declared client-side, and main.ts's onLogin/onRegister/onTotpSubmit only read `result.token` / `result.requires_2fa` / `result.partial_token`.\n`api.getMe()` (api.ts:321) is never called anywhere in Client/src, and its `MemberResponse` (types.ts:916-925) carries no totp_enabled either.\nServer/api/totp_handler.go:194-200 `if user.TOTPSecret != nil && *user.TOTPSecret != \"\" { 409 TOTP_ALREADY_ENABLED \"disable 2FA before re-enabling\" }`",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "flow-reconnect",
|
||||
"suggestedFix": "Fix it in the one payload both the fresh-connect and resume paths share rather than in each client writer: add `\"totp_enabled\": user.TOTPSecret != nil` to the `user` map in buildAuthOK (Server/ws/serve_ready.go:29-46), declaring the field on the auth_ok user in protocol/schema.json and regenerating Server/ws/message_types.go + Client/src/lib/protocolTypes.ts via the protocol-change skill. That makes authStore.user.totp_enabled authoritative on every connect, so the existing wholesale setAuth stays correct and no client-side merge is needed.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0355",
|
||||
"title": "The \"/\" quick-search hotkey swallows the keystroke inside the very inputs it focuses, so \"/\" can never be typed into the audit or log filter",
|
||||
"file": "Server/admin/static/index.html",
|
||||
"line": 2002,
|
||||
"severity": "medium",
|
||||
"why": "The global keydown handler calls e.preventDefault() for \"/\" whenever any .filter-search element exists, with no check that the event target is already a text field. On the Audit Log and Server Logs pages the search box IS that element, so the character is dropped instead of inserted. The hidden admin shell keeps its markup after logout, so the login form inherits the same swallow.",
|
||||
"repro": "Open Server Logs, click the \"Filter logs...\" box and type `api/v1`. The field shows `apiv1` — every \"/\" is preventDefault-ed, so no log line can be filtered by path. Same in the Audit Log search box. Secondary: after visiting Audit Log or Server Logs, let the session expire (or sign out) — the login overlay is shown while #content still holds a .filter-search, so typing a password containing \"/\" silently drops that character and focus() targets a display:none input.",
|
||||
"evidence": "index.html:2002 `if(e.key==='/'&&!document.querySelector('.modal-overlay.visible')){const s=document.querySelector('.filter-search');if(s){e.preventDefault();s.focus()}}`\nindex.html:1494 the Server Logs toolbar renders `<input class=\"filter-search\" placeholder=\"Filter logs...\" ... oninput=\"state.logSearch=this.value;renderLogLines()\">`\nindex.html:218 `.hidden{display:none!important}` — hideAll() only hides #adminShell, it never clears #content.",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "hotspot-server-admin-static",
|
||||
"suggestedFix": "Add one target guard at index.html:2002 before preventDefault: `if(e.key==='/'&&!document.querySelector('.modal-overlay.visible')){const t=e.target;if(t&&(t.isContentEditable||/^(input|textarea|select)$/i.test(t.tagName)))return;const s=document.querySelector('.filter-search');if(s){e.preventDefault();s.focus()}}` — this single guard fixes both the filter fields and the login form.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0356",
|
||||
"title": "The Ctrl+K quick switcher is entirely unstyled — none of its five classes exist in any stylesheet, so the arrow-key selection is invisible and the result list has no scroller",
|
||||
"file": "Client/src/components/QuickSwitcher.ts",
|
||||
"line": 172,
|
||||
"severity": "medium",
|
||||
"why": "`createQuickSwitcher` builds its whole UI out of `quick-switcher`, `quick-switcher__input`, `quick-switcher__results`, `quick-switcher__item` and `quick-switcher__item--active`. `grep -n \"switcher\" Client/src/styles/*.css` returns **zero** matches across all five loaded stylesheets (tokens/base/login/app/theme-neon-glow, the complete set imported by `main.ts:3-7`), and only the backdrop (`quick-switcher-overlay`, line 166) carries an inline style. The `base.css` reset then applies: the modal is a transparent block with no background/width/padding on a 60%-black backdrop, `.quick-switcher__results` gets no `max-height`/`overflow` so a long channel list runs off the bottom of the viewport with no scroller, and — the functional break — `quick-switcher__item--active`, the class `renderResults()` moves on every ArrowUp/ArrowDown (lines 56-58, 110-126), paints nothing at all, so the keyboard highlight the widget's entire navigation model depends on is invisible.",
|
||||
"repro": "Press Ctrl+K anywhere in MainPage (OverlayManagers.createQuickSwitcherManager → createQuickSwitcher). The overlay opens as unstyled text on a dark backdrop; press ArrowDown repeatedly — `activeIndex` advances and `aria-activedescendant` moves, but no row is visually highlighted, so Enter (line 129-136) navigates to a channel the user had no way to see was selected. On a server with ~30+ channels the results list also extends past the viewport bottom with no scrollbar, making the lower entries unreachable by mouse.",
|
||||
"evidence": "QuickSwitcher.ts:56 class: isActive ? \"quick-switcher__item quick-switcher__item--active\" : \"quick-switcher__item\",\nQuickSwitcher.ts:172 const modal = createElement(\"div\", { class: \"quick-switcher\" });\nQuickSwitcher.ts:181 class: \"quick-switcher__input\",\nQuickSwitcher.ts:192 class: \"quick-switcher__results\",\n$ grep -n \"switcher\" Client/src/styles/*.css -> (no output)\n(compare: `.quick-switch*` — the *other* overlay — has 18 rules in app.css, and SearchOverlay's `.search-result-item--active` is defined)",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "hotspot-client-src-components",
|
||||
"suggestedFix": "Add the missing block to Client/src/styles/app.css (next to the existing .quick-switch-* rules) rather than to each call site — at minimum `.quick-switcher__item--active { background: var(--bg-modifier-selected); color: var(--text-normal); }` so the roving highlight paints, plus `.quick-switcher { width: 480px; max-width: 90vw; background: var(--bg-primary); border-radius: 8px; padding: 12px; }`, `.quick-switcher__input { width: 100%; padding: 8px; background: var(--bg-tertiary); color: var(--text-normal); border-radius: 4px; }`, `.quick-switcher__results { max-height: 50vh; overflow-y: auto; }` and `.quick-switcher__item { display: flex; gap: 8px; align-items: center; padding: 6px 8px; border-radius: 4px; cursor: pointer; }`.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0357",
|
||||
"title": "Message search silently returns nothing for any query containing punctuation — sanitizeFTSQuery deletes separators instead of folding them to a space",
|
||||
"file": "Server/db/message_queries.go",
|
||||
"line": 50,
|
||||
"severity": "low",
|
||||
"why": "messages_fts uses FTS5's default unicode61 tokenizer, where every non-alphanumeric rune ('_', '.', '\\'', '/', ':', '@', …) is a token separator, so \"user_id\" is indexed as the two tokens `user` and `id`. sanitizeFTSQuery keeps letters/digits/space, folds only '-' to a space, and silently DROPS every other rune — which concatenates the neighbouring tokens into one term that exists nowhere in the index. The '-' arm exists for exactly this reason (its own comment: \"Folding to a space (rather than dropping it) still matches the indexed tokens\"), but the rule was never applied to the other separators.",
|
||||
"repro": "Post a message \"don't touch user_id in docs/protocol.md\". FTS indexes the tokens don, t, touch, user, id, in, docs, protocol, md. Now search (GET /api/v1/messages/search?q=user_id, or the in-app search overlay): sanitizeFTSQuery returns \"userid\", the MATCH finds no such token, and SearchMessages returns zero hits with HTTP 200. Same for \"don't\" -> \"dont\", \"docs/protocol\" -> \"docsprotocol\", \"example.com\" -> \"examplecom\". Searching \"user id\" (with a space) matches, proving the content is indexed and reachable — only the punctuated spelling the user actually copied out of the message is silently unmatchable. Expected: fold each separator to a space, exactly as '-' already is, so the query becomes the same token sequence the tokenizer produced.",
|
||||
"evidence": "\tfor _, r := range q {\n\t\tswitch {\n\t\tcase unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ':\n\t\t\tsb.WriteRune(r)\n\t\tcase r == '-':\n\t\t\tsb.WriteRune(' ')\n\t\t}\n\t}\n// (no default: every other rune is dropped, joining adjacent tokens)",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "db-storage",
|
||||
"suggestedFix": "In sanitizeFTSQuery, replace the special-cased `case r == '-': sb.WriteRune(' ')` with a `default: sb.WriteRune(' ')` arm, so every non-alphanumeric rune folds to a space exactly as '-' already does. Output stays within the documented charset (letters, digits, spaces), so the fuzz/unit contracts still hold, and the query becomes the same token sequence unicode61 produced when indexing.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0358",
|
||||
"title": "EditMessageContent lacks the `AND deleted = 0` guard its SoftDeleteMessage / SetMessagePinned siblings have, so an edit racing a delete rewrites a tombstone and broadcasts chat_edited for a deleted message",
|
||||
"file": "Server/db/message_queries.go",
|
||||
"line": 179,
|
||||
"severity": "low",
|
||||
"why": "MessageService.EditMessage checks msg.Deleted from a read taken before the write, and db.EditMessage re-reads the row but still issues an UPDATE keyed on id alone. OC-0284 added `AND deleted = 0` to SoftDeleteMessage and SetMessagePinned (and both check RowsAffected) precisely so a message that races the writer surfaces as ErrNotFound instead of silently succeeding; the edit path was not given the same guard, so it commits content/edited_at onto an already soft-deleted row and then reports success.",
|
||||
"repro": "Alice has message 100 open in the composer for editing. Interleave: (a) EditMessage reads message 100 via GetMessage — Deleted=false, passes the msg.Deleted guard and editMessageCheckAccess; (b) a moderator's chat_delete for message 100 commits (deleted=1, DecrementMentionCounts reverses its badges); (c) Alice's EditMessageContent UPDATE runs — no deleted predicate, so it rewrites the tombstone's content and stamps edited_at, then ReplaceMessageMentions re-inserts message_mentions rows for the now-deleted message that nothing will ever reverse; (d) the service returns success and the ws layer fans out chat_edited for a message every client has already tombstoned. Expected: `AND deleted = 0` plus a RowsAffected check mapping to ErrDeletedMessage, exactly as DeleteMessage and SetMessagePinned already do.",
|
||||
"evidence": "messages.sql:19 -- name: EditMessageContent :one\n UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ?\nmessages.sql:24 UPDATE messages SET deleted = 1 WHERE id = ? AND deleted = 0;\nmessages.sql:27 UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0;\nmessage_queries.go:179 updated, err := d.q.EditMessageContent(ctx, dbgen.EditMessageContentParams{Content: content, ID: id})",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "db-storage",
|
||||
"suggestedFix": "Add `AND deleted = 0` to the EditMessageContent statement in Server/db/queries/sqlite/messages.sql (via the db-change skill so dbgen regenerates) and, in db.EditMessage, map the resulting sql.ErrNoRows to ErrNotFound/a deleted-message error rather than a bare wrap, so MessageService.EditMessage surfaces ErrDeletedMessage — one guard in the shared query, exactly as OC-0284 did for SoftDeleteMessage.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0359",
|
||||
"title": "secret_store's read-back-mismatch branch deletes the keyring entry before the fallback write commits — the exact eager-purge its sibling branch was fixed to defer",
|
||||
"file": "Client/src-tauri/src/secret_store.rs",
|
||||
"line": 149,
|
||||
"severity": "low",
|
||||
"why": "set_with's write-failure arm (line 164) was fixed to defer its keyring_delete until after fallback_set has actually committed, with a comment saying an eager delete \"risks erasing the last good copy of the secret if the fallback write fails too.\" The read-back-mismatch arm 15 lines above does exactly that eager delete, then falls through to `fallback_set(account, secret)?` — whose `?` returns before any replacement copy exists. If the fallback write fails, the credential/identity key that was in the keyring is gone and nothing was written anywhere.",
|
||||
"repro": "A machine whose OS credential store accepts writes but serves a stale value. User re-logs in: save_credential -> secret_store::set -> keyring_set returns Ok(()); keyring_get returns the PREVIOUS (still-valid) credential blob, which != the new secret, so the Ok(Some(_)) arm at line 139 runs keyring_delete and removes it. Execution reaches line 179; fallback_set fails (app.path().app_data_dir() unresolvable, credential_fallback.key cannot be created, or store.save() hits ENOSPC) and returns Err. Both stores are now empty: the next load_credential returns Ok(None), indistinguishable from first login, and the user's saved credential — or, via save_identity_key, the voice-E2EE identity private key every peer has TOFU-pinned — is permanently destroyed. The existing test at secret_store.rs:648 only covers this arm with a SUCCEEDING fallback, so nothing locks the current behavior; the mirror-image test for the sibling arm (line 594, set_with_keeps_the_stale_keyring_entry_when_the_write_and_fallback_both_fail) has no counterpart here.",
|
||||
"evidence": "Client/src-tauri/src/secret_store.rs:139-153\n Ok(Some(_)) => {\n log::error!(... \"returned a different secret than was written\" ...);\n if let Err(e) = keyring_delete(account) {\n log::warn!(\"{SERVICE}: could not remove the mismatched entry for '{account}': {e}\");\n }\n }\n...\nline 179: fallback_set(account, secret)?;\n\nContrast, same function, line 164-175:\n Err(e) => {\n ...\n // But the purge must wait until fallback_set below has actually\n // committed the replacement: deleting now, before that write is\n // known to succeed, risks erasing the last good copy of the secret\n // if the fallback write fails too.\n purge_stale_keyring_after_fallback_commits = true;\n }",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "tauri-rust",
|
||||
"suggestedFix": "In the Ok(Some(_)) arm (Client/src-tauri/src/secret_store.rs:139-153), drop the inline keyring_delete and reuse the existing deferral flag: `purge_stale_keyring_after_fallback_commits = true;`. The shared purge block at lines 181-188 then runs it only after fallback_set has committed, making both degraded arms obey the same ordering invariant with no new code path and no test change.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0360",
|
||||
"title": "LiveKitSession keeps a second copy of the master output-volume multiplier that setOutputVolume never updates, so the diagnostics dump always reports the app-startup value",
|
||||
"file": "Client/src/lib/livekitSession.ts",
|
||||
"line": 153,
|
||||
"severity": "low",
|
||||
"why": "The same value lives in two places: AudioElements.outputVolumeMultiplier (the one that is actually applied to participants) and LiveKitSession.outputVolumeMultiplier. setOutputVolume writes the pref and the AudioElements field only; the LiveKitSession field is initialised once from localStorage at module construction and never written again, yet it is what buildSessionDebugInfo publishes.",
|
||||
"repro": "1. Launch the client with the stored `owncord:settings:outputVolume` at 100. LiveKitSession.outputVolumeMultiplier = 1.\n2. Settings -> Voice & Audio -> drag Output Volume to 40%. VoiceAudioTab calls setOutputVolume(40) -> AudioElements.outputVolumeMultiplier = 0.4 and audio really is at 40%.\n3. Settings -> Logs -> copy diagnostics (LogsTab.ts:288 getSessionDebugInfo()), or run `__owncord.lkDebug()`. The dump reports outputVolumeMultiplier: 1 while every participant is actually being played at 0.4 — the field is stale for the whole app session and only re-reads the pref on the next launch.",
|
||||
"evidence": "livekitSession.ts:153 — `private outputVolumeMultiplier = loadPref<number>(\"outputVolume\", 100) / 100;` (no other assignment to this field exists in the file)\nlivekitSession.ts:1769-1774 — `return buildSessionDebugInfo({ room: this._room, currentChannelId: this._currentChannelId, outputVolumeMultiplier: this.outputVolumeMultiplier, ... })`\naudioElements.ts:248-252 —\n setOutputVolume(volume: number): void {\n const clamped = Math.max(0, Math.min(200, volume));\n savePref(\"outputVolume\", clamped);\n this.outputVolumeMultiplier = clamped / 100;\nlivekitDiagnostics.ts:171 — `outputVolumeMultiplier,` is emitted verbatim into the debug object.",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "state-desync",
|
||||
"suggestedFix": "Delete the duplicate field at livekitSession.ts:153 and read the single source of truth in getSessionDebugInfo: change line 1772 to `outputVolumeMultiplier: this._audioElements.getOutputVolumeMultiplier(),` (that getter already exists at audioElements.ts:90-92). One line, no caller or signature changes — buildSessionDebugInfo's deps shape is unchanged.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0361",
|
||||
"title": "Admin Users / Audit Log pagination offers a phantom empty next page at exact multiples of PAGE_SIZE",
|
||||
"file": "Server/admin/static/index.html",
|
||||
"line": 832,
|
||||
"severity": "low",
|
||||
"why": "The \"has more\" decision is `users.length < PAGE_SIZE` on a page fetched with `limit=PAGE_SIZE`, so a full page is indistinguishable from \"a full page and nothing after it\". The server API supports over-fetching (and this repo already uses the fetch-one-extra pattern correctly in `MessageService.GetMessages`, which asks for `limit+1` and derives `hasMore := len(msgs) > limit`), so the admin panel is the odd one out. Same defect at line 1433 for the audit log.",
|
||||
"repro": "Server with exactly 50 users (PAGE_SIZE = 50). Open Admin -> Users: `GET /users?limit=50&offset=0` returns 50 rows, so `users.length < PAGE_SIZE` is false and the \">\" button stays enabled. Clicking it sets usersPage=2 -> `GET /users?limit=50&offset=50` returns [] -> the table renders \"No users found\" with \"Page 2\" showing. The same sequence reproduces on Admin -> Audit Log (index.html:1433) with an entry count that is an exact multiple of 50.",
|
||||
"evidence": "const offset=(state.usersPage-1)*PAGE_SIZE;\nusers=await api('GET','/users?limit='+PAGE_SIZE+'&offset='+offset)\n...\nhtml+='<button class=\"page-btn\" '+(users.length<PAGE_SIZE?'disabled':'')+' onclick=\"state.usersPage++;renderContent()\">></button>';\n// audit log sibling, line 1433:\nhtml+='<button class=\"page-btn\" '+(!entries||entries.length<PAGE_SIZE?'disabled':'')+' onclick=\"state.auditPage++;renderContent()\">></button>';",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "ordering-boundary",
|
||||
"suggestedFix": "Fetch one row past the page and derive hasMore from the overflow, matching MessageService.GetMessages. In renderUsers: `const rows=await api('GET','/users?limit='+(PAGE_SIZE+1)+'&offset='+offset); const hasMore=rows.length>PAGE_SIZE; users=rows.slice(0,PAGE_SIZE);` then line 832 becomes `(!hasMore?'disabled':'')`. Apply the identical change in renderAudit (lines 1396/1433), assigning the sliced array to state.auditCache so the \"N entries on this page\" label stays accurate. limit=51 is inside the server's 1..500 clamp, so no server change is needed.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0362",
|
||||
"title": "clearCustomEmoji resets the emoji generation counter to 0, so a previous server's in-flight GET /emoji is accepted by the next server's session — and that server's own reply is then rejected as stale",
|
||||
"file": "Client/src/stores/emoji.store.ts",
|
||||
"line": 98,
|
||||
"severity": "low",
|
||||
"why": "The OC-0251 staleness guard in setCustomEmoji compares the caller's snapshotted `rev` against the store's current `rev`, but clearCustomEmoji (called from MainPage.destroy on logout/server switch) either resets `rev` to 0 via INITIAL or, when the set is already empty, early-returns and never touches `rev` at all. Either way the counter is back at 0 for the next session, which snapshots `emojiRevAtFetch = 0` too — so a reply belonging to the previous server still matches and is applied, which is precisely what clearCustomEmoji exists to prevent, and it then bumps rev to 1 so the new server's own reply is discarded as \"stale\".",
|
||||
"repro": "1) Connect to server A. dispatcher's ready handler snapshots emojiRevAtFetch = 0 and issues GET /emoji to A. 2) Before that reply lands (slow/remote A), switch profiles to server B. MainPage.destroy() calls clearCustomEmoji(); the store is still empty, so line 98 early-returns and rev stays 0. 3) B's ready handler snapshots emojiRevAtFetch = 0 and issues its own GET /emoji. 4) A's late reply arrives first: setCustomEmoji(A_list, 0) sees 0 === 0, applies A's emoji and bumps rev to 1. 5) B's reply arrives: setCustomEmoji(B_list, 0) sees 0 !== 1 and is skipped. Result: for the whole B session, resolveEmoji answers from A's set — B's own shortcodes render as literal `:name:` text, and any A shortcode present in a B message renders `<img src=\"/api/v1/emoji/<A-id>/image\">` resolved against B's host, i.e. an unrelated emoji of B's. Nothing recovers until an emoji_update broadcast happens on B.",
|
||||
"evidence": "emoji.store.ts:47-51 const INITIAL: EmojiState = { emoji: [], byShortcode: new Map(), rev: 0 };\nemoji.store.ts:90-93 emojiStore.setState((prev) => { if (rev !== undefined && rev !== (prev.rev ?? 0)) return prev; return { emoji: next, byShortcode, rev: (prev.rev ?? 0) + 1 }; });\nemoji.store.ts:97-99 export function clearCustomEmoji(): void { emojiStore.setState((prev) => (prev.emoji.length === 0 ? prev : INITIAL)); }\ndispatcher.ts:571-574 const emojiRevAtFetch = emojiStore.getState().rev ?? 0; api.listEmoji().then((list) => setCustomEmoji(list, emojiRevAtFetch))\nMainPage.ts:916-920 // Custom emoji belong to the server this page was connected to. ... clearCustomEmoji();",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "hotspot-server-db",
|
||||
"suggestedFix": "Make clearCustomEmoji invalidate in-flight fetches instead of resetting the counter: `emojiStore.setState((prev) => ({ emoji: [], byShortcode: new Map(), rev: (prev.rev ?? 0) + 1 }));` — unconditional (drop the `emoji.length === 0` early return), so any snapshotted rev from the previous session can never match again. One change in the shared store; no caller edits needed.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0363",
|
||||
"title": "Clicking any emoji permanently deletes every custom-emoji entry from the shared recent-emoji list whenever the current server's emoji set is not loaded",
|
||||
"file": "Client/src/components/EmojiPicker.ts",
|
||||
"line": 540,
|
||||
"severity": "low",
|
||||
"why": "getRecentEmoji applies a display-time filter that drops `:shortcode:` entries which do not resolve against the *current* server's emojiStore. addRecentEmoji feeds that filtered list straight back into localStorage.setItem, so a read-side presentation filter becomes a destructive write against the single, host-unscoped `owncord:recent-emoji` key.",
|
||||
"repro": "On server A (which defines custom emoji :blob:), use :blob: — recents become [\":blob:\", \"😀\"]. Switch to server B, which has no :blob:. Open the picker and click any plain unicode emoji. addRecentEmoji calls getRecentEmoji(), whose line-531 filter drops \":blob:\" because resolveEmoji returns null on B, and line 543 writes the shortened list back. Returning to server A, :blob: is gone from Recent forever. The same wipe happens on server A itself if the picker is used before the ready-time GET /emoji resolves, or after it fails (dispatcher.ts:574's .catch only logs \"Failed to load custom emoji\"), since emojiStore is empty and every stored shortcode fails to resolve. The existing test (tests/unit/emoji-picker.test.ts:330) only pins the display filter, not the write-back.",
|
||||
"evidence": "EmojiPicker.ts:509 const RECENT_KEY = \"owncord:recent-emoji\";\nEmojiPicker.ts:531 .filter((e) => !(e.startsWith(\":\") && e.endsWith(\":\")) || resolveEmoji(e) !== null)\nEmojiPicker.ts:539-543\nfunction addRecentEmoji(emoji: string): void {\n const recent = getRecentEmoji().filter((e) => e !== emoji);\n recent.unshift(emoji);\n try {\n localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT)));\nemoji.store.ts:111 return emojiStore.getState().byShortcode.get(name) ?? null; // current server only",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "hotspot-server-db",
|
||||
"suggestedFix": "Split the read paths in Client/src/components/EmojiPicker.ts: add a `readStoredRecent()` that only parses/validates the JSON string array (no resolveEmoji filter), have `addRecentEmoji` build its list from that, and keep the `resolveEmoji` filter inside `getRecentEmoji()` for display only.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0364",
|
||||
"title": "Admin Users page reports a lapsed temporary ban as \"Banned: Yes\" forever — every other surface treats the user as active",
|
||||
"file": "Server/admin/static/index.html",
|
||||
"line": 805,
|
||||
"severity": "low",
|
||||
"why": "The Users table derives its Banned column and Status dot from the raw `users.banned` column alone; nothing in the panel ever reads `ban_expires` (zero hits for ban_expires/BanExpires in that file), even though Server/db/admin_queries.go:87 maps it and Server/admin/types.go:101 ships it as `ban_expires`. Nothing on the server clears `users.banned` when a temporary ban lapses — expiry is decided lazily by auth.IsEffectivelyBanned and by the `replace(ban_expires,' ','T') <= strftime(...)` arm in ListMembers / CountUsersWithoutTOTP / notBannedClause / GetOwnerUser. So the row keeps banned=1 with a past ban_expires for life while the account is fully active (logs in, passes AuthMiddleware and the WS handshake, appears in the roster, counts for the last-admin and require_2fa guards), and the admin panel — the only surface where an operator reviews ban state — is the one place that still calls them banned.",
|
||||
"repro": "1. From the desktop client, right-click a member -> Ban and pick a finite duration from the ban-duration dropdown (Client/src/components/AdminActions.ts:345; sent as ban_duration_hours by Client/src/lib/api.ts:794). The server stores banned=1 with ban_expires = now+N hours (Server/admin/handlers_users.go:169-176 -> db.BanUser).\n2. Let the ban lapse, or seed it directly: UPDATE users SET banned=1, ban_expires='2020-01-01T00:00:00Z' WHERE id=?.\n3. Log in as that user: login succeeds, they appear in every client's member list, they can be @mentioned, and they count toward the last-admin / require_2fa guards.\n4. Open the admin panel's Users page: the same account renders with a red \"Banned: Yes\" badge, the stale ban reason under it, a \"banned\" status dot instead of Online, and only an Unban action — the Ban button is hidden, so re-banning them takes two steps (Unban, then Ban).",
|
||||
"evidence": "Server/admin/static/index.html:805-806,823:\n const status=u.Status||u.status||'offline';const banned=u.Banned||u.banned||false;\n const statusDot=banned?'banned':status;const statusLabel=banned?'Banned':status==='online'?'Online':'Offline';\n if(banned)html+='<button class=\"act-btn\" title=\"Unban\" onclick=\"unbanUser('+uid+')\">'+I.check+'</button>';\n else html+='<button class=\"act-btn danger\" title=\"Ban\" ...>'+I.ban+'</button>';\n\nServer/admin/types.go:99-101 (data the panel is given but ignores):\n\tBanned bool `json:\"banned\"`\n\tBanReason *string `json:\"ban_reason,omitempty\"`\n\tBanExpires *string `json:\"ban_expires,omitempty\"`\n\nServer/auth/helpers.go:73-91 (the rule every other surface applies):\n\tif u == nil || !u.Banned { return false }\n\tif u.BanExpires == nil { return true }\n\t... return time.Now().UTC().Before(t.UTC())",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "explore-1",
|
||||
"suggestedFix": "Derive the effective flag once in renderUsers (Server/admin/static/index.html:805) instead of reading the raw column: `const rawBan=u.Banned||u.banned||false; const exp=u.ban_expires||u.BanExpires||null; const banned=rawBan&&(!exp||new Date(String(exp).replace(' ','T').replace(/Z?$/,'Z')).getTime()>Date.now());` — the replace() normalises SQLite's space-separated form the same way db.notBannedClause does. That one expression fixes the badge, the status dot and the Ban/Unban button choice together. Do not move the computation into toAdminUserResponse: the API's raw `banned` is what the panel would still need to offer an Unban that clears the stale row.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0365",
|
||||
"title": "GifPicker re-registers every result cell's click listener on the picker-lifetime AbortSignal on each search, retaining every discarded set of 20 GIF thumbnails",
|
||||
"file": "Client/src/components/GifPicker.ts",
|
||||
"line": 117,
|
||||
"severity": "low",
|
||||
"why": "`renderGifs` replaces the whole cell set (`clearChildren(gridArea)` then a fresh `.gp-grid`) on every load, but binds each new item's click listener to `signal` — the AbortController created once at `createGifPicker` and only aborted in `destroy()`. Per the DOM spec, `addEventListener` with a signal installs an abort algorithm on that signal holding a strong reference to the EventTarget, so every detached `.gp-item` (and its `<img>` with a decoded Klipy GIF) stays reachable until the picker closes. This is the identical mechanism already accepted and fixed for EmojiPicker.ts:620, SearchOverlay.ts:96, QuickSwitcher.ts:83, MemberList.ts:469 and MessageList.ts:332 — GifPicker is the one picker in that family that was not covered, and its cells are animated images rather than text.",
|
||||
"repro": "Open the composer's GIF picker (MessageInput.ts:989). `loadGifs(\"\")` renders 20 trending cells, each with a click listener on `signal`. Type a query: the 300 ms debounce (GifPicker.ts:181) fires `loadGifs(\"cats\")`, `renderGifs` calls `clearChildren(gridArea)` and builds 20 brand-new cells with 20 more listeners on the same signal. Refine the search five times and the picker is holding 6 × 20 = 120 detached `<div class=\"gp-item\">` nodes plus their `<img>` GIF payloads, all rooted in `abortController.signal`'s abort-algorithm list, until `destroy()` runs when the picker is closed (MessageInput.ts:964). Take a heap snapshot after the fifth search: the detached-node count grows by 20 per search and never drops.",
|
||||
"evidence": "Client/src/components/GifPicker.ts:46-47\n const abortController = new AbortController();\n const signal = abortController.signal;\n\nClient/src/components/GifPicker.ts:91-127\n function renderGifs(gifs: readonly GifResult[]): void {\n clearChildren(gridArea); // detaches the previous cell set...\n ...\n for (const gif of gifs) {\n const item = createElement(\"div\", { class: \"gp-item\", ... });\n ...\n item.addEventListener(\n \"click\",\n () => { options.onSelect(gif.fullUrl); options.onClose(); },\n { signal }, // ...but the picker-lifetime signal keeps holding it\n );\n\nClient/src/components/GifPicker.ts:206-211\n function destroy(): void {\n if (debounceTimer !== null) clearTimeout(debounceTimer);\n abortController.abort(); // the only release point",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "explore-3",
|
||||
"suggestedFix": "Register one delegated click listener on gridArea at mount time (alongside enableRovingNavigation at line 78), and drop the per-item addEventListener at line 117 — mirroring EmojiPicker's data-emoji delegation: store the URL on the cell as a data attribute (e.g. createElement(\"div\", { class: \"gp-item\", ..., \"data-full-url\": gif.fullUrl })) and have the delegated handler do const cell = (e.target as Element).closest<HTMLElement>(\".gp-item\"); if (cell?.dataset.fullUrl) { options.onSelect(cell.dataset.fullUrl); options.onClose(); }.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0366",
|
||||
"title": "resetBlocksStore() rewinds blockedByMeRev to 0, so a previous session's in-flight GET /blocks is accepted by the next session (and the next session's own reply is not protected)",
|
||||
"file": "Client/src/stores/blocks.store.ts",
|
||||
"line": 96,
|
||||
"severity": "low",
|
||||
"why": "`resetBlocksStore()` writes the module-level `INITIAL`, which carries `blockedByMeRev: 0`. The OC-0218 staleness guard in `setBlockedByMe` compares the caller's snapshotted revision against the store's current revision — resetting that counter to its starting value makes a token snapshotted in the *previous* server/account session match again in the *next* one, so the stale reply is applied instead of skipped. This is exactly the defect already confirmed on the twin counter in `emoji.store.ts` (`clearCustomEmoji` resetting `rev` to 0), and it defeats the cross-server isolation `clearAuth` calls `resetBlocksStore` to guarantee (auth.store.ts:77-82, 105).",
|
||||
"repro": "1. User A signs into server S1. `ready` fires; dispatcher.ts:551 snapshots `blockedByMeRevAtFetch = 0` (fresh session) and issues `GET /blocks` with no AbortSignal (api.ts:686 — dispatcher passes none). A has blocked user id 7 on S1.\n2. Before the HTTP reply lands (slow link / large server), A logs out (or the session is terminated). `clearAuth()` runs `resetBlocksStore()` → state is `INITIAL`, so `blockedByMeRev` goes back to 0 and `blockedByMe` is empty.\n3. User B signs in (another server, or the same server as a different account). Its `ready` snapshots `blockedByMeRev = 0` and issues its own `GET /blocks`.\n4. S1's earlier reply now resolves: `setBlockedByMe([7], 0)`. The guard at line 58 compares 0 === 0, passes, and writes A's blocked-user ids into B's store.\n5. Result: in B's session, `dmComposerBlockReason` (line 103-106) disables the DM composer for whoever user id 7 is on this server with \"You've blocked this user. Unblock to send messages.\", and MemberList's context menu (MemberList.ts:294) offers \"Unblock\" for a user B never blocked. Because `setBlockedByMe` does not bump the revision, B's own correct reply is *also* accepted at rev 0 — so whichever of the two HTTP responses lands last wins, and if the stale one loses the race the wrong list persists until the next `ready` or block toggle.",
|
||||
"evidence": "blocks.store.ts:38-42 const INITIAL: BlocksState = { blockedByMe: new Set(), blockedByThem: new Set(), blockedByMeRev: 0 };\nblocks.store.ts:56-61 export function setBlockedByMe(userIds, rev?) { blocksStore.setState((prev) => { if (rev !== undefined && rev !== (prev.blockedByMeRev ?? 0)) return prev; return { ...prev, blockedByMe: new Set(userIds) }; }); }\nblocks.store.ts:95-97 export function resetBlocksStore(): void { blocksStore.setState(() => INITIAL); }\nauth.store.ts:105 resetBlocksStore();\ndispatcher.ts:551-554 const blockedByMeRevAtFetch = blocksStore.getState().blockedByMeRev ?? 0;\n api.listBlocks().then((r) => setBlockedByMe(r.blocked_user_ids, blockedByMeRevAtFetch))\napi.ts:686 listBlocks(signal?: AbortSignal) // dispatcher passes no signal, so the fetch is never aborted on logout",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "explore-3",
|
||||
"suggestedFix": "Make the revision monotonic across resets instead of rewinding it — one guard in the shared function: `export function resetBlocksStore(): void { blocksStore.setState((prev) => ({ ...INITIAL, blockedByMeRev: (prev.blockedByMeRev ?? 0) + 1 })); }`. Every snapshot taken before the reset then fails the equality at line 58, so no pre-logout reply can be applied, while a post-reset ready-time snapshot still matches.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0367",
|
||||
"title": "Create Role prefills a position that is already taken, so every role created after the first is refused",
|
||||
"file": "Server/admin/static/index.html",
|
||||
"line": 1310,
|
||||
"severity": "low",
|
||||
"why": "The create modal always prefills position = myPosition()-1 and saveRole always sends the field, so the server's auto-placement path (which walks down to the highest free slot) is unreachable. CreateRole rejects an explicitly requested position that is occupied, and the slot directly below the actor is occupied by the previous role the admin created.",
|
||||
"repro": "Sign in as Owner (position 100). Roles → Create Role → name \"Helper\" → Save: created at position 99. Roles → Create Role again → name \"Greeter\": the Position field is prefilled 99 again, Save returns 400 \"position 99 is already used by another role\". Every subsequent create fails on its own default until the admin manually types a free number; the server's free-slot fallback never runs because the panel never omits position.",
|
||||
"evidence": "index.html:1310 `const position=role?role.position:Math.max(0,myPosition()-1);`\nindex.html:1360 `position:parseInt(document.getElementById('rolePos').value,10)||0,`\nServer/service/role.go (CreateRole): `if in.Position != nil { ... if taken[position] { return nil, fmt.Errorf(\"%w: position %d is already used by another role\", ErrBadRequest, position) } } else { for position > 0 && taken[position] { position-- } ... }`",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "hotspot-server-admin-static",
|
||||
"suggestedFix": "Mirror the server's walk-down in the prefill at index.html:1310: `let position;if(role)position=role.position;else{const taken={};(state.roleList||[]).forEach(r=>{taken[r.position]=true});position=Math.max(0,myPosition()-1);while(position>0&&taken[position])position--;}` (equivalently, omit `position` from the create body so role.go's auto-placement branch runs).",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0368",
|
||||
"title": "UserProfilePopup's hand-rolled focus trap lets Tab/Shift+Tab walk out of the aria-modal dialog",
|
||||
"file": "Client/src/components/UserProfilePopup.ts",
|
||||
"line": 378,
|
||||
"severity": "low",
|
||||
"why": "The popup re-implements the focus trap instead of using @lib/a11y's trapFocus, and its copy is missing the two guards that helper documents as required: it never treats `document.activeElement === popup` (the dialog container, which is exactly what mount() focuses at line 341) as an edge of the cycle, and it returns without `e.preventDefault()` when the dialog holds no focusable control (line 373). Either case falls through to the browser's native tab order, so focus leaves a dialog that declares `aria-modal=\"true\"` and lands on controls hidden behind a full-screen `.upp-overlay` (position:fixed; inset:0; z-index:200).",
|
||||
"repro": "Case A (any member): click a row in the member list; the popup mounts and focus sits on the popup container. Press Shift+Tab as the first keystroke. activeElement is the popup, which is neither `first` nor `last`, so no branch fires, nothing is prevented, and the browser moves focus backwards to the last tabbable element of the page *behind* the overlay (composer, sidebar buttons) — visually covered and unclickable, with no way to tab back in. Case B (own row): clicking your own member row builds a popup with zero buttons (MemberList.ts:257-260 omits onMessage when isSelf, and never passes onCall), so `focusable.length === 0` and plain Tab escapes the dialog the same way.",
|
||||
"evidence": "line 341: popup.focus(); // activeElement is now the popup container itself\nline 373: if (focusable.length === 0) return; // no preventDefault -> native Tab escapes\nline 378: if (e.shiftKey && document.activeElement === first) {\nline 381: } else if (!e.shiftKey && document.activeElement === last) {\n// neither branch matches while activeElement === popup\n// contrast Client/src/lib/a11y.ts:74-88, which preventDefaults the empty case and\n// wraps when `active === container`.",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "explore-3",
|
||||
"suggestedFix": "Delete the hand-rolled listener at UserProfilePopup.ts:363-384 and call the shared helper instead: `import { trapFocus } from \"@lib/a11y\";` then `trapFocus(popup, signal);` — it preventDefaults the empty-dialog case and treats `active === container` as an edge that wraps.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0369",
|
||||
"title": "Any self role/profile update wipes the custom-status text the user is typing in the user-bar status picker, and silently discards it",
|
||||
"file": "Client/src/components/UserBar.ts",
|
||||
"line": 285,
|
||||
"severity": "low",
|
||||
"why": "The `authStore` → `s.user` subscriber unconditionally pushes the server's stored custom status back into the picker's input with no \"is the user editing it\" guard. `StatusPicker.setCustomStatus` both overwrites `customInputEl.value` and resets `lastCommittedCustom`, so the in-progress text is not just visually replaced — the later blur/Enter commit compares against the freshly-reset watermark, short-circuits, and never sends what was typed.",
|
||||
"repro": "Open the user bar's status dropdown and type \"on vacation\" into the custom-status input; do not press Enter yet. Have an admin change your role — dispatcher.ts:892 runs `updateUser({ role: payload.role })` on a self MEMBER_UPDATE, which replaces `authStore.user` with a new object (auth.store.ts:127-130). The selector `(s) => s.user` fires, `statusPicker.setCustomStatus(serverCustomStatus() ?? \"\")` runs, the input now reads the old server value and `lastCommittedCustom` equals it. Click away: `commit()` in StatusPicker.ts:178-183 computes `text === lastCommittedCustom` and returns early, so \"on vacation\" is never saved or sent. The identical clobber happens on a self USER_UPDATE (dispatcher.ts:947 `setAuth({...currentUser, ...})`).",
|
||||
"evidence": " disposable.onStoreChange(\n authStore,\n (s) => s.user,\n () => {\n updateFromState();\n statusPicker?.setCustomStatus(serverCustomStatus() ?? \"\"); // no focus/dirty guard\n },\n );\n\n// StatusPicker.ts\n function setCustomStatus(text: string): void {\n lastCommittedCustom = text;\n if (customInputEl !== null) customInputEl.value = text;\n }\n const commit = (): void => {\n const text = input.value.trim().slice(0, MAX_CUSTOM_STATUS_LEN);\n if (text === lastCommittedCustom) return; // <-- typed text now silently dropped",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "hotspot-client-src-components",
|
||||
"suggestedFix": "Guard the push in the UserBar subscriber (one place, not per caller): skip the overwrite when the user is mid-edit, e.g. expose the input's focus state (or accept a `force` flag) and in UserBar.ts:283-286 call `statusPicker?.setCustomStatus(...)` only when the custom-status input is not `document.activeElement`. Equivalently, inside `StatusPicker.setCustomStatus` return early if `customInputEl !== null && document.activeElement === customInputEl`, leaving `lastCommittedCustom` untouched so the pending commit still fires on blur.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0370",
|
||||
"title": "Arrow-key navigation in the mention/emoji autocomplete never scrolls the active row into view, so rows past the 8th are highlighted off-screen",
|
||||
"file": "Client/src/components/inline-autocomplete.ts",
|
||||
"line": 167,
|
||||
"severity": "low",
|
||||
"why": "handleKeydown moves `activeIndex` and calls render(), which only repaints the `ma-item--active` class and re-aims `aria-activedescendant`. Nothing ever calls `scrollIntoView` on the active row, but the list is a fixed-height scroller (`.mention-autocomplete .ma-list { max-height: 240px; overflow-y: auto; }`, app.css:2838) while the widget renders up to 10 rows of ~29-30px each (~290-300px). aria-activedescendant does not scroll the container, so the highlighted row can sit entirely outside the visible box and Enter inserts something the user cannot see.",
|
||||
"repro": "In a server with ≥10 members whose names share a prefix, type `@user` in the composer. filterMentionSuggestions returns MAX_MENTION_SUGGESTIONS = 10 rows; `.ma-list` clips at 240px so only ~8 are visible. Press ArrowDown nine times: activeIndex reaches 8 then 9, render() marks those rows `ma-item--active`, but the list scrollTop is still 0, so no row appears highlighted anywhere on screen. Pressing Enter inserts the 10th suggestion the user never saw. The same happens immediately on a single ArrowUp from index 0, which wraps to index 9 (line 172) — the popup looks like nothing is selected. Identical for the emoji popup (10 rows at 30px each with `.ea-preview` 18px + 12px padding).",
|
||||
"evidence": " case \"ArrowDown\":\n e.preventDefault();\n activeIndex = (activeIndex + 1) % suggestions.length;\n render(); // render() has no scrollIntoView / scrollTop write\n return true;\n case \"ArrowUp\":\n activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;\n/* app.css:2838 */ .mention-autocomplete .ma-list { max-height: 240px; overflow-y: auto; }",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "explore-3",
|
||||
"suggestedFix": "One change in the shared widget: at the end of `render()` in Client/src/components/inline-autocomplete.ts, after the aria-activedescendant block, add `if (suggestions.length > 0) (list.children[activeIndex] as HTMLElement | undefined)?.scrollIntoView({ block: \"nearest\" });`. That covers both the mention and emoji popups and both arrow directions.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0371",
|
||||
"title": "`btn-modal-cancel` is defined in no stylesheet — every modal's Cancel button (and the NSFW gate's \"Go Back\") renders as bare unstyled text beside a styled Save pill",
|
||||
"file": "Client/src/components/CreateChannelModal.ts",
|
||||
"line": 134,
|
||||
"severity": "low",
|
||||
"why": "Four components give their secondary action the class `btn-modal-cancel`, but no CSS file in the repo defines `.btn-modal-cancel`. The styled sibling that clearly belongs to it, `.btn-cancel` (login.css:989 — padding, muted colour, hover underline), has zero users. With base.css:34's global `button { border:none; background:none; color:inherit; }` reset the Cancel control ends up with no padding, no background, no border and no hover state, flush against the `.btn-modal-save` accent pill in the same `.modal-footer` flex row.",
|
||||
"repro": "Open the channel context menu → \"Create Channel\". The footer holds `<button class=\"btn-modal-cancel\">Cancel</button>` next to `<button class=\"btn-modal-save\">Create Channel</button>`. `grep -rn 'btn-modal-cancel' --include=*.css .` returns nothing, so only base.css's `button` reset applies: Cancel paints as inherit-coloured text with zero padding (a click target the height of one text line), while Save is a padded accent-filled button. Same on EditChannelModal.ts:332, DeleteChannelModal.ts:70 and NsfwGate.ts:74 (\"Go Back\" next to the \"Continue\" save button). The intended rule exists but is spelled `.btn-cancel`, which nothing uses.",
|
||||
"evidence": "CreateChannelModal.ts:132-136\n const cancelBtn = createElement(\n \"button\",\n { class: \"btn-modal-cancel\", type: \"button\" },\n \"Cancel\",\n );\nlogin.css:989 .btn-cancel { padding: 8px 16px; ... } /* zero users */\nlogin.css:999 .btn-modal-save { padding: 8px 20px; background: var(--accent); ... }",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "explore-3",
|
||||
"suggestedFix": "Rename the orphaned selector: change `.btn-cancel` / `.btn-cancel:hover` at Client/src/styles/login.css:987 and :995 to `.btn-modal-cancel` / `.btn-modal-cancel:hover`. One CSS edit fixes all four call sites; do not touch the components.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0372",
|
||||
"title": "Quick-switch server overlay has no max-height and no scroller, so with enough saved profiles rows and the \"Add new server\" button are clipped off-screen and unreachable",
|
||||
"file": "Client/src/components/QuickSwitchOverlay.ts",
|
||||
"line": 52,
|
||||
"severity": "low",
|
||||
"why": "The overlay's modal is `.quick-switch-modal`, which sets `overflow: hidden` with no `max-height`, and `.quick-switch-list` sets neither. Its parent `.quick-switch-backdrop` is `position: fixed; inset: 0; display: flex; align-items: center`, so once the row list makes the modal taller than the viewport the box overflows the fixed backdrop symmetrically above and below the fold. Nothing scrolls — a fixed-position backdrop creates no scrollable overflow and the modal clips its own children — so those rows can never be reached. Every other modal in the app gets this right (`.modal { max-height: 80vh; overflow-y: auto; }`, login.css:945).",
|
||||
"repro": "Save 14 server profiles, then click the disconnect/switch button in the UserBar. createQuickSwitchOverlay renders one `.quick-switch-item` per profile plus the `add-new` row; each is 36px icon + 10px×2 padding + 1px×2 border = 58px, on top of a ~70px header and ~40px footer. On a 800px-tall window the modal is ~940px and, being flex-centred in a viewport-sized fixed backdrop, extends ~70px above y=0 and ~70px below the bottom. The first profile row and the \"Add new server\" row are outside the viewport, the modal's `overflow:hidden` shows no scrollbar, and the page does not scroll — so the user cannot add a server or switch to the clipped profiles without deleting profiles first.",
|
||||
"evidence": "QuickSwitchOverlay.ts:52 const modal = createElement(\"div\", { class: \"quick-switch-modal\" });\napp.css:5253 .quick-switch-backdrop { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }\napp.css:5262 .quick-switch-modal { width: 380px; max-width: 90vw; overflow: hidden; } /* no max-height */\napp.css:5285 .quick-switch-list { padding: 0 12px 8px; } /* no overflow-y */\nlogin.css:945 .modal { width: 440px; max-height: 80vh; overflow-y: auto; } /* every other modal */",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "explore-3",
|
||||
"suggestedFix": "In Client/src/styles/app.css, give `.quick-switch-modal` (line 5262) `max-height: 85vh; display: flex; flex-direction: column;` and give `.quick-switch-list` (line 5285) `overflow-y: auto;` (plus `min-height: 0`), so the row list scrolls while the header and footer stay pinned. No TS change needed.",
|
||||
"confidence": "medium",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0373",
|
||||
"title": "Audit action filter cannot be cleared once its option leaves the page: dropdown reads \"All Actions\" while the filter is still applied",
|
||||
"file": "Server/admin/static/index.html",
|
||||
"line": 1417,
|
||||
"severity": "low",
|
||||
"why": "The <select> options are rebuilt from the actions present on the *currently fetched page*, but state.auditActionFilter is global and survives paging. When the filtered action does not occur on the new page, no option carries `selected`, so the browser falls back to the first option (\"All Actions\") while the filter is still in force — and because the element's DOM value is already \"all\", picking \"All Actions\" fires no change event, so the filter can no longer be cleared from the control.",
|
||||
"repro": "1. Audit Log, page 1 (50 rows) contains at least one `channel_delete`. Select \"channel_delete\" in the dropdown.\n2. Click \">\" to page 2, whose 50 rows contain no `channel_delete`.\n3. renderAudit emits only the \"All Actions\" option (no `selected` anywhere), so the dropdown displays \"All Actions\" — but state.auditActionFilter is still 'channel_delete', so `filtered` is empty and the table reads \"No matching entries\" beside 50 fetched rows.\n4. Open the dropdown and choose \"All Actions\": the element's value is already \"all\", so no `change` event fires and state.auditActionFilter is never reset. The page stays blank until the admin selects some other action first and then re-selects \"All Actions\".",
|
||||
"evidence": "Server/admin/static/index.html:1400\n const actionTypes=[...new Set(state.auditCache.map(e=>e.action).filter(Boolean))].sort(); // current page only\nServer/admin/static/index.html:1415-1418\n html+='<select class=\"filter-select\" onchange=\"state.auditActionFilter=this.value;refilterAudit()\">';\n html+='<option value=\"all\" '+(state.auditActionFilter==='all'?'selected':'')+'>All Actions</option>';\n actionTypes.forEach(t=>{html+='<option value=\"'+esc(t)+'\" '+(state.auditActionFilter===t?'selected':'')+'>'+esc(t)+'</option>'});\nServer/admin/static/index.html:1403-1405 — the row filter still reads the stale state:\n if(state.auditActionFilter!=='all'&&e.action!==state.auditActionFilter)return false;\nstate.auditActionFilter is declared once at line 315 and is never reset by paging (`state.auditPage++;renderContent()` at line 1432).",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "hotspot-server-admin-static",
|
||||
"suggestedFix": "Make the option set always contain the active filter so the control and the state cannot diverge, in the one place that builds it (index.html:1400): `const actionTypes=[...new Set(state.auditCache.map(e=>e.action).filter(Boolean).concat(state.auditActionFilter!=='all'?[state.auditActionFilter]:[]))].sort();`. The stale filter then still renders as a `selected` option, the dropdown tells the truth, and choosing \"All Actions\" is a real value change that fires `change`.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0374",
|
||||
"title": "ReorderRoles compacts positions to a gapless N..1 block, permanently breaking role creation for every non-owner role manager",
|
||||
"file": "Server/service/role.go",
|
||||
"line": 459,
|
||||
"severity": "low",
|
||||
"why": "ReorderRoles normalizes the manageable roles to positions N, N-1, … 1 with no gaps and never 0, while CreateRole's default placement (role.go:249-254) only accepts a strictly-positive unoccupied slot below the actor. After a single reorder there is no such slot for any actor below the owner, so role creation fails permanently — and the error it returns tells the admin to \"reorder existing roles first\", which re-compacts to the same dense block and cannot help.",
|
||||
"repro": "Default install: Owner 100, Admin 80, Moderator 60, Member 40. (1) As the Owner, click one reorder arrow on the admin Roles page (Server/admin/static/index.html:1291 moveRole → PATCH /roles/reorder). ReorderRoles writes Admin=3, Moderator=2, Member=1; Owner stays at 100. (2) Sign in as the Admin (now position 3, holds MANAGE_ROLES) and POST /roles with no `position` field. taken={100,3,2,1}; the loop at role.go:249 steps 2 → 1 → 0 and the call returns ErrBadRequest \"no free position below your rank — reorder existing roles first\". (3) Reorder as the Admin: its one manageable role goes back to position 1, so step 2 fails identically — forever. The Moderator at position 2 is in the same state. Through the panel it is worse: the Create Role modal prefills Math.max(0, myPosition()-1) (index.html:1310), which post-compaction is always an occupied slot, so the explicit-position path returns \"position N is already used by another role\" and the omitted-position path returns the message above; UpdateRole's position change (role.go:321-331) is dead for the same reason.",
|
||||
"evidence": "role.go:457-460\n\tpositions := make(map[int64]int, len(orderedIDs))\n\tfor i, id := range orderedIDs {\n\t\tpositions[id] = len(orderedIDs) - i\n\t}\n\nrole.go:248-254\n\t} else {\n\t\tfor position > 0 && taken[position] {\n\t\t\tposition--\n\t\t}\n\t\tif position <= 0 {\n\t\t\treturn nil, fmt.Errorf(\"%w: no free position below your rank — reorder existing roles first\", ErrBadRequest)\n\t\t}\n\nServer/service/role_test.go:505 TestReorderRoles_NormalizesPositions already locks the compaction (Admin→4, Helper→3, Moderator→2, Member→1); no test covers CreateRole after a reorder.",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "explore-3",
|
||||
"suggestedFix": "Preserve gaps in the single shared normalizer instead of guarding each caller: in ReorderRoles (Server/service/role.go:457-460) space the slots under the actor, e.g. stride := actor.Position / (len(orderedIDs) + 1) (>=1) and positions[id] = (len(orderedIDs) - i) * stride. That keeps every position unique, strictly below actor.Position, and in the same order, while leaving free slots for CreateRole's default placement. TestReorderRoles_NormalizesPositions must be updated to assert ordering/uniqueness/below-actor rather than the literal 4,3,2,1 values.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0375",
|
||||
"title": "The server's own voice_state echo strips \"(You)\" off your local self-view tile in the video grid",
|
||||
"file": "Client/src/pages/MainPage.ts",
|
||||
"line": 815,
|
||||
"severity": "low",
|
||||
"why": "The mid-call relabel loop added for OC-0227 iterates the whole voice roster, which includes the current user, and calls `videoGrid.setLabel(uid, remoteTileLabel(uid, false))`. The local self-view tile is registered under `tileId === currentUserId` (VideoModeController.ts:184) with the label `` `${myName} (You)` ``, and `remoteTileLabel` never appends \"(You)\". So the loop overwrites the self tile's label with the bare display name the moment the server echoes back `voice_state` with `camera: true`. Two writers of one tile label disagree, and the last writer is the wrong one — your own tile becomes indistinguishable from a remote participant's.",
|
||||
"repro": "1. Join a voice channel and turn your camera on. `setLocalCamera(true)` (screenShare.ts:228) bumps the voice-store signature; the subscriber runs with `voiceUsers[channel][self].camera` still false (no server echo yet), so the relabel loop skips self and `checkVideoMode()` adds the self tile labelled \"Alice (You)\". 2. The client sends `voice_camera`; the server broadcasts `voice_state` for you with `camera: true`. 3. `updateVoiceState` writes `camera: true` onto the self entry in `voiceUsers`, the subscriber re-runs, and the loop now takes the `if (u.camera)` branch for `uid === currentUserId`: `prevTileLabels.get(self)` is undefined ≠ \"Alice\", so it calls `videoGrid.setLabel(self, \"Alice\")`. 4. Your own self-view tile now reads plain \"Alice\" for the rest of the call, with nothing marking it as yours. (Same path turns the local screenshare tile's \"Your Screen\" fallback into \"User <id> (Screen)\" when no member record supplies a display name.)",
|
||||
"evidence": "// MainPage.ts:808-821 — no `uid === currentUserId` skip\nfor (const [uid, u] of users) {\n ...\n if (u.camera) {\n const label = remoteTileLabel(uid, false); // returns `name`, never `name (You)`\n if (prevTileLabels.get(uid) !== label) {\n prevTileLabels.set(uid, label);\n videoGrid?.setLabel(uid, label); // uid === currentUserId hits the SELF tile\n }\n }\n\n// VideoModeController.ts:180-190 — the self tile is keyed by currentUserId\nif (voice.localCamera) {\n if (!localTileAdded) {\n const localStream = getLocalCameraStream();\n if (localStream !== null) {\n videoGrid.addStream(currentUserId, myName ? `${myName} (You)` : \"You\", localStream, { isSelf: true, ... });\n localTileAdded = true;\n\n// voice.store.ts:231-241 — updateVoiceState puts SELF into voiceUsers with camera from the payload\nnextUsers.set(payload.user_id, { userId: payload.user_id, username: payload.username, ..., camera: payload.camera, ... });",
|
||||
"status": "open",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "general-2026-08-29",
|
||||
"lens": "explore-3",
|
||||
"suggestedFix": "In the loop at Client/src/pages/MainPage.ts:808-829, leave the signature accumulation alone but skip the local user for relabeling: hoist `const selfId = getCurrentUserId();` and wrap both setLabel blocks in `if (uid !== selfId) { ... }` — VideoModeController is the sole writer of the self tiles' \"(You)\" / \"Your Screen\" labels.",
|
||||
"confidence": "high",
|
||||
"finder": "opus"
|
||||
},
|
||||
{
|
||||
"id": "OC-0376",
|
||||
"title": "Register commits the account and burns the invite, then answers 500 when the session insert fails",
|
||||
"file": "Server/api/auth_handler.go",
|
||||
"line": 201,
|
||||
"severity": "low",
|
||||
"why": "handleRegister hashes the password first so a hashing failure cannot burn an invite (the comment at line 153 states that intent), and CreateUserWithInvite consumes the invite and creates the user in one transaction. The session insert that follows is outside that transaction: when CreateSession fails the handler returns 500 \"failed to create session\", but the user row and the invite use are already committed. The caller sees a failed registration; retrying gets 400 \"invalid invite or credentials\" (username taken, invite exhausted) while a login with the same password succeeds. Same shape on the verify-totp path (OC-0378).",
|
||||
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_RegisterPolicyAndFailurePaths/session_insert_fails' ./api/ — the row installs a BEFORE INSERT ON sessions trigger, registers with a valid invite, and pins today's outcome: 500, user row present, invite use_count 1.",
|
||||
"evidence": "Server/api/auth_handler.go:166 uid, err := database.CreateUserWithInvite(...) // commits user + invite use\nServer/api/auth_handler.go:201 if _, err := database.CreateSession(...); err != nil { // 500 after the commit\nServer/api/auth_handler.go:153 // Hash password before consuming the invite so that a hashing failure\n // does not burn a valid invite code.",
|
||||
"suggestedFix": "Either answer 201 without a token when the session insert fails after the account commit (the account exists; the client logs in), or move the session insert into the CreateUserWithInvite transaction so registration is atomic. Belongs to the AuthService in B3-2/B3-9, not to the handler.",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "b3-1-auth-characterization-2026-08-29",
|
||||
"lens": "characterization",
|
||||
"confidence": "high",
|
||||
"finder": "claude",
|
||||
"fixed": "2026-08-30",
|
||||
"fix": {
|
||||
"commit": "85d86dc7",
|
||||
"test": "Server/api/auth_characterization_test.go, Server/db/coverage_boost_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "OC-0377",
|
||||
"title": "verify-totp maps a database error while loading the challenged user to 401, indistinguishable from an expired challenge",
|
||||
"file": "Server/api/totp_handler.go",
|
||||
"line": 141,
|
||||
"severity": "low",
|
||||
"why": "totpChallengeSecret folds `err != nil` from GetUserByID into the same 401 \"invalid or expired two-factor challenge\" that a missing user or missing secret gets. A transient database fault during the second factor therefore reads as a bad challenge: the client drops the partial token and asks the user to log in again, and the attempt has already been recorded against the per-user totp_fail cap by the limiter.Allow call above it. Every sibling path in the slice maps a non-sentinel database error to 5xx (login 500, AuthMiddleware 503) precisely so an outage is not mistaken for a credential failure.",
|
||||
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_VerifyTOTPFailurePaths/user_lookup_fails' ./api/ — the row renames the users table after the challenge is issued and pins today's 401.",
|
||||
"evidence": "Server/api/totp_handler.go:140 user, err := database.GetUserByID(r.Context(), challengeUserID)\nServer/api/totp_handler.go:141 if err != nil || user == nil || user.TOTPSecret == nil {\nServer/api/totp_handler.go:142 writeJSON(w, http.StatusUnauthorized, errorResponse{ ... \"invalid or expired two-factor challenge\" })\nServer/api/auth_handler.go:488-497 the login sibling: a non-nil error is a genuine DB failure -> 500 \"login temporarily unavailable\"",
|
||||
"suggestedFix": "Split the condition: `err != nil` -> 500 INTERNAL_ERROR (\"two-factor verification temporarily unavailable\") without RegisterFailure and with the limiter reservation undone or not made; keep 401 for `user == nil || user.TOTPSecret == nil`. Fix in B3-9 after B3-2 lands, and flip the characterization row with it.",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "b3-1-auth-characterization-2026-08-29",
|
||||
"lens": "characterization",
|
||||
"confidence": "high",
|
||||
"finder": "claude",
|
||||
"fixed": "2026-08-30",
|
||||
"fix": {
|
||||
"commit": "f7015809",
|
||||
"test": "Server/api/auth_characterization_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "OC-0378",
|
||||
"title": "verify-totp consumes the partial challenge before the session insert, so a store failure forces the user back to the password step",
|
||||
"file": "Server/api/totp_handler.go",
|
||||
"line": 107,
|
||||
"severity": "low",
|
||||
"why": "handleVerifyTOTP calls partialStore.Consume before issueSession. When CreateSession fails the handler answers 500 \"failed to create session\", but the challenge is already gone (and the code is marked used by VerifyTOTPCodeOnce), so the only way forward is a fresh POST /login with the password. A verified second factor is discarded because of a persistence hiccup that has nothing to do with the credential. Same shape as OC-0376 on the register path.",
|
||||
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_VerifyTOTPFailurePaths/session_insert_fails' ./api/ — the row installs a BEFORE INSERT ON sessions trigger, verifies a valid code, pins the 500, drops the trigger and pins that the same partial token is now refused with 401.",
|
||||
"evidence": "Server/api/totp_handler.go:107 if _, ok := partialStore.Consume(partialToken); !ok { // challenge gone here\nServer/api/totp_handler.go:115 token, err := issueSession(r.Context(), database, user.ID, challenge.Device, challenge.IP) // fails after it",
|
||||
"suggestedFix": "Keep the claim atomic and first: Consume the challenge before issuing the session (as today), then on CreateSession failure re-issue or restore the challenge for the same user/device/IP so the verified second factor is not discarded. The restore must also keep the accepted verification usable: VerifyTOTPCodeOnce has already recorded (user, code) in UsedTOTPCodeStore for 90 s, so an immediate retry with the authenticator's still-current code would be refused as a replay - either carry the verified state on the restored challenge (retry issues the session without a new code) or roll back that MarkUsed claim together with the challenge. Do NOT issue the session before Consume: two concurrent requests holding the same partial token can pass Lookup with different valid codes from the +/-1 step window (the used-code store keys on (user, code), not the token), both would create sessions, and the losing Consume would leave an unreturned bearer session in the database; if the order must change, the loser has to revoke the session it created. Belongs to the AuthService in B3-2/B3-9.",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-29",
|
||||
"hunt": "b3-1-auth-characterization-2026-08-29",
|
||||
"lens": "characterization",
|
||||
"confidence": "high",
|
||||
"finder": "claude",
|
||||
"fixed": "2026-08-30",
|
||||
"fix": {
|
||||
"commit": "be37d7ee",
|
||||
"test": "Server/api/auth_characterization_test.go, Server/auth/totp_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "OC-0379",
|
||||
"title": "ownerOnlyMiddleware still re-read the role adminAuthMiddleware had already resolved — OC-0345's fix kept the redundant query and the 503 branch that existed only to serve it",
|
||||
"file": "Server/admin/middleware.go",
|
||||
"line": 130,
|
||||
"severity": "low",
|
||||
"why": "OC-0345's title named two defects: the redundant GetRoleByID and the transient-fault-as-403 collapse. Its fix (PR #1454) repaired only the error mapping — deliberately, per its own suggestedFix, to avoid touching the two tests that injected only adminUserKey — so the owner gate still issued a second role read on every owner-only request and carried a private 503 path whose only job was that read's failures. The register's closure evidence for OC-0345 ('Reuse the authenticated context…') was half-met, and the function's doc comment claimed to avoid the redundant query it performed.",
|
||||
"repro": "Verified at dev 7abdd941 by the 2026-08-31 post-merge audit: middleware.go:130 ran database.GetRoleByID inside ownerOnlyMiddleware while adminAuthMiddleware had stored the same principal's *db.Role under adminRoleKey (:90) and requirePerm (:104) already consumed it query-free. The nine owner-only routes in Server/admin/api.go paid the extra read; a roles-table fault on it produced the gate's own 503 although the perimeter had just proven the database healthy on the same request.",
|
||||
"evidence": "RED first: TestOwnerOnlyMiddleware_NoSecondRoleLookup (role in context, roles table renamed away) failed 503 against the old middleware — the second lookup, observed. GREEN after: the gate consumes adminRoleKey (missing role fails closed as 401, exactly requirePerm's contract; position below Owner stays 403), the query and its 503 branch are deleted, and the signature drops *db.DB at all nine call sites, so reintroducing a lookup is a compile-visible change. Revert-proof: restoring the old middleware body fails the build at api.go:150.",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-31",
|
||||
"hunt": "postmerge-audit-2026-08-31",
|
||||
"lens": "audit",
|
||||
"confidence": "high",
|
||||
"finder": "claude",
|
||||
"fixed": "2026-08-31",
|
||||
"fix": {
|
||||
"commit": "bcdc0ef3",
|
||||
"test": "Server/admin/middleware_and_spawn_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,46 +39,6 @@ ownership, dependency automation — gets **at most a short block at the end**,
|
||||
and only when it changes something a contributor or fork holder must do
|
||||
(a moved directory, a renamed module, a new required command).
|
||||
|
||||
## Unreleased
|
||||
|
||||
User-visible: one change to how updates roll out, and two new documents.
|
||||
Not user-visible: the protocol now carries a version number.
|
||||
|
||||
### Login & connection
|
||||
|
||||
- The client and server now agree on a protocol version ("epoch") when
|
||||
connecting. This release is epoch 1; clients from v1.2.0-alpha.4 and earlier
|
||||
still connect.
|
||||
- A client too old for its server is told "update the client" on the connect
|
||||
screen, with the usual Update Now button — instead of failing in confusing
|
||||
ways. The saved login is kept, so the updated client signs back in by
|
||||
itself.
|
||||
- **Upgrade the server before the clients.** The server only offers client
|
||||
releases that speak its own protocol epoch, so a protocol-changing release
|
||||
reaches clients once the server runs it. Releases that do not change the
|
||||
protocol are offered as before.
|
||||
|
||||
### Admin panel
|
||||
|
||||
- Creating or revoking an invite, and installing or uninstalling a plugin, now
|
||||
show up in the audit log. Invite entries name the invite by id, never by
|
||||
code.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `docs/trust-model.md` answers "who can read my messages?": the server
|
||||
operator can read text and files; voice, video and screen share are
|
||||
end-to-end encrypted; what beta does not claim. Every claim cites the code
|
||||
or test behind it.
|
||||
- `docs/architecture/plugins.md`: plugins are experimental, off by default,
|
||||
compiled out of release binaries, and carry no API promise.
|
||||
|
||||
### Repository
|
||||
|
||||
- `protocol/schema.json` declares `protocol_epoch`; `npm run generate` emits it
|
||||
as `ws.ProtocolEpoch` and `PROTOCOL_EPOCH`. Rules for bumping it:
|
||||
`docs/protocol.md`, Compatibility.
|
||||
|
||||
## v1.2.0-alpha.4
|
||||
|
||||
**62 bug fixes**, all user-visible, plus repository work that changes nothing an
|
||||
|
||||
@@ -11,12 +11,11 @@ and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
|
||||
|
||||
CI fails on drift, and the next generator run silently discards your edit.
|
||||
|
||||
| Generated | Source of truth | Workflow |
|
||||
| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
|
||||
| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `protocol/schema.json` | `protocol-change` skill |
|
||||
| `gendocs:*` blocks in `docs/api.md`, `docs/schema.md`, `docs/server-configuration.md` | `Server/api/router.go`, `Server/migrations/`, `Server/config/config.go` | `cd Server && go run -tags otel,wazero ./cmd/gendocs` |
|
||||
| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
|
||||
| Generated | Source of truth | Workflow |
|
||||
| ---------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
|
||||
| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `protocol/schema.json` | `protocol-change` skill |
|
||||
| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
|
||||
|
||||
## Bug-hunt ledger
|
||||
|
||||
|
||||
@@ -15,11 +15,6 @@ export default tseslint.config(
|
||||
rules: {
|
||||
// --- Key rules from T-191 ---
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
// A switch over a union that misses a member is a silent drop, not a type error.
|
||||
"@typescript-eslint/switch-exhaustiveness-check": [
|
||||
"error",
|
||||
{ considerDefaultExhaustiveForUnions: true },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
Generated
+146
-170
@@ -31,13 +31,13 @@
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitest/browser-playwright": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint": "^10.9.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"knip": "^6.32.2",
|
||||
"oxlint": "^1.80.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
@@ -2107,9 +2107,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz",
|
||||
"integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.79.0.tgz",
|
||||
"integrity": "sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2124,9 +2124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz",
|
||||
"integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.79.0.tgz",
|
||||
"integrity": "sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2141,9 +2141,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz",
|
||||
"integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.79.0.tgz",
|
||||
"integrity": "sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2158,9 +2158,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-x64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz",
|
||||
"integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.79.0.tgz",
|
||||
"integrity": "sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2175,9 +2175,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz",
|
||||
"integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.79.0.tgz",
|
||||
"integrity": "sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2192,9 +2192,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz",
|
||||
"integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.79.0.tgz",
|
||||
"integrity": "sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2209,9 +2209,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz",
|
||||
"integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.79.0.tgz",
|
||||
"integrity": "sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2226,16 +2226,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.79.0.tgz",
|
||||
"integrity": "sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2246,16 +2243,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz",
|
||||
"integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.79.0.tgz",
|
||||
"integrity": "sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2266,16 +2260,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.79.0.tgz",
|
||||
"integrity": "sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2286,16 +2277,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.79.0.tgz",
|
||||
"integrity": "sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2306,16 +2294,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz",
|
||||
"integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.79.0.tgz",
|
||||
"integrity": "sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2326,16 +2311,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.79.0.tgz",
|
||||
"integrity": "sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2346,16 +2328,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.79.0.tgz",
|
||||
"integrity": "sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2366,16 +2345,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz",
|
||||
"integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.79.0.tgz",
|
||||
"integrity": "sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2386,9 +2362,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz",
|
||||
"integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.79.0.tgz",
|
||||
"integrity": "sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2403,9 +2379,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz",
|
||||
"integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.79.0.tgz",
|
||||
"integrity": "sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2420,9 +2396,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz",
|
||||
"integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.79.0.tgz",
|
||||
"integrity": "sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2437,9 +2413,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz",
|
||||
"integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.79.0.tgz",
|
||||
"integrity": "sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3319,17 +3295,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
|
||||
"integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
|
||||
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.68.0",
|
||||
"@typescript-eslint/type-utils": "8.68.0",
|
||||
"@typescript-eslint/utils": "8.68.0",
|
||||
"@typescript-eslint/visitor-keys": "8.68.0",
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/type-utils": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -3342,7 +3318,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.68.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -3358,16 +3334,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz",
|
||||
"integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
|
||||
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.68.0",
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/typescript-estree": "8.68.0",
|
||||
"@typescript-eslint/visitor-keys": "8.68.0",
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3383,14 +3359,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
|
||||
"integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
|
||||
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.68.0",
|
||||
"@typescript-eslint/types": "^8.68.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.67.0",
|
||||
"@typescript-eslint/types": "^8.67.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3405,14 +3381,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
|
||||
"integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
|
||||
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/visitor-keys": "8.68.0"
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3423,9 +3399,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
|
||||
"integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
|
||||
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3440,15 +3416,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
|
||||
"integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
|
||||
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/typescript-estree": "8.68.0",
|
||||
"@typescript-eslint/utils": "8.68.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -3465,9 +3441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
|
||||
"integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
|
||||
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3479,16 +3455,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
|
||||
"integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
|
||||
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.68.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.68.0",
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/visitor-keys": "8.68.0",
|
||||
"@typescript-eslint/project-service": "8.67.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/visitor-keys": "8.67.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -3507,16 +3483,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
|
||||
"integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
|
||||
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.68.0",
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/typescript-estree": "8.68.0"
|
||||
"@typescript-eslint/scope-manager": "8.67.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3531,13 +3507,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
|
||||
"integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
|
||||
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/types": "8.67.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4199,9 +4175,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.9.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz",
|
||||
"integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==",
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
|
||||
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -5892,9 +5868,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz",
|
||||
"integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==",
|
||||
"version": "1.79.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.79.0.tgz",
|
||||
"integrity": "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -5907,25 +5883,25 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint/binding-android-arm-eabi": "1.80.0",
|
||||
"@oxlint/binding-android-arm64": "1.80.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.80.0",
|
||||
"@oxlint/binding-darwin-x64": "1.80.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.80.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.80.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.80.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.80.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.80.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.80.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.80.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.80.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.80.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.80.0"
|
||||
"@oxlint/binding-android-arm-eabi": "1.79.0",
|
||||
"@oxlint/binding-android-arm64": "1.79.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.79.0",
|
||||
"@oxlint/binding-darwin-x64": "1.79.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.79.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.79.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.79.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.79.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.79.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.79.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.79.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.79.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.79.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.79.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.79.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.79.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.79.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.79.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.79.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint-tsgolint": ">=7.0.2001",
|
||||
@@ -6750,16 +6726,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz",
|
||||
"integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==",
|
||||
"version": "8.67.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz",
|
||||
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.68.0",
|
||||
"@typescript-eslint/parser": "8.68.0",
|
||||
"@typescript-eslint/typescript-estree": "8.68.0",
|
||||
"@typescript-eslint/utils": "8.68.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"@typescript-eslint/typescript-estree": "8.67.0",
|
||||
"@typescript-eslint/utils": "8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
||||
+3
-3
@@ -45,13 +45,13 @@
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitest/browser-playwright": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint": "^10.9.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"knip": "^6.32.2",
|
||||
"oxlint": "^1.80.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { WsClient } from "./ws";
|
||||
import { toConnectionStatus, setActiveChannelProvider } from "./ws";
|
||||
import { authStore, setAuth, clearAuth, updateUser } from "@stores/auth.store";
|
||||
import { setTransientError, setConnectionStatus, setUpdateRequiredHost } from "@stores/ui.store";
|
||||
import { setTransientError, setConnectionStatus } from "@stores/ui.store";
|
||||
import {
|
||||
setChannels,
|
||||
setRoles,
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
addChannel,
|
||||
updateChannel,
|
||||
removeChannel,
|
||||
noteChannelMessage,
|
||||
incrementUnread,
|
||||
incrementMention,
|
||||
} from "@stores/channels.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
updateVoiceUserProfile,
|
||||
removeVoiceUser,
|
||||
setVoiceConfig,
|
||||
setSpeakers,
|
||||
joinVoiceChannel,
|
||||
leaveVoiceChannel,
|
||||
} from "@stores/voice.store";
|
||||
@@ -74,14 +76,13 @@ import type { DmChannelPayload } from "./types";
|
||||
import { isTextLikeChannel } from "./types";
|
||||
import type { ApiClient } from "./api";
|
||||
import { invalidateReactionUsers } from "@components/message-list/reaction-tooltip";
|
||||
import { parseTimestamp } from "@components/message-list/formatting";
|
||||
import { notifyIncomingMessage } from "./notifications";
|
||||
import { mentionsCurrentUser } from "./mentions";
|
||||
import { ensureIdentityKeyPublished } from "@lib/identity";
|
||||
import { markChannelRead } from "./read-state";
|
||||
import { createLogger } from "./logger";
|
||||
import { showToast } from "./toast";
|
||||
import { ServerMessageType as S, PROTOCOL_EPOCH } from "./protocolTypes";
|
||||
import { ServerMessageType as S } from "./protocolTypes";
|
||||
// SidebarDmHelpers is page-level, but addDmToChannelsStore is the only
|
||||
// place the DM->channelsStore mirror row is synthesized (selectDmConversation
|
||||
// on open); the dm_channel_close fallback below needs the same synthesis for
|
||||
@@ -292,15 +293,7 @@ export function wireDispatcher(
|
||||
ws.on(S.AUTH_ERROR, (payload) => {
|
||||
log.error("Auth failed", { message: payload.message });
|
||||
setTransientError(payload.message);
|
||||
const epochRefusal = payload.code === "protocol_epoch_unsupported";
|
||||
// The server speaks a newer protocol than this build: hand the host to
|
||||
// the connect page so it can offer the client update right there.
|
||||
if (epochRefusal && (payload.server_epoch ?? 0) > PROTOCOL_EPOCH) {
|
||||
setUpdateRequiredHost(api?.getConfig?.().host ?? null);
|
||||
}
|
||||
// A protocol refusal is not a bad token: say so, so main.ts keeps the
|
||||
// stored credential for the relaunch after the update.
|
||||
clearAuth(epochRefusal ? "protocol_epoch" : "user");
|
||||
clearAuth();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -509,8 +502,8 @@ export function wireDispatcher(
|
||||
// offline keeps a phantom row here (closeDmLocally fixes this exact
|
||||
// shape for the live dm_channel_close path; this is its ready-time
|
||||
// equivalent), and a DM read elsewhere keeps a stale unread/mention
|
||||
// count (noteChannelMessage bumps the mirror in parallel with dmStore
|
||||
// once it exists, but only dmStore is restated above).
|
||||
// count (incrementUnread/incrementMention bump the mirror in parallel
|
||||
// with dmStore once it exists, but only dmStore is restated above).
|
||||
// Reconcile every dm-typed row against the just-restated payload.
|
||||
channelsStore.setState((prev) => {
|
||||
const dmById = new Map(dmPayloads.map((d) => [d.channel_id, d]));
|
||||
@@ -683,7 +676,7 @@ export function wireDispatcher(
|
||||
// ones — the burst is exactly the messages missed while away (a
|
||||
// full-ready resume sends no burst at all; ready's unread_count values
|
||||
// are authoritative there). DM channel IDs are not in channelsStore
|
||||
// (they use dmStore), so noteChannelMessage is a no-op for DMs, but the
|
||||
// (they use dmStore), so incrementUnread is a no-op for DMs, but the
|
||||
// own-message guard is applied here for defence-in-depth.
|
||||
//
|
||||
// isReplayFrame is computed here (rather than only below, where the
|
||||
@@ -692,7 +685,7 @@ export function wireDispatcher(
|
||||
const isReplayFrame =
|
||||
lastReconnectHandshakeAt !== null &&
|
||||
Date.now() - lastReconnectHandshakeAt < REPLAY_GATE_WINDOW_MS &&
|
||||
parseTimestamp(payload.timestamp).getTime() < lastReconnectHandshakeAt - serverClockSkewMs;
|
||||
Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;
|
||||
// highlightsCurrentUser (mentions.ts) treats @everyone and @here as one
|
||||
// bit, because the wire carries only one: mentions_everyone. But the
|
||||
// server's applyMentionCounts (mentions.go) narrows an @here fan-out to
|
||||
@@ -710,15 +703,15 @@ export function wireDispatcher(
|
||||
const isDetached = isWindowDetached(payload.channel_id);
|
||||
|
||||
if ((payload.channel_id !== activeId || isDetached) && !isOwnMessage) {
|
||||
// noteChannelMessage skips the active channel by default —
|
||||
// evenIfActive (isDetached here) is a no-op for a genuinely
|
||||
// non-active channel, since its internal guard only fires when
|
||||
// channelId IS the active one. It also guards both counters behind
|
||||
// payload.id vs. the channel's lastMessageId watermark (OC-0328), so
|
||||
// a message already reflected in a `ready` snapshot (delivered
|
||||
// between the server's registerNow and buildReady, then redelivered
|
||||
// as a queued chat_message) does not double-count.
|
||||
noteChannelMessage(payload.channel_id, payload.id, isMention, isDetached);
|
||||
// incrementUnread/incrementMention skip the active channel by
|
||||
// default — evenIfActive (isDetached here) is a no-op for a
|
||||
// genuinely non-active channel, since their internal guard only
|
||||
// fires when channelId IS the active one.
|
||||
incrementUnread(payload.channel_id, isDetached);
|
||||
// A mention is an unread too — the mention badge just outranks it.
|
||||
if (isMention) {
|
||||
incrementMention(payload.channel_id, isDetached);
|
||||
}
|
||||
}
|
||||
|
||||
// Update DM store last message if this message belongs to a DM channel.
|
||||
@@ -737,7 +730,7 @@ export function wireDispatcher(
|
||||
);
|
||||
} else {
|
||||
// The DM badge reads dmStore's mentionCount (mute-immune, rendered
|
||||
// by DmSidebar) — noteChannelMessage above no-ops for DM ids, which
|
||||
// by DmSidebar) — incrementMention above no-ops for DM ids, which
|
||||
// are absent from channelsStore. isMention is passed through so the
|
||||
// mention bump sits behind updateDmLastMessage's own message-id
|
||||
// guard (OC-0242) — a separate unconditional increment here would
|
||||
@@ -769,7 +762,7 @@ export function wireDispatcher(
|
||||
notifyIncomingMessage(payload);
|
||||
// Refresh the skew estimate from this accepted-as-live frame so it
|
||||
// stays current for the next reconnect.
|
||||
serverClockSkewMs = Date.now() - parseTimestamp(payload.timestamp).getTime();
|
||||
serverClockSkewMs = Date.now() - Date.parse(payload.timestamp);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -878,6 +871,13 @@ export function wireDispatcher(
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.MEMBER_LEAVE, (payload) => {
|
||||
log.info("Member left", { userId: payload.user_id });
|
||||
removeMember(payload.user_id);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.MEMBER_BAN, (payload) => {
|
||||
log.info("Member banned", { userId: payload.user_id });
|
||||
@@ -1068,19 +1068,13 @@ export function wireDispatcher(
|
||||
// late-arriving voice_leave for a channel we already left (and rejoined
|
||||
// elsewhere) must not kill a newer join. Read the store before
|
||||
// leaveVoiceChannel() below clears currentChannelId.
|
||||
const sameChannel = voiceStore.getState().currentChannelId === payload.channel_id;
|
||||
const shouldTeardownSession = isSelf && sameChannel;
|
||||
const shouldTeardownSession =
|
||||
isSelf && voiceStore.getState().currentChannelId === payload.channel_id;
|
||||
// Notify E2EE state machine so key holder can rotate the room key, and
|
||||
// (when applicable) tear down the media session — both through one lazy
|
||||
// import so the two effects cannot land in different ticks.
|
||||
// OC-0311: voice_leave is broadcast to the whole channelReadAudience,
|
||||
// i.e. everyone with READ_MESSAGES on THAT channel — not just its
|
||||
// voice participants. Scope the E2EE notification to this client's own
|
||||
// voice channel so a peer leaving a channel we merely read (and never
|
||||
// shared a call with) cannot delete their key, clear their
|
||||
// verification, or trigger a room-key rotation in our live session.
|
||||
void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {
|
||||
if (sameChannel) void handleParticipantLeft(payload.user_id);
|
||||
void handleParticipantLeft(payload.user_id);
|
||||
if (shouldTeardownSession) void leaveVoice(false);
|
||||
});
|
||||
// Clear local voice state only for the same channel-match case as the
|
||||
@@ -1104,6 +1098,12 @@ export function wireDispatcher(
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.VOICE_SPEAKERS, (payload) => {
|
||||
setSpeakers(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.VOICE_TOKEN, (payload) => {
|
||||
void livekitSession().then(({ handleVoiceToken }) =>
|
||||
|
||||
@@ -29,8 +29,6 @@ export function isValidHost(host: string): boolean {
|
||||
// one colon means the whole string is the address — a single colon is
|
||||
// reserved for the host:port separator below.
|
||||
if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;
|
||||
// DNS name or IPv4 literal, optionally with a port. Explicit ASCII class
|
||||
// (not `\w`, which wrongly includes '_') to match the Rust proxies'
|
||||
// charset exactly.
|
||||
return /^[A-Za-z0-9.-]+(:\d+)?$/.test(host);
|
||||
// DNS name or IPv4 literal, optionally with a port.
|
||||
return /^[\w.-]+(:\d+)?$/.test(host);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
// Usage: import { MessageType } from "@lib/protocolTypes";
|
||||
// ws.send({ type: MessageType.CHAT_SEND, payload: { ... } });
|
||||
|
||||
// The wire epoch this client speaks; sent in the auth frame and checked by
|
||||
// the server. See docs/protocol.md, Compatibility.
|
||||
export const PROTOCOL_EPOCH = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server → Client message types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -33,10 +29,12 @@ export const ServerMessageType = {
|
||||
VOICE_STATE: "voice_state",
|
||||
VOICE_CONFIG: "voice_config",
|
||||
VOICE_TOKEN: "voice_token",
|
||||
VOICE_SPEAKERS: "voice_speakers",
|
||||
VOICE_LEAVE: "voice_leave", // broadcast (same string as client msg)
|
||||
VOICE_MOVED: "voice_moved",
|
||||
VOICE_DISCONNECTED: "voice_disconnected",
|
||||
MEMBER_JOIN: "member_join",
|
||||
MEMBER_LEAVE: "member_leave",
|
||||
MEMBER_UPDATE: "member_update",
|
||||
USER_UPDATE: "user_update",
|
||||
MEMBER_BAN: "member_ban",
|
||||
|
||||
+7
-14
@@ -282,15 +282,6 @@ export interface AuthOkPayload {
|
||||
|
||||
export interface AuthErrorPayload {
|
||||
readonly message: string;
|
||||
/**
|
||||
* Set only when the server refused this client's protocol epoch
|
||||
* (`"protocol_epoch_unsupported"`); the epochs say which side is older.
|
||||
* Absent on every other refusal.
|
||||
*/
|
||||
readonly code?: "protocol_epoch_unsupported";
|
||||
readonly client_epoch?: number;
|
||||
readonly server_epoch?: number;
|
||||
readonly min_epoch?: number;
|
||||
}
|
||||
|
||||
export interface ReadyPayload {
|
||||
@@ -473,9 +464,7 @@ export interface VoiceConfigPayload {
|
||||
readonly max_users: number;
|
||||
}
|
||||
|
||||
/** Argument shape for `voice.store.setSpeakers` — fed by LiveKit's
|
||||
* ActiveSpeakersChanged, not by a wire message.
|
||||
* CRITICAL: uses threshold_mode, NOT mode. */
|
||||
/** CRITICAL: uses threshold_mode, NOT mode. */
|
||||
export interface VoiceSpeakersPayload {
|
||||
readonly channel_id: number;
|
||||
readonly speakers: readonly number[];
|
||||
@@ -519,6 +508,10 @@ export interface MemberJoinPayload {
|
||||
readonly status?: UserStatus;
|
||||
}
|
||||
|
||||
export interface MemberLeavePayload {
|
||||
readonly user_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full role list after any role mutation. The server sends the whole list
|
||||
* rather than a delta, so the store is replaced wholesale — a dropped
|
||||
@@ -640,8 +633,6 @@ export interface ErrorPayload {
|
||||
export interface AuthPayload {
|
||||
readonly token: string;
|
||||
readonly last_seq?: number;
|
||||
/** The wire epoch this client speaks — always `PROTOCOL_EPOCH`. */
|
||||
readonly epoch: number;
|
||||
/**
|
||||
* The channel this client had open when it disconnected, sent only on a
|
||||
* resume (`last_seq > 0`).
|
||||
@@ -774,12 +765,14 @@ export type ServerMessage =
|
||||
| (WsEnvelope<VoiceStatePayload> & { readonly type: "voice_state" })
|
||||
| (WsEnvelope<VoiceLeavePayload> & { readonly type: "voice_leave" })
|
||||
| (WsEnvelope<VoiceConfigPayload> & { readonly type: "voice_config" })
|
||||
| (WsEnvelope<VoiceSpeakersPayload> & { readonly type: "voice_speakers" })
|
||||
| (WsEnvelope<VoiceTokenPayload> & { readonly type: "voice_token" })
|
||||
| (WsEnvelope<VoiceMovedPayload> & { readonly type: "voice_moved" })
|
||||
| (WsEnvelope<VoiceDisconnectedPayload> & { readonly type: "voice_disconnected" })
|
||||
| (WsEnvelope<VoiceE2EEAnnouncePayload> & { readonly type: "voice_e2ee_announce" })
|
||||
| (WsEnvelope<VoiceE2EEOfferPayload> & { readonly type: "voice_e2ee_offer" })
|
||||
| (WsEnvelope<MemberJoinPayload> & { readonly type: "member_join" })
|
||||
| (WsEnvelope<MemberLeavePayload> & { readonly type: "member_leave" })
|
||||
| (WsEnvelope<MemberUpdatePayload> & { readonly type: "member_update" })
|
||||
| (WsEnvelope<UserUpdatePayload> & { readonly type: "user_update" })
|
||||
| (WsEnvelope<MemberBanPayload> & { readonly type: "member_ban" })
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import type { ServerMessage, ClientMessage } from "./types";
|
||||
import { createLogger } from "./logger";
|
||||
import { PROTOCOL_EPOCH } from "./protocolTypes";
|
||||
|
||||
const log = createLogger("ws");
|
||||
|
||||
@@ -449,7 +448,6 @@ export function createWsClient() {
|
||||
payload: {
|
||||
token: config.token,
|
||||
last_seq: lastSeq,
|
||||
epoch: PROTOCOL_EPOCH,
|
||||
...(activeChannelId !== null ? { active_channel_id: activeChannelId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
+7
-37
@@ -12,7 +12,7 @@ import { createApiClient } from "@lib/api";
|
||||
import { createWsClient, normalizeHostForCertCompare } from "@lib/ws";
|
||||
import { wireDispatcher, wireConnectionStatus } from "@lib/dispatcher";
|
||||
import { authStore, clearAuth } from "@stores/auth.store";
|
||||
import { setTransientError, uiStore, setUpdateRequiredHost } from "@stores/ui.store";
|
||||
import { setTransientError } from "@stores/ui.store";
|
||||
import { voiceStore, leaveVoiceChannel } from "@stores/voice.store";
|
||||
import { createConnectPage } from "@pages/ConnectPage";
|
||||
import { applyStoredAppearance } from "@lib/appearance";
|
||||
@@ -20,8 +20,6 @@ import { restoreTheme } from "@lib/themes";
|
||||
import { initPtt } from "@lib/ptt";
|
||||
import { createNavigationGuard } from "@lib/navigation-guard";
|
||||
import { createConnectedOverlay } from "@components/ConnectedOverlay";
|
||||
import { createUpdateNotifier } from "@components/UpdateNotifier";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { ConnectedOverlayControl } from "@components/ConnectedOverlay";
|
||||
import { createLogger, applyStoredLogLevel } from "@lib/logger";
|
||||
import { initLogPersistence, flushLogs } from "@lib/logPersistence";
|
||||
@@ -628,27 +626,6 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
|
||||
|
||||
safeMount(connectPage, appEl!);
|
||||
|
||||
// A server refused this client's protocol epoch as too old: offer the
|
||||
// update on the connect page itself. The main page's notifier never
|
||||
// mounts on a refusal, so without this the user would have to fetch the
|
||||
// installer by hand. Subscribed, not read once: on a first login or a
|
||||
// startup auto-login this page is already mounted when the refusal
|
||||
// arrives and nothing re-renders it (no overlay exists before auth_ok, so
|
||||
// the isAuthenticated subscriber below does not navigate).
|
||||
let updateNotifier: MountableComponent | null = null;
|
||||
const offerUpdate = (host: string | null): void => {
|
||||
if (!host) return;
|
||||
setUpdateRequiredHost(null);
|
||||
// A later refusal (another server tried from this same page) replaces
|
||||
// the banner rather than being ignored.
|
||||
updateNotifier?.destroy?.();
|
||||
const notifier = createUpdateNotifier({ serverUrl: `https://${host}` });
|
||||
notifier.mount(appEl!);
|
||||
updateNotifier = notifier;
|
||||
};
|
||||
const unsubUpdateRequired = uiStore.subscribeSelector((s) => s.updateRequiredHost, offerUpdate);
|
||||
offerUpdate(uiStore.getState().updateRequiredHost);
|
||||
|
||||
// Periodic health check — re-run every 15s so offline servers update when they come back
|
||||
const healthCheckInterval = setInterval(() => {
|
||||
runHealthChecks(connectPage, getProfileList());
|
||||
@@ -658,8 +635,6 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
|
||||
currentPage = {
|
||||
destroy() {
|
||||
clearInterval(healthCheckInterval);
|
||||
unsubUpdateRequired();
|
||||
updateNotifier?.destroy?.();
|
||||
connectPage.destroy?.();
|
||||
},
|
||||
};
|
||||
@@ -830,17 +805,12 @@ authStore.subscribeSelector(
|
||||
// kicked us by shutting down: the token is still valid, and deleting
|
||||
// the credential would break auto-login every time the server restarts.
|
||||
const host = api.getConfig().host;
|
||||
const reason = authStore.getState().logoutReason;
|
||||
if (host && reason !== "server_shutdown") {
|
||||
// A protocol-epoch refusal keeps the credential too: the token is
|
||||
// still valid, and the update the connect page offers relaunches
|
||||
// straight into auto-login with it (sessionStorage — and so the
|
||||
// skip flag below — does not survive that relaunch).
|
||||
if (reason !== "protocol_epoch") void deleteCredential(host);
|
||||
// Whenever this session must not turn around and auto-login with the
|
||||
// credential (removed, or just refused), say so. A server_shutdown
|
||||
// keeps the credential precisely so auto-login still works on
|
||||
// restart, so it deliberately does not set this.
|
||||
if (host && authStore.getState().logoutReason !== "server_shutdown") {
|
||||
void deleteCredential(host);
|
||||
// Same condition on purpose: whenever the credential is being removed,
|
||||
// the connect page must not turn around and auto-login with it. A
|
||||
// server_shutdown keeps the credential precisely so auto-login still
|
||||
// works on restart, so it deliberately does not set this.
|
||||
sessionStorage.setItem("owncord:skip-auto-login", "1");
|
||||
}
|
||||
router.navigate("connect");
|
||||
|
||||
@@ -21,12 +21,7 @@ const log = createLogger("auth.store");
|
||||
* server-initiated kick whose token is still valid — the logout wiring keeps
|
||||
* the saved credential in that case so auto-login works when the server
|
||||
* comes back. */
|
||||
/**
|
||||
* Why the session ended. "protocol_epoch": the server refused this client's
|
||||
* wire epoch — the token is still valid, so main.ts keeps the stored
|
||||
* credential and the update it offers relaunches into auto-login.
|
||||
*/
|
||||
export type LogoutReason = "user" | "server_shutdown" | "protocol_epoch";
|
||||
export type LogoutReason = "user" | "server_shutdown";
|
||||
|
||||
export interface AuthState {
|
||||
readonly token: string | null;
|
||||
|
||||
@@ -388,51 +388,6 @@ export function incrementMention(channelId: number, evenIfActive = false): void
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an incoming channel message: bumps unread (and mention, when
|
||||
* `isMention`) unless `messageId` is already reflected in the channel's
|
||||
* watermark — mirrors dm.store's `updateDmLastMessage` (OC-0242).
|
||||
*
|
||||
* OC-0328: a message delivered between the server's registerNow and
|
||||
* buildReady is both counted in `ready`'s snapshot (unread_count/
|
||||
* last_message_id already advanced) AND redelivered as a queued
|
||||
* chat_message once the socket drains. Both counters must sit behind the
|
||||
* SAME watermark read in one setState — splitting the guard across
|
||||
* incrementUnread/incrementMention can't work, since the first call would
|
||||
* already have advanced lastMessageId before the second one checked it.
|
||||
*
|
||||
* `evenIfActive` mirrors incrementUnread's escape hatch — see its doc.
|
||||
*/
|
||||
export function noteChannelMessage(
|
||||
channelId: number,
|
||||
messageId: number,
|
||||
isMention: boolean,
|
||||
evenIfActive = false,
|
||||
): void {
|
||||
channelsStore.setState((prev) => {
|
||||
if (prev.activeChannelId === channelId && !evenIfActive) {
|
||||
return prev;
|
||||
}
|
||||
const existing = prev.channels.get(channelId);
|
||||
if (existing === undefined) {
|
||||
return prev;
|
||||
}
|
||||
const isReplay = existing.lastMessageId !== null && messageId <= existing.lastMessageId;
|
||||
if (isReplay) {
|
||||
return prev;
|
||||
}
|
||||
const updated: Channel = {
|
||||
...existing,
|
||||
unreadCount: existing.unreadCount + 1,
|
||||
mentionCount: isMention ? existing.mentionCount + 1 : existing.mentionCount,
|
||||
lastMessageId: messageId,
|
||||
};
|
||||
const next = new Map(prev.channels);
|
||||
next.set(channelId, updated);
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the unread and mention counts for a channel — they clear together. */
|
||||
export function clearUnread(channelId: number): void {
|
||||
channelsStore.setState((prev) => {
|
||||
|
||||
@@ -146,7 +146,6 @@ export function updateDmLastMessage(
|
||||
if (updated === undefined) return prev;
|
||||
const rest = prev.channels.filter((c) => c.channelId !== channelId);
|
||||
const isReplay = updated.lastMessageId !== null && messageId <= updated.lastMessageId;
|
||||
if (isReplay) return prev;
|
||||
return {
|
||||
channels: [
|
||||
{
|
||||
@@ -154,8 +153,8 @@ export function updateDmLastMessage(
|
||||
lastMessageId: messageId,
|
||||
lastMessage: content,
|
||||
lastMessageAt: timestamp,
|
||||
unreadCount: updated.unreadCount + 1,
|
||||
mentionCount: isMention ? updated.mentionCount + 1 : updated.mentionCount,
|
||||
unreadCount: isReplay ? updated.unreadCount : updated.unreadCount + 1,
|
||||
mentionCount: isMention && !isReplay ? updated.mentionCount + 1 : updated.mentionCount,
|
||||
},
|
||||
...rest,
|
||||
],
|
||||
|
||||
@@ -105,7 +105,7 @@ export function addMember(payload: MemberJoinPayload): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a member from a member_ban event. */
|
||||
/** Remove a member from a member_leave event. */
|
||||
export function removeMember(userId: number): void {
|
||||
membersStore.setState((prev) => {
|
||||
const next = new Map(prev.members);
|
||||
|
||||
@@ -14,12 +14,6 @@ export interface UiState {
|
||||
readonly connectionStatus: "connected" | "reconnecting" | "disconnected";
|
||||
readonly transientError: string | null;
|
||||
readonly persistentError: string | null;
|
||||
/**
|
||||
* Host of a server that refused this client's protocol epoch as too old.
|
||||
* main.ts consumes it when the connect page mounts, to offer the update
|
||||
* there — the main page's own notifier never mounts on a refusal.
|
||||
*/
|
||||
readonly updateRequiredHost: string | null;
|
||||
readonly collapsedCategories: ReadonlySet<string>;
|
||||
readonly sidebarMode: "channels" | "dms";
|
||||
readonly activeDmUserId: number | null;
|
||||
@@ -34,7 +28,6 @@ const INITIAL_STATE: UiState = {
|
||||
connectionStatus: "disconnected",
|
||||
transientError: null,
|
||||
persistentError: null,
|
||||
updateRequiredHost: null,
|
||||
collapsedCategories: new Set(),
|
||||
sidebarMode: "channels",
|
||||
activeDmUserId: null,
|
||||
@@ -115,10 +108,6 @@ export function setTransientError(msg: string | null): void {
|
||||
}
|
||||
|
||||
/** Set a persistent error message that requires user action. */
|
||||
export function setUpdateRequiredHost(host: string | null): void {
|
||||
uiStore.setState((prev) => ({ ...prev, updateRequiredHost: host }));
|
||||
}
|
||||
|
||||
export function setPersistentError(msg: string | null): void {
|
||||
uiStore.setState((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -477,9 +477,9 @@ export function setVoiceConfig(payload: VoiceConfigPayload): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Update speaking state for users from LiveKit's ActiveSpeakersChanged.
|
||||
* Updates ALL users including local (LiveKit is the sole authority for
|
||||
* speaking detection). */
|
||||
/** Update speaking state for users from a voice_speakers event or
|
||||
* LiveKit's ActiveSpeakersChanged. Updates ALL users including local
|
||||
* (LiveKit is now the sole authority for speaking detection). */
|
||||
export function setSpeakers(payload: VoiceSpeakersPayload): void {
|
||||
voiceStore.setState((prev) => {
|
||||
const existingChannel = prev.voiceUsers.get(payload.channel_id);
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
// CONTRACT TEST. Pins the exact key set of the `auth` frame that
|
||||
// Client/src/lib/ws.ts sends as the first message after the WebSocket opens
|
||||
// (ws.ts:441-453) -- the client side of the same wire contract a sibling Go
|
||||
// test freezes for the server. B2-2 added the `epoch` field (the wire epoch
|
||||
// this client speaks, PROTOCOL_EPOCH from protocolTypes.ts); the key sets
|
||||
// below include it deliberately. Any further field MUST fail here until it is
|
||||
// added on purpose. Extend this file, do not replace or delete it.
|
||||
//
|
||||
// Assertions compare exact key sets (sorted Object.keys -- key order has no
|
||||
// wire meaning), never toHaveProperty, so an unexpected added key fails just
|
||||
// as loudly as a missing one.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// vi.mock is hoisted per file; the factories resolve to the shared handles
|
||||
// exported from ../unit/helpers/ws-mocks (see that module's doc comment --
|
||||
// it is shared across all ws-*.test.ts files, this one included).
|
||||
vi.mock("@tauri-apps/api/core", async () => ({
|
||||
invoke: (await import("../unit/helpers/ws-mocks")).mockInvoke,
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", async () => ({
|
||||
listen: (await import("../unit/helpers/ws-mocks")).mockListen,
|
||||
}));
|
||||
|
||||
import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "../unit/helpers/ws-mocks";
|
||||
import { createWsClient, setActiveChannelProvider } from "../../src/lib/ws";
|
||||
import { PROTOCOL_EPOCH } from "../../src/lib/protocolTypes";
|
||||
|
||||
/** Parses the most recently sent `auth` frame (envelope + payload) from ws_send. */
|
||||
function getAuthFrame(): { type: string; payload: Record<string, unknown> } {
|
||||
const authCall = mockInvoke.mock.calls.find(
|
||||
(c) =>
|
||||
c[0] === "ws_send" &&
|
||||
typeof c[1]?.message === "string" &&
|
||||
(c[1].message as string).includes('"type":"auth"'),
|
||||
);
|
||||
expect(authCall).toBeDefined();
|
||||
return JSON.parse((authCall![1] as { message: string }).message) as {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("contract: auth frame key set (epoch 1)", () => {
|
||||
let client: ReturnType<typeof createWsClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockInvoke.mockReset();
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
mockListen.mockClear();
|
||||
eventHandlers.clear();
|
||||
// activeChannelProvider is a module-level singleton (registered once at
|
||||
// app bootstrap in dispatcher.ts) -- reset it so state doesn't leak
|
||||
// across tests/files.
|
||||
setActiveChannelProvider(null);
|
||||
client = createWsClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.disconnect();
|
||||
setActiveChannelProvider(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fresh connect: envelope keys are exactly [type, payload, id], payload keys exactly [token, last_seq, epoch]", async () => {
|
||||
client.connect({ host: "localhost:8443", token: "t" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
const frame = getAuthFrame();
|
||||
// send() (ws.ts:631-637) wraps every outgoing message with a correlation
|
||||
// `id` via `{ ...msg, id }` -- that's a generic per-send addition, not
|
||||
// part of the auth-specific payload contract, but it IS part of what
|
||||
// actually goes over the wire, so the envelope pin has three keys, not
|
||||
// the two the auth message literal at ws.ts:446-453 has on its own.
|
||||
expect(Object.keys(frame).sort()).toEqual(["id", "payload", "type"]);
|
||||
expect(frame.type).toBe("auth");
|
||||
expect(Object.keys(frame.payload).sort()).toEqual(["epoch", "last_seq", "token"]);
|
||||
expect(frame.payload.token).toBe("t");
|
||||
expect(frame.payload.last_seq).toBe(0);
|
||||
expect(frame.payload.epoch).toBe(PROTOCOL_EPOCH);
|
||||
expect(PROTOCOL_EPOCH).toBe(1);
|
||||
});
|
||||
|
||||
it("resume with a registered active-channel provider: payload keys exactly [token, last_seq, active_channel_id, epoch]", async () => {
|
||||
client.connect({ host: "localhost:8443", token: "t" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
emitTauriEvent(
|
||||
"ws-message",
|
||||
JSON.stringify({
|
||||
type: "auth_ok",
|
||||
seq: 7,
|
||||
payload: {
|
||||
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
||||
server_name: "S",
|
||||
motd: "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
setActiveChannelProvider(() => 42);
|
||||
|
||||
emitTauriEvent("ws-state", "closed");
|
||||
mockInvoke.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
const frame = getAuthFrame();
|
||||
expect(Object.keys(frame.payload).sort()).toEqual([
|
||||
"active_channel_id",
|
||||
"epoch",
|
||||
"last_seq",
|
||||
"token",
|
||||
]);
|
||||
expect(frame.payload.last_seq).toBe(7);
|
||||
expect(frame.payload.active_channel_id).toBe(42);
|
||||
});
|
||||
|
||||
it("resume without a provider registered: payload keys stay exactly [token, last_seq, epoch]", async () => {
|
||||
client.connect({ host: "localhost:8443", token: "t" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
emitTauriEvent(
|
||||
"ws-message",
|
||||
JSON.stringify({
|
||||
type: "auth_ok",
|
||||
seq: 3,
|
||||
payload: {
|
||||
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
||||
server_name: "S",
|
||||
motd: "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// No setActiveChannelProvider call -- stays null from beforeEach reset.
|
||||
emitTauriEvent("ws-state", "closed");
|
||||
mockInvoke.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
const frame = getAuthFrame();
|
||||
expect(Object.keys(frame.payload).sort()).toEqual(["epoch", "last_seq", "token"]);
|
||||
expect(frame.payload.last_seq).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* These tests use the existing Tauri mock infrastructure to simulate:
|
||||
* - WebSocket voice_state and voice_leave events
|
||||
* - Voice channel UI (sidebar voice users, voice widget)
|
||||
* - Connection quality, listen-only mode
|
||||
* - Speaker indicators, connection quality, listen-only mode
|
||||
*
|
||||
* NOTE: These tests do NOT exercise real LiveKit/WebRTC connections.
|
||||
* Real voice E2E requires the native test infrastructure (Tauri exe + LiveKit binary).
|
||||
@@ -68,6 +68,42 @@ test.describe("Voice lifecycle", () => {
|
||||
// Should now have 1 user
|
||||
await expect(page.locator(".voice-user-item")).toHaveCount(1, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test("speaker indicator updates on voice_speakers event", async ({ page }) => {
|
||||
// Wait for voice users to render
|
||||
await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 });
|
||||
|
||||
// Emit speakers event — user 3 (in channel 10 per the ready payload) speaks
|
||||
await emitWsMessage(page, {
|
||||
type: "voice_speakers",
|
||||
payload: {
|
||||
channel_id: 10,
|
||||
speakers: [3],
|
||||
},
|
||||
});
|
||||
|
||||
// The speaking user's avatar should have the speaking class
|
||||
const speakingAvatar = page.locator(".voice-user-item.speaking");
|
||||
await expect(speakingAvatar).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("speaker indicator clears when user stops speaking", async ({ page }) => {
|
||||
await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 });
|
||||
|
||||
// User starts speaking
|
||||
await emitWsMessage(page, {
|
||||
type: "voice_speakers",
|
||||
payload: { channel_id: 10, speakers: [3] },
|
||||
});
|
||||
await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// User stops speaking (empty speakers list)
|
||||
await emitWsMessage(page, {
|
||||
type: "voice_speakers",
|
||||
payload: { channel_id: 10, speakers: [] },
|
||||
});
|
||||
await expect(page.locator(".voice-user-item.speaking")).toHaveCount(0, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Voice widget", () => {
|
||||
@@ -168,7 +204,19 @@ test.describe("Voice WS flow", () => {
|
||||
await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
// 3. Permission recovery button — grant mic button appears when
|
||||
// 3. Speaker indicator animation — voice_speakers event adds .speaking class.
|
||||
test("voice_speakers event adds speaking class to voice user", async ({ page }) => {
|
||||
await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 });
|
||||
|
||||
await emitWsMessage(page, {
|
||||
type: "voice_speakers",
|
||||
payload: { channel_id: 10, speakers: [3] },
|
||||
});
|
||||
|
||||
await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
// 4. Permission recovery button — grant mic button appears when
|
||||
// listenOnly is true (display toggled via voice store subscription).
|
||||
test("grant mic button appears in listen-only mode", async ({ page }) => {
|
||||
await joinVoiceChannelByName(page);
|
||||
@@ -185,7 +233,7 @@ test.describe("Voice WS flow", () => {
|
||||
await expect(grantMicBtn).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
// 4. Device hot-swap toast — simulate a toast notification for device change.
|
||||
// 5. Device hot-swap toast — simulate a toast notification for device change.
|
||||
test("device change shows toast notification", async ({ page }) => {
|
||||
// Toast container is mounted by MainPage — inject a toast element.
|
||||
await page.evaluate(() => {
|
||||
@@ -203,7 +251,7 @@ test.describe("Voice WS flow", () => {
|
||||
await expect(toast).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
// 5. Connection quality warning — stats pane auto-expands on quality degradation.
|
||||
// 6. Connection quality warning — stats pane auto-expands on quality degradation.
|
||||
test("quality degradation auto-expands stats pane", async ({ page }) => {
|
||||
await joinVoiceChannelByName(page);
|
||||
const widget = page.locator("[data-testid='voice-widget']");
|
||||
@@ -221,7 +269,7 @@ test.describe("Voice WS flow", () => {
|
||||
await expect(statsPane).toHaveClass(/visible/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
// 6. Mute/deafen toggle — buttons use aria-pressed and .active-ctrl class.
|
||||
// 7. Mute/deafen toggle — buttons use aria-pressed and .active-ctrl class.
|
||||
test("mute and deafen buttons toggle state", async ({ page }) => {
|
||||
await joinVoiceChannelByName(page);
|
||||
const widget = page.locator("[data-testid='voice-widget']");
|
||||
@@ -239,7 +287,7 @@ test.describe("Voice WS flow", () => {
|
||||
await expect(deafenBtn).toHaveClass(/active-ctrl/);
|
||||
});
|
||||
|
||||
// 7. Voice timer — joinedAt is set by joinVoiceChannel() on click.
|
||||
// 8. Voice timer — joinedAt is set by joinVoiceChannel() on click.
|
||||
test("voice timer shows elapsed time", async ({ page }) => {
|
||||
await joinVoiceChannelByName(page);
|
||||
const widget = page.locator("[data-testid='voice-widget']");
|
||||
@@ -249,7 +297,7 @@ test.describe("Voice WS flow", () => {
|
||||
await expect(timer).toHaveText(/\d{2}:\d{2}/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
// 8. Token refresh — emitting a new voice_token doesn't disconnect.
|
||||
// 9. Token refresh — emitting a new voice_token doesn't disconnect.
|
||||
test("token refresh does not disconnect session", async ({ page }) => {
|
||||
await joinVoiceChannelByName(page);
|
||||
const widget = page.locator("[data-testid='voice-widget']");
|
||||
|
||||
@@ -72,7 +72,6 @@ const UI_INITIAL: UiState = {
|
||||
connectionStatus: "disconnected",
|
||||
transientError: null,
|
||||
persistentError: null,
|
||||
updateRequiredHost: null,
|
||||
collapsedCategories: new Set(),
|
||||
sidebarMode: "channels",
|
||||
activeDmUserId: null,
|
||||
|
||||
@@ -530,10 +530,10 @@ describe("Store integration via dispatcher", () => {
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// 8. Voice config
|
||||
// 8. Voice config and speakers
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("voice config", () => {
|
||||
describe("voice config and speakers", () => {
|
||||
it("stores voice config from voice_config event", () => {
|
||||
ws.simulate("voice_config", {
|
||||
channel_id: 3,
|
||||
@@ -554,6 +554,65 @@ describe("Store integration via dispatcher", () => {
|
||||
expect(config!.top_speakers).toBe(3);
|
||||
expect(config!.max_users).toBe(25);
|
||||
});
|
||||
|
||||
it("updates speaking states from voice_speakers event", () => {
|
||||
// First seed voice users in channel 3
|
||||
ws.simulate("ready", {
|
||||
channels: [],
|
||||
members: [],
|
||||
voice_states: [
|
||||
{ channel_id: 3, user_id: 1, muted: false, deafened: false },
|
||||
{ channel_id: 3, user_id: 2, muted: false, deafened: false },
|
||||
{ channel_id: 3, user_id: 3, muted: false, deafened: false },
|
||||
],
|
||||
roles: [],
|
||||
});
|
||||
|
||||
// User 1 and 3 are speaking
|
||||
ws.simulate("voice_speakers", {
|
||||
channel_id: 3,
|
||||
speakers: [1, 3],
|
||||
threshold_mode: "selective",
|
||||
});
|
||||
|
||||
const channelUsers = voiceStore.getState().voiceUsers.get(3);
|
||||
expect(channelUsers).toBeDefined();
|
||||
expect(channelUsers!.get(1)?.speaking).toBe(true);
|
||||
expect(channelUsers!.get(2)?.speaking).toBe(false);
|
||||
expect(channelUsers!.get(3)?.speaking).toBe(true);
|
||||
});
|
||||
|
||||
it("clears speaking when user is no longer in speakers list", () => {
|
||||
// Seed voice users
|
||||
ws.simulate("ready", {
|
||||
channels: [],
|
||||
members: [],
|
||||
voice_states: [
|
||||
{ channel_id: 3, user_id: 1, muted: false, deafened: false },
|
||||
{ channel_id: 3, user_id: 2, muted: false, deafened: false },
|
||||
],
|
||||
roles: [],
|
||||
});
|
||||
|
||||
// User 1 speaking
|
||||
ws.simulate("voice_speakers", {
|
||||
channel_id: 3,
|
||||
speakers: [1],
|
||||
threshold_mode: "forwarding",
|
||||
});
|
||||
|
||||
expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(true);
|
||||
|
||||
// Now nobody speaking
|
||||
ws.simulate("voice_speakers", {
|
||||
channel_id: 3,
|
||||
speakers: [],
|
||||
threshold_mode: "forwarding",
|
||||
});
|
||||
|
||||
expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(false);
|
||||
expect(voiceStore.getState().voiceUsers.get(3)!.get(2)?.speaking).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -619,6 +678,16 @@ describe("Store integration via dispatcher", () => {
|
||||
expect(members.get(50)!.username).toBe("new-user");
|
||||
});
|
||||
|
||||
it("removes member on member_leave event", () => {
|
||||
ws.simulate("member_join", {
|
||||
user: { id: 51, username: "leaving-user", avatar: null, role: "member", status: "online" },
|
||||
});
|
||||
expect(membersStore.getState().members.has(51)).toBe(true);
|
||||
|
||||
ws.simulate("member_leave", { user_id: 51 });
|
||||
expect(membersStore.getState().members.has(51)).toBe(false);
|
||||
});
|
||||
|
||||
it("updates member role on member_update event", () => {
|
||||
ws.simulate("member_join", {
|
||||
user: { id: 52, username: "role-user", avatar: null, role: "member", status: "online" },
|
||||
|
||||
@@ -71,7 +71,6 @@ function resetStores(): void {
|
||||
connectionStatus: "connected" as const,
|
||||
transientError: null,
|
||||
persistentError: null,
|
||||
updateRequiredHost: null,
|
||||
collapsedCategories: new Set<string>(),
|
||||
sidebarMode: "channels" as const,
|
||||
activeDmUserId: null,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
UNCATEGORIZED_VOICE_CATEGORY,
|
||||
incrementUnread,
|
||||
incrementMention,
|
||||
noteChannelMessage,
|
||||
clearUnread,
|
||||
getUnreadOnOpen,
|
||||
resetChannelsStore,
|
||||
@@ -626,67 +625,6 @@ describe("channels store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// OC-0328: a message delivered between the server's registerNow and
|
||||
// buildReady is both counted in `ready` (unread_count/last_message_id
|
||||
// already advanced) and redelivered as a queued chat_message — mirrors
|
||||
// dm.store's updateDmLastMessage guard (OC-0242).
|
||||
describe("noteChannelMessage", () => {
|
||||
it("increments unread and advances the watermark for a new message", () => {
|
||||
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
|
||||
|
||||
noteChannelMessage(1, 101, false);
|
||||
|
||||
const ch = channelsStore.getState().channels.get(1);
|
||||
expect(ch?.unreadCount).toBe(2);
|
||||
expect(ch?.lastMessageId).toBe(101);
|
||||
});
|
||||
|
||||
it("also increments mention when isMention is set", () => {
|
||||
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
|
||||
|
||||
noteChannelMessage(1, 101, true);
|
||||
|
||||
expect(channelsStore.getState().channels.get(1)?.mentionCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does not bump either counter for a message id already reflected in lastMessageId", () => {
|
||||
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
|
||||
|
||||
noteChannelMessage(1, 100, true); // same id ready already counted
|
||||
|
||||
const ch = channelsStore.getState().channels.get(1);
|
||||
expect(ch?.unreadCount).toBe(1); // unchanged
|
||||
expect(ch?.mentionCount).toBe(0); // unchanged
|
||||
});
|
||||
|
||||
it("skips increment for the active channel", () => {
|
||||
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
|
||||
setActiveChannel(1);
|
||||
|
||||
noteChannelMessage(1, 101, false);
|
||||
|
||||
expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it("bumps the active channel when evenIfActive is set", () => {
|
||||
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
|
||||
setActiveChannel(1);
|
||||
|
||||
noteChannelMessage(1, 101, false, true);
|
||||
|
||||
expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it("is a no-op for an unknown channel id", () => {
|
||||
setChannels(readyChannels);
|
||||
const before = channelsStore.getState();
|
||||
|
||||
noteChannelMessage(999, 1, false);
|
||||
|
||||
expect(channelsStore.getState()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearUnread", () => {
|
||||
it("resets unread count to 0", () => {
|
||||
setChannels(readyChannels);
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
/**
|
||||
* Model-based test for the client connection stack (B3-6 item 4, Tier 3a of
|
||||
* docs/plans/bug-detection-improvements.md).
|
||||
*
|
||||
* Property tests find bad functions; every recurring bug in this codebase's
|
||||
* history is a bad *ordering* — reconnect transfer, superseded voice sessions,
|
||||
* duplicate-message reconciliation, resync corruption, the logout/auto-login
|
||||
* race. `fc.commands` generates those orderings and shrinks a long failure to
|
||||
* its minimal reproducer.
|
||||
*
|
||||
* System under test — the REAL modules, wired together the way main.ts wires
|
||||
* them: `createWsClient()` (src/lib/ws.ts) driving `wireDispatcher()`
|
||||
* (src/lib/dispatcher.ts) into the real stores. Only the boundaries are
|
||||
* mocked:
|
||||
* - the Tauri IPC surface (`invoke`/`listen`), via the shared ws-mocks
|
||||
* helper the ws-*.test.ts files already use — this is the wire, and
|
||||
* driving it is the only way to exercise ws.ts's own state machine;
|
||||
* - the LiveKit media layer, notifications, toasts and identity publishing,
|
||||
* exactly as dispatcher.test.ts mocks them. `handleParticipantLeft` keeps
|
||||
* its one store-visible effect (clearing the departed peer's E2EE
|
||||
* verification) so the verification invariant stays two-sided.
|
||||
* Nothing that the invariants describe is mocked: ws.ts's seq watermark,
|
||||
* messages.store's id reconciliation, voice.store's session and verification
|
||||
* state are all the production implementations.
|
||||
*
|
||||
* Reproducing a failure: fast-check prints the seed, the counterexample and a
|
||||
* `replayPath`. Re-run with `OWNCORD_MODEL_SEED=<seed>` to replay exactly.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import fc from "fast-check";
|
||||
|
||||
// vi.mock is hoisted per file; these factories resolve to the shared handles
|
||||
// exported from ./helpers/ws-mocks (see that module's doc comment).
|
||||
vi.mock("@tauri-apps/api/core", async () => ({
|
||||
invoke: (await import("./helpers/ws-mocks")).mockInvoke,
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/event", async () => ({
|
||||
listen: (await import("./helpers/ws-mocks")).mockListen,
|
||||
}));
|
||||
|
||||
vi.mock("@lib/notifications", () => ({
|
||||
notifyIncomingMessage: vi.fn(),
|
||||
cleanupNotificationAudio: vi.fn(),
|
||||
}));
|
||||
vi.mock("@lib/screenShare", () => ({
|
||||
rollbackPendingVideo: vi.fn(() => undefined),
|
||||
}));
|
||||
vi.mock("@lib/toast", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
vi.mock("@lib/identity", () => ({
|
||||
ensureIdentityKeyPublished: vi.fn(async () => true),
|
||||
}));
|
||||
// The media layer is out of scope for a connection model (it needs a real
|
||||
// LiveKit room and WebCrypto), but handleParticipantLeft's store-visible
|
||||
// effect is not: livekitE2EE.ts drops a departed peer's verification, and the
|
||||
// verification invariant below has to be able to observe that happening.
|
||||
vi.mock("@lib/livekitSession", async () => {
|
||||
const { clearPeerVerification, clearPeerVerifications } = await import("@stores/voice.store");
|
||||
return {
|
||||
handleVoiceToken: vi.fn(async () => {}),
|
||||
handleParticipantLeft: vi.fn(async (userId: number) => {
|
||||
clearPeerVerification(userId);
|
||||
}),
|
||||
handleE2EEAnnounce: vi.fn(async () => {}),
|
||||
handleE2EEOffer: vi.fn(async () => {}),
|
||||
leaveVoice: vi.fn(() => {
|
||||
clearPeerVerifications();
|
||||
}),
|
||||
cleanupAll: vi.fn(),
|
||||
isVoiceConnected: vi.fn(() => false),
|
||||
isVoiceSessionActive: vi.fn(() => false),
|
||||
setMuted: vi.fn(),
|
||||
setDeafened: vi.fn(),
|
||||
disableCamera: vi.fn(async () => {}),
|
||||
disableScreenshare: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks";
|
||||
import { createWsClient, type WsClient } from "../../src/lib/ws";
|
||||
import { wireDispatcher, type DispatcherCleanup } from "../../src/lib/dispatcher";
|
||||
import { clearAuth } from "../../src/stores/auth.store";
|
||||
import { getChannelMessages } from "../../src/stores/messages.store";
|
||||
import { voiceStore, setPeerVerification } from "../../src/stores/voice.store";
|
||||
import type {
|
||||
AuthOkPayload,
|
||||
ChatMessagePayload,
|
||||
ReadyPayload,
|
||||
ReadyVoiceState,
|
||||
VoiceStatePayload,
|
||||
} from "../../src/lib/types";
|
||||
|
||||
const HOST = "localhost:8443";
|
||||
const TOKEN = "test-token";
|
||||
const TEXT_CHANNEL = 1;
|
||||
/** The two voice channels a Supersede can move between. */
|
||||
const VOICE_CHANNELS = [10, 11] as const;
|
||||
const SELF_ID = 1;
|
||||
const PEER_IDS = [2, 3] as const;
|
||||
/** Small id pool so redelivery collisions happen by construction. */
|
||||
const MESSAGE_IDS = { min: 1, max: 6 } as const;
|
||||
|
||||
/**
|
||||
* Minimal reference implementation of the expected state — deliberately NOT a
|
||||
* second copy of the real logic: a handful of scalars the commands maintain by
|
||||
* hand, which is what makes a disagreement meaningful.
|
||||
*/
|
||||
interface Model {
|
||||
/** Socket open AND authenticated (auth_ok seen). */
|
||||
connected: boolean;
|
||||
/** The seq watermark ws.ts must declare in the next auth frame. */
|
||||
seq: number;
|
||||
/** Message ids the store must hold for TEXT_CHANNEL, in arrival order. */
|
||||
ids: number[];
|
||||
/** The voice channel the newest join owns, or null. */
|
||||
voiceChannel: number | null;
|
||||
/**
|
||||
* Peers in that voice channel whose E2EE identity is verified. In this model
|
||||
* a peer in our call is exactly a verified peer — the announce/TOFU crypto
|
||||
* itself belongs to livekitE2EE's own tests, not to a connection model.
|
||||
*/
|
||||
verifiedPeers: number[];
|
||||
}
|
||||
|
||||
interface Real {
|
||||
readonly client: WsClient;
|
||||
readonly cleanup: DispatcherCleanup;
|
||||
/** Every envelope handed to the Tauri `ws_send` command, newest last. */
|
||||
readonly sent: Array<{ type?: string; payload?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which invariant families the generated sequences actually reached. Asserted
|
||||
* at the end of the file: a family that stops being reachable (a `check()`
|
||||
* that can no longer fire, a payload the dispatcher starts ignoring) is a
|
||||
* silent hole, not a pass.
|
||||
*/
|
||||
const exercised = { ids: 0, seq: 0, verified: 0, staleTeardown: 0, resumeReplay: 0 };
|
||||
|
||||
/** Let the ws client's awaits and the dispatcher's lazy imports settle. */
|
||||
async function settle(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
}
|
||||
|
||||
function emit(type: string, payload: unknown, seq?: number): void {
|
||||
emitTauriEvent(
|
||||
"ws-message",
|
||||
JSON.stringify(seq === undefined ? { type, payload } : { type, payload, seq }),
|
||||
);
|
||||
}
|
||||
|
||||
function member(id: number, username: string) {
|
||||
return { id, username, avatar: null, role: "member", status: "online" as const };
|
||||
}
|
||||
|
||||
function authOkPayload(replaySource: "none" | "buffer"): AuthOkPayload {
|
||||
return {
|
||||
user: { id: SELF_ID, username: "me", avatar: null, role: "member" },
|
||||
server_name: "test",
|
||||
motd: "",
|
||||
replay_source: replaySource,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The message a resume replays: one that committed while we were away, or —
|
||||
* once the id pool is exhausted — a redelivery of one we already hold, which
|
||||
* is the other real shape a replay burst takes.
|
||||
*/
|
||||
function replayedMessageId(m: Model): number {
|
||||
for (let id = MESSAGE_IDS.min; id <= MESSAGE_IDS.max; id++) {
|
||||
if (!m.ids.includes(id)) return id;
|
||||
}
|
||||
return m.ids[m.ids.length - 1] as number;
|
||||
}
|
||||
|
||||
function chatPayload(id: number): ChatMessagePayload {
|
||||
return {
|
||||
id,
|
||||
channel_id: TEXT_CHANNEL,
|
||||
user: { id: PEER_IDS[0], username: "peer2", avatar: null },
|
||||
content: `message ${id}`,
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function voiceState(channelId: number, userId: number): VoiceStatePayload {
|
||||
return {
|
||||
channel_id: channelId,
|
||||
user_id: userId,
|
||||
username: userId === SELF_ID ? "me" : `peer${userId}`,
|
||||
muted: false,
|
||||
deafened: false,
|
||||
speaking: false,
|
||||
camera: false,
|
||||
screenshare: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The `ready` snapshot a server would build for this model state. */
|
||||
function readyPayload(m: Model, dropPeers: readonly number[] = []): ReadyPayload {
|
||||
const voiceChannel = m.voiceChannel;
|
||||
const roster: ReadyVoiceState[] =
|
||||
voiceChannel === null
|
||||
? []
|
||||
: [SELF_ID, ...m.verifiedPeers.filter((uid) => !dropPeers.includes(uid))].map((uid) => ({
|
||||
channel_id: voiceChannel,
|
||||
user_id: uid,
|
||||
muted: false,
|
||||
deafened: false,
|
||||
}));
|
||||
return {
|
||||
channels: [
|
||||
{
|
||||
id: TEXT_CHANNEL,
|
||||
name: "general",
|
||||
type: "text",
|
||||
category: null,
|
||||
position: 0,
|
||||
last_message_id: m.ids.length > 0 ? Math.max(...m.ids) : 0,
|
||||
},
|
||||
],
|
||||
members: [member(SELF_ID, "me"), ...PEER_IDS.map((id) => member(id, `peer${id}`))],
|
||||
voice_states: roster,
|
||||
roles: [],
|
||||
dm_channels: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The peers the real store currently reports as verified, sorted. */
|
||||
function realVerifiedPeers(): number[] {
|
||||
const verifications = voiceStore.getState().peerVerifications;
|
||||
return [...(verifications?.values() ?? [])]
|
||||
.filter((v) => v.status === "verified")
|
||||
.map((v) => v.userId)
|
||||
.toSorted((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* The four invariants from the design, checked after every command so
|
||||
* shrinking lands on the first step that broke one.
|
||||
*/
|
||||
function checkInvariants(m: Model, r: Real, afterSeeding = false): void {
|
||||
// 1. Message ids never duplicate, and the store holds exactly the ids the
|
||||
// connection delivered — a redelivery reconciles in place, it does not
|
||||
// add a row, and a resync does not lose one.
|
||||
const ids = getChannelMessages(TEXT_CHANNEL).map((row) => row.id);
|
||||
expect(new Set(ids).size, `duplicate message ids: ${ids.join(",")}`).toBe(ids.length);
|
||||
expect(ids).toEqual(m.ids);
|
||||
if (m.ids.length > 0) exercised.ids++;
|
||||
|
||||
// 2. Per-client seq is monotonic. Not checked here: the watermark is only
|
||||
// observable when ws.ts puts it on the wire, so the assertion lives in
|
||||
// `connectCmd` against that connect's own auth frame. Anything asserted
|
||||
// here would only be the model against itself.
|
||||
|
||||
// 3. A verified peer never flips to unverified (and back). The model is the
|
||||
// arbiter: a peer stays verified until it genuinely leaves our call, and
|
||||
// a re-join re-verifies — anything else is a flip. `afterSeeding` marks
|
||||
// the one call that runs immediately after Supersede wrote the
|
||||
// verifications itself: it still asserts, but it must not count as
|
||||
// coverage, or the family would look reached even if every check that
|
||||
// survives a later command disappeared.
|
||||
expect(realVerifiedPeers()).toEqual(m.verifiedPeers.toSorted((a, b) => a - b));
|
||||
if (m.verifiedPeers.length > 0 && !afterSeeding) exercised.verified++;
|
||||
|
||||
// 4. An aborted attempt never tears down a live session owned by a newer
|
||||
// attempt: the store's voice session always belongs to the newest join.
|
||||
expect(voiceStore.getState().currentChannelId).toBe(m.voiceChannel);
|
||||
|
||||
// The transport must never be left in a state nobody asked for.
|
||||
expect(["disconnected", "connecting", "authenticating", "connected", "reconnecting"]).toContain(
|
||||
r.client.getState(),
|
||||
);
|
||||
}
|
||||
|
||||
type Cmd = fc.AsyncCommand<Model, Real>;
|
||||
|
||||
function cmd(
|
||||
name: string,
|
||||
check: (m: Model) => boolean,
|
||||
run: (m: Model, r: Real) => Promise<void>,
|
||||
): Cmd {
|
||||
return { check, run, toString: () => name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect: open the socket, hand over the auth frame, complete the handshake.
|
||||
*
|
||||
* The handshake has two shapes on the wire, and they are not interchangeable
|
||||
* (`Server/ws/serve.go`; the epoch-1 fixtures record both):
|
||||
* - fresh / full-resync fallback (`last_seq` 0, or replay refused):
|
||||
* `handleFreshConnect` writes `auth_ok` with `replay_source: "none"`
|
||||
* followed by `ready` — `fresh-connect.json` records exactly that.
|
||||
* - resume (`last_seq > 0`, replay accepted): `reconnectWriteReplay` writes
|
||||
* `auth_ok` with the tier, then the missed events, and **no `ready` at
|
||||
* all** — `resume-replay.json` records `auth_ok(buffer)` → `presence` →
|
||||
* `chat_message` → `presence`.
|
||||
* Modelling a resume as `auth_ok` + `ready` (as this did before) meant the
|
||||
* dispatcher never saw the `auth_ok → replayed events` ordering, and the
|
||||
* post-resume state was repaired by a snapshot that never arrives.
|
||||
*/
|
||||
const connectCmd = cmd(
|
||||
"Connect",
|
||||
(m) => !m.connected,
|
||||
async (m, r) => {
|
||||
// Only frames sent by THIS attempt count — an earlier attempt's auth frame
|
||||
// must never be mistaken for one this connect produced.
|
||||
const before = r.sent.length;
|
||||
r.client.connect({ host: HOST, token: TOKEN });
|
||||
await settle();
|
||||
emitTauriEvent("ws-state", "open");
|
||||
await settle();
|
||||
|
||||
// Invariant 2's only real assertion point: the auth frame is where the seq
|
||||
// watermark becomes observable. A fresh connect declares 0 and proves
|
||||
// nothing, so only a resume (`m.seq > 0`, i.e. frames survived a
|
||||
// Disconnect without a resync or logout in between) counts as coverage.
|
||||
const resume = m.seq > 0;
|
||||
const auth = r.sent.slice(before).find((e) => e.type === "auth");
|
||||
expect(auth, "connect sent no auth frame").toBeDefined();
|
||||
expect(auth?.payload?.last_seq).toBe(m.seq);
|
||||
if (resume) exercised.seq++;
|
||||
|
||||
emit("auth_ok", authOkPayload(resume ? "buffer" : "none"));
|
||||
const replayId = resume ? replayedMessageId(m) : null;
|
||||
if (replayId === null) {
|
||||
emit("ready", readyPayload(m));
|
||||
} else {
|
||||
emit("chat_message", chatPayload(replayId), m.seq + 1);
|
||||
}
|
||||
await settle();
|
||||
|
||||
m.connected = true;
|
||||
if (replayId !== null) {
|
||||
m.seq += 1;
|
||||
if (!m.ids.includes(replayId)) m.ids.push(replayId);
|
||||
// The replay burst is the only thing that repairs this client's state —
|
||||
// no `ready` follows it — so the frame has to be in the store already.
|
||||
// Re-adding a `ready` here would let a snapshot do that repair instead,
|
||||
// which is the shape this test must not silently accept.
|
||||
expect(getChannelMessages(TEXT_CHANNEL).map((row) => row.id)).toContain(replayId);
|
||||
exercised.resumeReplay++;
|
||||
}
|
||||
expect(r.client.getState()).toBe("connected");
|
||||
checkInvariants(m, r);
|
||||
},
|
||||
);
|
||||
|
||||
/** Disconnect: the proxy drops the socket. Automatic reconnect keeps lastSeq. */
|
||||
const disconnectCmd = cmd(
|
||||
"Disconnect",
|
||||
(m) => m.connected,
|
||||
async (m, r) => {
|
||||
emitTauriEvent("ws-state", "closed");
|
||||
await settle();
|
||||
|
||||
m.connected = false;
|
||||
expect(r.client.getState()).toBe("reconnecting");
|
||||
checkInvariants(m, r);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* RegisterNow: the server registered this connection and then built `ready`.
|
||||
* A message that committed in between is in the snapshot AND is redelivered as
|
||||
* a queued frame — the redelivery must reconcile, not duplicate.
|
||||
*
|
||||
* `ready` never travels alone: `handleFreshConnect` is its only writer and it
|
||||
* always follows that path's `auth_ok`, so this drives the full handshake.
|
||||
* `replay_source: "none"` resets ws.ts's watermark, and the redelivered frame
|
||||
* then carries the server's restarted counter (OC-0032).
|
||||
*/
|
||||
const registerNowCmd = cmd(
|
||||
"RegisterNow",
|
||||
(m) => m.connected && m.ids.length > 0,
|
||||
async (m, r) => {
|
||||
emit("auth_ok", authOkPayload("none"));
|
||||
emit("ready", readyPayload(m));
|
||||
emit("chat_message", chatPayload(m.ids[m.ids.length - 1] as number), 1);
|
||||
await settle();
|
||||
|
||||
m.seq = 1;
|
||||
checkInvariants(m, r);
|
||||
},
|
||||
);
|
||||
|
||||
/** Receive(id, seq): a sequenced frame off the wire. */
|
||||
function receiveCmd(id: number, seq: number): Cmd {
|
||||
return cmd(
|
||||
`Receive(id=${id},seq=${seq})`,
|
||||
(m) => m.connected,
|
||||
async (m, r) => {
|
||||
emit("chat_message", chatPayload(id), seq);
|
||||
await settle();
|
||||
|
||||
if (seq > m.seq) m.seq = seq;
|
||||
if (!m.ids.includes(id)) m.ids.push(id);
|
||||
checkInvariants(m, r);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supersede(next, stale): a newer voice join takes over the session, then a
|
||||
* teardown frame for this client's own voice membership lands.
|
||||
* - stale: it names the channel the *older*, already-superseded attempt
|
||||
* owned. It must not tear down the newer session (the server broadcasts a
|
||||
* voice_leave to the leaver on every channel switch, and it can arrive
|
||||
* after the new join has already been granted).
|
||||
* - live: it names the current channel, so this really is our departure —
|
||||
* the session ends and the E2EE state goes with it.
|
||||
*/
|
||||
function supersedeCmd(next: number, stale: boolean): Cmd {
|
||||
const channel = VOICE_CHANNELS[next] as number;
|
||||
const other = VOICE_CHANNELS[1 - next] as number;
|
||||
return cmd(
|
||||
`Supersede(channel=${channel},${stale ? "stale" : "live"})`,
|
||||
(m) => m.connected,
|
||||
async (m, r) => {
|
||||
emit("voice_state", voiceState(channel, SELF_ID));
|
||||
for (const uid of PEER_IDS) emit("voice_state", voiceState(channel, uid));
|
||||
await settle();
|
||||
// The newer session verifies its peers (livekitE2EE does this from each
|
||||
// peer's announce; the crypto is out of scope here).
|
||||
for (const uid of PEER_IDS) {
|
||||
setPeerVerification({
|
||||
userId: uid,
|
||||
status: "verified",
|
||||
safetyNumber: `sn-${uid}`,
|
||||
sessionFingerprint: `fp-${uid}`,
|
||||
});
|
||||
}
|
||||
m.voiceChannel = channel;
|
||||
m.verifiedPeers = [...PEER_IDS];
|
||||
checkInvariants(m, r, true);
|
||||
|
||||
// …and now the teardown frame arrives.
|
||||
emit("voice_leave", { channel_id: stale ? other : channel, user_id: SELF_ID });
|
||||
await settle();
|
||||
if (stale) {
|
||||
exercised.staleTeardown++;
|
||||
} else {
|
||||
m.voiceChannel = null;
|
||||
m.verifiedPeers = [];
|
||||
}
|
||||
checkInvariants(m, r);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resync(dropPeer): a full re-sync — the server rebuilt this client's state
|
||||
* from scratch, so its own seq counter may have restarted below our stale
|
||||
* watermark and the watermark must reset with it (ws.ts's replay_source
|
||||
* "none" branch). `dropPeer` models the peer who left our call during the
|
||||
* outage: a full resync never replays that voice_leave, so the ready-time
|
||||
* reconciliation is the only thing that can drop their key and verification.
|
||||
*/
|
||||
function resyncCmd(dropPeer: boolean): Cmd {
|
||||
return cmd(
|
||||
`Resync(dropPeer=${dropPeer})`,
|
||||
(m) => m.connected,
|
||||
async (m, r) => {
|
||||
const dropped = dropPeer && m.verifiedPeers.length > 0 ? [m.verifiedPeers[0] as number] : [];
|
||||
emit("auth_ok", authOkPayload("none"));
|
||||
emit("ready", readyPayload(m, dropped));
|
||||
await settle();
|
||||
|
||||
m.seq = 0;
|
||||
m.verifiedPeers = m.verifiedPeers.filter((uid) => !dropped.includes(uid));
|
||||
checkInvariants(m, r);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Logout: the intentional teardown — transport and every domain store. */
|
||||
const logoutCmd = cmd(
|
||||
"Logout",
|
||||
(m) => m.connected || m.ids.length > 0 || m.voiceChannel !== null,
|
||||
async (m, r) => {
|
||||
r.client.disconnect();
|
||||
clearAuth();
|
||||
await settle();
|
||||
|
||||
m.connected = false;
|
||||
m.seq = 0;
|
||||
m.ids = [];
|
||||
m.voiceChannel = null;
|
||||
m.verifiedPeers = [];
|
||||
expect(r.client.getState()).toBe("disconnected");
|
||||
checkInvariants(m, r);
|
||||
},
|
||||
);
|
||||
|
||||
const commandArbs = [
|
||||
// Listed twice on purpose: every other command needs a live connection, so
|
||||
// an under-weighted Connect leaves most generated sequences doing nothing.
|
||||
fc.constant(connectCmd),
|
||||
fc.constant(connectCmd),
|
||||
fc.constant(disconnectCmd),
|
||||
fc.constant(registerNowCmd),
|
||||
fc
|
||||
.tuple(fc.integer(MESSAGE_IDS), fc.integer({ min: 0, max: 8 }))
|
||||
.map(([id, seq]) => receiveCmd(id, seq)),
|
||||
fc.tuple(fc.integer({ min: 0, max: 1 }), fc.boolean()).map(([n, s]) => supersedeCmd(n, s)),
|
||||
fc.boolean().map((drop) => resyncCmd(drop)),
|
||||
fc.constant(logoutCmd),
|
||||
];
|
||||
|
||||
/**
|
||||
* Fixed by default so CI is reproducible run to run; override to replay a
|
||||
* reported counterexample (`OWNCORD_MODEL_SEED=<seed> npm test`). A malformed
|
||||
* override throws rather than silently handing fast-check `NaN` (or the `0`
|
||||
* an empty variable coerces to) and running a different suite than the one
|
||||
* that was asked for.
|
||||
*/
|
||||
function modelSeed(): number {
|
||||
const raw = process.env.OWNCORD_MODEL_SEED;
|
||||
if (raw === undefined) return 20260830;
|
||||
// Number("") is 0 and Number("abc") is NaN — both would run a different
|
||||
// suite than the one that was asked for, so neither gets a fallback.
|
||||
const seed = Number(raw);
|
||||
if (raw.trim() === "" || !Number.isInteger(seed)) {
|
||||
throw new Error(`OWNCORD_MODEL_SEED must be an integer, got ${JSON.stringify(raw)}`);
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
const SEED = modelSeed();
|
||||
const NUM_RUNS = 150;
|
||||
const MAX_COMMANDS = 30;
|
||||
|
||||
describe("connection model (fc.commands over the real ws client + dispatcher)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("holds every connection invariant under generated orderings", async () => {
|
||||
const live: { real: Real | null } = { real: null };
|
||||
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.commands(commandArbs, { maxCommands: MAX_COMMANDS, size: "large" }),
|
||||
async (cmds) => {
|
||||
try {
|
||||
await fc.asyncModelRun(() => {
|
||||
// Fresh transport, dispatcher and stores per run — a run must
|
||||
// never inherit the previous one's timers or listeners.
|
||||
vi.clearAllTimers();
|
||||
mockInvoke.mockReset();
|
||||
mockListen.mockClear();
|
||||
eventHandlers.clear();
|
||||
clearAuth();
|
||||
|
||||
const sent: Real["sent"] = [];
|
||||
mockInvoke.mockImplementation(
|
||||
async (command: string, args?: { message?: string }) => {
|
||||
if (command === "ws_send" && typeof args?.message === "string") {
|
||||
sent.push(JSON.parse(args.message) as Real["sent"][number]);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const client = createWsClient();
|
||||
const real: Real = { client, cleanup: wireDispatcher(client), sent };
|
||||
live.real = real;
|
||||
const model: Model = {
|
||||
connected: false,
|
||||
seq: 0,
|
||||
ids: [],
|
||||
voiceChannel: null,
|
||||
verifiedPeers: [],
|
||||
};
|
||||
return { model, real };
|
||||
}, cmds);
|
||||
} finally {
|
||||
live.real?.cleanup();
|
||||
live.real?.client.disconnect();
|
||||
live.real = null;
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: NUM_RUNS, seed: SEED },
|
||||
);
|
||||
});
|
||||
|
||||
it("reached every invariant family (a family that stops firing is a hole)", () => {
|
||||
for (const [family, count] of Object.entries(exercised)) {
|
||||
expect(count, `invariant family "${family}" was never exercised`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -28,8 +28,7 @@ import {
|
||||
listCustomEmoji,
|
||||
resolveEmoji,
|
||||
} from "../../src/stores/emoji.store";
|
||||
import { uiStore, setUpdateRequiredHost } from "../../src/stores/ui.store";
|
||||
import { PROTOCOL_EPOCH } from "../../src/lib/protocolTypes";
|
||||
import { uiStore } from "../../src/stores/ui.store";
|
||||
import {
|
||||
clearReactionUsersCache,
|
||||
getCachedReactionUsers,
|
||||
@@ -285,52 +284,6 @@ describe("WS Dispatcher", () => {
|
||||
expect(uiStore.getState().transientError).toBe("Invalid token");
|
||||
});
|
||||
|
||||
it("marks the server host as needing a client update when auth_error refuses this client's epoch as too old", () => {
|
||||
cleanup();
|
||||
setUpdateRequiredHost(null);
|
||||
const getConfig = vi.fn(() => ({ host: "chat.example:8443", token: "t" }));
|
||||
cleanup = wireDispatcher(mock.ws, { listBlocks: vi.fn().mockResolvedValue([]), getConfig });
|
||||
|
||||
mock.dispatch("auth_error", {
|
||||
message: "update the client",
|
||||
code: "protocol_epoch_unsupported",
|
||||
client_epoch: PROTOCOL_EPOCH,
|
||||
server_epoch: PROTOCOL_EPOCH + 1,
|
||||
min_epoch: PROTOCOL_EPOCH + 1,
|
||||
});
|
||||
|
||||
expect(uiStore.getState().updateRequiredHost).toBe("chat.example:8443");
|
||||
expect(uiStore.getState().transientError).toBe("update the client");
|
||||
expect(authStore.getState().isAuthenticated).toBe(false);
|
||||
// The token is still valid — main.ts keeps the stored credential on this
|
||||
// reason so the update relaunches straight into auto-login (Codex P2).
|
||||
expect(authStore.getState().logoutReason).toBe("protocol_epoch");
|
||||
});
|
||||
|
||||
it("does not offer a client update when the SERVER is the older side, or on an ordinary auth_error", () => {
|
||||
cleanup();
|
||||
setUpdateRequiredHost(null);
|
||||
const getConfig = vi.fn(() => ({ host: "chat.example:8443", token: "t" }));
|
||||
cleanup = wireDispatcher(mock.ws, { listBlocks: vi.fn().mockResolvedValue([]), getConfig });
|
||||
|
||||
mock.dispatch("auth_error", {
|
||||
message: "update the server",
|
||||
code: "protocol_epoch_unsupported",
|
||||
client_epoch: PROTOCOL_EPOCH,
|
||||
server_epoch: PROTOCOL_EPOCH - 1,
|
||||
min_epoch: PROTOCOL_EPOCH - 1,
|
||||
});
|
||||
expect(uiStore.getState().updateRequiredHost).toBeNull();
|
||||
|
||||
// Server older than the client: still a protocol refusal, still a valid
|
||||
// token — the credential must survive this one too.
|
||||
expect(authStore.getState().logoutReason).toBe("protocol_epoch");
|
||||
|
||||
mock.dispatch("auth_error", { message: "Invalid token" });
|
||||
expect(uiStore.getState().updateRequiredHost).toBeNull();
|
||||
expect(authStore.getState().logoutReason).toBe("user");
|
||||
});
|
||||
|
||||
it("wires ready to channels, members, and voice stores", () => {
|
||||
mock.dispatch("ready", {
|
||||
channels: [
|
||||
@@ -481,54 +434,6 @@ describe("WS Dispatcher", () => {
|
||||
expect(ch?.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
// OC-0328: mirrors the DM-side "does not double-count" test below. A
|
||||
// channel message delivered between the server's registerNow and
|
||||
// buildReady is both counted in `ready`'s snapshot (lastMessageId already
|
||||
// advanced to its id) AND redelivered as a queued chat_message once the
|
||||
// socket drains — the channel path had no replay guard at all.
|
||||
it("does not double-count a channel unread/mention whose id is already reflected in lastMessageId", () => {
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
user: { id: 5, username: "me", avatar: null, role: "member" },
|
||||
}));
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(5, {
|
||||
id: 5,
|
||||
name: "off-topic",
|
||||
type: "text" as const,
|
||||
category: null,
|
||||
position: 0,
|
||||
unreadCount: 1,
|
||||
mentionCount: 1,
|
||||
lastMessageId: 200, // already reflects message 200 via `ready`
|
||||
canSend: true,
|
||||
topic: "",
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
});
|
||||
return { ...prev, channels: ch, activeChannelId: 1 }; // active is channel 1
|
||||
});
|
||||
|
||||
// The same message redelivered as a queued chat_message.
|
||||
mock.dispatch("chat_message", {
|
||||
id: 200,
|
||||
channel_id: 5,
|
||||
user: { id: 2, username: "bob", avatar: null },
|
||||
content: "hey @me",
|
||||
mentions: [5],
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: "2026-03-15T10:00:00Z",
|
||||
});
|
||||
|
||||
const ch = channelsStore.getState().channels.get(5);
|
||||
expect(ch?.unreadCount).toBe(1);
|
||||
expect(ch?.mentionCount).toBe(1);
|
||||
});
|
||||
|
||||
// OC-0204: "active channel" normally means "the user is watching the live
|
||||
// tail", so skipping the unread bump there is correct — until a jump to an
|
||||
// old permalink/reply/search hit leaves the SAME active channel showing a
|
||||
@@ -774,84 +679,6 @@ describe("WS Dispatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// OC-0315: payload.timestamp is the raw SQLite datetime('now') string —
|
||||
// naive UTC, no 'Z' suffix (the server never emits one). Date.parse (used
|
||||
// by the replay-gate comparison, unlike the parseTimestamp helper built
|
||||
// for exactly this) interprets that as LOCAL time. On a viewer whose zone
|
||||
// is east of UTC, the parsed epoch reads *earlier* than the true instant,
|
||||
// so a genuinely live message can look like it predates the reconnect
|
||||
// handshake and gets silently swallowed by the replay gate — worst when
|
||||
// serverClockSkewMs is still 0 (nothing has been sampled yet this
|
||||
// session), since nothing else offsets the bias. Pin a real east-of-UTC
|
||||
// zone to observe it; skip where the pin isn't honored (see the probe in
|
||||
// renderers.test.ts's DST block for why a worker-thread pool can't).
|
||||
const oc0315OriginalTZ = process.env.TZ;
|
||||
process.env.TZ = "Asia/Tokyo";
|
||||
const oc0315PinHonored = new Date(2026, 0, 15).getTimezoneOffset() === -540;
|
||||
if (oc0315OriginalTZ === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = oc0315OriginalTZ;
|
||||
}
|
||||
|
||||
describe.skipIf(!oc0315PinHonored)(
|
||||
"[OC-0315] naive-UTC server timestamps vs a non-UTC viewer clock",
|
||||
() => {
|
||||
const originalTZ = process.env.TZ;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.TZ = "Asia/Tokyo";
|
||||
vi.mocked(mockNotifyIncomingMessage).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTZ === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = originalTZ;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not misclassify a live message as a replay when serverClockSkewMs is still 0 (cold, never sampled)", () => {
|
||||
// Sanity: really pinned east of UTC (Tokyo has no DST, so this is
|
||||
// stable year-round, unlike the America/New_York probe elsewhere).
|
||||
expect(new Date(2026, 0, 15).getTimezoneOffset()).toBe(-540);
|
||||
|
||||
mock.dispatch("auth_ok", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
||||
server_name: "TestServer",
|
||||
motd: "",
|
||||
});
|
||||
const handshakeAt = Date.now();
|
||||
// Second auth_ok in the same dispatcher lifetime = a reconnect.
|
||||
mock.dispatch("auth_ok", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
||||
server_name: "TestServer",
|
||||
motd: "",
|
||||
});
|
||||
|
||||
// A genuinely live message, 1s after the handshake, stamped by the
|
||||
// server in its real wire form: naive UTC, no 'Z'.
|
||||
vi.setSystemTime(handshakeAt + 1000);
|
||||
const naiveUtcTimestamp = new Date(Date.now())
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d{3}Z$/, "");
|
||||
mock.dispatch("chat_message", {
|
||||
id: 1,
|
||||
channel_id: 1,
|
||||
user: { id: 2, username: "bob", avatar: null },
|
||||
content: "live now, naive-UTC timestamp",
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: naiveUtcTimestamp,
|
||||
});
|
||||
|
||||
expect(mockNotifyIncomingMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("mention counts", () => {
|
||||
function seedChannel(): void {
|
||||
channelsStore.setState((prev) => {
|
||||
@@ -1274,6 +1101,23 @@ describe("WS Dispatcher", () => {
|
||||
expect(membersStore.getState().members.has(77)).toBe(false);
|
||||
});
|
||||
|
||||
it("wires member_leave to members store", () => {
|
||||
membersStore.setState((prev) => {
|
||||
const m = new Map(prev.members);
|
||||
m.set(99, {
|
||||
id: 99,
|
||||
username: "bye",
|
||||
avatar: null,
|
||||
role: "member",
|
||||
status: "online" as const,
|
||||
});
|
||||
return { ...prev, members: m };
|
||||
});
|
||||
|
||||
mock.dispatch("member_leave", { user_id: 99 });
|
||||
expect(membersStore.getState().members.has(99)).toBe(false);
|
||||
});
|
||||
|
||||
it("wires voice_state to voice store", () => {
|
||||
mock.dispatch("voice_state", {
|
||||
channel_id: 2,
|
||||
@@ -2885,37 +2729,6 @@ describe("WS Dispatcher", () => {
|
||||
expect(mockHandleParticipantLeft).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
// OC-0311: voice_leave is broadcast to channelReadAudience(channel), i.e.
|
||||
// everyone with READ_MESSAGES on THAT channel — not just its voice
|
||||
// participants. A client can read channel B (and so receive B's
|
||||
// voice_leave frames) while its own live voice session is in channel A.
|
||||
// Without a channel guard, a peer leaving a channel this client merely
|
||||
// reads mutates this client's own E2EE peer state (deletes the peer's key,
|
||||
// clears their verification badge, retires their key, and can trigger a
|
||||
// room-key rotation) for a call that peer was never part of.
|
||||
it("[OC-0311] does not touch E2EE peer state for a voice_leave from a channel this client is not in", async () => {
|
||||
vi.mocked(mockHandleParticipantLeft).mockClear();
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
user: { id: 5, username: "me", avatar: null, role: "member" },
|
||||
}));
|
||||
// This client's live voice session is channel 3.
|
||||
voiceStore.setState((prev) => ({
|
||||
...prev,
|
||||
currentChannelId: 3,
|
||||
}));
|
||||
|
||||
// A peer leaves channel 99, which this client can merely read (hence
|
||||
// seeing the broadcast) but is not the client's own voice channel.
|
||||
mock.dispatch("voice_leave", {
|
||||
channel_id: 99,
|
||||
user_id: 7,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockHandleParticipantLeft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mirrors a moderator mute/deafen into the local flags and honors it", async () => {
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
@@ -3164,6 +2977,38 @@ describe("WS Dispatcher", () => {
|
||||
expect(configs.get(3)).toBeDefined();
|
||||
});
|
||||
|
||||
it("wires voice_speakers to voice store", () => {
|
||||
voiceStore.setState((prev) => {
|
||||
const users = new Map(
|
||||
[1, 2, 4].map((userId) => [
|
||||
userId,
|
||||
{
|
||||
userId,
|
||||
username: `user${userId}`,
|
||||
muted: false,
|
||||
deafened: false,
|
||||
speaking: false,
|
||||
camera: false,
|
||||
screenshare: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const voiceUsers = new Map(prev.voiceUsers);
|
||||
voiceUsers.set(3, users);
|
||||
return { ...prev, voiceUsers };
|
||||
});
|
||||
|
||||
mock.dispatch("voice_speakers", {
|
||||
channel_id: 3,
|
||||
speakers: [1, 2, 3],
|
||||
});
|
||||
|
||||
const users = voiceStore.getState().voiceUsers.get(3);
|
||||
expect(users?.get(1)?.speaking).toBe(true);
|
||||
expect(users?.get(2)?.speaking).toBe(true);
|
||||
expect(users?.get(4)?.speaking).toBe(false);
|
||||
});
|
||||
|
||||
it("wires voice_token to handleVoiceToken", async () => {
|
||||
const { handleVoiceToken } = await import("@lib/livekitSession");
|
||||
|
||||
|
||||
@@ -357,36 +357,6 @@ describe("dmStore", () => {
|
||||
const ch = dmStore.getState().channels[0]!;
|
||||
expect(ch.unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
// OC-0317: a replayed/stale id must not regress the lastMessageId
|
||||
// watermark either — its sibling updateDmLastMessagePreview already
|
||||
// returns `prev` untouched on replay (OC-0301); this function must do
|
||||
// the same so a later, genuinely-new frame in the same burst is still
|
||||
// correctly recognized as new instead of looking like another replay.
|
||||
it("does not regress lastMessageId, lastMessage, or lastMessageAt on a replay", () => {
|
||||
setDmChannels([
|
||||
makeDm({
|
||||
channelId: 5,
|
||||
unreadCount: 1,
|
||||
lastMessageId: 102,
|
||||
lastMessage: "second",
|
||||
lastMessageAt: "2026-03-28T12:00:02Z",
|
||||
}),
|
||||
]);
|
||||
updateDmLastMessage(5, 101, "stale-replay", "2026-03-28T12:00:01Z");
|
||||
const ch = dmStore.getState().channels[0]!;
|
||||
expect(ch.lastMessageId).toBe(102);
|
||||
expect(ch.lastMessage).toBe("second");
|
||||
expect(ch.lastMessageAt).toBe("2026-03-28T12:00:02Z");
|
||||
expect(ch.unreadCount).toBe(1);
|
||||
|
||||
// The next genuinely-new frame must still be counted as new, not
|
||||
// treated as a second replay because the watermark got rolled back.
|
||||
updateDmLastMessage(5, 103, "third", "2026-03-28T12:00:03Z");
|
||||
const after = dmStore.getState().channels[0]!;
|
||||
expect(after.lastMessageId).toBe(103);
|
||||
expect(after.unreadCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateDmLastMessagePreview ──────────────────────────
|
||||
|
||||
@@ -27,15 +27,6 @@ describe("isValidHost", () => {
|
||||
expect(isValidHost("chat.example.com:8443")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a host containing an underscore (OC-0322: Rust proxies reject it)", () => {
|
||||
// http_proxy::validate_remote_host and livekit_proxy::validate_remote_host
|
||||
// only allow is_ascii_alphanumeric() || '.' | '-' | ':' | '[' | ']' -- JS
|
||||
// `\w` wrongly includes '_', which would let the client save/accept a
|
||||
// host neither Rust proxy can ever connect to.
|
||||
expect(isValidHost("chat_example.com")).toBe(false);
|
||||
expect(isValidHost("my_server.lan:8443")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an IPv4 literal, optionally with a port", () => {
|
||||
expect(isValidHost("192.168.1.1")).toBe(true);
|
||||
expect(isValidHost("192.168.1.1:8443")).toBe(true);
|
||||
|
||||
@@ -1842,149 +1842,3 @@ describe("E2EEManager", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── HP-2 question 4: adversarial membership and key-change rules ───────────
|
||||
// Each test pins one rule from docs/trust-model.md §"What is end-to-end
|
||||
// encrypted" that had no dedicated test before HP-2, or records a known gap
|
||||
// so the fix has a RED waiting for it.
|
||||
|
||||
describe("E2EEManager — HP-2 adversarial membership and key-change rules", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMembers.clear();
|
||||
mockMembers.set(PEER_ID, { identityPublicKey: "peer-identity-b64" });
|
||||
mockVoiceState.voiceUsers.clear();
|
||||
vi.mocked(getIdentityPin).mockResolvedValue({ status: "unpinned" });
|
||||
vi.mocked(storeIdentityPin).mockResolvedValue("stored");
|
||||
});
|
||||
|
||||
it("[HP-2 known gap] a modified server that adds an unknown member at first contact gets the room key wrapped to it", async () => {
|
||||
// Membership is server-controlled and the client accepts any first-sight
|
||||
// identity (verifyPeerAnnounce). A server that inserts a member row it
|
||||
// holds the identity key for, then relays a well-signed announce for it,
|
||||
// is keyed by the holder like any real peer. The client has no
|
||||
// independent membership evidence — the voice roster is server state
|
||||
// too, and here it does not even list the newcomer.
|
||||
//
|
||||
// This pins TODAY's behaviour. When authenticated membership (or
|
||||
// "refuse unrecognised participants") lands, this test goes RED and the
|
||||
// expectations below invert. docs/trust-model.md §"What beta does not
|
||||
// claim" names the gap.
|
||||
const INTRUDER = 99;
|
||||
mockMembers.set(INTRUDER, { identityPublicKey: "server-supplied-identity-b64" });
|
||||
expect(mockVoiceState.voiceUsers.size).toBe(0);
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
await mgr.setupKeyExchange(true, 1);
|
||||
ws.send.mockClear();
|
||||
|
||||
await mgr.handleAnnounce(INTRUDER, "aW50cnVkZXI=", "sig-the-server-can-make");
|
||||
|
||||
// Today: keyed, pinned, verified — the RED of the desired rule (no offer,
|
||||
// no pin) is recorded in docs/plans/hp-2-scorecard-2026-08-29.md Q4.
|
||||
const offers = sendsOfType(ws, "voice_e2ee_offer");
|
||||
expect(offers).toHaveLength(1);
|
||||
expect((offers[0] as any).payload.target_user_id).toBe(INTRUDER);
|
||||
expect(mgr.peerPublicKeys.has(INTRUDER)).toBe(true);
|
||||
expect(storeIdentityPin).toHaveBeenCalledWith(
|
||||
"localhost:7880",
|
||||
String(INTRUDER),
|
||||
"server-supplied-identity-b64",
|
||||
);
|
||||
expect(setPeerVerification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: INTRUDER, status: "verified" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("[HP-2] a second device's key, once trusted, overwrites the account pin — the first device then mismatches", async () => {
|
||||
// Pins are one per account ({host}:{userId}) while identity keys are per
|
||||
// install (identity.ts; migration 017 holds one identity_public_key per
|
||||
// user). Trusting device 2 therefore evicts device 1's pin, and device
|
||||
// 1's next announce is blocked as a mismatch. docs/trust-model.md
|
||||
// §"What is end-to-end encrypted" states the flip-flop; this pins it.
|
||||
const DEVICE1 = "device1-identity-b64";
|
||||
const DEVICE2 = "device2-identity-b64";
|
||||
vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE1 });
|
||||
mockMembers.set(PEER_ID, { identityPublicKey: DEVICE2 }); // server row: last announcer wins
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
await mgr.setupKeyExchange(true, 1);
|
||||
ws.send.mockClear();
|
||||
|
||||
// Device 2 announces: pinned key differs → blocked, nothing wrapped.
|
||||
await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMg==", "sig2");
|
||||
expect(setPeerVerification).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
|
||||
);
|
||||
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
||||
|
||||
// The human clicks "Trust new key": the ONE slot is overwritten and the
|
||||
// buffered announce replays against the new pin, so device 2 is keyed.
|
||||
vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE2 });
|
||||
expect(await mgr.rePinPeerIdentity(PEER_ID, DEVICE2)).toBe(true);
|
||||
expect(storeIdentityPin).toHaveBeenCalledTimes(1);
|
||||
expect(storeIdentityPin).toHaveBeenCalledWith("localhost:7880", String(PEER_ID), DEVICE2);
|
||||
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
||||
const importsBefore = vi.mocked(importPublicKey).mock.calls.length;
|
||||
|
||||
// Device 1 comes back (the server row carries its key again) and is the
|
||||
// one that mismatches now — no offer, no key imported, no second pin.
|
||||
mockMembers.set(PEER_ID, { identityPublicKey: DEVICE1 });
|
||||
await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMQ==", "sig1");
|
||||
expect(setPeerVerification).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
|
||||
);
|
||||
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
||||
expect(vi.mocked(importPublicKey).mock.calls.length).toBe(importsBefore);
|
||||
expect(storeIdentityPin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("[HP-2 / OC-0316] a peer whose socket dropped across a rotation is re-keyed with the rotated key when the server replays its announce", async () => {
|
||||
// Server side: hub.go re-relays the resumed client's stored announce to
|
||||
// the channel (TestRegisterNow_ReannouncesOwnKeyOnResume). Holder side,
|
||||
// pinned here: that replay is a duplicate announce, and the offer it
|
||||
// triggers must carry the CURRENT room key and epoch — not the key the
|
||||
// peer held before its outage.
|
||||
// Round-trip import/export so the replayed announce is recognised as the
|
||||
// SAME ephemeral key (the duplicate path), not a changed one.
|
||||
vi.mocked(importPublicKey).mockImplementation(
|
||||
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
||||
);
|
||||
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
||||
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
||||
);
|
||||
try {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
await mgr.setupKeyExchange(true, 1);
|
||||
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
||||
expect(mgr.epoch).toBe(1);
|
||||
|
||||
// Peer's WebSocket drops (media stays up, so no participant-left); the
|
||||
// periodic rotation fires meanwhile.
|
||||
await mgr.rotateKeyPeriodically();
|
||||
expect(mgr.epoch).toBe(2);
|
||||
const rotatedKey = (mgr as any)._roomKey as Uint8Array;
|
||||
ws.send.mockClear();
|
||||
vi.mocked(wrapRoomKey).mockClear();
|
||||
vi.mocked(importPublicKey).mockClear();
|
||||
|
||||
// Peer resumes; the server replays its unchanged announce to us.
|
||||
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
||||
|
||||
expect(importPublicKey).not.toHaveBeenCalled(); // duplicate, not a new key
|
||||
const offers = sendsOfType(ws, "voice_e2ee_offer");
|
||||
expect(offers).toHaveLength(1);
|
||||
expect((offers[0] as any).payload.target_user_id).toBe(PEER_ID);
|
||||
expect(wrapRoomKey).toHaveBeenCalledTimes(1);
|
||||
const [, , wrappedKey, wrappedEpoch] = vi.mocked(wrapRoomKey).mock.calls[0]!;
|
||||
expect(wrappedKey).toBe(rotatedKey);
|
||||
expect(wrappedEpoch).toBe(2);
|
||||
} finally {
|
||||
vi.mocked(importPublicKey).mockImplementation(
|
||||
async () => ({ type: "public" }) as unknown as CryptoKey,
|
||||
);
|
||||
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,14 +81,6 @@ vi.mock("@lib/profiles", () => ({
|
||||
// `api.getConfig().host` read (main.ts:776) after a login sets it via
|
||||
// `api.setConfig({ host })` (main.ts:515).
|
||||
const mockLogin = vi.fn();
|
||||
// UpdateNotifier (mounted on the connect page after a protocol-epoch refusal)
|
||||
// calls checkForUpdate; stub the Tauri-backed updater so the test observes the
|
||||
// call instead of an invoke() into nothing.
|
||||
const mockCheckForUpdate = vi.fn();
|
||||
vi.mock("@lib/updater", () => ({
|
||||
checkForUpdate: (...args: unknown[]) => mockCheckForUpdate(...args),
|
||||
downloadAndInstallUpdate: vi.fn(),
|
||||
}));
|
||||
const mockApiState = { host: "" };
|
||||
vi.mock("@lib/api", () => ({
|
||||
createApiClient: vi.fn(() => ({
|
||||
@@ -159,8 +151,6 @@ vi.mock("@lib/dispatcher", async () => {
|
||||
|
||||
import { mockInvoke, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks";
|
||||
import { clearAuth } from "@stores/auth.store";
|
||||
import { deleteCredential } from "@lib/credentials";
|
||||
import { uiStore, setUpdateRequiredHost } from "@stores/ui.store";
|
||||
import { loadUserStatus, loadUserStatusOrigin } from "@lib/userStatus";
|
||||
import { createMainPage } from "@pages/MainPage";
|
||||
import { setActivePresenceSender, type PresenceSender } from "@lib/presence";
|
||||
@@ -389,87 +379,3 @@ describe("main.ts connect-page skip-auto-login flag (OC-0028)", () => {
|
||||
expect(sessionStorage.getItem("owncord:skip-auto-login")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("main.ts connect page after a protocol-epoch refusal (B2-2)", () => {
|
||||
it("mounts the update notifier on the connect page so a refused client can update in place", async () => {
|
||||
await loginAndReachAuthOk("server-a.example:8443", "alex", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "member" },
|
||||
server_name: "Server A",
|
||||
motd: "",
|
||||
});
|
||||
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
|
||||
// The real dispatcher's auth_error handler records the host when the
|
||||
// server says this client's epoch is too old (dispatcher.test.ts covers
|
||||
// that); the dispatcher is stubbed here, so set what it would have set,
|
||||
// then end the session the way auth_error does.
|
||||
mockCheckForUpdate.mockResolvedValue({ available: false, version: null, body: null });
|
||||
setUpdateRequiredHost("server-a.example:8443");
|
||||
clearAuth();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// The notifier checks 3 s after mount (UpdateNotifier.ts mount()).
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(mockCheckForUpdate).toHaveBeenCalledWith("https://server-a.example:8443");
|
||||
// Consumed on mount: the next connect page must not re-check.
|
||||
expect(uiStore.getState().updateRequiredHost).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the update when the refusal lands on an already-mounted connect page (first login / startup auto-login)", async () => {
|
||||
// No session, no overlay: the connect page rendered at startup is the
|
||||
// one the refusal arrives on, and nothing re-renders it (Codex P1). The
|
||||
// dispatcher is stubbed here; set what its auth_error handler sets.
|
||||
mockCheckForUpdate.mockClear();
|
||||
mockCheckForUpdate.mockResolvedValue({ available: false, version: null, body: null });
|
||||
setUpdateRequiredHost("server-c.example:8443");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(mockCheckForUpdate).toHaveBeenCalledWith("https://server-c.example:8443");
|
||||
expect(uiStore.getState().updateRequiredHost).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the stored credential on a protocol-epoch refusal, unlike an ordinary auth_error", async () => {
|
||||
await loginAndReachAuthOk("server-d.example:8443", "alex", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "member" },
|
||||
server_name: "Server D",
|
||||
motd: "",
|
||||
});
|
||||
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
|
||||
vi.mocked(deleteCredential).mockClear();
|
||||
// What the dispatcher does on protocol_epoch_unsupported (Codex P2).
|
||||
clearAuth("protocol_epoch");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// The token is still valid: the credential stays so the update can
|
||||
// relaunch into auto-login. (The skip-auto-login flag is set on the same
|
||||
// path, but the connect page consumes it on mount, so it cannot be read
|
||||
// back here — the quick-switch test above covers that consumption.)
|
||||
expect(deleteCredential).not.toHaveBeenCalled();
|
||||
|
||||
// Contrast: the same logout for an ordinary reason removes it.
|
||||
await loginAndReachAuthOk("server-d.example:8443", "alex", {
|
||||
user: { id: 1, username: "alex", avatar: null, role: "member" },
|
||||
server_name: "Server D",
|
||||
motd: "",
|
||||
});
|
||||
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
clearAuth("user");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(deleteCredential).toHaveBeenCalledWith("server-d.example:8443");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,7 +204,6 @@ function resetStores(): void {
|
||||
connectionStatus: "disconnected" as const,
|
||||
transientError: null,
|
||||
persistentError: null,
|
||||
updateRequiredHost: null,
|
||||
collapsedCategories: new Set<string>(),
|
||||
sidebarMode: "channels" as const,
|
||||
activeDmUserId: null,
|
||||
|
||||
@@ -36,7 +36,6 @@ function resetStores(): void {
|
||||
connectionStatus: "disconnected" as const,
|
||||
transientError: null,
|
||||
persistentError: null,
|
||||
updateRequiredHost: null,
|
||||
collapsedCategories: new Set<string>(),
|
||||
sidebarMode: "channels" as const,
|
||||
activeDmUserId: null,
|
||||
|
||||
@@ -23,7 +23,6 @@ function resetStores(): void {
|
||||
connectionStatus: "disconnected" as const,
|
||||
transientError: null,
|
||||
persistentError: null,
|
||||
updateRequiredHost: null,
|
||||
collapsedCategories: new Set<string>(),
|
||||
sidebarMode: "channels" as const,
|
||||
activeDmUserId: null,
|
||||
|
||||
@@ -88,12 +88,13 @@ const sampleVoiceConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
// Not a wire message — VoiceSpeakersPayload is the argument shape for
|
||||
// voice.store's setSpeakers, fed by LiveKit's ActiveSpeakersChanged.
|
||||
const sampleVoiceSpeakers: VoiceSpeakersPayload = {
|
||||
channel_id: 10,
|
||||
speakers: [1, 5, 12],
|
||||
threshold_mode: "forwarding" as const,
|
||||
const sampleVoiceSpeakers = {
|
||||
type: "voice_speakers" as const,
|
||||
payload: {
|
||||
channel_id: 10,
|
||||
speakers: [1, 5, 12],
|
||||
threshold_mode: "forwarding" as const,
|
||||
},
|
||||
};
|
||||
|
||||
describe("ServerMessage discriminated union", () => {
|
||||
@@ -141,7 +142,7 @@ describe("AUDIT Critical: threshold_mode (CRIT-2, CRIT-3)", () => {
|
||||
});
|
||||
|
||||
it("VoiceSpeakersPayload uses threshold_mode NOT mode", () => {
|
||||
const speakers: VoiceSpeakersPayload = sampleVoiceSpeakers;
|
||||
const speakers: VoiceSpeakersPayload = sampleVoiceSpeakers.payload;
|
||||
expect(speakers.threshold_mode).toBe("forwarding");
|
||||
// @ts-expect-error — mode is not a valid field
|
||||
expect(speakers.mode).toBeUndefined();
|
||||
@@ -154,6 +155,13 @@ describe("AUDIT Critical: threshold_mode (CRIT-2, CRIT-3)", () => {
|
||||
expect(["forwarding", "selective"]).toContain(msg.payload.threshold_mode);
|
||||
}
|
||||
});
|
||||
|
||||
it("voice_speakers ServerMessage carries threshold_mode", () => {
|
||||
const msg: ServerMessage = sampleVoiceSpeakers;
|
||||
if (msg.type === "voice_speakers") {
|
||||
expect(msg.payload.threshold_mode).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AUDIT Critical: no channel_focus message type", () => {
|
||||
@@ -177,10 +185,12 @@ describe("AUDIT Critical: no channel_focus message type", () => {
|
||||
"voice_state",
|
||||
"voice_leave",
|
||||
"voice_config",
|
||||
"voice_speakers",
|
||||
"voice_offer",
|
||||
"voice_answer",
|
||||
"voice_ice",
|
||||
"member_join",
|
||||
"member_leave",
|
||||
"member_update",
|
||||
"member_ban",
|
||||
"server_restart",
|
||||
|
||||
@@ -28,7 +28,6 @@ function resetStore(): void {
|
||||
connectionStatus: "disconnected" as const,
|
||||
transientError: null,
|
||||
persistentError: null,
|
||||
updateRequiredHost: null,
|
||||
collapsedCategories: new Set<string>(),
|
||||
sidebarMode: "channels" as const,
|
||||
activeDmUserId: null,
|
||||
|
||||
@@ -57,7 +57,6 @@ function resetStores(): void {
|
||||
connectionStatus: "disconnected" as const,
|
||||
transientError: null,
|
||||
persistentError: null,
|
||||
updateRequiredHost: null,
|
||||
collapsedCategories: new Set<string>(),
|
||||
sidebarMode: "channels" as const,
|
||||
activeDmUserId: null,
|
||||
|
||||
@@ -664,12 +664,12 @@ describe("voice store", () => {
|
||||
expect(voiceStore.getState().voiceUsers.get(10)?.get(1)?.speaking).toBe(false);
|
||||
});
|
||||
|
||||
it("updates remote users' speaking state from LiveKit", () => {
|
||||
// LiveKit says user 2 is speaking
|
||||
it("updates remote users' speaking state from server", () => {
|
||||
// Server says user 2 is speaking
|
||||
setSpeakers({ channel_id: 10, speakers: [2], threshold_mode: "forwarding" });
|
||||
expect(voiceStore.getState().voiceUsers.get(10)?.get(2)?.speaking).toBe(true);
|
||||
|
||||
// LiveKit says nobody is speaking — remote user updated, local unchanged
|
||||
// Server says nobody is speaking — remote user updated, local unchanged
|
||||
setSpeakers({ channel_id: 10, speakers: [], threshold_mode: "forwarding" });
|
||||
expect(voiceStore.getState().voiceUsers.get(10)?.get(2)?.speaking).toBe(false);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -25,16 +25,7 @@ linters:
|
||||
- nestif # deeply nested if-blocks
|
||||
- dupl # verbatim duplicated blocks
|
||||
|
||||
# Silent-drop and sentinel-error classes (added 2026-08-30).
|
||||
- exhaustive # a switch over an enum-like type with no default that misses a member
|
||||
- errorlint # errors.Is/As instead of == and type assertions on wrapped errors; %w in Errorf
|
||||
- durationcheck # time.Duration multiplied by a Duration-typed value, e.g. d*time.Second where d is already a Duration
|
||||
|
||||
settings:
|
||||
exhaustive:
|
||||
# An explicit default branch is a deliberate catch-all; only switches without
|
||||
# one, which fall through in silence, must list every member.
|
||||
default-signifies-exhaustive: true
|
||||
staticcheck:
|
||||
checks:
|
||||
- "all"
|
||||
|
||||
+1
-47
@@ -11,15 +11,7 @@ prometheus.
|
||||
- `db/` hand-written query wrappers; `db/dbgen/` is generated (see `db-change`)
|
||||
- `cmd/` executable tooling, one `package main` per subdirectory —
|
||||
`cmd/genprotocol/` regenerates the protocol constants from `protocol/schema.json`,
|
||||
`cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`; add
|
||||
`-profile alpha` for the deterministic B3-7 dataset behind
|
||||
`Server/testdata/snapshots/v1.2.0-alpha.4.sqlite` — regenerate that snapshot
|
||||
only deliberately, per its README),
|
||||
`cmd/dbinventory/` prints the `db`-importer table for
|
||||
`docs/architecture/server-boundaries.md` (exits 1 on an unlisted importer).
|
||||
`cmd/gendocs/` rewrites the route, table and config-key index blocks in
|
||||
`docs/` and must be run as `go run -tags otel,wazero ./cmd/gendocs`
|
||||
(`make docs-verify` fails on drift).
|
||||
`cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`).
|
||||
`scripts/` holds shell/JS tooling only; no Go entry point lives there
|
||||
- `admin/` web admin panel · `updater/` self-update + signature verification ·
|
||||
`plugin/` WASM plugin runtime (`-tags wazero`) · `telemetry/` OTel (`-tags otel`)
|
||||
@@ -42,41 +34,3 @@ prometheus.
|
||||
- Prefer the standard library. `syncutil` exists so lock usage is uniform and
|
||||
detectable; do not hand-roll around it. `Server/invariants/` enforces this
|
||||
at `go test` time; exceptions are greppable via `grep -rn "invariant:allow" Server/`.
|
||||
- Only `db/` and `service/` import `db` freely. Any other production file that
|
||||
imports it needs a row in `invariants/db_import_boundary.go` (`DBImportAllow`)
|
||||
with a disposition and reason — the B3 inventory, which only shrinks. New
|
||||
persistence goes behind a service, not into a handler.
|
||||
- Only `permissions/` calls the raw permission bit helpers (`HasPerm`,
|
||||
`HasAnyPerm`, `HasServerPerm`, `HasAdmin`, `EffectivePerms`,
|
||||
`EffectiveChannelPerms`). Everywhere else resolves a `permissions.Subject`
|
||||
and asks the predicate that owns the property (`CanViewChannel`,
|
||||
`CanAdmitSession`, `CanSendMessage`, `CanType`, `CanJoinVoice`,
|
||||
`CanModerateVoice`) — one predicate per security property, so a call site
|
||||
cannot re-derive half a rule. The residue that predates B2-5 is listed by
|
||||
symbol in `invariants/authz_chokepoint.go` (`AuthzResidueAllow`) with a
|
||||
class, a reason, and the exact helper calls it is frozen at — a row is an
|
||||
inventory, not a licence for the function, so a second raw call inside a
|
||||
listed one still fails. That list only shrinks too.
|
||||
|
||||
## Coverage floor
|
||||
|
||||
`coverage-floor.json` holds the aggregate floor and one floor per core package
|
||||
(`ws`, `service`, `permissions`, `auth`, `db`); `db/dbgen` and `cmd/` are
|
||||
excluded there because they are generated or entry points, and an exclusion is
|
||||
spelled without a trailing slash (`cmd`, not `cmd/`). CI checks it on the Linux
|
||||
leg, after the test steps that share the job. Locally, from `Server/`:
|
||||
|
||||
```bash
|
||||
go test -race ./... -coverprofile=coverage.out -cover
|
||||
bash scripts/coverage-floor.sh coverage.out
|
||||
```
|
||||
|
||||
**Ratchet.** A floor is the **lowest Linux figure observed** for that package,
|
||||
truncated to 0.1, **minus 0.1 where the package varied between runs** — `ws`
|
||||
and the aggregate do vary, because a few `-race` branches in `ws` are
|
||||
timing-dependent and move four or so statements per run. A PR that raises a
|
||||
figure raises that floor in the same PR; the number in the file is what the
|
||||
branch measured, not a stale one. Nobody lowers a floor without a hold-point
|
||||
(HP) entry recording why. Coverage also differs between the Linux and Windows
|
||||
legs, so the floors track the Linux figure and the check runs only there — on
|
||||
Windows the script will report `aggregate` and `ws` under floor, by design.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── Build stage ────────────────────────────────────────────────────────────
|
||||
FROM golang:1.27-bookworm AS builder
|
||||
FROM golang:1.26-bookworm AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
|
||||
+2
-45
@@ -4,10 +4,6 @@
|
||||
# test-deadlock Run the deadlock-detection pass CI also runs.
|
||||
# fuzz Actually fuzz. CI (and plain `go test`) only replays the
|
||||
# committed seed corpus; this generates new inputs.
|
||||
# sim Run the seeded hub simulation long: 10,000 steps per seed.
|
||||
# CI runs its 200 x 20 default through `go test -race ./...`.
|
||||
# bench-baseline Record a benchmark baseline into docs/plans/. Recorded, not
|
||||
# gated: no CI step reads it (that gate is B6's).
|
||||
# cover Per-package coverage (what CI uploads) + a function summary.
|
||||
# cover-all Cross-package coverage — the honest number. See below.
|
||||
# sqlc-generate Regenerate type-safe Go from sqlc.yaml (db/dbgen).
|
||||
@@ -15,15 +11,13 @@
|
||||
# sqlc-install Install the pinned sqlc version into $GOBIN.
|
||||
# protocol-generate Regenerate WS message-type constants (Go + TS) from ../protocol/schema.json.
|
||||
# protocol-verify Fail if the committed protocol constants are stale (used by CI).
|
||||
# docs-generate Regenerate the route/table/config index blocks in ../docs.
|
||||
# docs-verify Fail if those generated blocks are stale (used by CI).
|
||||
# otel-up Start Jaeger + Prometheus for local tracing development.
|
||||
# otel-down Stop and remove the OTel dev containers.
|
||||
|
||||
SQLC_VERSION := $(shell cat sqlc.version)
|
||||
|
||||
.PHONY: test test-deadlock fuzz sim bench-baseline cover cover-all sqlc-install sqlc-generate sqlc-verify \
|
||||
protocol-generate protocol-verify docs-generate docs-verify otel-up otel-down
|
||||
.PHONY: test test-deadlock fuzz cover cover-all sqlc-install sqlc-generate sqlc-verify \
|
||||
protocol-generate protocol-verify otel-up otel-down
|
||||
|
||||
test:
|
||||
go test -race -timeout 20m ./...
|
||||
@@ -54,26 +48,6 @@ fuzz:
|
||||
done; \
|
||||
done
|
||||
|
||||
# The seeded hub simulation (ws/hub_sim_test.go), long form: the default 20
|
||||
# seeds at 10,000 steps each instead of the 200 CI runs. A failure prints a
|
||||
# ready-to-paste OWNCORD_SIM_SEED=... OWNCORD_SIM_STEPS=... replay line.
|
||||
#
|
||||
# No make on Windows? OWNCORD_SIM_STEPS=10000 go test -race -count=1 -run '^TestHubSimulation$' ./ws/
|
||||
SIMSTEPS ?= 10000
|
||||
sim:
|
||||
OWNCORD_SIM_STEPS=$(SIMSTEPS) go test -race -count=1 -run '^TestHubSimulation$$' ./ws/
|
||||
|
||||
# The six Benchmark* the baseline is made of, six repeats each, through
|
||||
# benchstat into docs/plans/b3-bench-baseline-<date>.md. Deliberately local and
|
||||
# deliberately in no workflow: baselines are recorded, not gated (B6 owns the
|
||||
# gate). The script fails if any expected benchmark name is missing from the
|
||||
# run, so a rename cannot silently shorten the table.
|
||||
#
|
||||
# No make on Windows? ./scripts/bench-baseline.sh from Server/ in Git Bash.
|
||||
BENCH_COUNT ?= 6
|
||||
bench-baseline:
|
||||
BENCH_COUNT=$(BENCH_COUNT) ./scripts/bench-baseline.sh
|
||||
|
||||
# Matches the CI invocation. Note that `go test ./... -coverprofile` instruments
|
||||
# each package only for itself, so a package whose code is mostly exercised
|
||||
# through another package's tests reports far lower than its real coverage
|
||||
@@ -117,23 +91,6 @@ protocol-verify:
|
||||
exit 1 ; \
|
||||
)
|
||||
|
||||
# Route, table and config-key indexes in ../docs. Same shape as the two
|
||||
# generator checks above: regenerate, then fail on any diff. The tool also
|
||||
# exits non-zero on its own when a config key is documented nowhere.
|
||||
#
|
||||
# -tags otel,wazero is not optional: /metrics mounts only when the otel build
|
||||
# supplies a Prometheus handler, so the default build would generate an index
|
||||
# missing a production route. The tool refuses to run without it.
|
||||
docs-generate:
|
||||
go run -tags otel,wazero ./cmd/gendocs
|
||||
|
||||
docs-verify:
|
||||
go run -tags otel,wazero ./cmd/gendocs
|
||||
@git diff --exit-code ../docs/api.md ../docs/schema.md ../docs/server-configuration.md || ( \
|
||||
echo "ERROR: generated documentation blocks are stale. Run 'make docs-generate' and commit the result." ; \
|
||||
exit 1 ; \
|
||||
)
|
||||
|
||||
# Phase B Step 8 — local OTel development stack.
|
||||
# Starts Jaeger (traces) and Prometheus (metrics) in Docker.
|
||||
# Jaeger UI: http://localhost:16686
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package app
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build windows
|
||||
|
||||
package app
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -23,11 +23,11 @@ var staticFiles embed.FS
|
||||
//
|
||||
// /api/* — admin REST API (all require a moderation permission; see NewAdminAPI)
|
||||
// /* — embedded static files (SPA; index.html for unknown paths)
|
||||
func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, settings *service.SettingsService, opts ...SetupOptions) http.Handler {
|
||||
func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, opts ...SetupOptions) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Admin REST API mounted at /api
|
||||
r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, roles, settings, opts...))
|
||||
r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, roles, opts...))
|
||||
|
||||
// Static files — serve from the "static" sub-tree of the embedded FS.
|
||||
// The //go:embed static directive in this package embeds as "static/…",
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// http.Handler with all dependencies wired.
|
||||
func TestNewHandler_ReturnsNonNilHandler(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
if h == nil {
|
||||
t.Fatal("NewHandler returned nil handler")
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestNewHandler_ReturnsNonNilHandler(t *testing.T) {
|
||||
// responds with 200 and HTML content (the embedded admin SPA).
|
||||
func TestNewHandler_ServesStaticRoot(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -61,7 +61,7 @@ func TestNewHandler_ServesStaticRoot(t *testing.T) {
|
||||
// Content-Security-Policy header allowing inline scripts and styles.
|
||||
func TestNewHandler_SetsCSPOnRoot(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -77,7 +77,7 @@ func TestNewHandler_SetsCSPOnRoot(t *testing.T) {
|
||||
// through the NewHandler-returned handler (setup/status endpoint is unauthenticated).
|
||||
func TestNewHandler_APIRoutesMounted(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -93,7 +93,7 @@ func TestNewHandler_APIRoutesMounted(t *testing.T) {
|
||||
// /api require a valid token.
|
||||
func TestNewHandler_AuthProtectedRoute(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// /api/stats requires authentication
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stats", nil)
|
||||
@@ -110,7 +110,7 @@ func TestNewHandler_AuthProtectedRoute(t *testing.T) {
|
||||
func TestNewHandler_WithUpdater(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
if h == nil {
|
||||
t.Fatal("NewHandler with updater returned nil handler")
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func TestNewHandler_WithUpdater(t *testing.T) {
|
||||
// (position == 100) can reach backup endpoints.
|
||||
func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// createAdminUser creates an Owner-role user (role_id=1, position=100)
|
||||
ownerToken := createAdminUser(t, database)
|
||||
@@ -157,7 +157,7 @@ func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) {
|
||||
// (position < 100) cannot reach owner-only endpoints.
|
||||
func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// Create admin user (role_id=2, position=80)
|
||||
adminUID, _ := database.CreateUser(context.Background(), "middlewareadmin", "hash", 2)
|
||||
@@ -175,7 +175,7 @@ func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) {
|
||||
// reach owner-only endpoints.
|
||||
func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
memberToken := createMemberUser(t, database)
|
||||
|
||||
@@ -192,7 +192,7 @@ func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) {
|
||||
// rejected before reaching ownerOnlyMiddleware.
|
||||
func TestOwnerOnlyMiddleware_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
|
||||
|
||||
|
||||
+12
-12
@@ -65,7 +65,7 @@ func startSetupLimiterReap(rl *auth.RateLimiter) {
|
||||
// The optional trailing SetupOptions enables the first-run wizard's
|
||||
// config.yaml write-back and restart; without it the setup endpoints keep
|
||||
// their legacy account-only behaviour (the case in most tests).
|
||||
func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, settings *service.SettingsService, opts ...SetupOptions) http.Handler {
|
||||
func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, opts ...SetupOptions) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
var setupOpts SetupOptions
|
||||
@@ -147,36 +147,36 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
|
||||
// API tokens — Owner-only. Minting a network-reachable, revocation-
|
||||
// surviving bearer credential is gated like backups/updates.
|
||||
r.Get("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(handleListAPITokens(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(database, handleListAPITokens(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Post("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(handleCreateAPIToken(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(database, handleCreateAPIToken(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Delete("/tokens/{id}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(handleRevokeAPIToken(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(database, handleRevokeAPIToken(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requirePerm(permissions.ManageServer))
|
||||
r.Get("/settings", handleGetSettings(settings))
|
||||
r.Patch("/settings", handlePatchSettings(settings))
|
||||
r.Get("/settings", handleGetSettings(database))
|
||||
r.Patch("/settings", handlePatchSettings(database))
|
||||
})
|
||||
r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(handleBackup(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Get("/backups", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(handleListBackups()).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(database, handleListBackups()).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Delete("/backups/{name}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(handleDeleteBackup(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(database, handleDeleteBackup(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Post("/backups/{name}/restore", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(handleRestoreBackup(database, hub)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(database, handleRestoreBackup(database, hub)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Get("/updates", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(handleCheckUpdate(u)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(database, handleCheckUpdate(u)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// their own account via the admin panel.
|
||||
func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// The admin user created by createAdminUser has id=1. We try to patch id=1.
|
||||
@@ -37,7 +37,7 @@ func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) {
|
||||
// banned user unbans them and returns 200.
|
||||
func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create and ban a target user first.
|
||||
@@ -62,7 +62,7 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) {
|
||||
// expiry so the ban lapses on its own.
|
||||
func TestAdminAPI_PatchUser_TempBan(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "tempbanme", "hash", 3)
|
||||
@@ -86,7 +86,7 @@ func TestAdminAPI_PatchUser_TempBan(t *testing.T) {
|
||||
// TestAdminAPI_PatchUser_TempBanOutOfRange verifies duration bounds are enforced.
|
||||
func TestAdminAPI_PatchUser_TempBanOutOfRange(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "toolongban", "hash", 3)
|
||||
@@ -103,7 +103,7 @@ func TestAdminAPI_PatchUser_TempBanOutOfRange(t *testing.T) {
|
||||
// TestAdminAPI_PatchUser_InvalidBody verifies that a non-JSON body returns 400.
|
||||
func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "invalidbody", "hash", 3)
|
||||
@@ -125,7 +125,7 @@ func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) {
|
||||
// "type" field causes the channel to be created with type "text".
|
||||
func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{
|
||||
@@ -150,7 +150,7 @@ func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) {
|
||||
// TestAdminAPI_CreateChannel_InvalidBody verifies that a malformed body returns 400.
|
||||
func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader([]byte("not-json")))
|
||||
@@ -170,7 +170,7 @@ func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) {
|
||||
// the URL returns 400.
|
||||
func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/users/notanumber/sessions", token, nil)
|
||||
@@ -186,7 +186,7 @@ func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) {
|
||||
// returns 400.
|
||||
func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "malformed", "text", "", "", 0)
|
||||
@@ -208,7 +208,7 @@ func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) {
|
||||
// to 500 (testing the queryInt cap branch).
|
||||
func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Passing limit=9999 should be silently capped to 500.
|
||||
@@ -225,7 +225,7 @@ func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) {
|
||||
// when no updater is configured.
|
||||
func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
@@ -241,7 +241,7 @@ func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) {
|
||||
// returns 400.
|
||||
func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/channels/notanumber", token, nil)
|
||||
@@ -257,7 +257,7 @@ func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) {
|
||||
// returns 400.
|
||||
func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{"name": "x"}
|
||||
@@ -273,7 +273,7 @@ func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) {
|
||||
// TestAdminAPI_AuditLog_Pagination verifies that limit and offset params work.
|
||||
func TestAdminAPI_AuditLog_Pagination(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create several audit entries.
|
||||
@@ -304,7 +304,7 @@ func TestAdminAPI_AuditLog_Pagination(t *testing.T) {
|
||||
// hub is nil (the OnlineCount field defaults to 0).
|
||||
func TestAdminAPI_Stats_NilHub(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
|
||||
@@ -331,7 +331,7 @@ func TestAdminAPI_Stats_NilHub(t *testing.T) {
|
||||
// falls back to the default (testing the queryInt error-fallback branch).
|
||||
func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=notanumber", token, nil)
|
||||
@@ -345,7 +345,7 @@ func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) {
|
||||
// the default (testing the n < 1 branch of queryInt).
|
||||
func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// limit=0 triggers the n < 1 fallback in queryInt
|
||||
@@ -363,7 +363,7 @@ func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) {
|
||||
// BroadcastMemberBan).
|
||||
func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "ban-nohub", "hash", 3)
|
||||
@@ -388,7 +388,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) {
|
||||
func TestAdminAPI_LogStreamTicketFlow(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
logBuf := admin.NewRingBuffer(8)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
ticketResp := doRequest(t, handler, http.MethodPost, "/logs/ticket", token, nil)
|
||||
@@ -483,7 +483,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) {
|
||||
// around BroadcastMemberUpdate).
|
||||
func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "role-nohub", "hash", 3)
|
||||
@@ -508,7 +508,7 @@ func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) {
|
||||
// providing ban_reason is accepted (reason defaults to empty string).
|
||||
func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "banwithout", "hash", 3)
|
||||
@@ -529,7 +529,7 @@ func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) {
|
||||
func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "rolebroadcast", "hash", 3)
|
||||
@@ -551,7 +551,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) {
|
||||
// needs_setup=true when the database has no users.
|
||||
func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil)
|
||||
|
||||
@@ -571,7 +571,7 @@ func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) {
|
||||
// TestAdminAPI_SetupStatus_AlreadySetup verifies needs_setup=false when users exist.
|
||||
func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
_, _ = database.CreateUser(context.Background(), "existing", "hash", 1)
|
||||
|
||||
@@ -592,7 +592,7 @@ func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) {
|
||||
// session, channel, and invite.
|
||||
func TestAdminAPI_Setup_Success(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
body := map[string]string{
|
||||
"username": "owner",
|
||||
@@ -623,7 +623,7 @@ func TestAdminAPI_Setup_Success(t *testing.T) {
|
||||
// when users already exist.
|
||||
func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
_, _ = database.CreateUser(context.Background(), "existing", "hash", 1)
|
||||
|
||||
@@ -642,7 +642,7 @@ func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) {
|
||||
// username or password returns 400.
|
||||
func TestAdminAPI_Setup_MissingFields(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
body := map[string]string{
|
||||
"username": "",
|
||||
@@ -658,7 +658,7 @@ func TestAdminAPI_Setup_MissingFields(t *testing.T) {
|
||||
// TestAdminAPI_Setup_WeakPassword verifies that a weak password is rejected.
|
||||
func TestAdminAPI_Setup_WeakPassword(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
body := map[string]string{
|
||||
"username": "owner",
|
||||
@@ -674,7 +674,7 @@ func TestAdminAPI_Setup_WeakPassword(t *testing.T) {
|
||||
// TestAdminAPI_Setup_InvalidBody verifies that a non-JSON body returns 400.
|
||||
func TestAdminAPI_Setup_InvalidBody(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewReader([]byte("not-json")))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
+60
-66
@@ -27,12 +27,6 @@ func newTestModService(database *db.DB) *service.ModerationService {
|
||||
return service.NewModerationService(st, service.NewPermissionService(st, checker))
|
||||
}
|
||||
|
||||
// newTestSettingsService builds a real SettingsService over the test
|
||||
// database so the settings routes exercise the same policy production runs.
|
||||
func newTestSettingsService(database *db.DB) *service.SettingsService {
|
||||
return service.NewSettingsService(database)
|
||||
}
|
||||
|
||||
// newTestRoleService builds a real RoleService over the test database so the
|
||||
// role routes exercise the production authorization (MANAGE_ROLES + hierarchy)
|
||||
// instead of a stub.
|
||||
@@ -268,7 +262,7 @@ func doRequest(t *testing.T, handler http.Handler, method, path, token string, b
|
||||
|
||||
func TestAdminAPI_Stats_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
|
||||
@@ -291,7 +285,7 @@ func TestAdminAPI_Stats_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Stats_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
|
||||
|
||||
@@ -302,7 +296,7 @@ func TestAdminAPI_Stats_Unauthenticated(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Stats_Forbidden(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createMemberUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
|
||||
@@ -316,7 +310,7 @@ func TestAdminAPI_Stats_Forbidden(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListUsers_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil)
|
||||
@@ -337,7 +331,7 @@ func TestAdminAPI_ListUsers_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// No query params — should use defaults
|
||||
@@ -350,7 +344,7 @@ func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", "", nil)
|
||||
|
||||
@@ -368,7 +362,7 @@ func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) {
|
||||
// owner via the raw UPDATE).
|
||||
func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
ownerToken := createAdminUser(t, database) // Owner role (pos 100)
|
||||
|
||||
// A second owner-rank user: equal position, cannot be banned.
|
||||
@@ -431,7 +425,7 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchUser_BanUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a target user
|
||||
@@ -459,7 +453,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "rolechange", "hash", 3)
|
||||
@@ -481,7 +475,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchUser_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{"banned": true}
|
||||
@@ -494,7 +488,7 @@ func TestAdminAPI_PatchUser_NotFound(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchUser_InvalidID(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil)
|
||||
@@ -508,7 +502,7 @@ func TestAdminAPI_PatchUser_InvalidID(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ForceLogout_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "logoutme", "hash", 3)
|
||||
@@ -528,7 +522,7 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil)
|
||||
|
||||
@@ -541,7 +535,7 @@ func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListChannels_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
_, _ = database.AdminCreateChannel(context.Background(), "general", "text", "", "", 0)
|
||||
@@ -565,7 +559,7 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_CreateChannel_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{
|
||||
@@ -592,7 +586,7 @@ func TestAdminAPI_CreateChannel_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_CreateChannel_MissingName(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{
|
||||
@@ -609,7 +603,7 @@ func TestAdminAPI_CreateChannel_MissingName(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_UpdateChannel_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "old", "text", "", "", 0)
|
||||
@@ -630,7 +624,7 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{"name": "x"}
|
||||
@@ -645,7 +639,7 @@ func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_DeleteChannel_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "del-me", "text", "", "", 0)
|
||||
@@ -665,7 +659,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) {
|
||||
func TestAdminAPI_DeleteChannel_CleansVoiceBeforeDBDelete(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// The minimal admin schema has no voice_states; create it with the real
|
||||
@@ -728,7 +722,7 @@ func TestAdminAPI_DeleteChannel_CleansVoiceBeforeDBDelete(t *testing.T) {
|
||||
func TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "del-race", "voice", "", "", 0)
|
||||
@@ -754,7 +748,7 @@ func TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil)
|
||||
@@ -768,7 +762,7 @@ func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_AuditLog_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
uid, _ := database.CreateUser(context.Background(), "actor", "hash", 1)
|
||||
@@ -791,7 +785,7 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_AuditLog_Empty(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil)
|
||||
@@ -811,7 +805,7 @@ func TestAdminAPI_AuditLog_Empty(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_GetSettings_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/settings", token, nil)
|
||||
@@ -833,7 +827,7 @@ func TestAdminAPI_GetSettings_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchSettings_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -858,7 +852,7 @@ func TestAdminAPI_PatchSettings_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json")))
|
||||
@@ -875,7 +869,7 @@ func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Backup_RequiresOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// Admin (role 2) can authenticate but is not Owner (role 1, position 100)
|
||||
adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2)
|
||||
@@ -892,7 +886,7 @@ func TestAdminAPI_Backup_RequiresOwner(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Backup_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
|
||||
|
||||
@@ -909,7 +903,7 @@ func TestAdminAPI_Backup_Unauthenticated(t *testing.T) {
|
||||
// which logs an audit entry containing the actor_id.
|
||||
func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a target user to act on.
|
||||
@@ -943,7 +937,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) {
|
||||
// DELETE /users/{id}/sessions path.
|
||||
func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "logoutctx", "hash", 3)
|
||||
@@ -975,7 +969,7 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) {
|
||||
// returns 400 without writing anything to the database.
|
||||
func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -1001,7 +995,7 @@ func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) {
|
||||
// containing both valid and invalid keys is rejected entirely (no partial write).
|
||||
func TestAdminAPI_PatchSettings_RejectsMixedKeys(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -1045,7 +1039,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) {
|
||||
for _, key := range whitelistedKeys {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
value := "testvalue"
|
||||
@@ -1066,7 +1060,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) {
|
||||
// (no-op update) is accepted and returns the current settings.
|
||||
func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{}
|
||||
@@ -1079,7 +1073,7 @@ func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -1095,7 +1089,7 @@ func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing
|
||||
|
||||
func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrationClosed(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil {
|
||||
@@ -1115,7 +1109,7 @@ func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrat
|
||||
|
||||
func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -1137,7 +1131,7 @@ func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) {
|
||||
// re-run the enrollment count for a key nobody asked to change).
|
||||
func TestAdminAPI_PatchSettings_UnrelatedKeyNotBlockedByRequire2FAGate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Enroll the admin so the initial require_2fa enable succeeds.
|
||||
@@ -1183,7 +1177,7 @@ func TestAdminAPI_PatchSettings_UnrelatedKeyNotBlockedByRequire2FAGate(t *testin
|
||||
// expose the PasswordHash field in any returned user object.
|
||||
func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a second user so the list is non-trivial.
|
||||
@@ -1210,7 +1204,7 @@ func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) {
|
||||
// expose the TOTPSecret field.
|
||||
func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
|
||||
@@ -1229,7 +1223,7 @@ func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) {
|
||||
// are still present after the sensitive-field removal.
|
||||
func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
|
||||
@@ -1258,7 +1252,7 @@ func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) {
|
||||
// not expose PasswordHash in the returned user object.
|
||||
func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "patchvictim", "topsecretbcrypt", 3)
|
||||
@@ -1286,7 +1280,7 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) {
|
||||
// not expose TOTPSecret in the returned user object.
|
||||
func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "patchtotp", "hash", 3)
|
||||
@@ -1387,7 +1381,7 @@ func (m *mockHub) ClientCount() int {
|
||||
func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{
|
||||
@@ -1410,7 +1404,7 @@ func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) {
|
||||
func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
// nil hub: handler must not panic
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{"name": "safe-channel", "type": "text"}
|
||||
@@ -1424,7 +1418,7 @@ func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "before", "text", "", "", 0)
|
||||
@@ -1445,7 +1439,7 @@ func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "patchme", "text", "", "", 0)
|
||||
@@ -1460,7 +1454,7 @@ func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "delete-me", "text", "", "", 0)
|
||||
@@ -1480,7 +1474,7 @@ func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "del-no-hub", "text", "", "", 0)
|
||||
@@ -1509,7 +1503,7 @@ func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
func TestAdminAPI_DeleteChannel_SurvivesContextCancelAfterArchiveCommits(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "del-cancel-race", "text", "", "", 0)
|
||||
@@ -1549,7 +1543,7 @@ func TestAdminAPI_DeleteChannel_SurvivesContextCancelAfterArchiveCommits(t *test
|
||||
|
||||
func TestAdminAPI_CreateAPIToken_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database) // Owner role
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "ci-bot"})
|
||||
@@ -1579,7 +1573,7 @@ func TestAdminAPI_CreateAPIToken_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": " "})
|
||||
@@ -1594,7 +1588,7 @@ func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) {
|
||||
// negative value down the nil-expiresAt ("never expires") branch.
|
||||
func TestAdminAPI_CreateAPIToken_NegativeExpiresHours(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "neg-hours", "expires_hours": -1})
|
||||
@@ -1615,7 +1609,7 @@ func TestAdminAPI_CreateAPIToken_NegativeExpiresHours(t *testing.T) {
|
||||
// timestamp and hand back a token that 401s on first use.
|
||||
func TestAdminAPI_CreateAPIToken_HugeExpiresHours(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "huge-hours", "expires_hours": 3000000})
|
||||
@@ -1633,7 +1627,7 @@ func TestAdminAPI_CreateAPIToken_HugeExpiresHours(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListAPITokens_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
hash := auth.HashToken("raw-secret-value")
|
||||
@@ -1662,7 +1656,7 @@ func TestAdminAPI_ListAPITokens_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_RevokeAPIToken_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
hash := auth.HashToken("revoke-me")
|
||||
@@ -1684,7 +1678,7 @@ func TestAdminAPI_RevokeAPIToken_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_RevokeAPIToken_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/tokens/99999", token, nil)
|
||||
@@ -1698,7 +1692,7 @@ func TestAdminAPI_RevokeAPIToken_NotFound(t *testing.T) {
|
||||
// survives password change + bulk logout).
|
||||
func TestAdminAPI_Tokens_RequiresOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) // Admin, not Owner
|
||||
token := "admin-only-token"
|
||||
@@ -1712,7 +1706,7 @@ func TestAdminAPI_Tokens_RequiresOwner(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Tokens_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/tokens", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/admin"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/db/audittest"
|
||||
"github.com/J3vb/OwnCord/Server/permissions"
|
||||
)
|
||||
|
||||
// TestAuditCoverage_AdminMutations is the B2-6 audit table for the
|
||||
// admin-owned security-sensitive mutations: channel permission edits (role
|
||||
// and user layer), API-token create/revoke, settings changes and the setup
|
||||
// wizard's config write. The closing subtest runs the detail denylist over
|
||||
// the recorded corpus (plan docs/plans/b2-protocol-trust-compat-2026-08-28.md
|
||||
// § B2-6).
|
||||
func TestAuditCoverage_AdminMutations(t *testing.T) {
|
||||
|
||||
// fixture returns a handler, an owner token and a channel id, with the
|
||||
// recorder installed after seeding.
|
||||
fixture := func(t *testing.T) (http.Handler, *db.DB, string, int64) {
|
||||
t.Helper()
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, &mockPermInvalidator{},
|
||||
newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
return handler, database, token, chID
|
||||
}
|
||||
rows := []struct {
|
||||
name string
|
||||
action string
|
||||
run func(t *testing.T) (*audittest.Recorder, []string)
|
||||
}{
|
||||
{"channel role perms set", "channel_perms_update", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
handler, database, token, chID := fixture(t)
|
||||
rec := audittest.Install(t, database)
|
||||
w := doRequest(t, handler, http.MethodPut, "/channels/"+itoa(chID)+"/permissions/3", token,
|
||||
map[string]any{"allow": 0, "deny": permissions.ReadMessages})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
return rec, nil
|
||||
}},
|
||||
{"channel role perms clear", "channel_perms_clear", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
handler, database, token, chID := fixture(t)
|
||||
if w := doRequest(t, handler, http.MethodPut, "/channels/"+itoa(chID)+"/permissions/3", token,
|
||||
map[string]any{"allow": 0, "deny": permissions.ReadMessages}); w.Code != http.StatusOK {
|
||||
t.Fatalf("seed override: status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
rec := audittest.Install(t, database)
|
||||
w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID)+"/permissions/3", token, nil)
|
||||
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
return rec, nil
|
||||
}},
|
||||
{"channel user perms set", "channel_user_perms_update", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
handler, database, token, chID := fixture(t)
|
||||
target := seedOverrideTarget(t, database, "override-target")
|
||||
rec := audittest.Install(t, database)
|
||||
w := doRequest(t, handler, http.MethodPut, "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token,
|
||||
map[string]any{"allow": permissions.ReadMessages, "deny": permissions.SendMessages})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
return rec, nil
|
||||
}},
|
||||
{"channel user perms clear", "channel_user_perms_clear", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
handler, database, token, chID := fixture(t)
|
||||
target := seedOverrideTarget(t, database, "override-target")
|
||||
if w := doRequest(t, handler, http.MethodPut, "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token,
|
||||
map[string]any{"allow": permissions.ReadMessages, "deny": permissions.SendMessages}); w.Code != http.StatusOK {
|
||||
t.Fatalf("seed override: status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
rec := audittest.Install(t, database)
|
||||
w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token, nil)
|
||||
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
return rec, nil
|
||||
}},
|
||||
{"api token create", "api_token_create", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
handler, database, token, _ := fixture(t)
|
||||
rec := audittest.Install(t, database)
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token,
|
||||
map[string]any{"label": "ci bot", "username": "adminuser"})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
return rec, []string{resp.Token, token}
|
||||
}},
|
||||
{"api token revoke", "api_token_revoke", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
handler, database, token, _ := fixture(t)
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token,
|
||||
map[string]any{"label": "ci bot", "username": "adminuser"})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("seed token: status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
ID int64 `json:"id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
rec := audittest.Install(t, database)
|
||||
if w := doRequest(t, handler, http.MethodDelete, "/tokens/"+itoa(resp.ID), token, nil); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
return rec, []string{resp.Token, token}
|
||||
}},
|
||||
{"setting change", "setting_change", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
handler, database, token, _ := fixture(t)
|
||||
rec := audittest.Install(t, database)
|
||||
w := doRequest(t, handler, http.MethodPatch, "/settings", token,
|
||||
map[string]string{"motd": "welcome 4d0d1405"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
return rec, []string{"welcome 4d0d1405", token}
|
||||
}},
|
||||
{"config write (setup wizard)", "config_write", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
database := openAdminTestDB(t)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
handler := wizardHandler(t, database, cfgPath, make(chan string, 1))
|
||||
rec := audittest.Install(t, database)
|
||||
const password = "SecurePass123!"
|
||||
w := doRequest(t, handler, http.MethodPost, "/setup", "", map[string]any{
|
||||
"username": "owner",
|
||||
"password": password,
|
||||
"wizard": map[string]any{"server_name": "Audit Server"},
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
return rec, []string{password, resp.Token}
|
||||
}},
|
||||
}
|
||||
|
||||
var corpus []db.AuditEntry
|
||||
var secrets []string
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
rec, s := row.run(t)
|
||||
rec.Wait(t, row.action)
|
||||
corpus = append(corpus, rec.Entries()...)
|
||||
secrets = append(secrets, s...)
|
||||
})
|
||||
}
|
||||
t.Run("detail denylist", func(t *testing.T) {
|
||||
if len(corpus) == 0 {
|
||||
t.Fatal("no audit entries recorded")
|
||||
}
|
||||
audittest.AssertSafeDetails(t, corpus, secrets...)
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
)
|
||||
|
||||
// Scheduled-backup intervals for the backup_schedule setting values the admin
|
||||
@@ -36,8 +35,8 @@ const (
|
||||
//
|
||||
// The returned error feeds the maintenance loop's circuit breaker; settings
|
||||
// simply not existing (fresh DB mid-migration) is not an error.
|
||||
func MaintainBackups(ctx context.Context, database *db.DB, settings *service.SettingsService) error {
|
||||
schedule, err := settings.Setting(ctx, "backup_schedule")
|
||||
func MaintainBackups(ctx context.Context, database *db.DB) error {
|
||||
schedule, err := database.GetSetting(ctx, "backup_schedule")
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil
|
||||
@@ -61,7 +60,7 @@ func MaintainBackups(ctx context.Context, database *db.DB, settings *service.Set
|
||||
}
|
||||
}
|
||||
|
||||
if err := pruneExpiredBackups(ctx, database, settings); err != nil {
|
||||
if err := pruneExpiredBackups(ctx, database); err != nil {
|
||||
slog.Warn("backup retention pruning failed", "error", err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
@@ -119,8 +118,8 @@ func runScheduledBackup(ctx context.Context, database *db.DB, interval time.Dura
|
||||
|
||||
// pruneExpiredBackups deletes *.db backups whose mtime is older than the
|
||||
// backup_retention window (in days), always keeping the newest one.
|
||||
func pruneExpiredBackups(ctx context.Context, database *db.DB, settings *service.SettingsService) error {
|
||||
retStr, err := settings.Setting(ctx, "backup_retention")
|
||||
func pruneExpiredBackups(ctx context.Context, database *db.DB) error {
|
||||
retStr, err := database.GetSetting(ctx, "backup_retention")
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/admin"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
)
|
||||
|
||||
func listBackupFiles(t *testing.T, dir string) []string {
|
||||
@@ -50,7 +49,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Settings absent → no-op, no error.
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
t.Fatalf("MaintainBackups with no settings: %v", err)
|
||||
}
|
||||
if got := listBackupFiles(t, dir); len(got) != 0 {
|
||||
@@ -60,7 +59,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
// Schedule off → still a no-op.
|
||||
mustSetSetting(t, database, "backup_schedule", "off")
|
||||
mustSetSetting(t, database, "backup_retention", "7")
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
t.Fatalf("MaintainBackups with schedule=off: %v", err)
|
||||
}
|
||||
if got := listBackupFiles(t, dir); len(got) != 0 {
|
||||
@@ -69,7 +68,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
|
||||
// Daily → first tick creates exactly one scheduled backup.
|
||||
mustSetSetting(t, database, "backup_schedule", "daily")
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
t.Fatalf("MaintainBackups daily #1: %v", err)
|
||||
}
|
||||
files := listBackupFiles(t, dir)
|
||||
@@ -79,7 +78,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
first := filepath.Join(dir, files[0])
|
||||
|
||||
// Fresh backup on disk → next tick is a no-op.
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
t.Fatalf("MaintainBackups daily #2: %v", err)
|
||||
}
|
||||
if got := listBackupFiles(t, dir); len(got) != 1 {
|
||||
@@ -89,7 +88,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
// Backup older than a day (but inside retention) → a new one is taken and
|
||||
// the old one is kept.
|
||||
backdate(t, first, 25*time.Hour)
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
t.Fatalf("MaintainBackups daily #3: %v", err)
|
||||
}
|
||||
if got := listBackupFiles(t, dir); len(got) != 2 {
|
||||
@@ -98,7 +97,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
|
||||
// Old backup past the 7-day retention window → pruned; the fresh one stays.
|
||||
backdate(t, first, 8*24*time.Hour)
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
t.Fatalf("MaintainBackups daily #4: %v", err)
|
||||
}
|
||||
got := listBackupFiles(t, dir)
|
||||
@@ -133,7 +132,7 @@ func TestMaintainBackups_RetentionNeverDeletesNewest(t *testing.T) {
|
||||
backdate(t, older, 30*24*time.Hour)
|
||||
backdate(t, newer, 20*24*time.Hour)
|
||||
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
t.Fatalf("MaintainBackups: %v", err)
|
||||
}
|
||||
got := listBackupFiles(t, dir)
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
func TestAdminAPI_PatchChannel_ArchiveCleansVoice(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "archive-voice", "voice", "", "", 0)
|
||||
@@ -47,7 +47,7 @@ func TestAdminAPI_PatchChannel_ArchiveCleansVoice(t *testing.T) {
|
||||
func TestAdminAPI_PatchChannel_UnarchiveDoesNotCleanVoice(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "unarchive-voice", "voice", "", "", 0)
|
||||
@@ -89,7 +89,7 @@ func TestAdminAPI_PatchChannel_UnarchiveDoesNotCleanVoice(t *testing.T) {
|
||||
func TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "archive-cancel-race", "voice", "", "", 0)
|
||||
@@ -138,7 +138,7 @@ func TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(t *testin
|
||||
func TestAdminAPI_CreateChannel_SurvivesContextCancelAfterCommit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -46,7 +46,7 @@ func chdirTemp(t *testing.T) string {
|
||||
func TestHandleBackup_Success(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
|
||||
@@ -81,7 +81,7 @@ func TestHandleBackup_Success(t *testing.T) {
|
||||
func TestHandleBackup_RequiresOwner(t *testing.T) {
|
||||
_ = chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
adminUID, _ := database.CreateUser(context.Background(), "backupadmin", "hash", 2)
|
||||
token := "backup-admin-token"
|
||||
@@ -101,7 +101,7 @@ func TestHandleBackup_RequiresOwner(t *testing.T) {
|
||||
func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) {
|
||||
_ = chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/backups", token, nil)
|
||||
@@ -124,7 +124,7 @@ func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) {
|
||||
func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) {
|
||||
_ = chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a backup first.
|
||||
@@ -166,7 +166,7 @@ func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) {
|
||||
func TestHandleDeleteBackup_Success(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a real backup file to delete.
|
||||
@@ -197,7 +197,7 @@ func TestHandleDeleteBackup_Success(t *testing.T) {
|
||||
func TestHandleDeleteBackup_NotFound(t *testing.T) {
|
||||
_ = chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/backups/nonexistent.db", token, nil)
|
||||
@@ -212,7 +212,7 @@ func TestHandleDeleteBackup_NotFound(t *testing.T) {
|
||||
func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) {
|
||||
_ = chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// The chi router URL-decodes the path parameter, so ".." arrives decoded.
|
||||
@@ -230,7 +230,7 @@ func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) {
|
||||
func TestHandleDeleteBackup_RequiresOwner(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
adminUID, _ := database.CreateUser(context.Background(), "deladmin", "hash", 2)
|
||||
token := "del-admin-token"
|
||||
@@ -255,7 +255,7 @@ func TestHandleDeleteBackup_RequiresOwner(t *testing.T) {
|
||||
func TestHandleRestoreBackup_Success(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Set up backup and data directories.
|
||||
@@ -359,7 +359,7 @@ func TestHandleRestoreBackup_Success(t *testing.T) {
|
||||
func TestHandleRestoreBackup_RollsBackWhenCopyFails(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
@@ -453,7 +453,7 @@ func TestHandleRestoreBackup_RollsBackWhenCopyFails(t *testing.T) {
|
||||
func TestHandleRestoreBackup_RestartsWhenCloseFails(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
@@ -500,7 +500,7 @@ func TestHandleRestoreBackup_RestartsWhenCloseFails(t *testing.T) {
|
||||
func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
@@ -556,7 +556,7 @@ func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) {
|
||||
func TestHandleRestoreBackup_UsesConfiguredDatabasePath(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
@@ -622,7 +622,7 @@ func TestHandleRestoreBackup_UsesConfiguredDatabasePath(t *testing.T) {
|
||||
func TestHandleRestoreBackup_NotFound(t *testing.T) {
|
||||
_ = chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backups/missing.db/restore", token, nil)
|
||||
@@ -637,7 +637,7 @@ func TestHandleRestoreBackup_NotFound(t *testing.T) {
|
||||
func TestHandleRestoreBackup_InvalidName(t *testing.T) {
|
||||
_ = chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backups/..evil.db/restore", token, nil)
|
||||
@@ -653,7 +653,7 @@ func TestHandleRestoreBackup_InvalidName(t *testing.T) {
|
||||
func TestHandleListBackups_ErrorReadingDir(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create data/ directory but make "backups" a file instead of a directory.
|
||||
@@ -681,7 +681,7 @@ func TestHandleListBackups_ErrorReadingDir(t *testing.T) {
|
||||
func TestHandleRestoreBackup_RequiresOwner(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
adminUID, _ := database.CreateUser(context.Background(), "restoreadmin", "hash", 2)
|
||||
token := "restore-admin-token"
|
||||
|
||||
@@ -29,7 +29,7 @@ func (m *mockPermInvalidator) InvalidateAll() {
|
||||
|
||||
func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
|
||||
@@ -67,7 +67,7 @@ func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) {
|
||||
|
||||
func TestGetChannelPermissions_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/channels/9999/permissions", token, nil)
|
||||
@@ -78,7 +78,7 @@ func TestGetChannelPermissions_NotFound(t *testing.T) {
|
||||
|
||||
func TestGetChannelPermissions_DMRejected(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "dm-chan", "dm", "", "", 0)
|
||||
@@ -99,7 +99,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
|
||||
@@ -160,7 +160,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) {
|
||||
|
||||
func TestPutChannelPermission_MasksUnknownBits(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret2", "text", "", "", 0)
|
||||
@@ -190,7 +190,7 @@ func TestPutChannelPermission_MasksUnknownBits(t *testing.T) {
|
||||
|
||||
func TestPutChannelPermission_UnknownRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret3", "text", "", "", 0)
|
||||
@@ -207,7 +207,7 @@ func TestPutChannelPermission_UnknownRole(t *testing.T) {
|
||||
|
||||
func TestPutChannelPermission_NonAdminForbidden(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_ = createAdminUser(t, database)
|
||||
memberToken := createMemberUser(t, database)
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestPutChannelPermission_NonAdminForbidden(t *testing.T) {
|
||||
// a channel override — the escalation this override endpoint must refuse.
|
||||
func TestPutChannelPermission_ModeratorCannotEscalate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "escalate", "text", "", "", 0)
|
||||
@@ -258,7 +258,7 @@ func TestPutChannelPermission_ModeratorCannotEscalate(t *testing.T) {
|
||||
// a channel override, since ADMINISTRATOR bypasses the escalation guard.
|
||||
func TestPutChannelPermission_AdministratorCanGrantAnyBit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "admin-grant", "text", "", "", 0)
|
||||
@@ -287,7 +287,7 @@ func TestPutChannelPermission_AdministratorCanGrantAnyBit(t *testing.T) {
|
||||
// the actor's own mask — mirroring service.requireBelowActor.
|
||||
func TestPutChannelPermission_RefusesEqualOrHigherRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "hierarchy", "text", "", "", 0)
|
||||
@@ -320,7 +320,7 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret5", "text", "", "", 0)
|
||||
@@ -373,7 +373,7 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) {
|
||||
// TestPutChannelPermission_RefusesEqualOrHigherRole (A-2026-08-01).
|
||||
func TestDeleteChannelPermission_RefusesEqualOrHigherRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "hierarchy-del", "text", "", "", 0)
|
||||
@@ -417,7 +417,7 @@ func TestDeleteChannelPermission_RefusesEqualOrHigherRole(t *testing.T) {
|
||||
// (TestPutChannelPermission_UnknownRole).
|
||||
func TestDeleteChannelPermission_UnknownRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "hierarchy-del-404", "text", "", "", 0)
|
||||
@@ -440,7 +440,7 @@ func TestDeleteChannelPermission_UnknownRole(t *testing.T) {
|
||||
// not skip it just because the hierarchy guard alone passes.
|
||||
func TestDeleteChannelPermission_EscalationGuard(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// Helper role: low position, base permissions include MANAGE_MESSAGES.
|
||||
if _, err := database.ExecContext(context.Background(),
|
||||
@@ -485,7 +485,7 @@ func TestDeleteChannelPermission_EscalationGuard(t *testing.T) {
|
||||
// by this write, not just the (trivially empty) bits being written.
|
||||
func TestPutChannelPermission_ClearByZeroMaskEscalationGuard(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
if _, err := database.ExecContext(context.Background(),
|
||||
`INSERT INTO roles (id, name, color, permissions, position, is_default)
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestPutChannelUserPermission_PersistsInvalidatesAndAudits(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "override-target")
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestPutChannelUserPermission_PersistsInvalidatesAndAudits(t *testing.T) {
|
||||
// editor writes — one bit per row, in both directions at once.
|
||||
func TestPutChannelUserPermission_MaskRoundTrip(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "matrix-target")
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestPutChannelUserPermission_MaskRoundTrip(t *testing.T) {
|
||||
|
||||
func TestPutChannelUserPermission_MasksUnknownBits(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "junk-target")
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestPutChannelUserPermission_MasksUnknownBits(t *testing.T) {
|
||||
|
||||
func TestPutChannelUserPermission_UnknownUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "nope", "text", "", "", 0)
|
||||
@@ -179,7 +179,7 @@ func TestPutChannelUserPermission_UnknownUser(t *testing.T) {
|
||||
|
||||
func TestPutChannelUserPermission_DMRejected(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "dm-target")
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestPutChannelUserPermission_DMRejected(t *testing.T) {
|
||||
|
||||
func TestChannelUserPermission_NonAdminForbidden(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_ = createAdminUser(t, database)
|
||||
memberToken := createMemberUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "forbidden-target")
|
||||
@@ -231,7 +231,7 @@ func TestChannelUserPermission_NonAdminForbidden(t *testing.T) {
|
||||
// writing it into a per-user channel override.
|
||||
func TestPutChannelUserPermission_ModeratorCannotEscalate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
target := seedOverrideTarget(t, database, "escalate-target")
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestPutChannelUserPermission_ModeratorCannotEscalate(t *testing.T) {
|
||||
// access their role grants.
|
||||
func TestPutChannelUserPermission_CannotTargetHigherRankedUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
// Actor: Moderator at position 60 holding MANAGE_CHANNELS + READ_MESSAGES.
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "mod-hier")
|
||||
// Target holds a role ranked ABOVE the actor.
|
||||
@@ -300,7 +300,7 @@ func TestPutChannelUserPermission_CannotTargetHigherRankedUser(t *testing.T) {
|
||||
// override, since ADMINISTRATOR bypasses the escalation guard.
|
||||
func TestPutChannelUserPermission_AdministratorCanGrantAnyBit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "admin-grant-target")
|
||||
|
||||
@@ -329,7 +329,7 @@ func TestDeleteChannelUserPermission_ClearsOverride(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "clear-target")
|
||||
|
||||
@@ -391,7 +391,7 @@ func TestDeleteChannelUserPermission_ClearsOverride(t *testing.T) {
|
||||
// passes.
|
||||
func TestDeleteChannelUserPermission_EscalationGuard(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
// Actor: MANAGE_CHANNELS holder without MANAGE_MESSAGES or ADMINISTRATOR.
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
|
||||
target := seedOverrideTarget(t, database, "escalate-del-target")
|
||||
@@ -424,7 +424,7 @@ func TestDeleteChannelUserPermission_EscalationGuard(t *testing.T) {
|
||||
// (TestPutChannelPermission_ClearByZeroMaskEscalationGuard's per-user twin).
|
||||
func TestPutChannelUserPermission_ClearByZeroMaskEscalationGuard(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
|
||||
target := seedOverrideTarget(t, database, "escalate-zero-target")
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
func newChannelTestAPI(t *testing.T) (http.Handler, string, *db.DB) {
|
||||
t.Helper()
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
return handler, createAdminUser(t, database), database
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ func TestPatchChannel_RejectedPatchWritesNothing(t *testing.T) {
|
||||
func TestPatchChannel_BroadcastCarriesFeatureFlags(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
id := newChannel(t, handler, token, "lounge", "voice")
|
||||
|
||||
@@ -25,7 +25,7 @@ func newRolesHandler(t *testing.T, database *db.DB) (http.Handler, *mockHub, *mo
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv,
|
||||
newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
newTestModService(database), newTestRoleService(database))
|
||||
return handler, hub, inv, createAdminUser(t, database)
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestAdminAPI_Roles_ServiceUnavailableFailsClosed(t *testing.T) {
|
||||
// nil RoleService: the routes must refuse rather than fall through to an
|
||||
// unchecked write.
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil,
|
||||
newTestModService(database), nil, newTestSettingsService(database))
|
||||
newTestModService(database), nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
)
|
||||
|
||||
// ─── Settings Handlers ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Thin adapters over service.SettingsService (B3-8 settings/audit family):
|
||||
// the whitelist, boolean normalization, require_2fa preconditions, atomic
|
||||
// apply and audit rows all live in the service.
|
||||
|
||||
func handleGetSettings(settings *service.SettingsService) http.HandlerFunc {
|
||||
func handleGetSettings(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
all, err := settings.List(r.Context())
|
||||
settings, err := database.GetAllSettings(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, all)
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
func handlePatchSettings(settings *service.SettingsService) http.HandlerFunc {
|
||||
func handlePatchSettings(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var updates map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
@@ -33,15 +33,144 @@ func handlePatchSettings(settings *service.SettingsService) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
all, err := settings.Patch(r.Context(), actorFromContext(r), updates)
|
||||
if errors.Is(err, service.ErrBadRequest) {
|
||||
// Validate all keys against the whitelist before writing anything so
|
||||
// the operation is atomic from the caller's perspective.
|
||||
for key := range updates {
|
||||
if _, ok := allowedSettingKeys[key]; !ok {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST",
|
||||
fmt.Sprintf("unknown setting key: %q", key))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
normalizedUpdates, err := normalizeSettingUpdates(updates)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update settings")
|
||||
|
||||
if err := validateRequire2FAUpdate(r.Context(), database, normalizedUpdates); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, all)
|
||||
|
||||
actor := actorFromContext(r)
|
||||
|
||||
// Apply all settings atomically so a mid-loop failure doesn't leave
|
||||
// partial updates.
|
||||
tx, err := database.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to start transaction")
|
||||
return
|
||||
}
|
||||
for key, value := range normalizedUpdates {
|
||||
if _, txErr := tx.ExecContext(r.Context(),
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
key, value,
|
||||
); txErr != nil {
|
||||
_ = tx.Rollback()
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to commit settings")
|
||||
return
|
||||
}
|
||||
for key := range normalizedUpdates {
|
||||
slog.Info("setting changed", "actor_id", actor, "key", key)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "setting_change", "setting", 0,
|
||||
fmt.Sprintf("%s updated", key))
|
||||
}
|
||||
|
||||
settings, err := database.GetAllSettings(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSettingUpdates(updates map[string]string) (map[string]string, error) {
|
||||
normalized := make(map[string]string, len(updates))
|
||||
for key, value := range updates {
|
||||
normalized[key] = value
|
||||
switch key {
|
||||
case "require_2fa", "registration_open":
|
||||
parsed, err := parseBooleanSettingValue(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
if parsed {
|
||||
normalized[key] = "1"
|
||||
} else {
|
||||
normalized[key] = "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func validateRequire2FAUpdate(ctx context.Context, database *db.DB, updates map[string]string) error {
|
||||
targetRequire2FA, err := targetBoolSetting(ctx, database, updates, "require_2fa")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !targetRequire2FA {
|
||||
return nil
|
||||
}
|
||||
|
||||
registrationOpen, err := targetBoolSetting(ctx, database, updates, "registration_open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if registrationOpen {
|
||||
return fmt.Errorf("require_2fa cannot be enabled while registration is open")
|
||||
}
|
||||
|
||||
// The enrollment count only matters when this request is actually turning
|
||||
// require_2fa on. Without this guard, an unrelated PATCH (motd, server
|
||||
// name, backup settings, ...) inherits require_2fa's *current* value via
|
||||
// targetBoolSetting's DB fallback and gets rejected by a precondition
|
||||
// about a value it never touches — wedging the whole settings page once
|
||||
// any non-banned user without TOTP exists.
|
||||
if _, changingRequire2FA := updates["require_2fa"]; !changingRequire2FA {
|
||||
return nil
|
||||
}
|
||||
|
||||
count, err := database.CountUsersWithoutTOTP(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate 2FA enrollment")
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("require_2fa cannot be enabled until all users have 2FA enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func targetBoolSetting(ctx context.Context, database *db.DB, updates map[string]string, key string) (bool, error) {
|
||||
if value, ok := updates[key]; ok {
|
||||
return parseBooleanSettingValue(value)
|
||||
}
|
||||
value, err := database.GetSetting(ctx, key)
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return parseBooleanSettingValue(value)
|
||||
}
|
||||
|
||||
func parseBooleanSettingValue(value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true":
|
||||
return true, nil
|
||||
case "0", "false":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid boolean value %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestAdminAPI_PatchUser_RefusedRoleChangeDoesNotLeaveBanCommitted(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// Moderator: BAN_MEMBERS (and everything below bit 20), but not
|
||||
// MANAGE_ROLES (bit 24) — moderatorMask is perm_gates_test.go's constant
|
||||
|
||||
@@ -29,7 +29,7 @@ func (m *unbanMockHub) BroadcastMemberUnban(userID int64) {
|
||||
func TestAdminAPI_PatchUser_UnbanBroadcastsMemberUnban(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &unbanMockHub{mockHub: &mockHub{}}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "unbanbroadcast", "hash", 3)
|
||||
@@ -63,7 +63,7 @@ func TestAdminAPI_PatchUser_UnbanBroadcastsMemberUnban(t *testing.T) {
|
||||
func TestAdminAPI_PatchUser_RoleChangeRefreshesVisibility(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "rolerefresh", "hash", 3)
|
||||
@@ -120,7 +120,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcastsEvenIfRoleReReadFails(t *testing
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
invalidator := &roleDeletingInvalidator{database: database, deleteRoleID: 2, fallbackRoleID: 3}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, invalidator, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, invalidator, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "roleracetarget", "hash", 3)
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestAdminAPI_PatchRole_MemberLookupFailureBlanketInvalidates(t *testing.T)
|
||||
service.NewPermissionService(database, permissions.NewChecker(database)),
|
||||
)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv,
|
||||
newTestModService(database), roleSvc, newTestSettingsService(database))
|
||||
newTestModService(database), roleSvc)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Strip the seeded Member role down to READ_MESSAGES — a permissions
|
||||
|
||||
+1
-10
@@ -1,7 +1,6 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -59,15 +58,7 @@ func queryInt(r *http.Request, key string, defaultVal, minVal, maxVal int) int {
|
||||
// context by adminAuthMiddleware. Returns 0 if called outside that middleware
|
||||
// (should not happen in production).
|
||||
func actorFromContext(r *http.Request) int64 {
|
||||
return ActorIDFromContext(r.Context())
|
||||
}
|
||||
|
||||
// ActorIDFromContext returns the admin principal's user ID that
|
||||
// RequireAdminAuth stored in ctx, or 0 outside that middleware. Exported for
|
||||
// handlers mounted behind RequireAdminAuth from other packages (the plugin
|
||||
// admin surface in api) so their audit rows name the real actor.
|
||||
func ActorIDFromContext(ctx context.Context) int64 {
|
||||
user, ok := ctx.Value(adminUserKey).(*db.User)
|
||||
user, ok := r.Context().Value(adminUserKey).(*db.User)
|
||||
if !ok || user == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestAdminAPI_LogStreamTicketFlow_APIToken(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
logBuf := admin.NewRingBuffer(8)
|
||||
logBuf.Write(admin.LogEntry{Timestamp: "2026-07-31T10:00:00Z", Level: "INFO", Message: "hello from ring", Source: "server"})
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// An admin user authenticated only by an API token — no session row exists.
|
||||
uid, err := database.CreateUser(context.Background(), "apitokenadmin", "$2a$12$placeholder", 1)
|
||||
|
||||
+12
-11
@@ -116,22 +116,23 @@ func requirePerm(perm int64) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// ownerOnlyMiddleware wraps a handler to require the Owner role
|
||||
// (position == permissions.OwnerRolePosition). It consumes the *db.Role that
|
||||
// adminAuthMiddleware resolved and stored in the request context — the same
|
||||
// contract as requirePerm, so no second role read runs and no read-fault
|
||||
// error mapping exists here at all: OC-0345's 503 branch died with the query
|
||||
// it served, and OC-0379 pins the absence (a role read fault now surfaces
|
||||
// once, at the perimeter, as its 503). A request that somehow arrives without
|
||||
// the context role fails closed as unauthenticated.
|
||||
func ownerOnlyMiddleware(next http.Handler) http.Handler {
|
||||
// ownerOnlyMiddleware wraps a handler to require Owner role (position == 100).
|
||||
// It reads the user from context (set by adminAuthMiddleware) rather than
|
||||
// re-authenticating, avoiding redundant DB queries and session-expiry gaps.
|
||||
func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
role, ok := r.Context().Value(adminRoleKey).(*db.Role)
|
||||
if !ok || role == nil {
|
||||
user, ok := r.Context().Value(adminUserKey).(*db.User)
|
||||
if !ok || user == nil {
|
||||
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
role, err := database.GetRoleByID(r.Context(), user.RoleID)
|
||||
if err != nil || role == nil {
|
||||
writeErr(w, http.StatusForbidden, "FORBIDDEN", "role not found")
|
||||
return
|
||||
}
|
||||
|
||||
if role.Position < permissions.OwnerRolePosition {
|
||||
writeErr(w, http.StatusForbidden, "FORBIDDEN", "owner role required")
|
||||
return
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
@@ -140,42 +138,38 @@ CREATE TABLE IF NOT EXISTS invites (
|
||||
// ─── ownerOnlyMiddleware whitebox tests ──────────────────────────────────────
|
||||
|
||||
// TestOwnerOnlyMiddleware_NoUserInContext verifies that ownerOnlyMiddleware
|
||||
// returns 401 when the request context carries no authenticated principal —
|
||||
// simulates a call bypassing adminAuthMiddleware, which is what stores the
|
||||
// role the gate consumes.
|
||||
// returns 401 when there is no user stored in the request context.
|
||||
func TestOwnerOnlyMiddleware_NoUserInContext(t *testing.T) {
|
||||
database := openWhiteboxTestDB(t)
|
||||
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
handler := ownerOnlyMiddleware(database, next)
|
||||
|
||||
// Request with NO user in context — simulates a call bypassing adminAuthMiddleware.
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if reached {
|
||||
t.Error("next handler was reached despite an unauthenticated context")
|
||||
t.Error("next handler was reached despite missing user in context")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerOnlyMiddleware_UserWithoutRoleInContext verifies the gate fails
|
||||
// closed as 401 when the context carries a user but no role. Through the full
|
||||
// stack this cannot happen — adminAuthMiddleware stores both or refuses the
|
||||
// request (a genuinely missing role is its 401, a role read fault its 503) —
|
||||
// so a half-populated context means the perimeter did not run, and the gate
|
||||
// must treat that as unauthenticated rather than consult the database itself.
|
||||
// (The pre-OC-0379 middleware answered this shape by re-reading the role; the
|
||||
// old TestOwnerOnlyMiddleware_RoleNotFound covered that lookup's miss, a
|
||||
// branch that no longer exists.)
|
||||
func TestOwnerOnlyMiddleware_UserWithoutRoleInContext(t *testing.T) {
|
||||
// TestOwnerOnlyMiddleware_RoleNotFound verifies that ownerOnlyMiddleware
|
||||
// returns 403 when the user's role_id does not exist in the database.
|
||||
func TestOwnerOnlyMiddleware_RoleNotFound(t *testing.T) {
|
||||
database := openWhiteboxTestDB(t)
|
||||
|
||||
// Create a user initially with a valid role, then mutate role_id to a
|
||||
// nonexistent value (disabling FK checks temporarily so SQLite allows it).
|
||||
uid, err := database.CreateUser(context.Background(), "orphanuser", "$2a$12$x", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
@@ -185,60 +179,45 @@ func TestOwnerOnlyMiddleware_UserWithoutRoleInContext(t *testing.T) {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
// Disable FK enforcement, update role_id, re-enable.
|
||||
if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil {
|
||||
t.Fatalf("disable FK: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(context.Background(), `UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil {
|
||||
t.Fatalf("UPDATE role_id: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil {
|
||||
t.Fatalf("re-enable FK: %v", err)
|
||||
}
|
||||
user.RoleID = 9999 // mirror the DB value in our in-memory struct
|
||||
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
handler := ownerOnlyMiddleware(database, next)
|
||||
|
||||
// User injected, role deliberately absent.
|
||||
// Inject user into context as adminAuthMiddleware would.
|
||||
ctx := context.WithValue(context.Background(), adminUserKey, user)
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if reached {
|
||||
t.Error("next handler was reached despite no role in context")
|
||||
t.Error("next handler was reached despite missing role")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401 (no role in context is unauthenticated)", w.Code)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403 (role not found)", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp["error"] != "UNAUTHORIZED" {
|
||||
t.Errorf("error = %q, want UNAUTHORIZED", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerOnlyMiddleware_BelowOwnerForbidden verifies a role below the Owner
|
||||
// position is refused with 403 "owner role required". The role comes straight
|
||||
// from the context — the middleware reads nothing else, so a plain struct is
|
||||
// the whole setup.
|
||||
func TestOwnerOnlyMiddleware_BelowOwnerForbidden(t *testing.T) {
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
|
||||
mod := &db.Role{ID: 2, Name: "Moderator", Position: 50}
|
||||
ctx := context.WithValue(context.Background(), adminRoleKey, mod)
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if reached {
|
||||
t.Error("next handler was reached for a below-owner role")
|
||||
}
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403 (owner role required)", w.Code)
|
||||
if resp["error"] != "FORBIDDEN" {
|
||||
t.Errorf("error = %q, want FORBIDDEN", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,10 +234,6 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
role, err := database.GetRoleByID(context.Background(), user.RoleID)
|
||||
if err != nil || role == nil {
|
||||
t.Fatalf("GetRoleByID: %v", err)
|
||||
}
|
||||
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -266,11 +241,9 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
handler := ownerOnlyMiddleware(database, next)
|
||||
|
||||
// Both keys, as adminAuthMiddleware stores them.
|
||||
ctx := context.WithValue(context.Background(), adminUserKey, user)
|
||||
ctx = context.WithValue(ctx, adminRoleKey, role)
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
@@ -289,7 +262,7 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
|
||||
// role_id has been set to a nonexistent value returns 401.
|
||||
func TestAdminAuthMiddleware_RoleNotFound(t *testing.T) {
|
||||
database := openWhiteboxTestDB(t)
|
||||
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
uid, err := database.CreateUser(context.Background(), "noroleuser", "$2a$12$x", 1)
|
||||
if err != nil {
|
||||
@@ -368,7 +341,7 @@ func TestHandleListChannels_DBError(t *testing.T) {
|
||||
// when the database query fails.
|
||||
func TestHandleGetSettings_DBError(t *testing.T) {
|
||||
database := openWhiteboxTestDB(t)
|
||||
handler := handleGetSettings(service.NewSettingsService(database))
|
||||
handler := handleGetSettings(database)
|
||||
|
||||
_ = database.Close()
|
||||
|
||||
@@ -549,60 +522,3 @@ func TestSpawnDetached_CommandConstruction(t *testing.T) {
|
||||
t.Error("cmd.Stderr should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerOnlyMiddleware_RoleLookupFailureIs503 pinned OC-0345's 503 mapping
|
||||
// on the owner gate's own role read. OC-0379 removed that read entirely — the
|
||||
// gate consumes adminRoleKey and issues no query, so the branch this test
|
||||
// exercised no longer exists in any form. The 503-on-read-fault contract it
|
||||
// protected still holds where the one remaining role read lives: the
|
||||
// perimeter's default branch in adminAuthMiddleware (middleware.go), covered
|
||||
// by its own tests. TestOwnerOnlyMiddleware_NoSecondRoleLookup below is the
|
||||
// replacement pin: it renames the roles table away and requires the owner
|
||||
// path to succeed anyway.
|
||||
|
||||
// TestOwnerOnlyMiddleware_NoSecondRoleLookup pins OC-0379 (OC-0345's residue):
|
||||
// the owner gate consumes the role adminAuthMiddleware already resolved into
|
||||
// the request context and performs no role read of its own. The roles table is
|
||||
// renamed away exactly as the OC-0345 fault test did — if the middleware still
|
||||
// issues a role query, that query fails and the request cannot reach 200, so
|
||||
// this test is red for as long as the second lookup exists.
|
||||
func TestOwnerOnlyMiddleware_NoSecondRoleLookup(t *testing.T) {
|
||||
database := openWhiteboxTestDB(t)
|
||||
|
||||
uid, err := database.CreateUser(context.Background(), "ownerctx", "$2a$12$x", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
user, err := database.GetUserByID(context.Background(), uid)
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
role, err := database.GetRoleByID(context.Background(), user.RoleID)
|
||||
if err != nil || role == nil {
|
||||
t.Fatalf("GetRoleByID: %v", err)
|
||||
}
|
||||
// After this, any role read fails: the only way to 200 is the context role.
|
||||
if _, err := database.ExecContext(context.Background(), `ALTER TABLE roles RENAME TO roles_gone`); err != nil {
|
||||
t.Fatalf("hide roles: %v", err)
|
||||
}
|
||||
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
|
||||
ctx := context.WithValue(context.Background(), adminUserKey, user)
|
||||
ctx = context.WithValue(ctx, adminRoleKey, role)
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if !reached {
|
||||
t.Error("next handler was not reached: the owner gate performed a role lookup instead of consuming the context role")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 with no role read", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// session has expired is rejected with 401.
|
||||
func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// Create a user and session, then manually expire the session by setting
|
||||
// expires_at to a past timestamp via the exported Exec helper.
|
||||
@@ -54,7 +54,7 @@ func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) {
|
||||
// access immediately, not only when the session expires.
|
||||
func TestAdminAuthMiddleware_BannedAdmin(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
if w := doRequest(t, handler, http.MethodGet, "/stats", token, nil); w.Code != http.StatusOK {
|
||||
@@ -78,7 +78,7 @@ func TestAdminAuthMiddleware_BannedAdmin(t *testing.T) {
|
||||
// Authorization header returns 401.
|
||||
func TestAdminAuthMiddleware_MissingBearer(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestAdminAuthMiddleware_MissingBearer(t *testing.T) {
|
||||
// sessions table returns 401.
|
||||
func TestAdminAuthMiddleware_InvalidToken(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", "completely-invalid-token", nil)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestAdminAuthMiddleware_DBErrorIsNotUnauthorized(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/stats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
@@ -49,7 +49,7 @@ func createRoleUser(t *testing.T, database *db.DB, roleID int64, name string, pe
|
||||
func newModeratorHandler(t *testing.T) (http.Handler, *db.DB, string) {
|
||||
t.Helper()
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
return handler, database, token
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestPerimeter_ModeratorAdmitted(t *testing.T) {
|
||||
|
||||
func TestPerimeter_NoModerationBitsRejected(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
// MANAGE_MESSAGES alone is not a perimeter bit — it has no admin route.
|
||||
_, token := createRoleUser(t, database, 11, "Helper", permissions.ManageMessages, 50, "helperuser")
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestPatchUserBan_ModeratorAllowed(t *testing.T) {
|
||||
|
||||
func TestChannelRoutes_WithoutManageChannelsForbidden(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, token := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser")
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -170,7 +170,7 @@ func TestAuditAndSettings_ModeratorForbidden(t *testing.T) {
|
||||
|
||||
func TestAuditAndSettings_BitHoldersAllowed(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, auditToken := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser")
|
||||
_, cfgToken := createRoleUser(t, database, 13, "Configurator", permissions.ManageServer, 50, "cfguser")
|
||||
|
||||
@@ -215,7 +215,7 @@ func TestOwnerOnlyRoutes_ModeratorForbidden(t *testing.T) {
|
||||
|
||||
func TestForceLogout_RequiresKickMembers(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, token := createRoleUser(t, database, 14, "ChannelMod", permissions.ManageChannels, 60, "chanmoduser")
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "victim", "hash", 3)
|
||||
@@ -235,7 +235,7 @@ func TestForceLogout_RequiresKickMembers(t *testing.T) {
|
||||
|
||||
func TestForceLogout_HierarchyEnforced(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
|
||||
// Owner (role 1, position 100) outranks the moderator.
|
||||
@@ -269,7 +269,7 @@ func TestForceLogout_HierarchyEnforced(t *testing.T) {
|
||||
|
||||
func TestPatchUserRole_RequiresManageRoles(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
// The seeded Moderator mask stops at bit 19 — no MANAGE_ROLES (bit 24).
|
||||
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
targetUID, _ := database.CreateUser(context.Background(), "promoteme", "hash", 3)
|
||||
@@ -286,7 +286,7 @@ func TestPatchUserRole_RequiresManageRoles(t *testing.T) {
|
||||
|
||||
func TestPatchUserRole_CannotPromoteToOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
// Role 2 "Admin" (position 80) holds MANAGE_ROLES but is below Owner.
|
||||
_, token := createRoleUser(t, database, 2, "Admin", 0x3FFFFFFF, 80, "adminuser2")
|
||||
targetUID, _ := database.CreateUser(context.Background(), "wannabeowner", "hash", 3)
|
||||
@@ -304,7 +304,7 @@ func TestPatchUserRole_CannotPromoteToOwner(t *testing.T) {
|
||||
|
||||
func TestPatchUserRole_ModeratorCannotDemoteAdmin(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
// A moderator that does hold MANAGE_ROLES still cannot touch a higher rank.
|
||||
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask|permissions.ManageRoles, 60, "moduser")
|
||||
adminUID, err := database.CreateUser(context.Background(), "sitting-admin", "hash", 2)
|
||||
@@ -383,7 +383,7 @@ func TestGetMe_ReportsCallerPermissions(t *testing.T) {
|
||||
|
||||
func TestGetMe_OwnerFlagged(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/me", token, nil)
|
||||
|
||||
@@ -131,7 +131,7 @@ func TestApplyUpdate_Conflict409(t *testing.T) {
|
||||
t.Setenv("OWNCORD_CONTAINER", "0")
|
||||
database := openAdminTestDB(t)
|
||||
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
cases := []struct {
|
||||
@@ -165,7 +165,7 @@ func TestApplyUpdate_Conflict409(t *testing.T) {
|
||||
// POST /backups/{name}/restore refuses the same way — before any disk I/O.
|
||||
func TestRestoreBackup_Conflict409(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
admin.ForceRestartState(false) // restart pending
|
||||
@@ -190,7 +190,7 @@ func TestRestoreBackup_Conflict409(t *testing.T) {
|
||||
func TestRestore_CloseFailure_StillMarksPendingAndRequestsRestart(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestSetup_HonoursTrustedProxies(t *testing.T) {
|
||||
cfg.Server.TrustedProxies = []string{trustedProxyAddr + "/32"}
|
||||
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil,
|
||||
newTestModService(database), newTestRoleService(database), newTestSettingsService(database),
|
||||
newTestModService(database), newTestRoleService(database),
|
||||
admin.SetupOptions{RunningCfg: cfg})
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func TestSetupStatus_NeedsSetup(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
@@ -35,7 +35,7 @@ func TestSetupStatus_NeedsSetup(t *testing.T) {
|
||||
func TestSetupStatus_NoSetupNeeded(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
createAdminUser(t, database) // Create a user first
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
@@ -55,7 +55,7 @@ func TestSetupStatus_NoSetupNeeded(t *testing.T) {
|
||||
|
||||
func TestSetup_CreatesOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "myadmin",
|
||||
@@ -108,7 +108,7 @@ func TestSetup_CreatesOwner(t *testing.T) {
|
||||
// Server/api/auth_handler_test.go (TestRegister_UsernameNotHTMLEscaped).
|
||||
func TestSetup_UsernameNotHTMLEscaped(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "O'Brien",
|
||||
@@ -138,7 +138,7 @@ func TestSetup_UsernameNotHTMLEscaped(t *testing.T) {
|
||||
|
||||
func TestSetup_BlockedAfterFirstUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// First setup succeeds.
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
@@ -161,7 +161,7 @@ func TestSetup_BlockedAfterFirstUser(t *testing.T) {
|
||||
|
||||
func TestSetup_WeakPassword(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "admin",
|
||||
@@ -174,7 +174,7 @@ func TestSetup_WeakPassword(t *testing.T) {
|
||||
|
||||
func TestSetup_MissingFields(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "",
|
||||
@@ -189,7 +189,7 @@ func TestSetup_MissingFields(t *testing.T) {
|
||||
// server and asserts that exactly one owner is created (BUG-119).
|
||||
func TestSetup_ConcurrentRace(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
const goroutines = 20
|
||||
results := make(chan int, goroutines)
|
||||
@@ -242,7 +242,7 @@ func TestSetup_ConcurrentRace(t *testing.T) {
|
||||
// on an empty allowlist, and a foreign origin still does not.
|
||||
func TestSetup_SameOriginAllowedWithEmptyAllowlist(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"})
|
||||
if err != nil {
|
||||
@@ -264,7 +264,7 @@ func TestSetup_SameOriginAllowedWithEmptyAllowlist(t *testing.T) {
|
||||
|
||||
func TestSetup_ForeignOriginStillBlocked(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"})
|
||||
if err != nil {
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestSetupLimiter_ReapsStaleEntries(t *testing.T) {
|
||||
defer restoreHook()
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
if limiter == nil {
|
||||
t.Fatal("setup limiter was not captured — CaptureSetupLimiter hook not wired into NewAdminAPI")
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestSetup_SessionCreationFailureDoesNotOrphanOwner(t *testing.T) {
|
||||
t.Fatalf("DROP TABLE sessions: %v", err)
|
||||
}
|
||||
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "owner1",
|
||||
@@ -82,7 +82,7 @@ func TestSetup_InviteCreationFailureDoesNotOrphanOwner(t *testing.T) {
|
||||
t.Fatalf("DROP TABLE invites: %v", err)
|
||||
}
|
||||
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "owner2",
|
||||
|
||||
@@ -40,7 +40,7 @@ func wizardRunningCfg() *config.Config {
|
||||
func wizardHandler(t *testing.T, database *db.DB, cfgPath string, restarted chan string) http.Handler {
|
||||
t.Helper()
|
||||
t.Cleanup(admin.ResetRestartState)
|
||||
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database),
|
||||
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database),
|
||||
admin.SetupOptions{
|
||||
ConfigPath: cfgPath,
|
||||
RunningCfg: wizardRunningCfg(),
|
||||
|
||||
@@ -26,6 +26,22 @@ const (
|
||||
adminTokenHashKey
|
||||
)
|
||||
|
||||
// ─── Allowed settings keys ────────────────────────────────────────────────────
|
||||
|
||||
// allowedSettingKeys is the whitelist of keys that may be written via
|
||||
// PATCH /admin/api/settings. Derived from the settings table in SCHEMA.md.
|
||||
var allowedSettingKeys = map[string]struct{}{
|
||||
"server_name": {},
|
||||
"server_icon": {},
|
||||
"motd": {},
|
||||
"max_upload_bytes": {},
|
||||
"voice_quality": {},
|
||||
"require_2fa": {},
|
||||
"registration_open": {},
|
||||
"backup_schedule": {},
|
||||
"backup_retention": {},
|
||||
}
|
||||
|
||||
// ─── HubBroadcaster ──────────────────────────────────────────────────────────
|
||||
|
||||
// HubBroadcaster is the subset of ws.Hub needed by the admin package.
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
@@ -78,7 +78,7 @@ func TestAdminAPI_CheckUpdate_IncompleteReleaseNotInstallable(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
@@ -111,7 +111,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
@@ -128,7 +128,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
@@ -138,7 +138,7 @@ func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
// Create admin user (not owner - role 2)
|
||||
adminUID, _ := database.CreateUser(context.Background(), "adminonly2", "hash", 2)
|
||||
@@ -158,7 +158,7 @@ func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) {
|
||||
func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
// nil updater — the endpoint should return 503
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -171,7 +171,7 @@ func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) {
|
||||
// in the 503 response.
|
||||
func TestAdminAPI_ApplyUpdate_NilUpdater_ErrorCode(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -203,7 +203,7 @@ func TestAdminAPI_ApplyUpdate_NoUpdateAvailable(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -234,7 +234,7 @@ func TestAdminAPI_ApplyUpdate_CheckFails(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -262,7 +262,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -283,7 +283,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) {
|
||||
// unauthenticated requests to POST /updates/apply.
|
||||
func TestAdminAPI_ApplyUpdate_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
@@ -350,7 +350,7 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) {
|
||||
// the important thing is that the code path is executed.
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -371,7 +371,7 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) {
|
||||
func TestAdminAPI_ApplyUpdate_RefusedInContainer(t *testing.T) {
|
||||
t.Setenv("OWNCORD_CONTAINER", "1")
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -393,7 +393,7 @@ func TestAdminAPI_ApplyUpdate_RefusedInContainer(t *testing.T) {
|
||||
func TestAdminAPI_ApplyUpdate_ContainerOptOut(t *testing.T) {
|
||||
t.Setenv("OWNCORD_CONTAINER", "0")
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/api"
|
||||
"github.com/J3vb/OwnCord/Server/config"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/internal/app"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// absentPattern names the feature families OwnCord promises not to have:
|
||||
// no federation between servers, no server directory or discovery, no
|
||||
// public listing. docs/trust-model.md states the promise; the tests in this
|
||||
// file are the proof at the three boundaries a new feature has to cross —
|
||||
// an HTTP route, a WebSocket message type, a configuration key. They pin
|
||||
// vocabulary, not semantics: a feature smuggled under a neutral name passes,
|
||||
// which is why trust-model.md also carries the outbound-host table B6's
|
||||
// network capture checks. A hit here is a design change that needs that
|
||||
// document updated first, not a silent addition.
|
||||
var absentPattern = regexp.MustCompile(`(?i)federat|directory|discover|listing`)
|
||||
|
||||
// fullRouter builds the production router with every optional route family
|
||||
// switched on (uploads, voice, GIF proxy) so the walk below sees the whole
|
||||
// tree, not the bare-config subset setupRouter mounts.
|
||||
func fullRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
database, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open error: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("db.Migrate error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{Name: "Test Server", Port: 8443, DataDir: dir},
|
||||
Upload: config.UploadConfig{MaxSizeMB: 1, StorageDir: filepath.Join(dir, "uploads")},
|
||||
Voice: config.VoiceConfig{
|
||||
LiveKitAPIKey: "absence-test-key",
|
||||
LiveKitAPISecret: "absence-test-secret-at-least-32-chars-long",
|
||||
LiveKitURL: "ws://127.0.0.1:7880",
|
||||
},
|
||||
GIF: config.GIFConfig{APIKey: "absence-test"},
|
||||
}
|
||||
|
||||
rt, rtErr := app.StartRuntime(cfg, database, nil)
|
||||
if rtErr != nil {
|
||||
t.Fatalf("app.StartRuntime: %v", rtErr)
|
||||
}
|
||||
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
|
||||
t.Cleanup(cleanup)
|
||||
return handler
|
||||
}
|
||||
|
||||
// TestAbsenceContract_NoFederationDirectoryOrListingRoutes walks every route
|
||||
// the production router mounts (admin and plugin subrouters included) and
|
||||
// fails on the first one whose path names federation, a directory, discovery
|
||||
// or a listing. BPR-040/082/083.
|
||||
func TestAbsenceContract_NoFederationDirectoryOrListingRoutes(t *testing.T) {
|
||||
handler := fullRouter(t)
|
||||
routes, ok := handler.(chi.Routes)
|
||||
if !ok {
|
||||
t.Fatalf("NewRouter returned %T, want a chi.Routes so the mounted tree can be walked", handler)
|
||||
}
|
||||
|
||||
var total, adminRoutes int
|
||||
var hits []string
|
||||
walk := func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
total++
|
||||
if strings.HasPrefix(route, "/admin/") {
|
||||
adminRoutes++
|
||||
}
|
||||
if absentPattern.MatchString(route) {
|
||||
hits = append(hits, method+" "+route)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := chi.Walk(routes, walk); err != nil {
|
||||
t.Fatalf("chi.Walk: %v", err)
|
||||
}
|
||||
|
||||
// Guard against a vacuous pass: the walk must have seen the real tree,
|
||||
// including the mounted admin subrouter, not an empty or wrapped mux.
|
||||
if total < 100 {
|
||||
t.Fatalf("walked only %d routes; expected the full production router (>= 100)", total)
|
||||
}
|
||||
if adminRoutes == 0 {
|
||||
t.Fatal("walk saw no /admin/ routes; the mounted admin subrouter was not traversed")
|
||||
}
|
||||
|
||||
if len(hits) > 0 {
|
||||
t.Fatalf("routes matching %q must not exist (see docs/trust-model.md, \"What OwnCord does not have\"):\n %s",
|
||||
absentPattern, strings.Join(hits, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbsenceContract_NoFederationDirectoryOrListingWireTypes reads the
|
||||
// protocol schema (the source of truth ws/message_types.go is generated from)
|
||||
// and fails on any WebSocket message type in either direction whose wire name
|
||||
// matches the pattern. A federation or directory feature carried entirely by
|
||||
// new frames would otherwise pass the route test above.
|
||||
func TestAbsenceContract_NoFederationDirectoryOrListingWireTypes(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "protocol", "schema.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read protocol/schema.json: %v", err)
|
||||
}
|
||||
var schema struct {
|
||||
ClientToServer []struct {
|
||||
Wire string `json:"wire"`
|
||||
} `json:"client_to_server"`
|
||||
ServerToClient []struct {
|
||||
Wire string `json:"wire"`
|
||||
} `json:"server_to_client"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("parse protocol/schema.json: %v", err)
|
||||
}
|
||||
|
||||
var total int
|
||||
var hits []string
|
||||
for _, dir := range [][]struct {
|
||||
Wire string `json:"wire"`
|
||||
}{schema.ClientToServer, schema.ServerToClient} {
|
||||
for _, m := range dir {
|
||||
total++
|
||||
if absentPattern.MatchString(m.Wire) {
|
||||
hits = append(hits, m.Wire)
|
||||
}
|
||||
}
|
||||
}
|
||||
if total < 40 {
|
||||
t.Fatalf("read only %d wire types from the schema; expected the full protocol (>= 40)", total)
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Fatalf("WebSocket message types matching %q must not exist (see docs/trust-model.md, \"What OwnCord does not have\"):\n %s",
|
||||
absentPattern, strings.Join(hits, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbsenceContract_NoFederationDirectoryOrListingConfigKeys walks the
|
||||
// koanf tags of config.Config and fails on any dotted key matching the
|
||||
// pattern. A feature that needs a peer list, a directory URL or a discovery
|
||||
// toggle has to surface here, so this is the third boundary.
|
||||
func TestAbsenceContract_NoFederationDirectoryOrListingConfigKeys(t *testing.T) {
|
||||
// On-disk paths that happen to contain a pattern word. Each entry names
|
||||
// a filesystem location, never a network one; adding to this list needs
|
||||
// the same justification as a route hit.
|
||||
allowed := map[string]string{
|
||||
"plugins.directory": "the on-disk plugin directory (Server/config/config.go PluginsConfig.Directory)",
|
||||
}
|
||||
|
||||
keys := koanfKeys(reflect.TypeFor[config.Config](), "")
|
||||
if len(keys) < 30 {
|
||||
t.Fatalf("collected only %d config keys; expected the full config surface (>= 30)", len(keys))
|
||||
}
|
||||
var hits []string
|
||||
for _, k := range keys {
|
||||
if !absentPattern.MatchString(k) {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[k]; ok {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, k)
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Fatalf("config keys matching %q must not exist (see docs/trust-model.md, \"What OwnCord does not have\"):\n %s",
|
||||
absentPattern, strings.Join(hits, "\n "))
|
||||
}
|
||||
for k := range allowed {
|
||||
if !slices.Contains(keys, k) {
|
||||
t.Errorf("allowlisted config key %q no longer exists; drop it from the allowlist", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// koanfKeys returns every dotted koanf key reachable from t, recursing into
|
||||
// nested structs the same way koanf unmarshals them.
|
||||
func koanfKeys(t reflect.Type, prefix string) []string {
|
||||
for t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
var keys []string
|
||||
for f := range t.Fields() {
|
||||
tag, ok := f.Tag.Lookup("koanf")
|
||||
if !ok || tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
key := tag
|
||||
if prefix != "" {
|
||||
key = prefix + "." + tag
|
||||
}
|
||||
ft := f.Type
|
||||
for ft.Kind() == reflect.Ptr {
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Struct {
|
||||
keys = append(keys, koanfKeys(ft, key)...)
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/db/audittest"
|
||||
)
|
||||
|
||||
// TestAuditCoverage_PluginLifecycle is the plugin half of the B2-6 audit
|
||||
// table: install and uninstall each emit an audit entry, and neither detail
|
||||
// carries anything from the archive beyond the plugin name.
|
||||
func TestAuditCoverage_PluginLifecycle(t *testing.T) {
|
||||
install := func(t *testing.T) (http.Handler, *db.DB, int64) {
|
||||
t.Helper()
|
||||
reg, mem := newTestPluginRegistryWithStore(t)
|
||||
h := NewPluginAdminHandler(reg, mem, mem)
|
||||
body, contentType := buildZipUpload(t, validPluginZip(t))
|
||||
req := httptest.NewRequest("POST", "/install", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("install: status = %d; body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
row, err := mem.GetPluginByName(context.Background(), "hello")
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("GetPluginByName: %v", err)
|
||||
}
|
||||
return h, mem, row.ID
|
||||
}
|
||||
|
||||
rows := []struct {
|
||||
name string
|
||||
action string
|
||||
run func(t *testing.T) *audittest.Recorder
|
||||
}{
|
||||
{"plugin install", "plugin_install", func(t *testing.T) *audittest.Recorder {
|
||||
reg, mem := newTestPluginRegistryWithStore(t)
|
||||
h := NewPluginAdminHandler(reg, mem, mem)
|
||||
rec := audittest.Install(t, mem)
|
||||
body, contentType := buildZipUpload(t, validPluginZip(t))
|
||||
req := httptest.NewRequest("POST", "/install", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("install: status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
return rec
|
||||
}},
|
||||
{"plugin uninstall", "plugin_uninstall", func(t *testing.T) *audittest.Recorder {
|
||||
h, mem, id := install(t)
|
||||
rec := audittest.Install(t, mem)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("DELETE", "/"+strconv.FormatInt(id, 10), nil))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("uninstall: status = %d; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
return rec
|
||||
}},
|
||||
}
|
||||
|
||||
var corpus []db.AuditEntry
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
rec := row.run(t)
|
||||
rec.Wait(t, row.action)
|
||||
corpus = append(corpus, rec.Entries()...)
|
||||
})
|
||||
}
|
||||
t.Run("detail denylist", func(t *testing.T) {
|
||||
if len(corpus) == 0 {
|
||||
t.Fatal("no audit entries recorded")
|
||||
}
|
||||
audittest.AssertSafeDetails(t, corpus)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPluginsHandlerUninstallUnknownID pins Codex's P2 on #1441: the registry
|
||||
// treats an unknown id as an idempotent no-op, so the handler must answer 404
|
||||
// and write no plugin_uninstall row for a plugin that never existed.
|
||||
func TestPluginsHandlerUninstallUnknownID(t *testing.T) {
|
||||
reg, mem := newTestPluginRegistryWithStore(t)
|
||||
h := NewPluginAdminHandler(reg, mem, mem)
|
||||
rec := audittest.Install(t, mem)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("DELETE", "/999", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := rec.Entries(); len(got) != 0 {
|
||||
t.Fatalf("unknown plugin must not audit; got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/db/audittest"
|
||||
)
|
||||
|
||||
// TestAuditCoverage_APIMutations is the B2-6 audit table for the
|
||||
// api-owned security-sensitive mutations (TOTP enrolment and removal,
|
||||
// account self-deletion). The plugin lifecycle rows live in
|
||||
// audit_coverage_plugin_test.go because their fixtures are package-internal.
|
||||
// The closing subtest runs the detail denylist over the recorded corpus
|
||||
// (plan docs/plans/b2-protocol-trust-compat-2026-08-28.md § B2-6).
|
||||
func TestAuditCoverage_APIMutations(t *testing.T) {
|
||||
const password = "Password1!"
|
||||
|
||||
// enrolTOTP runs enable+confirm for token and returns the TOTP secret
|
||||
// and the confirmation code, both fixture secrets for the denylist.
|
||||
enrolTOTP := func(t *testing.T, router http.Handler, token string) (secret, code string) {
|
||||
t.Helper()
|
||||
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
|
||||
map[string]string{"password": password})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("enable: status = %d; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var enableResp map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&enableResp)
|
||||
secret = extractSecretFromURI(t, enableResp["qr_uri"].(string))
|
||||
code, _ = auth.GenerateTOTPCode(secret, time.Now().UTC())
|
||||
rr = postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token,
|
||||
map[string]string{"password": password, "code": code})
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("confirm: status = %d; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
return secret, code
|
||||
}
|
||||
|
||||
rows := []struct {
|
||||
name string
|
||||
action string
|
||||
run func(t *testing.T) (*audittest.Recorder, []string)
|
||||
}{
|
||||
{"totp enable", "totp_enabled", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
database := newAuthTestDB(t)
|
||||
router := buildAuthRouter(database, auth.NewRateLimiter())
|
||||
token := loginAndGetToken(t, router, database, "totpenable", 4)
|
||||
rec := audittest.Install(t, database)
|
||||
secret, code := enrolTOTP(t, router, token)
|
||||
return rec, []string{password, token, secret, code}
|
||||
}},
|
||||
{"totp disable", "totp_disabled", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
database := newAuthTestDB(t)
|
||||
router := buildAuthRouter(database, auth.NewRateLimiter())
|
||||
token := loginAndGetToken(t, router, database, "totpdisable", 4)
|
||||
secret, code := enrolTOTP(t, router, token)
|
||||
rec := audittest.Install(t, database)
|
||||
rr := deleteWithToken(t, router, "/api/v1/users/me/totp", token,
|
||||
map[string]string{"password": password})
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("disable: status = %d; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
return rec, []string{password, token, secret, code}
|
||||
}},
|
||||
{"account delete", "account_deleted", func(t *testing.T) (*audittest.Recorder, []string) {
|
||||
database := newAuthTestDB(t)
|
||||
router := buildAuthRouter(database, auth.NewRateLimiter())
|
||||
hash, _ := auth.HashPassword(password)
|
||||
uid, _ := database.CreateUser(context.Background(), "selfdelete", hash, 4)
|
||||
token, _ := auth.GenerateToken()
|
||||
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
|
||||
rec := audittest.Install(t, database)
|
||||
rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token,
|
||||
map[string]string{"password": password})
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete account: status = %d; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
return rec, []string{password, hash, token}
|
||||
}},
|
||||
}
|
||||
|
||||
var corpus []db.AuditEntry
|
||||
var secrets []string
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
rec, s := row.run(t)
|
||||
rec.Wait(t, row.action)
|
||||
corpus = append(corpus, rec.Entries()...)
|
||||
secrets = append(secrets, s...)
|
||||
})
|
||||
}
|
||||
t.Run("detail denylist", func(t *testing.T) {
|
||||
if len(corpus) == 0 {
|
||||
t.Fatal("no audit entries recorded")
|
||||
}
|
||||
audittest.AssertSafeDetails(t, corpus, secrets...)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,53 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
)
|
||||
|
||||
// AuthService is the consumer-owned interface behind the auth routes: every
|
||||
// call auth_handler.go and totp_handler.go make below the transport layer, and
|
||||
// nothing more (layout-refactor supplement, "interface beside the consumer").
|
||||
// service.AuthService implements it. A handler decodes and validates the
|
||||
// request, calls one method, and encodes either the result or the returned
|
||||
// service.Err* value; every lockout, password compare, sentinel mapping,
|
||||
// audit write and broadcast lives behind these nine methods.
|
||||
//
|
||||
// Nine methods stand in for the ten *db.DB methods, two db functions and two
|
||||
// db sentinels the two handlers called directly at 71d867cb
|
||||
// (docs/architecture/server-boundaries.md, "Auth slice").
|
||||
type AuthService interface {
|
||||
// RegistrationPolicy reports whether registration is permitted right now.
|
||||
// It is the one gate that runs before the body is read: two
|
||||
// characterization rows pin a closed server's 403 ahead of any
|
||||
// credential, malformed body included.
|
||||
RegistrationPolicy(ctx context.Context) error
|
||||
// Register consumes the invite, creates the account and issues a session.
|
||||
// in is already validated (see service.RegisterInput).
|
||||
Register(ctx context.Context, in service.RegisterInput) (*service.AuthResult, error)
|
||||
// Login runs the lockout gates and the constant-time password check, then
|
||||
// issues a session or, for an enrolled account, starts a two-factor
|
||||
// challenge.
|
||||
Login(ctx context.Context, in service.LoginInput) (*service.AuthResult, error)
|
||||
// VerifyTOTP completes a challenge Login started and issues the session,
|
||||
// bound to the login request's device and IP rather than this one's.
|
||||
VerifyTOTP(ctx context.Context, partialToken, code string) (*service.AuthResult, error)
|
||||
// Logout revokes p.Session server-side and clears the custom status.
|
||||
Logout(ctx context.Context, p service.Principal) error
|
||||
// DeleteAccount confirms the password, anonymises and bans the account and
|
||||
// broadcasts member_ban. ip is only logged and audited.
|
||||
DeleteAccount(ctx context.Context, p service.Principal, password, ip string) error
|
||||
// EnableTOTP confirms the password and stages a pending secret; qrURI is
|
||||
// the enrolment payload for the authenticator app.
|
||||
EnableTOTP(ctx context.Context, p service.Principal, password string) (qrURI string, err error)
|
||||
// ConfirmTOTP verifies code against the pending secret, persists it and
|
||||
// revokes the caller's other sessions.
|
||||
ConfirmTOTP(ctx context.Context, p service.Principal, password, code string) (*service.TOTPChangeResult, error)
|
||||
// DisableTOTP confirms the password, refuses while the server requires
|
||||
// 2FA, clears the secret and revokes the caller's other sessions.
|
||||
DisableTOTP(ctx context.Context, p service.Principal, password string) (*service.TOTPChangeResult, error)
|
||||
}
|
||||
|
||||
// The production implementation satisfies the interface it was extracted for.
|
||||
var _ AuthService = (*service.AuthService)(nil)
|
||||
+540
-130
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/permissions"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -18,9 +21,16 @@ import (
|
||||
// maxLoginUsernameLen bounds the username accepted by handleLogin, mirroring
|
||||
// auth.ValidateUsername's 32-rune cap on registered usernames. Enforced
|
||||
// before the value is ever used to build a RateLimiter map key — see the
|
||||
// check in loginReadRequest for why.
|
||||
// check in handleLogin for why.
|
||||
const maxLoginUsernameLen = 32
|
||||
|
||||
// genericAuthError is returned for all login/register failures to avoid
|
||||
// revealing whether a username exists.
|
||||
var genericAuthError = errorResponse{
|
||||
Error: "INVALID_CREDENTIALS",
|
||||
Message: "invalid invite or credentials",
|
||||
}
|
||||
|
||||
// registerRequest is the JSON body for POST /api/v1/auth/register.
|
||||
type registerRequest struct {
|
||||
Username string `json:"username"`
|
||||
@@ -34,6 +44,25 @@ type loginRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// userResponse is the user shape included in auth responses.
|
||||
type userResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
// DisplayName and About are always present (null = unset) so the settings
|
||||
// form can tell "cleared" from "the server does not know this field".
|
||||
DisplayName *string `json:"display_name"`
|
||||
About *string `json:"about"`
|
||||
// CustomStatus is the user's own free-text status line.
|
||||
CustomStatus *string `json:"custom_status"`
|
||||
// Status is the user's own true status, invisible included. This response
|
||||
// only ever describes the caller, so there is nothing to hide from them.
|
||||
Status string `json:"status"`
|
||||
RoleID int64 `json:"role_id"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// authSuccessResponse is returned on successful login/register.
|
||||
type authSuccessResponse struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
@@ -42,56 +71,77 @@ type authSuccessResponse struct {
|
||||
User *userResponse `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// MountAuthRoutes registers all auth endpoints on the given router. svc owns
|
||||
// every decision below the transport (service.AuthService in production, see
|
||||
// AuthService); requireAuth is the AuthMiddleware the authenticated routes
|
||||
// mount, built by the caller because it needs the database handle this file
|
||||
// no longer sees. Rate limiters are applied per-endpoint as specified;
|
||||
// trustedProxies is the list of CIDRs whose X-Forwarded-For / X-Real-IP
|
||||
// headers are honoured for rate-limiting IP resolution.
|
||||
func MountAuthRoutes(r chi.Router, svc AuthService, requireAuth func(http.Handler) http.Handler, limiter *auth.RateLimiter, trustedProxies []string) {
|
||||
r.Route("/api/v1/auth", func(r chi.Router) {
|
||||
r.With(RateLimitMiddleware(limiter, "register:", scaledAuthLimit(registerRateLimitPerMinute), time.Minute, trustedProxies)).
|
||||
Post("/register", handleRegister(svc, trustedProxies))
|
||||
// AuthBroadcaster is the interface handleDeleteAccount uses to notify
|
||||
// connected WebSocket clients that an account is gone. Satisfied by *ws.Hub
|
||||
// (which already implements BroadcastMemberBan for the admin ban path this
|
||||
// mirrors).
|
||||
type AuthBroadcaster interface {
|
||||
BroadcastMemberBan(userID int64)
|
||||
}
|
||||
|
||||
r.With(RateLimitMiddleware(limiter, "login:", scaledAuthLimit(loginRateLimitPerMinute), time.Minute, trustedProxies)).
|
||||
Post("/login", handleLogin(svc, trustedProxies))
|
||||
// MountAuthRoutes registers all auth endpoints on the given router.
|
||||
// Rate limiters are applied per-endpoint as specified. trustedProxies is the
|
||||
// list of CIDRs whose X-Forwarded-For / X-Real-IP headers are honoured for
|
||||
// rate-limiting IP resolution. totpKey is the AES-256 key used to encrypt
|
||||
// TOTP secrets at rest (M1 security hardening).
|
||||
//
|
||||
// broadcaster is variadic and optional: MountAuthRoutes is called before the
|
||||
// hub exists (router.go mounts auth routes first, and the hub needs the
|
||||
// router to register its own webhook route), so a caller that cannot supply
|
||||
// one yet may omit it entirely and self-deletion simply sends no event,
|
||||
// exactly like today. A caller mounted after hub creation should pass it so
|
||||
// DELETE /api/v1/auth/account can broadcast the same member_ban event the
|
||||
// admin ban path already sends for the identical anonymise-and-ban DB state.
|
||||
func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string, totpKey []byte, broadcaster ...AuthBroadcaster) {
|
||||
var ab AuthBroadcaster
|
||||
if len(broadcaster) > 0 {
|
||||
ab = broadcaster[0]
|
||||
}
|
||||
registerLimiter := limiter
|
||||
loginLimiter := limiter
|
||||
partialStore := auth.NewPartialAuthStore(partialAuthStoreTTL)
|
||||
pendingTOTPStore := auth.NewPendingTOTPStore(pendingTOTPStoreTTL)
|
||||
usedTOTPCodes := auth.NewUsedTOTPCodeStore()
|
||||
|
||||
r.Route("/api/v1/auth", func(r chi.Router) {
|
||||
r.With(RateLimitMiddleware(registerLimiter, "register:", scaledAuthLimit(registerRateLimitPerMinute), time.Minute, trustedProxies)).
|
||||
Post("/register", handleRegister(database, trustedProxies))
|
||||
|
||||
r.With(RateLimitMiddleware(loginLimiter, "login:", scaledAuthLimit(loginRateLimitPerMinute), time.Minute, trustedProxies)).
|
||||
Post("/login", handleLogin(database, limiter, partialStore, trustedProxies))
|
||||
|
||||
r.With(RateLimitMiddleware(limiter, "totp_verify:", scaledAuthLimit(verifyTOTPRateLimitPerMinute), time.Minute, trustedProxies)).
|
||||
Post("/verify-totp", handleVerifyTOTP(svc))
|
||||
Post("/verify-totp", handleVerifyTOTP(database, partialStore, limiter, usedTOTPCodes, totpKey))
|
||||
|
||||
r.With(requireAuth).
|
||||
Post("/logout", handleLogout(svc))
|
||||
r.With(AuthMiddleware(database)).
|
||||
Post("/logout", handleLogout(database))
|
||||
|
||||
r.With(requireAuth).
|
||||
r.With(AuthMiddleware(database)).
|
||||
Get("/me", handleMe())
|
||||
|
||||
r.With(requireAuth,
|
||||
r.With(AuthMiddleware(database),
|
||||
RateLimitMiddleware(limiter, "del_account:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
|
||||
Delete("/account", handleDeleteAccount(svc))
|
||||
Delete("/account", handleDeleteAccount(database, limiter, ab))
|
||||
})
|
||||
|
||||
r.With(requireAuth,
|
||||
r.With(AuthMiddleware(database),
|
||||
RateLimitMiddleware(limiter, "totp:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
|
||||
Post("/api/v1/users/me/totp/enable", handleEnableTOTP(svc))
|
||||
Post("/api/v1/users/me/totp/enable", handleEnableTOTP(pendingTOTPStore, limiter))
|
||||
|
||||
r.With(requireAuth,
|
||||
r.With(AuthMiddleware(database),
|
||||
RateLimitMiddleware(limiter, "totp:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
|
||||
Post("/api/v1/users/me/totp/confirm", handleConfirmTOTP(svc))
|
||||
Post("/api/v1/users/me/totp/confirm", handleConfirmTOTP(database, pendingTOTPStore, usedTOTPCodes, limiter, totpKey))
|
||||
|
||||
r.With(requireAuth,
|
||||
r.With(AuthMiddleware(database),
|
||||
RateLimitMiddleware(limiter, "totp:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
|
||||
Delete("/api/v1/users/me/totp", handleDisableTOTP(svc))
|
||||
Delete("/api/v1/users/me/totp", handleDisableTOTP(database, pendingTOTPStore, limiter))
|
||||
}
|
||||
|
||||
// handleRegister processes POST /api/v1/auth/register.
|
||||
func handleRegister(svc AuthService, trustedProxies []string) http.HandlerFunc {
|
||||
func handleRegister(database *db.DB, trustedProxies []string) http.HandlerFunc {
|
||||
proxyNets := parseCIDRList(trustedProxies) // W3-3a: parse once at construction
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// The policy gate runs before any credential is read: a closed
|
||||
// server refuses even a malformed body with the policy's 403.
|
||||
if err := svc.RegistrationPolicy(r.Context()); err != nil {
|
||||
writeAuthError(r.Context(), w, err)
|
||||
if !registerPolicyGate(w, r, database) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,21 +150,116 @@ func handleRegister(svc AuthService, trustedProxies []string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := svc.Register(r.Context(), service.RegisterInput{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
InviteCode: req.InviteCode,
|
||||
Device: truncateDevice(r.Header.Get("User-Agent")),
|
||||
IP: clientIPWithProxies(r, proxyNets),
|
||||
})
|
||||
// Hash password before consuming the invite so that a hashing failure
|
||||
// does not burn a valid invite code.
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeAuthError(r.Context(), w, err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to process registration",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, authResponse(res))
|
||||
|
||||
// Atomically consume the invite and create the user so failed
|
||||
// registrations do not burn a valid invite code.
|
||||
uid, err := database.CreateUserWithInvite(r.Context(), req.Username, hash, int(permissions.MemberRoleID), req.InviteCode)
|
||||
if err != nil {
|
||||
// UNIQUE constraint violation → duplicate username → 400.
|
||||
// Any other DB error → 500.
|
||||
switch {
|
||||
case db.IsUniqueConstraintError(err):
|
||||
writeJSON(w, http.StatusBadRequest, genericAuthError)
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
writeJSON(w, http.StatusBadRequest, genericAuthError)
|
||||
default:
|
||||
slog.Error("CreateUserWithInvite failed", "err", err, "username", req.Username)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "registration failed — please try again",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ip := clientIPWithProxies(r, proxyNets)
|
||||
slog.Info("user registered", "username", req.Username, "user_id", uid, "ip", ip)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "user_register", "user", uid,
|
||||
"new account created via invite")
|
||||
|
||||
// Issue session.
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to create session",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
device := truncateDevice(r.Header.Get("User-Agent"))
|
||||
if _, err := database.CreateSession(r.Context(), uid, auth.HashToken(token), device, ip); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to create session",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := database.GetUserByID(r.Context(), uid)
|
||||
if err != nil || user == nil {
|
||||
slog.Error("failed to fetch user after registration", "user_id", uid, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "registration succeeded but user fetch failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, authSuccessResponse{
|
||||
Token: token,
|
||||
Requires2FA: false,
|
||||
User: toUserResponse(user),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// registerPolicyGate reports whether registration is currently permitted,
|
||||
// writing the refusal response itself when it is not.
|
||||
func registerPolicyGate(w http.ResponseWriter, r *http.Request, database *db.DB) bool {
|
||||
registrationOpen, err := isRegistrationOpen(r.Context(), database)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to load registration policy",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if !registrationOpen {
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||
Error: "FORBIDDEN",
|
||||
Message: "registration is currently closed",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
require2FA, err := isRequire2FAEnabled(r.Context(), database)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to load registration policy",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if require2FA {
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||
Error: "FORBIDDEN",
|
||||
Message: "registration is unavailable while two-factor authentication is required",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// registerReadRequest decodes and validates the registration body, writing the
|
||||
// rejection response itself when the input cannot be used.
|
||||
func registerReadRequest(w http.ResponseWriter, r *http.Request) (registerRequest, bool) {
|
||||
@@ -182,7 +327,7 @@ func registerReadRequest(w http.ResponseWriter, r *http.Request) (registerReques
|
||||
}
|
||||
|
||||
// handleLogin processes POST /api/v1/auth/login.
|
||||
func handleLogin(svc AuthService, trustedProxies []string) http.HandlerFunc {
|
||||
func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth.PartialAuthStore, trustedProxies []string) http.HandlerFunc {
|
||||
proxyNets := parseCIDRList(trustedProxies) // W3-3a: parse once at construction
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, ok := loginReadRequest(w, r)
|
||||
@@ -190,17 +335,77 @@ func handleLogin(svc AuthService, trustedProxies []string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := svc.Login(r.Context(), service.LoginInput{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Device: truncateDevice(r.Header.Get("User-Agent")),
|
||||
IP: clientIPWithProxies(r, proxyNets),
|
||||
})
|
||||
if err != nil {
|
||||
writeAuthError(r.Context(), w, err)
|
||||
ip := clientIPWithProxies(r, proxyNets)
|
||||
|
||||
user, ok := loginAuthenticate(w, r, database, limiter, req, ip)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authResponse(res))
|
||||
|
||||
if auth.IsEffectivelyBanned(user) {
|
||||
slog.Warn("banned user login attempt", "username", user.Username, "user_id", user.ID, "ip", ip)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "login_blocked_banned", "user", user.ID,
|
||||
"banned user attempted login from "+ip)
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||
Error: "FORBIDDEN",
|
||||
Message: "your account has been suspended",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
require2FA, err := isRequire2FAEnabled(r.Context(), database)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to load authentication policy",
|
||||
})
|
||||
return
|
||||
}
|
||||
if user.TOTPSecret != nil {
|
||||
partialToken, err := partialStore.Issue(user.ID, truncateDevice(r.Header.Get("User-Agent")), ip)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to start two-factor challenge",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authSuccessResponse{
|
||||
PartialToken: partialToken,
|
||||
Requires2FA: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
if require2FA {
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||
Error: "FORBIDDEN",
|
||||
Message: "two-factor authentication must be enabled on this account before login",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Issue session.
|
||||
token, err := issueSession(r.Context(), database, user.ID, truncateDevice(r.Header.Get("User-Agent")), ip)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to create session",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Don't set status to "online" here — the WebSocket connection in
|
||||
// serve.go does that when the user actually connects. Setting it here
|
||||
// would leave the user permanently "online" if they never open a WS
|
||||
// connection or if the client crashes before connecting.
|
||||
slog.Info("user logged in", "username", user.Username, "user_id", user.ID, "ip", ip)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "user_login", "user", user.ID,
|
||||
"logged in from "+ip)
|
||||
writeJSON(w, http.StatusOK, authSuccessResponse{
|
||||
Token: token,
|
||||
Requires2FA: false,
|
||||
User: toUserResponse(user),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,13 +434,12 @@ func loginReadRequest(w http.ResponseWriter, r *http.Request) (loginRequest, boo
|
||||
}
|
||||
|
||||
// F: reject an over-long username before it is ever used to build a
|
||||
// RateLimiter map key (unameKey, failKey, userFailKey, lockout keys in
|
||||
// service.AuthService). Unlike registration, login has no account to
|
||||
// validate against yet, so nothing else bounds this value — an
|
||||
// unauthenticated caller could otherwise pin an arbitrarily large,
|
||||
// body-sized string as a retained key (Cleanup only evicts it after
|
||||
// hours). Mirrors the same 32-rune cap auth.ValidateUsername enforces at
|
||||
// registration.
|
||||
// RateLimiter map key below (unameKey, failKey, userFailKey, lockout
|
||||
// keys). Unlike registration, login has no account to validate
|
||||
// against yet, so nothing else bounds this value — an unauthenticated
|
||||
// caller could otherwise pin an arbitrarily large, body-sized string
|
||||
// as a retained key (Cleanup only evicts it after hours). Mirrors the
|
||||
// same 32-rune cap auth.ValidateUsername enforces at registration.
|
||||
if utf8.RuneCountInString(req.Username) > maxLoginUsernameLen {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "INVALID_INPUT",
|
||||
@@ -246,18 +450,144 @@ func loginReadRequest(w http.ResponseWriter, r *http.Request) (loginRequest, boo
|
||||
return req, true
|
||||
}
|
||||
|
||||
// loginAuthenticate runs the lockout gates, the constant-time password compare
|
||||
// and the failure accounting for one login attempt. It returns the
|
||||
// authenticated user, or false after writing the rejection response itself.
|
||||
func loginAuthenticate(w http.ResponseWriter, r *http.Request, database *db.DB, limiter *auth.RateLimiter, req loginRequest, ip string) (*db.User, bool) {
|
||||
// Check per-IP lockout first.
|
||||
lockKey := "login_lock:" + ip
|
||||
if limiter.IsLockedOut(lockKey) {
|
||||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||||
Error: "RATE_LIMITED",
|
||||
Message: "account temporarily locked due to too many failed attempts",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// BUG-110: Also check per-username lockout to prevent distributed brute force.
|
||||
// F1: canonicalize the username the same way GetUserByUsername does (COLLATE
|
||||
// NOCASE) before keying the lockout, so case variants of one account
|
||||
// (admin/Admin/ADMIN) share a single bucket instead of each getting its own.
|
||||
unameKey := strings.ToLower(req.Username)
|
||||
userLockKey := "login_user_lock:" + unameKey
|
||||
if limiter.IsLockedOut(userLockKey) {
|
||||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||||
Error: "RATE_LIMITED",
|
||||
Message: "account temporarily locked due to too many failed attempts",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Constant-time lookup: always attempt bcrypt compare even when user
|
||||
// does not exist to prevent timing-based username enumeration.
|
||||
user, err := database.GetUserByUsername(r.Context(), req.Username)
|
||||
|
||||
// Distinguish DB errors from authentication failures. DB errors
|
||||
// should NOT increment the rate limiter — otherwise a transient
|
||||
// DB outage would lock out legitimate users.
|
||||
if err != nil && user == nil {
|
||||
// Could be a real DB error or simply "user not found".
|
||||
// GetUserByUsername returns (nil, nil) for not-found, so a
|
||||
// non-nil error here is a genuine DB failure.
|
||||
slog.Error("login: GetUserByUsername failed", "err", err, "ip", ip)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "login temporarily unavailable",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
failKey := "login_fail:" + ip
|
||||
userFailKey := "login_user_fail:" + unameKey
|
||||
// F3: atomically reserve this attempt BEFORE the bcrypt compare. The
|
||||
// read-only IsLockedOut gates above are check-then-act: N concurrent
|
||||
// requests all pass them before any failure is recorded below, so the
|
||||
// per-username cap — the only cross-IP brute-force defence — bound
|
||||
// only sequential attackers. Allow records the attempt under the
|
||||
// limiter's lock, capping a concurrent burst at the same budget a
|
||||
// sequential attacker gets. Sized at threshold+1 so the sequential
|
||||
// accepted-input set is unchanged: failures 1–10 still land, the 10th
|
||||
// still trips the lockout (via the Check below), and a correct
|
||||
// password on attempt 10 still succeeds — successful logins reset
|
||||
// both counters. The reservation sits after the DB-error return above
|
||||
// so a transient DB outage still does not consume attempts.
|
||||
if !limiter.Allow(failKey, scaledAuthLimit(loginFailureThreshold)+1, loginFailureWindow) ||
|
||||
!limiter.Allow(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) {
|
||||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||||
Error: "RATE_LIMITED",
|
||||
Message: "account temporarily locked due to too many failed attempts",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
// Always run the password check — with an empty hash when the user does
|
||||
// not exist. auth.CheckPassword performs a dummy bcrypt comparison for an
|
||||
// empty hash, so bcrypt executes on every path and response time stays
|
||||
// constant, preventing timing-based username enumeration. (A `user == nil
|
||||
// || CheckPassword(...)` short-circuit would skip bcrypt entirely for
|
||||
// unknown usernames, reintroducing the timing side-channel.)
|
||||
storedHash := ""
|
||||
if user != nil {
|
||||
storedHash = user.PasswordHash
|
||||
}
|
||||
if !auth.CheckPassword(storedHash, req.Password) {
|
||||
// The attempt was already recorded atomically up-front (F3); here
|
||||
// only decide the lockouts, at the same boundary as before: the
|
||||
// 10th in-window failure locks the key. Check is read-only, so
|
||||
// the reservation is not double-counted.
|
||||
if !limiter.Check(failKey, scaledAuthLimit(loginFailureThreshold)+1, loginFailureWindow) {
|
||||
limiter.Lockout(r.Context(), lockKey, loginLockoutDuration)
|
||||
}
|
||||
// BUG-110: per-username lockout on threshold.
|
||||
if !limiter.Check(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) {
|
||||
limiter.Lockout(r.Context(), userLockKey, loginUserLockoutDuration)
|
||||
}
|
||||
slog.Info("login failed", "ip", ip, "username_len", len(req.Username))
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "invalid credentials",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Reset failure counters on success.
|
||||
limiter.Reset(r.Context(), failKey)
|
||||
limiter.Reset(r.Context(), userFailKey)
|
||||
return user, true
|
||||
}
|
||||
|
||||
// handleLogout processes POST /api/v1/auth/logout.
|
||||
func handleLogout(svc AuthService) http.HandlerFunc {
|
||||
func handleLogout(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := principal(r)
|
||||
if !ok || p.Session == nil {
|
||||
writeNotAuthenticated(w)
|
||||
sess, ok := r.Context().Value(SessionKey).(*db.Session)
|
||||
if !ok || sess == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "not authenticated",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := svc.Logout(r.Context(), p); err != nil {
|
||||
writeAuthError(r.Context(), w, err)
|
||||
|
||||
// The client clears its token optimistically — once logout reaches the
|
||||
// server, the revocation must not die with a dropped connection.
|
||||
if err := database.DeleteSession(context.WithoutCancel(r.Context()), sess.TokenHash); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to logout",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// A custom status is a "what I am doing right now" note. Leaving it
|
||||
// standing after the user signed out states something about them that
|
||||
// is no longer true, so logout clears it — unlike the chosen presence
|
||||
// status, which is a preference and deliberately survives.
|
||||
if err := database.UpdateUserCustomStatus(context.WithoutCancel(r.Context()), sess.UserID, nil); err != nil {
|
||||
slog.Warn("failed to clear custom status on logout", "user_id", sess.UserID, "err", err)
|
||||
}
|
||||
|
||||
slog.Info("user logged out", "user_id", sess.UserID)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, sess.UserID, "user_logout", "user", sess.UserID, "")
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -265,12 +595,15 @@ func handleLogout(svc AuthService) http.HandlerFunc {
|
||||
// handleMe processes GET /api/v1/auth/me.
|
||||
func handleMe() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := principal(r)
|
||||
if !ok {
|
||||
writeNotAuthenticated(w)
|
||||
user, ok := r.Context().Value(UserKey).(*db.User)
|
||||
if !ok || user == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "not authenticated",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toUserResponse(p.User))
|
||||
writeJSON(w, http.StatusOK, toUserResponse(user))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,14 +612,30 @@ type deleteAccountRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// handleDeleteAccount processes DELETE /api/v1/auth/account. The caller must
|
||||
// supply their current password for confirmation; the lockout, the compare
|
||||
// and the member_ban broadcast are the service's.
|
||||
func handleDeleteAccount(svc AuthService) http.HandlerFunc {
|
||||
// handleDeleteAccount processes DELETE /api/v1/auth/account.
|
||||
// The caller must supply their current password for confirmation.
|
||||
// Progressive lockout mirrors the login handler: 3 failures → 15-min lock.
|
||||
// broadcaster may be nil, in which case no event is sent and other connected
|
||||
// clients converge on their next reconnect instead (same fallback every
|
||||
// other broadcaster-optional handler in this package uses).
|
||||
func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter, broadcaster AuthBroadcaster) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := principal(r)
|
||||
if !ok {
|
||||
writeNotAuthenticated(w)
|
||||
user, ok := r.Context().Value(UserKey).(*db.User)
|
||||
if !ok || user == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "not authenticated",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Per-user lockout to prevent password brute-force on this destructive endpoint.
|
||||
lockKey := auth.Key("delete_lock", user.ID)
|
||||
if limiter.IsLockedOut(lockKey) {
|
||||
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
||||
Error: "RATE_LIMITED",
|
||||
Message: "too many failed attempts, try again later",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,70 +648,80 @@ func handleDeleteAccount(svc AuthService) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.DeleteAccount(r.Context(), p, req.Password, clientIP(r)); err != nil {
|
||||
writeAuthError(r.Context(), w, err)
|
||||
if req.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "INVALID_INPUT",
|
||||
Message: "password is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the supplied password matches the stored hash.
|
||||
failKey := auth.Key("delete_fail", user.ID)
|
||||
if !auth.CheckPassword(user.PasswordHash, req.Password) {
|
||||
if !limiter.Allow(failKey, deleteAccountFailureThreshold, deleteAccountFailureWindow) {
|
||||
limiter.Lockout(r.Context(), lockKey, deleteAccountLockoutDuration)
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "INVALID_INPUT",
|
||||
Message: "incorrect password",
|
||||
})
|
||||
return
|
||||
}
|
||||
limiter.Reset(r.Context(), failKey)
|
||||
|
||||
if err := database.DeleteAccount(r.Context(), user.ID); err != nil {
|
||||
if errors.Is(err, db.ErrLastAdmin) {
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||
Error: "FORBIDDEN",
|
||||
Message: "cannot delete the last admin account",
|
||||
})
|
||||
return
|
||||
}
|
||||
slog.Error("DeleteAccount failed", "err", err, "user_id", user.ID)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "failed to delete account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ip := clientIP(r)
|
||||
slog.Info("account deleted", "username", user.Username, "user_id", user.ID, "ip", ip)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "account_deleted", "user", user.ID,
|
||||
"account self-deleted from "+ip)
|
||||
|
||||
// DeleteAccount left the row in exactly the state an admin ban does
|
||||
// (anonymised, banned, sessions revoked) — broadcast the same event so
|
||||
// every other connected client drops the deleted user immediately
|
||||
// instead of keeping their pre-deletion username until it reconnects.
|
||||
if broadcaster != nil {
|
||||
broadcaster.BroadcastMemberBan(user.ID)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// authResponse encodes a service result: a session, or the two-factor
|
||||
// challenge Login started instead of one.
|
||||
func authResponse(res *service.AuthResult) authSuccessResponse {
|
||||
if res.Requires2FA {
|
||||
return authSuccessResponse{
|
||||
PartialToken: res.PartialToken,
|
||||
Requires2FA: true,
|
||||
}
|
||||
// toUserResponse converts a db.User to the API response shape.
|
||||
func toUserResponse(u *db.User) *userResponse {
|
||||
avatar := ""
|
||||
if u.Avatar != nil {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
return authSuccessResponse{
|
||||
Token: res.Token,
|
||||
Requires2FA: false,
|
||||
User: toUserResponse(res.User),
|
||||
resp := &userResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Avatar: avatar,
|
||||
DisplayName: u.DisplayName,
|
||||
About: u.About,
|
||||
CustomStatus: u.CustomStatus,
|
||||
Status: u.Status,
|
||||
RoleID: u.RoleID,
|
||||
TOTPEnabled: u.TOTPSecret != nil,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// writeNotAuthenticated is the refusal for a route mounted behind
|
||||
// AuthMiddleware that still finds no usable principal on the request.
|
||||
func writeNotAuthenticated(w http.ResponseWriter) {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
// writeAuthError encodes a service.Err* refusal from the auth slice. Each
|
||||
// named value's Error() is the public message; its category picks the
|
||||
// status and code, and two values carry a code of their own. Anything that
|
||||
// is not an auth refusal is a contract bug in the service, logged and
|
||||
// answered as a generic 500 so no cause leaks to the client.
|
||||
func writeAuthError(ctx context.Context, w http.ResponseWriter, err error) {
|
||||
var status int
|
||||
var code string
|
||||
switch {
|
||||
case errors.Is(err, service.ErrRegistrationRejected):
|
||||
status, code = http.StatusBadRequest, "INVALID_CREDENTIALS"
|
||||
case errors.Is(err, service.ErrTOTPAlreadyEnabled):
|
||||
status, code = http.StatusConflict, "TOTP_ALREADY_ENABLED"
|
||||
case errors.Is(err, service.ErrRateLimited):
|
||||
status, code = http.StatusTooManyRequests, "RATE_LIMITED"
|
||||
case errors.Is(err, service.ErrUnauthorized):
|
||||
status, code = http.StatusUnauthorized, "UNAUTHORIZED"
|
||||
case errors.Is(err, service.ErrForbidden):
|
||||
status, code = http.StatusForbidden, "FORBIDDEN"
|
||||
case errors.Is(err, service.ErrInvalidInput):
|
||||
status, code = http.StatusBadRequest, "INVALID_INPUT"
|
||||
case errors.Is(err, service.ErrBadRequest):
|
||||
status, code = http.StatusBadRequest, "BAD_REQUEST"
|
||||
case errors.Is(err, service.ErrInternal):
|
||||
status, code = http.StatusInternalServerError, "INTERNAL_ERROR"
|
||||
default:
|
||||
slog.ErrorContext(ctx, "auth service returned a non-refusal error", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "INTERNAL_ERROR", Message: "internal error"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, status, errorResponse{Error: code, Message: err.Error()})
|
||||
return resp
|
||||
}
|
||||
|
||||
// truncateDevice truncates the User-Agent to prevent oversized session records.
|
||||
@@ -374,3 +733,54 @@ func truncateDevice(ua string) string {
|
||||
}
|
||||
return ua
|
||||
}
|
||||
|
||||
func issueSession(ctx context.Context, database *db.DB, userID int64, device, ip string) (string, error) {
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), device, ip); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func isRequire2FAEnabled(ctx context.Context, database *db.DB) (bool, error) {
|
||||
return getBooleanSetting(ctx, database, "require_2fa", false)
|
||||
}
|
||||
|
||||
func isRegistrationOpen(ctx context.Context, database *db.DB) (bool, error) {
|
||||
return getBooleanSetting(ctx, database, "registration_open", true)
|
||||
}
|
||||
|
||||
func getBooleanSetting(ctx context.Context, database *db.DB, key string, defaultValue bool) (bool, error) {
|
||||
value, err := database.GetSetting(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return defaultValue, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return parseBooleanSettingValue(value)
|
||||
}
|
||||
|
||||
func parseBooleanSettingValue(value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true":
|
||||
return true, nil
|
||||
case "0", "false":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid boolean setting value %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func requirePasswordConfirmation(user *db.User, password string) error {
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
if !auth.CheckPassword(user.PasswordHash, password) {
|
||||
return fmt.Errorf("password confirmation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/api"
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -33,7 +32,7 @@ func TestDeleteAccount_BroadcastsMemberBan(t *testing.T) {
|
||||
broadcaster := &recordingAuthBroadcaster{}
|
||||
|
||||
r := chi.NewRouter()
|
||||
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, broadcaster), api.AuthMiddleware(database), limiter, nil)
|
||||
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey, broadcaster)
|
||||
|
||||
hash, _ := auth.HashPassword("correctPass1")
|
||||
uid, _ := database.CreateUser(context.Background(), "deletebroadcast", hash, 4)
|
||||
@@ -52,14 +51,14 @@ func TestDeleteAccount_BroadcastsMemberBan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A nil broadcaster (the shape every test mount uses) must keep working
|
||||
// exactly as before: no event, no panic.
|
||||
// Omitting the broadcaster (the shape every existing MountAuthRoutes call
|
||||
// site uses today) must keep working exactly as before: no event, no panic.
|
||||
func TestDeleteAccount_NoBroadcasterOmitted(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
|
||||
r := chi.NewRouter()
|
||||
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, nil), api.AuthMiddleware(database), limiter, nil)
|
||||
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey)
|
||||
|
||||
hash, _ := auth.HashPassword("correctPass1")
|
||||
uid, _ := database.CreateUser(context.Background(), "deletenobroadcast", hash, 4)
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/J3vb/OwnCord/Server/api"
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -46,7 +45,7 @@ func buildAuthRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler {
|
||||
|
||||
func buildAuthRouterWithProxies(database *db.DB, limiter *auth.RateLimiter, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, nil), api.AuthMiddleware(database), limiter, trustedProxies)
|
||||
api.MountAuthRoutes(r, database, limiter, trustedProxies, testTOTPKey)
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/updater"
|
||||
"github.com/J3vb/OwnCord/Server/ws"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
@@ -59,16 +58,6 @@ func handleClientUpdate(u *updater.Updater) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Server first, clients second: never advertise a client that speaks
|
||||
// a newer wire epoch than this server — it would auto-update straight
|
||||
// into a refused handshake. The epoch comes from the release's signed
|
||||
// manifest; a manifest that does not verify is withheld the same way.
|
||||
epoch, err := u.ReleaseProtocolEpoch(r.Context(), info)
|
||||
if err != nil || epoch > ws.ProtocolEpoch {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the updater artifact and its signature for the requested
|
||||
// target ("{os}-{arch}-{installer}", e.g. "windows-x86_64-nsis").
|
||||
// Targets without a published updater artifact get 204 — never a
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user