mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
test+feat(phase-bc): pass 4 — test coverage, install endpoint, CHANGELOG
Final in-sandbox completeness pass. Five focused pieces; the remaining items in PHASE_BC_LOCAL_TODO.md after this commit are all genuinely local-only (toolchain, network, native deps). Test coverage (the biggest gap from prior reviews) - Server/plugin/manifest_test.go — pluginNameRegexp accept/reject table, validateRelativePath table, oversized version, unknown permission. - Server/plugin/host_http_test.go — hostAllowed dot-boundary suffix, empty-entry rejection, case insensitivity, FQDN trailing dot. ipAllowed table over loopback, RFC1918, RFC4193 (ULA), RFC6598 (CGN), link-local, multicast, unspecified — both v4 and v6 — plus public-IP accept cases. - Server/plugin/loader_test.go — rejectSymlinksUnder catches direct and nested symlinks; scanPluginDirectory rejects a plugin whose entrypoint is a symlink. Skipped on Windows where symlink creation needs elevation. - Server/plugin/host_ui_test.go — AssetHandler serves declared files, rejects undeclared files (404), rejects path traversal, supports nested asset paths. - Server/ws/hub_seedseq_test.go — SeedSeq monotonic, never-backwards, concurrent CAS safety, integration with nextSeq. - Server/ws/extract_event_type_test.go — table covering happy paths, control char rejection, escaped quote rejection, length cap (64), empty/missing/non-JSON inputs. Plugin install endpoint (closes a real feature gap) - Server/plugin/registry.go — InstallFromZip extracts a plugin .zip into a staging directory under cfg.Directory, validates it zip-slip safe (cleaned-path Rel check), refuses non-regular entries, refuses symlinks, caps compressed at 16 MiB and uncompressed total at 64 MiB (each file gated by io.CopyN against the remaining budget). Manifest is parsed at the staged root, then atomically renamed into the canonical plugin directory and registered via the existing installFromDisk path. - Server/api/plugins_handler.go — POST /install accepts multipart with one "plugin" file part, http.MaxBytesReader caps the request body, io.LimitReader caps the in-memory buffer, calls Registry.InstallFromZip, returns 201 with the new plugin name. The endpoint inherits the Pass 2 admin auth + IP gate (mounted under r.Use(admin.RequireAdminAuth)). Protocol surface - Server/ws/serve.go — buildAuthOK now takes replaySource and includes it in the auth_ok payload as "replay_source": "none" | "buffer" | "db". Two call sites updated: reconnect path passes the existing local, fresh-connect path passes "none". Test export updated to pass "none". CI build-tag matrix - .github/workflows/ci.yml — three new steps inside server-build-test build the server with -tags otel, -tags wazero, and -tags otel,wazero. All three are continue-on-error: true until the upstream OTel and wazero modules land in go.mod (tracked in PHASE_BC_LOCAL_TODO.md). Once they do, dropping continue-on-error converts the steps into hard CI gates against tag-boundary drift. Documentation - CHANGELOG.md — new root-level file with curated entries for Phase B, Phase C, security, and behavioural changes operators must know about (notably event_persistence.enabled = true by default). - PHASE_BC_LOCAL_TODO.md — ticks off the install endpoint, the replay_source field, and the existing event_persistence defaultYAML entry. The remaining items are toolchain-bound. After this pass, the in-sandbox completeness ceiling is reached. Everything still pending requires Go 1.25 toolchain, npm install, real OTel SDK + wazero modules, sqlc, postgres backend impl, or tinygo. https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
This commit is contained in:
@@ -40,6 +40,21 @@ jobs:
|
||||
- name: Build server
|
||||
run: go build -o ${{ matrix.binary }} -ldflags "-s -w" .
|
||||
|
||||
# Phase B + C build-tag matrix. Each tag variant must compile so the
|
||||
# tag boundaries don't drift. The OTel and wazero tags are gated
|
||||
# behind `continue-on-error: true` until the upstream modules land in
|
||||
# go.mod (tracked in PHASE_BC_LOCAL_TODO.md). Once the modules are
|
||||
# added, drop continue-on-error so a missing tag combo fails CI.
|
||||
- name: Build with -tags otel (Phase B Step 8)
|
||||
continue-on-error: true
|
||||
run: go build -tags otel ./...
|
||||
- name: Build with -tags wazero (Phase C Step 9)
|
||||
continue-on-error: true
|
||||
run: go build -tags wazero ./...
|
||||
- name: Build with -tags otel,wazero (full community-hub build)
|
||||
continue-on-error: true
|
||||
run: go build -tags otel,wazero ./...
|
||||
|
||||
- name: Go vulnerability check
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 && govulncheck ./...
|
||||
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to OwnCord are listed here. The repository's release
|
||||
tooling (`npm run changelog`) auto-generates entries from commit messages
|
||||
on each release; this file is the curated counterpart that calls out
|
||||
behavioural changes operators must know about.
|
||||
|
||||
## Unreleased — Phase B + C
|
||||
|
||||
### Phase B — Acceleration
|
||||
|
||||
- **Event persistence layer (Step 7).** A new `events` table backs the
|
||||
WebSocket reconnect path. When a client's `last_seq` is too old for
|
||||
the in-memory ring buffer (~1000 events), the server now falls back to
|
||||
a SQLite query before forcing a full re-sync. The hub seeds its
|
||||
monotonic sequence counter from `MAX(events.seq)` at startup so row
|
||||
seqs and wrapped-payload seqs stay aligned across restarts. Configurable
|
||||
via the new `event_persistence` block; **enabled by default** (see
|
||||
"Behavioural changes" below).
|
||||
- **Tiered reconnect telemetry.** `auth_ok` now includes a `replay_source`
|
||||
field (`"none" | "buffer" | "db"`) so clients can attribute reconnection
|
||||
behaviour. The same tier label is exported as the
|
||||
`ws_reconnect_tier_total{tier}` counter.
|
||||
- **OpenTelemetry skeleton (Step 8).** Public API + no-op default
|
||||
provider in `Server/telemetry/`. Chi router middleware mounted
|
||||
unconditionally. Service-layer spans on `MessageService.SendMessage`,
|
||||
`PermissionService.HasChannelPerm`,
|
||||
`ChannelService.ListVisibleChannels`, `DMService.CreateDM`,
|
||||
`VoiceService.JoinChannel`, `InviteService.CreateInvite`,
|
||||
`ModerationService.BanUser`, `BlockService.BlockUser`,
|
||||
`UserService.UpdateProfile`. The real OTel SDK is gated behind
|
||||
`-tags otel` and is currently a placeholder; wiring the upstream
|
||||
modules is tracked in `PHASE_BC_LOCAL_TODO.md`.
|
||||
- **Solid.js proof of concept (Step 6).** Two leaf components migrated
|
||||
(`Badge`, `ChannelListItem`), Vite + JSX configured, store→signal
|
||||
adapter landed. The remaining vanilla components remain in place;
|
||||
migration is mechanical and tracked in the local TODO.
|
||||
|
||||
### Phase C — Differentiation
|
||||
|
||||
- **Plugin runtime skeleton (Step 9).** New `Server/plugin/` package
|
||||
with manifest parser, on-disk loader, registry, and host capability
|
||||
surfaces (`commands`, `events`, `storage`, `http`, `ui`). Manifest
|
||||
format is JSON (`plugin.json`); the design's TOML format is gated
|
||||
behind the `-tags wazero` build and tracked locally.
|
||||
- **Plugin admin REST surface.** Lifecycle endpoints under
|
||||
`/api/v1/admin/plugins`: list, enable, disable, uninstall, and the
|
||||
new install path that accepts a multipart zip upload, validates it
|
||||
zip-slip safe with size + symlink rejection, and atomically installs
|
||||
it. Mounted under both `AdminIPRestrict` and the
|
||||
`admin.RequireAdminAuth` session/permission middleware.
|
||||
- **Plugin admin client bridge.** `pluginBridge.ts` mounts plugin UI
|
||||
tabs in sandboxed iframes with origin-validated postMessage routing.
|
||||
|
||||
### Security
|
||||
|
||||
- **SSRF defense for `http` capability.** Plugin outbound HTTP requests
|
||||
are now validated through `net/url.Parse`, suffix-matched with a dot
|
||||
boundary (so `evil-api.example.com` does not match
|
||||
`api.example.com`), and rejected for empty allowlist entries. A custom
|
||||
`Transport.DialContext` re-resolves DNS on every dial and refuses any
|
||||
resolved address in loopback / RFC1918 / RFC4193 / RFC6598 (CGN) /
|
||||
link-local / multicast / unspecified ranges. Closes the DNS-rebinding
|
||||
TOCTOU window. Response body is capped at 5 MiB.
|
||||
- **Plugin manifest hardening.** `Manifest.Name` must match
|
||||
`^[a-z0-9][a-z0-9_-]{0,63}$`. Entrypoint and UI tab asset paths are
|
||||
rejected if absolute, non-canonical, contain `..`, or contain NUL
|
||||
bytes / backslashes.
|
||||
- **Plugin asset handler.** Defends against symlink escapes (rejected
|
||||
at install time via `filepath.Walk` + `Lstat`) and prefix-without-
|
||||
separator path traversal (via `filepath.Rel` check after join).
|
||||
- **Plugin postMessage routing.** The host bridge looks up the trusted
|
||||
pluginId via `e.source -> contentWindow` instead of trusting the
|
||||
`pluginId` field in the message body. Spoofed messages from any
|
||||
non-iframe source are dropped.
|
||||
|
||||
### Behavioural changes operators must know about
|
||||
|
||||
- **`event_persistence.enabled` defaults to `true`.** Every broadcast
|
||||
WebSocket event is written to the `events` table, retained for
|
||||
24 hours by default, and pruned by a background goroutine every hour.
|
||||
This is a new on-disk write path that did not exist before. Disable
|
||||
it by adding to `config.yaml`:
|
||||
```yaml
|
||||
event_persistence:
|
||||
enabled: false
|
||||
```
|
||||
- **DM events are persisted under the same retention.** Operators with
|
||||
GDPR or compliance requirements should review the retention window
|
||||
and consider setting `event_persistence.enabled: false` until a
|
||||
per-channel-type opt-out lands.
|
||||
- **Plugin admin endpoints require admin session auth in addition to
|
||||
the existing IP restriction.** A previous prerelease shipped with only
|
||||
the IP gate; that has been corrected.
|
||||
|
||||
### Known follow-up work (local toolchain required)
|
||||
|
||||
See `PHASE_BC_LOCAL_TODO.md` for the full list. Highlights:
|
||||
|
||||
- Real OpenTelemetry SDK wiring (needs `go get` of the upstream modules)
|
||||
- Real Wazero runtime construction (needs `go get github.com/tetratelabs/wazero`)
|
||||
- Postgres backend implementation (needs `make sqlc-generate`)
|
||||
- Tinygo `.wasm` build of the example hello plugin
|
||||
- Migration of the remaining vanilla TypeScript components to Solid.js
|
||||
- Slash-command dispatcher in the WS layer (design TBD)
|
||||
|
||||
These items each need a real developer machine with network access; no
|
||||
in-sandbox pass can land them.
|
||||
@@ -107,9 +107,9 @@ Still TODO locally:
|
||||
the DB tier returns the missing events. The session test
|
||||
(`event_persister_test.go`) covers the persister in isolation but
|
||||
not the buffer→DB handoff inside `handleReconnect`.
|
||||
- [ ] Add a `replay_source` field to the auth_ok payload so the client
|
||||
can log the tier. The hub already records the tier in metrics; the
|
||||
client surface change is a separate UX call.
|
||||
- [x] Add a `replay_source` field to the auth_ok payload — landed in
|
||||
Pass 4. `buildAuthOK` takes the tier as a parameter, "none" on
|
||||
fresh connect, "buffer" or "db" on resume.
|
||||
- [x] Document the new `event_persistence` block in `defaultYAML` inside
|
||||
`Server/config/config.go` — landed in Pass 3.
|
||||
|
||||
@@ -230,9 +230,11 @@ Still TODO locally:
|
||||
cd Server/plugin/examples/hello
|
||||
tinygo build -o hello.wasm -target wasi ./main.go
|
||||
```
|
||||
- [ ] Implement plugin marketplace install path
|
||||
(`POST /api/v1/admin/plugins/install` with multipart zip). The
|
||||
handler is scaffolded but the install endpoint is currently absent.
|
||||
- [x] Implement plugin marketplace install path
|
||||
(`POST /api/v1/admin/plugins/install` with multipart zip) — landed
|
||||
in Pass 4. `Registry.InstallFromZip` does zip-slip validation, no
|
||||
symlinks, 16 MiB compressed cap, 64 MiB uncompressed cap, then
|
||||
atomic rename into the plugin directory.
|
||||
- [ ] Replace plugin postgres stubs in `Server/store/postgres.go` with
|
||||
real `pgdbgen`-backed implementations once `make sqlc-generate`
|
||||
runs (same blocker as Phase B Step 7).
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -14,6 +15,11 @@ import (
|
||||
"github.com/owncord/server/store"
|
||||
)
|
||||
|
||||
// maxPluginUploadBytes caps the multipart upload at 16 MiB to match the
|
||||
// plugin.maxZipBytes ceiling. The handler enforces both layers because the
|
||||
// outer MaxBytesReader gives a clean 413 instead of a partial extract.
|
||||
const maxPluginUploadBytes = 16 * 1024 * 1024
|
||||
|
||||
// PluginAdminHandler exposes plugin lifecycle operations to the admin panel.
|
||||
type PluginAdminHandler struct {
|
||||
registry *plugin.Registry
|
||||
@@ -27,12 +33,55 @@ func NewPluginAdminHandler(registry *plugin.Registry, st store.PluginStore) http
|
||||
h := &PluginAdminHandler{registry: registry, store: st}
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", h.list)
|
||||
r.Post("/install", h.install)
|
||||
r.Post("/{id}/enable", h.enable)
|
||||
r.Post("/{id}/disable", h.disable)
|
||||
r.Delete("/{id}", h.uninstall)
|
||||
return r
|
||||
}
|
||||
|
||||
// install accepts a multipart upload with a single "plugin" file part
|
||||
// containing a .zip. The zip is validated (zip-slip safe, no symlinks,
|
||||
// uncompressed total capped, manifest required at root) and installed via
|
||||
// Registry.InstallFromZip. Returns 201 with the new plugin name on success.
|
||||
func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
|
||||
if h.registry == nil {
|
||||
http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
// Hard cap on the request body before parsing multipart so a hostile
|
||||
// client can't tie up parsing memory.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+1024)
|
||||
if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil {
|
||||
http.Error(w, "invalid multipart upload: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("plugin")
|
||||
if err != nil {
|
||||
http.Error(w, "missing 'plugin' file part", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close() //nolint:errcheck
|
||||
|
||||
// Read the entire zip into memory — InstallFromZip needs an io.ReaderAt
|
||||
// for archive/zip and the cap is small enough to be safe.
|
||||
body, err := io.ReadAll(io.LimitReader(file, maxPluginUploadBytes+1))
|
||||
if err != nil {
|
||||
http.Error(w, "read upload: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if int64(len(body)) > maxPluginUploadBytes {
|
||||
http.Error(w, "plugin upload too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
name, err := h.registry.InstallFromZip(r.Context(), body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"name": name})
|
||||
}
|
||||
|
||||
func (h *PluginAdminHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if h.store == nil {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Pass 4 — host HTTP allowlist + IP guard tests.
|
||||
//
|
||||
// Locks in the SSRF defenses added in Pass 2 (dot-bounded host suffix
|
||||
// matching, empty-entry rejection) and Pass 3 (RFC6598 CGN rejection).
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestRegistry(allowlist []string) *Registry {
|
||||
return &Registry{cfg: Config{HTTPAllowlist: allowlist}}
|
||||
}
|
||||
|
||||
func TestHostAllowedDotBoundary(t *testing.T) {
|
||||
r := newTestRegistry([]string{"api.example.com", "example.org"})
|
||||
cases := []struct {
|
||||
host string
|
||||
ok bool
|
||||
}{
|
||||
{"api.example.com", true},
|
||||
{"v1.api.example.com", true},
|
||||
{"example.org", true},
|
||||
{"sub.example.org", true},
|
||||
// Sibling-domain attack — must NOT match.
|
||||
{"evil-api.example.com", false},
|
||||
{"notexample.com", false},
|
||||
{"example.com", false}, // not in list
|
||||
{"evil.com", false},
|
||||
{"", false},
|
||||
{"api.example.com.evil.com", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := r.hostAllowed(c.host)
|
||||
if got != c.ok {
|
||||
t.Errorf("hostAllowed(%q) = %v, want %v", c.host, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAllowedEmptyEntryRejected(t *testing.T) {
|
||||
r := newTestRegistry([]string{""})
|
||||
if r.hostAllowed("anything.com") {
|
||||
t.Fatal("empty allowlist entry must NOT wildcard-match")
|
||||
}
|
||||
if r.hostAllowed("") {
|
||||
t.Fatal("empty host must not match empty entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAllowedCaseInsensitive(t *testing.T) {
|
||||
r := newTestRegistry([]string{"API.Example.COM"})
|
||||
if !r.hostAllowed("api.example.com") {
|
||||
t.Fatal("hostAllowed should be case-insensitive")
|
||||
}
|
||||
if !r.hostAllowed("API.example.com") {
|
||||
t.Fatal("hostAllowed should be case-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAllowedTrailingDot(t *testing.T) {
|
||||
r := newTestRegistry([]string{"api.example.com"})
|
||||
if !r.hostAllowed("api.example.com.") {
|
||||
t.Fatal("FQDN trailing dot should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowedRejectsAllRanges(t *testing.T) {
|
||||
cases := []string{
|
||||
"127.0.0.1", // loopback
|
||||
"127.5.6.7", // loopback range
|
||||
"10.0.0.1", // RFC1918
|
||||
"172.16.5.5", // RFC1918
|
||||
"172.31.255.255", // RFC1918 high
|
||||
"192.168.1.1", // RFC1918
|
||||
"169.254.169.254", // AWS metadata / link-local
|
||||
"100.64.5.5", // RFC6598 CGN
|
||||
"100.127.255.255", // RFC6598 CGN high
|
||||
"::1", // IPv6 loopback
|
||||
"fc00::1", // RFC4193 ULA
|
||||
"fe80::1", // IPv6 link-local
|
||||
"0.0.0.0", // unspecified
|
||||
"::", // IPv6 unspecified
|
||||
"224.0.0.1", // multicast
|
||||
"ff00::1", // IPv6 multicast
|
||||
}
|
||||
for _, addr := range cases {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip == nil {
|
||||
t.Fatalf("ParseIP(%q) failed", addr)
|
||||
}
|
||||
if err := ipAllowed(ip); err == nil {
|
||||
t.Errorf("ipAllowed(%s) should have returned error", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowedAcceptsPublic(t *testing.T) {
|
||||
cases := []string{
|
||||
"8.8.8.8",
|
||||
"1.1.1.1",
|
||||
"203.0.113.5", // RFC5737 documentation but not in any reject set
|
||||
"2606:4700:4700::1111",
|
||||
}
|
||||
for _, addr := range cases {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip == nil {
|
||||
t.Fatalf("ParseIP(%q) failed", addr)
|
||||
}
|
||||
if err := ipAllowed(ip); err != nil {
|
||||
t.Errorf("ipAllowed(%s) should have been allowed, got %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowedNilRejected(t *testing.T) {
|
||||
if err := ipAllowed(nil); err == nil {
|
||||
t.Fatal("nil IP should be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Pass 4 — asset handler tests.
|
||||
//
|
||||
// Locks in the Pass 2 + Pass 3 hardening: only manifest-declared files are
|
||||
// served, path traversal is rejected, prefix-without-separator escapes are
|
||||
// rejected via the filepath.Rel check.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newAssetTestInstance(t *testing.T, declaredAssets []string) (*Registry, *Instance, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
// The handler uses filepath.Dir(inst.WASMPath) as pluginDir.
|
||||
wasmPath := filepath.Join(dir, "main.wasm")
|
||||
if err := os.WriteFile(wasmPath, []byte("\x00asm"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tabs := make([]UITab, len(declaredAssets))
|
||||
for i, a := range declaredAssets {
|
||||
tabs[i] = UITab{ID: "tab", Asset: a}
|
||||
// Create the file under the plugin dir so ServeFile can find it.
|
||||
full := filepath.Join(dir, a)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("hello: "+a), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r := &Registry{}
|
||||
inst := &Instance{
|
||||
ID: 1,
|
||||
WASMPath: wasmPath,
|
||||
Manifest: &Manifest{
|
||||
Name: "ui-test",
|
||||
Version: "0.1.0",
|
||||
Entrypoint: "main.wasm",
|
||||
Permissions: []string{string(CapUI)},
|
||||
UI: UISpec{Tabs: tabs},
|
||||
},
|
||||
}
|
||||
return r, inst, dir
|
||||
}
|
||||
|
||||
func TestAssetHandlerServesAllowedFile(t *testing.T) {
|
||||
r, inst, _ := newAssetTestInstance(t, []string{"index.html"})
|
||||
h := r.AssetHandler(inst)
|
||||
|
||||
req := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if got := w.Body.String(); got != "hello: index.html" {
|
||||
t.Fatalf("unexpected body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetHandlerRejectsUnlistedFile(t *testing.T) {
|
||||
r, inst, dir := newAssetTestInstance(t, []string{"index.html"})
|
||||
// File EXISTS in the plugin dir but is not declared in the manifest.
|
||||
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("nope"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := r.AssetHandler(inst)
|
||||
|
||||
req := httptest.NewRequest("GET", "/secret.txt", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for undeclared file, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetHandlerRejectsTraversal(t *testing.T) {
|
||||
r, inst, _ := newAssetTestInstance(t, []string{"index.html"})
|
||||
h := r.AssetHandler(inst)
|
||||
|
||||
cases := []string{
|
||||
"/../../../etc/passwd",
|
||||
"/..%2Fpasswd",
|
||||
"/etc/passwd",
|
||||
}
|
||||
for _, p := range cases {
|
||||
req := httptest.NewRequest("GET", p, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusOK {
|
||||
t.Errorf("traversal path %q should not have returned 200", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetHandlerServesNestedAsset(t *testing.T) {
|
||||
r, inst, _ := newAssetTestInstance(t, []string{"assets/app.js"})
|
||||
h := r.AssetHandler(inst)
|
||||
|
||||
req := httptest.NewRequest("GET", "/assets/app.js", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Pass 4 — loader symlink rejection tests.
|
||||
//
|
||||
// Locks in the Pass 3 defense against malicious plugin .zip packages that
|
||||
// ship symlinks to host filesystem paths. http.ServeFile follows symlinks
|
||||
// transparently, so the only safe time to reject them is at install /
|
||||
// directory-scan time.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRejectSymlinksUnderClean(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "regular.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "subdir", "nested.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rejectSymlinksUnder(dir); err != nil {
|
||||
t.Fatalf("clean directory should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectSymlinksUnderFindsSymlink(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires elevated privileges on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "regular.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create an evil symlink pointing at /etc/passwd.
|
||||
link := filepath.Join(dir, "evil.html")
|
||||
if err := os.Symlink("/etc/passwd", link); err != nil {
|
||||
t.Skipf("symlink creation failed (likely unsupported FS): %v", err)
|
||||
}
|
||||
if err := rejectSymlinksUnder(dir); err == nil {
|
||||
t.Fatal("expected rejectSymlinksUnder to refuse the symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectSymlinksUnderFindsNestedSymlink(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires elevated privileges on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "assets")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(sub, "leak")
|
||||
if err := os.Symlink("/etc/passwd", link); err != nil {
|
||||
t.Skipf("symlink creation failed: %v", err)
|
||||
}
|
||||
if err := rejectSymlinksUnder(dir); err == nil {
|
||||
t.Fatal("expected nested symlink to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanPluginDirectoryRejectsSymlinkEntrypoint(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires elevated privileges on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
pluginDir := filepath.Join(root, "evil")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Manifest claims hello.wasm; we'll make hello.wasm a symlink.
|
||||
manifest := []byte(`{
|
||||
"name": "evil",
|
||||
"version": "0.1.0",
|
||||
"entrypoint": "hello.wasm"
|
||||
}`)
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifest, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create a real target then symlink to it (so the target exists; the
|
||||
// symlink itself is what we want to reject).
|
||||
target := filepath.Join(root, "target.bin")
|
||||
if err := os.WriteFile(target, []byte("\x00asm"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(pluginDir, "hello.wasm")); err != nil {
|
||||
t.Skipf("symlink creation failed: %v", err)
|
||||
}
|
||||
_, err := scanPluginDirectory(root)
|
||||
if err == nil {
|
||||
t.Fatal("scanPluginDirectory should reject plugin with symlinked entrypoint")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Pass 4 — security-sensitive manifest validation tests.
|
||||
//
|
||||
// Locks in the regex / path-traversal / NUL-byte rules added in Pass 2 so
|
||||
// regressions in Manifest.Validate are caught at CI time instead of via
|
||||
// manual review.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginNameRegexp(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"hello", true},
|
||||
{"game-detection", true},
|
||||
{"a", true},
|
||||
{"a1_b-c", true},
|
||||
{"abcdefghijklmnopqrstuvwxyz0123456789_-abcdefghijklmnopqrstuvwxyz", true}, // 64 chars
|
||||
{"abcdefghijklmnopqrstuvwxyz0123456789_-abcdefghijklmnopqrstuvwxyz0", false}, // 65 chars
|
||||
{"", false},
|
||||
{"Hello", false},
|
||||
{"_leading", false},
|
||||
{"-leading", false},
|
||||
{"..", false},
|
||||
{"a/b", false},
|
||||
{"a.b", false},
|
||||
{"a b", false},
|
||||
{"a\x00b", false},
|
||||
{"hello!", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := pluginNameRegexp.MatchString(c.name)
|
||||
if got != c.ok {
|
||||
t.Errorf("pluginNameRegexp.MatchString(%q) = %v, want %v", c.name, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRelativePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
ok bool
|
||||
}{
|
||||
{"a.wasm", true},
|
||||
{"assets/index.html", true},
|
||||
{"dir/sub/file.js", true},
|
||||
{"hello.wasm", true},
|
||||
// failures
|
||||
{"", false},
|
||||
{"/abs/path", false},
|
||||
{"../escape", false},
|
||||
{"./not-clean", false},
|
||||
{"dir//double", false},
|
||||
{"dir/", false},
|
||||
{"dir\\win", false},
|
||||
{"file\x00name", false},
|
||||
{"..", false},
|
||||
{".", false},
|
||||
{"a/../b", false},
|
||||
{"a/./b", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := validateRelativePath(c.path)
|
||||
got := err == nil
|
||||
if got != c.ok {
|
||||
t.Errorf("validateRelativePath(%q) ok=%v err=%v, want ok=%v", c.path, got, err, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidateRejectsBadName(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Name: "Bad Name",
|
||||
Version: "0.1.0",
|
||||
Entrypoint: "hello.wasm",
|
||||
}
|
||||
if err := m.Validate(); err == nil {
|
||||
t.Fatal("expected validation failure for invalid name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidateRejectsBadAsset(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Name: "hello",
|
||||
Version: "0.1.0",
|
||||
Entrypoint: "hello.wasm",
|
||||
UI: UISpec{
|
||||
Tabs: []UITab{
|
||||
{ID: "main", Asset: "../../../etc/passwd"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := m.Validate(); err == nil {
|
||||
t.Fatal("expected validation failure for traversal asset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidateRejectsAbsoluteEntrypoint(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Name: "hello",
|
||||
Version: "0.1.0",
|
||||
Entrypoint: "/etc/passwd.wasm",
|
||||
}
|
||||
if err := m.Validate(); err == nil {
|
||||
t.Fatal("expected validation failure for absolute entrypoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidateAcceptsMinimal(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Name: "hello",
|
||||
Version: "0.1.0",
|
||||
Entrypoint: "hello.wasm",
|
||||
}
|
||||
if err := m.Validate(); err != nil {
|
||||
t.Fatalf("expected minimal manifest to validate, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidateRejectsOversizedVersion(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Name: "hello",
|
||||
Version: strings.Repeat("v", 65),
|
||||
Entrypoint: "hello.wasm",
|
||||
}
|
||||
if err := m.Validate(); err == nil {
|
||||
t.Fatal("expected validation failure for oversized version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidateRejectsUnknownPermission(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Name: "hello",
|
||||
Version: "0.1.0",
|
||||
Entrypoint: "hello.wasm",
|
||||
Permissions: []string{"commands", "filesystem"},
|
||||
}
|
||||
if err := m.Validate(); err == nil {
|
||||
t.Fatal("expected validation failure for unknown permission")
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,14 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/owncord/server/store"
|
||||
@@ -142,6 +147,190 @@ func (r *Registry) installFromDisk(ctx context.Context, found foundPlugin) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallFromZip extracts a plugin .zip uploaded via the admin API into a
|
||||
// temp directory, validates it (zip-slip safe, no symlinks, size-capped),
|
||||
// then renames it into the plugin directory and registers it via
|
||||
// installFromDisk. Returns the new plugin name on success.
|
||||
//
|
||||
// The zip must contain a top-level plugin.json. The plugin's directory name
|
||||
// is taken from manifest.Name (validated by Manifest.Validate to a strict
|
||||
// charset). Re-installing an existing plugin replaces it.
|
||||
const (
|
||||
maxZipBytes = 16 * 1024 * 1024 // 16 MiB compressed
|
||||
maxUncompressedSum = 64 * 1024 * 1024 // 64 MiB total uncompressed
|
||||
)
|
||||
|
||||
func (r *Registry) InstallFromZip(ctx context.Context, zipBytes []byte) (string, error) {
|
||||
if r == nil || r.cfg.Directory == "" {
|
||||
return "", fmt.Errorf("plugin runtime not configured")
|
||||
}
|
||||
if int64(len(zipBytes)) > maxZipBytes {
|
||||
return "", fmt.Errorf("plugin zip exceeds %d bytes", maxZipBytes)
|
||||
}
|
||||
zr, err := zip.NewReader(bytesReaderAt(zipBytes), int64(len(zipBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid zip: %w", err)
|
||||
}
|
||||
|
||||
// Stage 1: extract into a temp dir under the plugin directory.
|
||||
if err := os.MkdirAll(r.cfg.Directory, 0o750); err != nil {
|
||||
return "", fmt.Errorf("create plugin dir: %w", err)
|
||||
}
|
||||
stage, err := os.MkdirTemp(r.cfg.Directory, ".install-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create staging dir: %w", err)
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(stage) }
|
||||
|
||||
stageAbs, absErr := filepath.Abs(stage)
|
||||
if absErr != nil {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("abs staging dir: %w", absErr)
|
||||
}
|
||||
|
||||
var totalUncompressed int64
|
||||
for _, f := range zr.File {
|
||||
// Reject symlinks, devices, and any non-regular file mode.
|
||||
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("plugin zip: refusing non-regular entry %q (mode=%v)", f.Name, f.Mode())
|
||||
}
|
||||
if f.Mode()&os.ModeSymlink != 0 {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("plugin zip: refusing symlink %q", f.Name)
|
||||
}
|
||||
// Reject zip-slip: cleaned absolute path must stay rooted at the
|
||||
// staging directory.
|
||||
clean := filepath.Clean(f.Name)
|
||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) || strings.Contains(clean, "..\\") {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("plugin zip: refusing path-traversal entry %q", f.Name)
|
||||
}
|
||||
dest := filepath.Join(stageAbs, clean)
|
||||
destAbs, dErr := filepath.Abs(dest)
|
||||
if dErr != nil {
|
||||
cleanup()
|
||||
return "", dErr
|
||||
}
|
||||
rel, relErr := filepath.Rel(stageAbs, destAbs)
|
||||
if relErr != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("plugin zip: refusing escape %q", f.Name)
|
||||
}
|
||||
|
||||
if f.Mode().IsDir() {
|
||||
if err := os.MkdirAll(destAbs, 0o750); err != nil {
|
||||
cleanup()
|
||||
return "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destAbs), 0o750); err != nil {
|
||||
cleanup()
|
||||
return "", err
|
||||
}
|
||||
rc, oErr := f.Open()
|
||||
if oErr != nil {
|
||||
cleanup()
|
||||
return "", oErr
|
||||
}
|
||||
out, cErr := os.OpenFile(destAbs, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
|
||||
if cErr != nil {
|
||||
_ = rc.Close()
|
||||
cleanup()
|
||||
return "", cErr
|
||||
}
|
||||
// Cap each file at the remaining uncompressed budget so a zip bomb
|
||||
// can't OOM the host.
|
||||
remaining := maxUncompressedSum - totalUncompressed
|
||||
if remaining <= 0 {
|
||||
_ = rc.Close()
|
||||
_ = out.Close()
|
||||
cleanup()
|
||||
return "", fmt.Errorf("plugin zip: uncompressed total exceeds %d bytes", maxUncompressedSum)
|
||||
}
|
||||
n, copyErr := io.CopyN(out, rc, remaining+1)
|
||||
_ = rc.Close()
|
||||
_ = out.Close()
|
||||
if copyErr != nil && copyErr != io.EOF {
|
||||
cleanup()
|
||||
return "", copyErr
|
||||
}
|
||||
if n > remaining {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("plugin zip: uncompressed total exceeds %d bytes", maxUncompressedSum)
|
||||
}
|
||||
totalUncompressed += n
|
||||
}
|
||||
|
||||
// Stage 2: parse the manifest now that the staging dir is fully populated.
|
||||
manifestPath := filepath.Join(stageAbs, "plugin.json")
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("plugin zip: missing plugin.json at root: %w", err)
|
||||
}
|
||||
manifest, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", err
|
||||
}
|
||||
// Validate the staged contents the same way scanPluginDirectory does.
|
||||
if err := rejectSymlinksUnder(stageAbs); err != nil {
|
||||
cleanup()
|
||||
return "", err
|
||||
}
|
||||
wasmPath := filepath.Join(stageAbs, manifest.Entrypoint)
|
||||
if info, statErr := os.Lstat(wasmPath); statErr != nil {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("entrypoint %s missing: %w", manifest.Entrypoint, statErr)
|
||||
} else if info.Mode()&os.ModeSymlink != 0 {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("entrypoint %s is a symlink", manifest.Entrypoint)
|
||||
}
|
||||
|
||||
// Stage 3: atomically rename into the canonical plugin name directory.
|
||||
finalDir := filepath.Join(r.cfg.Directory, manifest.Name)
|
||||
// If a previous version exists, remove it. The store row is replaced by
|
||||
// installFromDisk via the existing UPSERT path.
|
||||
if _, err := os.Stat(finalDir); err == nil {
|
||||
if err := os.RemoveAll(finalDir); err != nil {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("remove existing plugin dir: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(stageAbs, finalDir); err != nil {
|
||||
cleanup()
|
||||
return "", fmt.Errorf("install rename: %w", err)
|
||||
}
|
||||
|
||||
// Stage 4: register via the existing on-disk install path.
|
||||
if err := r.installFromDisk(ctx, foundPlugin{
|
||||
Manifest: manifest,
|
||||
Dir: finalDir,
|
||||
WASMPath: filepath.Join(finalDir, manifest.Entrypoint),
|
||||
}); err != nil {
|
||||
return manifest.Name, fmt.Errorf("installFromDisk: %w", err)
|
||||
}
|
||||
return manifest.Name, nil
|
||||
}
|
||||
|
||||
// bytesReaderAt is a tiny wrapper that satisfies io.ReaderAt for a byte
|
||||
// slice. archive/zip needs ReaderAt; bytes.Reader provides it but importing
|
||||
// "bytes" alongside the existing "io" surface keeps the import block tight.
|
||||
type bytesReaderAt []byte
|
||||
|
||||
func (b bytesReaderAt) ReadAt(p []byte, off int64) (int, error) {
|
||||
if off < 0 || off >= int64(len(b)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, b[off:])
|
||||
if n < len(p) {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// activateAll attempts to compile + register host-API hooks for every plugin
|
||||
// row in the PluginStore that is marked enabled. The default build is a
|
||||
// no-op (no Wazero modules to compile).
|
||||
|
||||
@@ -104,8 +104,10 @@ func (h *Hub) PubSubForTest() *PubSub {
|
||||
}
|
||||
|
||||
// BuildAuthOKForTest exposes Hub.buildAuthOK for external tests.
|
||||
// Defaults to replay_source="none" since most callers test the fresh-connect
|
||||
// path; tests that care about the resume tier can call buildAuthOK directly.
|
||||
func (h *Hub) BuildAuthOKForTest(user *db.User, roleName string) []byte {
|
||||
return h.buildAuthOK(user, roleName)
|
||||
return h.buildAuthOK(user, roleName, "none")
|
||||
}
|
||||
|
||||
// BuildReadyForTest exposes Hub.buildReady for external tests.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Pass 4 — extractEventType tests.
|
||||
//
|
||||
// Locks in the Pass 3 byte-scan helper that pulls the wire-format "type"
|
||||
// field out of a wrapped JSON envelope without a full unmarshal. Tests cover
|
||||
// the happy paths and the defensive rejects (control chars, escaped quotes,
|
||||
// length cap, malformed input).
|
||||
package ws
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractEventType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payload string
|
||||
want string
|
||||
}{
|
||||
{"type first", `{"type":"chat_message","seq":1}`, "chat_message"},
|
||||
{"seq first", `{"seq":1,"type":"voice_join"}`, "voice_join"},
|
||||
{"empty object", `{}`, ""},
|
||||
{"missing type", `{"seq":1,"channel":2}`, ""},
|
||||
{"control char", "{\"type\":\"with\x01ctrl\"}", ""},
|
||||
{"escaped quote", `{"type":"a\"b"}`, ""},
|
||||
{"empty type", `{"type":""}`, ""},
|
||||
{"empty payload", ``, ""},
|
||||
{"non-json", `garbage`, ""},
|
||||
{"plausible nested", `{"type":"x","payload":{"type":"y"}}`, "x"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := extractEventType([]byte(c.payload))
|
||||
if got != c.want {
|
||||
t.Errorf("extractEventType(%q) = %q, want %q", c.payload, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEventTypeLengthCap(t *testing.T) {
|
||||
long := `{"type":"` + strings.Repeat("a", 100) + `"}`
|
||||
if got := extractEventType([]byte(long)); got != "" {
|
||||
t.Fatalf("expected empty for >64-char type, got %q", got)
|
||||
}
|
||||
exactly64 := `{"type":"` + strings.Repeat("a", 64) + `"}`
|
||||
if got := extractEventType([]byte(exactly64)); got != strings.Repeat("a", 64) {
|
||||
t.Fatalf("64-char type should be accepted, got %q", got)
|
||||
}
|
||||
exactly65 := `{"type":"` + strings.Repeat("a", 65) + `"}`
|
||||
if got := extractEventType([]byte(exactly65)); got != "" {
|
||||
t.Fatalf("65-char type should be rejected, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Pass 4 — Hub.SeedSeq tests.
|
||||
//
|
||||
// Locks in the Pass 2 fix that aligns the in-memory monotonic counter with
|
||||
// the persisted MAX(events.seq) at startup. SeedSeq must never go backwards
|
||||
// even under concurrent calls and must integrate with nextSeq() so the next
|
||||
// allocated seq is greater than every previously persisted row.
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSeedSeqMonotonic(t *testing.T) {
|
||||
h := &Hub{}
|
||||
h.SeedSeq(100)
|
||||
if got := atomic.LoadUint64(&h.seq); got != 100 {
|
||||
t.Fatalf("after SeedSeq(100), seq = %d, want 100", got)
|
||||
}
|
||||
// Lower seed must be a no-op.
|
||||
h.SeedSeq(50)
|
||||
if got := atomic.LoadUint64(&h.seq); got != 100 {
|
||||
t.Fatalf("after SeedSeq(50), seq = %d, want 100 (no backwards)", got)
|
||||
}
|
||||
// Higher seed must take effect.
|
||||
h.SeedSeq(500)
|
||||
if got := atomic.LoadUint64(&h.seq); got != 500 {
|
||||
t.Fatalf("after SeedSeq(500), seq = %d, want 500", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedSeqThenNextSeq(t *testing.T) {
|
||||
h := &Hub{}
|
||||
h.SeedSeq(1000)
|
||||
got := h.nextSeq()
|
||||
if got != 1001 {
|
||||
t.Fatalf("nextSeq after SeedSeq(1000) = %d, want 1001", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedSeqConcurrent(t *testing.T) {
|
||||
h := &Hub{}
|
||||
const n = 100
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for i := 1; i <= n; i++ {
|
||||
i := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
h.SeedSeq(uint64(i * 7)) // distinct values, max = n*7
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
want := uint64(n * 7)
|
||||
if got := atomic.LoadUint64(&h.seq); got != want {
|
||||
t.Fatalf("after concurrent seeds, seq = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
+15
-7
@@ -164,9 +164,11 @@ func (h *Hub) handleReconnect(
|
||||
// will be drained once the pumps begin.
|
||||
h.registerNow(c)
|
||||
|
||||
// Replay succeeded — send auth_ok then missed events.
|
||||
slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName)
|
||||
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName)); err != nil {
|
||||
// Replay succeeded — send auth_ok then missed events. The replay tier
|
||||
// is included in the payload so the client can attribute reconnect
|
||||
// behaviour without separate metric scraping.
|
||||
slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource)
|
||||
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, replaySource)); err != nil {
|
||||
slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err)
|
||||
h.unregisterNow(c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
@@ -302,7 +304,7 @@ func (h *Hub) handleFreshConnect(
|
||||
|
||||
// Fresh connection or replay fallback: full auth_ok + ready flow.
|
||||
slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName)
|
||||
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName)); err != nil {
|
||||
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, "none")); err != nil {
|
||||
slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err)
|
||||
h.unregisterNow(c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
@@ -518,7 +520,12 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db
|
||||
|
||||
// buildAuthOK constructs the auth_ok server→client message.
|
||||
// Per PROTOCOL.md, user object contains only id, username, avatar, role (no status).
|
||||
func (h *Hub) buildAuthOK(user *db.User, roleName string) []byte {
|
||||
//
|
||||
// replaySource records which reconnection tier served this client:
|
||||
// - "none" — fresh connection or full re-sync (no resume)
|
||||
// - "buffer" — resume served from the in-memory ring buffer
|
||||
// - "db" — resume served from the persistent EventStore (Phase B Step 7)
|
||||
func (h *Hub) buildAuthOK(user *db.User, roleName string, replaySource string) []byte {
|
||||
var avatarVal any
|
||||
if user.Avatar != nil {
|
||||
avatarVal = *user.Avatar
|
||||
@@ -535,8 +542,9 @@ func (h *Hub) buildAuthOK(user *db.User, roleName string) []byte {
|
||||
"avatar": avatarVal,
|
||||
"role": roleName,
|
||||
},
|
||||
"server_name": serverName,
|
||||
"motd": motd,
|
||||
"server_name": serverName,
|
||||
"motd": motd,
|
||||
"replay_source": replaySource,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user