Files
OwnCord/Server/ws/ringbuffer_test.go
T
J3vbandClaude Opus 4.8 58005c9c6f feat(auth): revocable API tokens, introspect MCP server, and a Go 1.26 idiom pass (#1266)
* feat(auth): add revocable API tokens (bot/service auth)

Add long-lived, revocable API tokens so headless clients (the introspection
MCP tool, bots, CI) can authenticate without a password. Presented as
"Authorization: Bearer <token>", a token authenticates as a specific user,
inheriting that user's role and permissions.

- migration 018 + dedicated api_tokens table (kept separate from sessions so
  bulk logout and the per-user session cap never touch these); only the
  SHA-256 hash is stored, raw token shown once at creation
- auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware
  and adminAuthMiddleware now call. Sessions are matched first so existing
  login behavior is unchanged; API tokens are a fallback only on session miss.
  A DB outage is returned wrapped, never mistaken for a bad token.
- `server token create|list|revoke` CLI: mints directly against the DB with no
  HTTP and no login — the password-free bootstrap path
- tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6),
  api middleware integration (valid + revoked token)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tools): add owncord-introspect MCP server

A local MCP dev tool that lets Claude Code introspect a running OwnCord
instance: read its logs, query any REST endpoint, and tail the desktop
client's log file. It is a thin wrapper over the existing API plus the
client log — no new product surface.

- tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk)
  exposes api_request (full read-write passthrough), server_logs (admin SSE
  ring-buffer stream), client_logs (reads the desktop log file)
- authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed
  cert and skips hostname checks (the cert has no SAN)
- registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN})
- un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while
  tools/livekit-server.exe and node_modules stay ignored
- docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dependencies): update and add various crate versions in Cargo.lock

* feat(admin): manage API tokens from the admin panel

Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke
API tokens from the web admin panel. Previously only the `server token`
CLI could manage them, which requires shell access to the host.

- POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in
  admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like
  backups/updates): an HTTP token-mint endpoint is a network-reachable
  credential-minting surface, and API tokens deliberately survive password
  change + bulk logout, so a hijacked admin session must not mint one.
- Reuses the same db.*APIToken calls as the CLI; create sources the actor
  from request context (audits who clicked, not the bound user); the raw
  token is returned once in the 201 body, never stored.
- Add json tags to db.APITokenListItem for snake_case wire consistency.
- Admin panel: "API Tokens" nav item + create modal, show-once reveal,
  revoke confirm in admin/static/index.html.
- Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: modernize to Go 1.26 idioms + enable modernize linter

Apply `golangci-lint modernize` autofixes across the server and enable the
linter in .golangci.yml so these stop re-accumulating (they built up only
because modernize was never in the config).

Production code: slices.Contains for hand-rolled membership loops (api
router, ws origin, db/account, plugin manifest); strings.SplitSeq for
allocation-free line/segment iteration (db/migrate, updater, livekit_proxy);
strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner);
any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go,
slices.Sort, maps.Copy, new(expr), interface{}->any.

- plugin/manifest.go parent-traversal check applied by hand: modernize
  skipped it (two conflicting rewrites); used the slices.Contains form.
- Removed the now-dead ptr() test helper after newexpr inlined its callers.
- Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite.

No behavior change. All four tag variants build, full test suite is green,
and golangci-lint (with modernize enabled) reports 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:25:46 +02:00

461 lines
12 KiB
Go

