Files
OwnCord/Server/api/audit_coverage_plugin_test.go
T
J3vbandClaude Fable 5 2b2d58abc1 feat(b2-6): safe audit coverage (S-02) (#1441)
* docs(b2-6): enumerate the security-sensitive mutations and their audit coverage

Step 1 of B2-6: the mutation inventory crossed with the 43 non-test
Audit( call sites at 67fdd18d, recorded in the plan's evidence block.
Invite create/revoke (S-02) and plugin install/uninstall have no audit
row; no timeout mutation exists (kick is force_logout / voice_mod_kick).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* added new doc

* docs(audit): update changelog and security documentation to include admin panel actions in audit log

* feat(b2-6): audit every security-sensitive mutation, invite and plugin rows first (S-02)

Step 2 of B2-6. TestAuditCoverage_* in service, api and admin drive each
mutation from the plan's inventory against a fake db.AuditStore
(Server/db/audittest) and assert the expected action arrives. Red before
this commit on exactly four rows: invite_create, invite_revoke,
plugin_install, plugin_uninstall.

- InviteService writes invite_create / invite_revoke naming the invite by
  id, never by code; RevokeInvite now takes the actor, threaded from the
  handler. A failed revoke writes nothing (test).
- The plugin admin handler takes a db.Auditor and writes plugin_install /
  plugin_uninstall against the RequireAdminAuth principal
  (admin.ActorIDFromContext, exported for that).
- docs/security.md lists the four new actions; CHANGELOG under Unreleased.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* test(b2-6): denylist over the recorded audit detail corpus

Step 3 of B2-6. Each TestAuditCoverage_* table now ends with a subtest
that runs audittest.AssertSafeDetails over every entry its rows recorded:
a shape denylist (bcrypt/argon2 hashes, password=/token=/secret=/
recovery-code key-value leaks, otpauth URIs, Bearer credentials) plus the
fixture's own secrets (raw tokens, passwords and hashes, TOTP secrets and
codes, invite codes, message bodies). audittest_test.go proves each class
bites and that ordinary details pass. Zero hits on the corpus at HEAD, so
no call site needed changing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-6): record pre-squash SHAs, red/green and denylist evidence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-6): audit committed invites on a canceled request; 404 unknown plugin uninstall

Codex P2s on #1441, both test-first:
- CreateInvite read the invite back on the request context, so a cancel
  after the insert committed returned an error and skipped invite_create.
  The read-back and audit now run on context.WithoutCancel, like the
  password-change tail. TestCreateInvite_AuditSurvivesCanceledLookup.
- Registry.UninstallPlugin is idempotent on an unknown id, so the handler
  wrote plugin_uninstall for plugins that never existed. It now checks the
  row first: 404 and no audit. TestPluginsHandlerUninstallUnknownID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-6): record the Codex review outcome on #1441

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:11:37 +02:00

102 lines
3.2 KiB
Go

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)
}
}