package ws_test
import (
"fmt"
"sync"
"testing"
"github.com/owncord/server/ws"
)
// ─── Push ────────────────────────────────────────────────────────────────────
func TestPush_SingleEntry(t *testing.T) {
rb := ws.NewEventRingBuffer(8)
rb.Push(1, 0, []byte("hello"))
// afterSeq=0 is before the oldest seq (1), so EventsSince returns nil
// (the buffer can't confirm it covers everything the caller missed).
// Verify via OldestSeq and a valid afterSeq instead.
if got := rb.OldestSeq(); got != 1 {
t.Fatalf("expected oldest seq 1, got %d", got)
}
// afterSeq = oldestSeq means "give me everything after seq 1" = nothing newer.
// But we can use EventsSince with afterSeq matching oldest to get items > oldest.
// There's only seq 1, and 1 > 1 is false, so we get 0 events.
got := rb.EventsSince(1)
if len(got) != 0 {
t.Fatalf("expected 0 events when afterSeq = only seq, got %d", len(got))
}
}
func TestPush_MultipleInOrder(t *testing.T) {
rb := ws.NewEventRingBuffer(8)
for i := uint64(1); i <= 5; i++ {
rb.Push(i, 0, fmt.Appendf(nil, "msg-%d", i))
}
// afterSeq == oldestSeq (1) → nil (BUG-085: conservative boundary).
if got := rb.EventsSince(1); got != nil {
t.Fatalf("expected nil when afterSeq == oldestSeq, got %d events", len(got))
}
// afterSeq one past oldest → replay seq 3..5.
got := rb.EventsSince(2)
if len(got) != 3 {
t.Fatalf("expected 3 events after seq 2, got %d", len(got))
}
for i, ev := range got {
want := fmt.Sprintf("msg-%d", i+3)
if string(ev) != want {
t.Errorf("event[%d]: expected %q, got %q", i, want, string(ev))
}
}
}
func TestPush_WrapsAround(t *testing.T) {
const cap = 4
rb := ws.NewEventRingBuffer(cap)
// Push 6 events into a buffer with capacity 4 — first two are evicted.
for i := uint64(1); i <= 6; i++ {
rb.Push(i, 0, fmt.Appendf(nil, "e%d", i))
}
got := rb.EventsSince(0)
// afterSeq=0 is older than oldest (seq 3), so EventsSince returns nil.
if got != nil {
t.Fatalf("expected nil (afterSeq too old), got %d events", len(got))
}
// Ask for events after seq 2 — still too old.
got = rb.EventsSince(2)
if got != nil {
t.Fatalf("expected nil (afterSeq 2 still evicted), got %d events", len(got))
}
// afterSeq == oldestSeq (3) → nil (BUG-085).
got = rb.EventsSince(3)
if got != nil {
t.Fatalf("expected nil when afterSeq == oldestSeq (3), got %d events", len(got))
}
// afterSeq one past oldest → replay seq 5, 6.
got = rb.EventsSince(4)
if len(got) != 2 {
t.Fatalf("expected 2 events after seq 4, got %d", len(got))
}
for i, want := range []string{"e5", "e6"} {
if string(got[i]) != want {
t.Errorf("event[%d]: expected %q, got %q", i, want, string(got[i]))
}
}
}
func TestPush_OverwritesOldest(t *testing.T) {
const cap = 3
rb := ws.NewEventRingBuffer(cap)
rb.Push(1, 0, []byte("a"))
rb.Push(2, 0, []byte("b"))
rb.Push(3, 0, []byte("c"))
if oldest := rb.OldestSeq(); oldest != 1 {
t.Fatalf("expected oldest seq 1, got %d", oldest)
}
// Overwrite seq 1.
rb.Push(4, 0, []byte("d"))
if oldest := rb.OldestSeq(); oldest != 2 {
t.Fatalf("expected oldest seq 2 after overwrite, got %d", oldest)
}
// afterSeq == oldestSeq (2) → nil (BUG-085).
if got := rb.EventsSince(2); got != nil {
t.Fatalf("expected nil when afterSeq == oldestSeq, got %d events", len(got))
}
// afterSeq one past oldest → replay seq 4 only.
got := rb.EventsSince(3)
if len(got) != 1 {
t.Fatalf("expected 1 event, got %d", len(got))
}
if string(got[0]) != "d" {
t.Errorf("expected [d], got [%s]", got[0])
}
}
// ─── EventsSince ─────────────────────────────────────────────────────────────
func TestEventsSince_EmptyBuffer(t *testing.T) {
rb := ws.NewEventRingBuffer(8)
got := rb.EventsSince(0)
if got != nil {
t.Fatalf("expected nil for empty buffer, got %d events", len(got))
}
}
func TestEventsSince_AfterSpecificSeq(t *testing.T) {
rb := ws.NewEventRingBuffer(8)
for i := uint64(1); i <= 5; i++ {
rb.Push(i, 0, fmt.Appendf(nil, "m%d", i))
}
got := rb.EventsSince(3)
if len(got) != 2 {
t.Fatalf("expected 2 events after seq 3, got %d", len(got))
}
if string(got[0]) != "m4" || string(got[1]) != "m5" {
t.Errorf("expected [m4, m5], got [%s, %s]", got[0], got[1])
}
}
func TestEventsSince_TooOld(t *testing.T) {
const cap = 4
rb := ws.NewEventRingBuffer(cap)
for i := uint64(1); i <= 6; i++ {
rb.Push(i, 0, []byte("x"))
}
// Oldest is seq 3. Requesting seq 1 should return nil.
got := rb.EventsSince(1)
if got != nil {
t.Fatalf("expected nil for evicted seq, got %d events", len(got))
}
}
func TestEventsSince_AtLatestSeq(t *testing.T) {
rb := ws.NewEventRingBuffer(8)
for i := uint64(1); i <= 5; i++ {
rb.Push(i, 0, []byte("x"))
}
got := rb.EventsSince(5)
// afterSeq equals latest — nothing newer exists.
if len(got) != 0 {
t.Fatalf("expected 0 events when afterSeq = latest, got %d", len(got))
}
}
func TestEventsSince_WraparoundOrder(t *testing.T) {
const cap = 4
rb := ws.NewEventRingBuffer(cap)
// Fill past capacity to force wrap.
for i := uint64(1); i <= 7; i++ {
rb.Push(i, 0, fmt.Appendf(nil, "v%d", i))
}
// afterSeq == oldestSeq (4) → nil (BUG-085).
if got := rb.EventsSince(4); got != nil {
t.Fatalf("expected nil when afterSeq == oldestSeq, got %d events", len(got))
}
// afterSeq one past oldest → replay seq 6, 7.
got := rb.EventsSince(5)
if len(got) != 2 {
t.Fatalf("expected 2 events, got %d", len(got))
}
for i, want := range []string{"v6", "v7"} {
if string(got[i]) != want {
t.Errorf("event[%d]: expected %q, got %q", i, want, string(got[i]))
}
}
}
func TestEventsSince_AfterSeqZero_ReturnsBehavior(t *testing.T) {
// afterSeq=0 is below the oldest seq in the buffer (seq starts at 1),
// so EventsSince treats it as "too old" and returns nil. This is correct:
// the server can't confirm the buffer covers everything the client missed.
rb := ws.NewEventRingBuffer(8)
for i := uint64(1); i <= 3; i++ {
rb.Push(i, 0, fmt.Appendf(nil, "a%d", i))
}
got := rb.EventsSince(0)
if got != nil {
t.Fatalf("expected nil for afterSeq=0 (before oldest), got %d events", len(got))
}
// If we start seqs from 0, afterSeq=0 equals oldest → nil (BUG-085).
rb2 := ws.NewEventRingBuffer(8)
rb2.Push(0, 0, []byte("z0"))
rb2.Push(1, 0, []byte("z1"))
rb2.Push(2, 0, []byte("z2"))
got = rb2.EventsSince(0)
if got != nil {
t.Fatalf("expected nil when afterSeq == oldestSeq (0), got %d events", len(got))
}
// afterSeq one past oldest → replay seq 2 only.
got = rb2.EventsSince(1)
if len(got) != 1 {
t.Fatalf("expected 1 event after seq 1, got %d", len(got))
}
if string(got[0]) != "z2" {
t.Errorf("expected [z2], got [%s]", got[0])
}
}
func TestEventsSince_AfterSeqEqualsOldest_ReturnsNil(t *testing.T) {
// BUG-085: When afterSeq == oldestSeq, the client's last event is the
// oldest in the buffer. We can't guarantee nothing was missed between
// the evicted event before oldest and oldest itself, so EventsSince
// must return nil to trigger a full ready payload.
const cap = 4
rb := ws.NewEventRingBuffer(cap)
// Push 6 events: buffer holds seq 3,4,5,6. Oldest = 3.
for i := uint64(1); i <= 6; i++ {
rb.Push(i, 0, fmt.Appendf(nil, "e%d", i))
}
if oldest := rb.OldestSeq(); oldest != 3 {
t.Fatalf("expected oldest seq 3, got %d", oldest)
}
// afterSeq == oldestSeq (3): must return nil, not empty slice.
got := rb.EventsSince(3)
if got == nil {
// This is the CORRECT behavior after the fix.
return
}
// Before the fix, this returns a non-nil slice [e4, e5, e6].
// That's wrong because the client at seq 3 might have missed events
// between the evicted seq 2 and seq 3.
t.Fatalf("expected nil when afterSeq == oldestSeq, got %d events", len(got))
}
// ─── OldestSeq ───────────────────────────────────────────────────────────────
func TestOldestSeq_Empty(t *testing.T) {
rb := ws.NewEventRingBuffer(8)
if got := rb.OldestSeq(); got != 0 {
t.Fatalf("expected 0 for empty buffer, got %d", got)
}
}
func TestOldestSeq_AfterInitialPushes(t *testing.T) {
rb := ws.NewEventRingBuffer(8)
rb.Push(10, 0, []byte("x"))
rb.Push(11, 0, []byte("y"))
if got := rb.OldestSeq(); got != 10 {
t.Fatalf("expected oldest seq 10, got %d", got)
}
}
func TestOldestSeq_AfterWraparound(t *testing.T) {
const cap = 3
rb := ws.NewEventRingBuffer(cap)
rb.Push(10, 0, []byte("a"))
rb.Push(20, 0, []byte("b"))
rb.Push(30, 0, []byte("c"))
rb.Push(40, 0, []byte("d")) // evicts seq 10
if got := rb.OldestSeq(); got != 20 {
t.Fatalf("expected oldest seq 20 after wraparound, got %d", got)
}
}
// ─── Concurrency ─────────────────────────────────────────────────────────────
func TestConcurrent_PushAndEventsSince(t *testing.T) {
const (
cap = 64
writers = 4
pushes = 500
readers = 4
reads = 500
)
rb := ws.NewEventRingBuffer(cap)
var wg sync.WaitGroup
// Concurrent writers.
for w := range writers {
wg.Add(1)
go func(base uint64) {
defer wg.Done()
for i := range uint64(pushes) {
rb.Push(base+i, 0, []byte("data"))
}
}(uint64(w) * pushes)
}
// Concurrent readers.
for range readers {
wg.Go(func() {
for range reads {
_ = rb.EventsSince(0)
_ = rb.OldestSeq()
}
})
}
wg.Wait()
// If we get here without a race detector complaint, the mutex is working.
// Sanity: buffer should have events.
if rb.OldestSeq() == 0 {
t.Fatal("expected non-zero oldest seq after concurrent pushes")
}
}
// ─── Table-driven: capacity boundary ─────────────────────────────────────────
func TestEventsSince_CapacityBoundaries(t *testing.T) {
tests := []struct {
name string
cap int
pushes int
afterSeq uint64
wantLen int // -1 means nil
wantFirst string
}{
{
name: "exactly at capacity, afterSeq=0 too old",
cap: 4,
pushes: 4,
afterSeq: 0,
wantLen: -1,
},
{
name: "exactly at capacity, afterSeq == oldest → nil",
cap: 4,
pushes: 4,
afterSeq: 1, // oldest=1, BUG-085: == returns nil
wantLen: -1,
},
{
name: "exactly at capacity, afterSeq one past oldest",
cap: 4,
pushes: 4,
afterSeq: 2,
wantLen: 2,
wantFirst: "e3",
},
{
name: "one past capacity",
cap: 4,
pushes: 5,
afterSeq: 1, // evicted
wantLen: -1,
},
{
name: "one past capacity, afterSeq == oldest → nil",
cap: 4,
pushes: 5,
afterSeq: 2, // oldest=2, BUG-085: == returns nil
wantLen: -1,
},
{
name: "one past capacity, afterSeq one past oldest",
cap: 4,
pushes: 5,
afterSeq: 3,
wantLen: 2,
wantFirst: "e4",
},
{
name: "double capacity, afterSeq == oldest → nil",
cap: 4,
pushes: 8,
afterSeq: 5, // oldest=5, BUG-085: == returns nil
wantLen: -1,
},
{
name: "double capacity, afterSeq one past oldest",
cap: 4,
pushes: 8,
afterSeq: 6,
wantLen: 2,
wantFirst: "e7",
},
{
name: "capacity 1, afterSeq == oldest → nil",
cap: 1,
pushes: 3,
afterSeq: 3, // oldest=3, BUG-085: == returns nil
wantLen: -1,
},
{
name: "capacity 1, afterSeq too old",
cap: 1,
pushes: 3,
afterSeq: 2,
wantLen: -1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rb := ws.NewEventRingBuffer(tc.cap)
for i := 1; i <= tc.pushes; i++ {
rb.Push(uint64(i), 0, fmt.Appendf(nil, "e%d", i))
}
got := rb.EventsSince(tc.afterSeq)
if tc.wantLen == -1 {
if got != nil {
t.Fatalf("expected nil, got %d events", len(got))
}
return
}
if len(got) != tc.wantLen {
t.Fatalf("expected %d events, got %d", tc.wantLen, len(got))
}
if tc.wantLen > 0 && string(got[0]) != tc.wantFirst {
t.Errorf("first event: expected %q, got %q", tc.wantFirst, string(got[0]))
}
})
}
}