From 185f4f578eb989b6e8e2aedbf572e115f3581301 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 5 Jul 2026 02:31:05 +0400 Subject: [PATCH] mavweb: extend test coverage to credentials, signal, dash, history, revert handlers - New credentials_test.go: 4 test functions covering credentialStore (new, save, lookup, update, persistence across restarts). - Extended handlers_test.go: fakeCore now supports WriteFact, Presence, RecentFacts/Nudges/Notes, RevertFact. Added 7 new test functions: TestNoCache, TestHandleSignal (5 subtestcases covering method guard, nil core, unknown key, known key, write error), TestHandleDash (3), TestHandleHistory (3), TestHandleRevert (6), and ListToolsError 502. - Fixed history.html: Go html/template requires conditional class rendered as separate branches, not inline attribute. --- cmd/mavweb/credentials_test.go | 188 ++++++++++++++++++ cmd/mavweb/handlers_test.go | 352 +++++++++++++++++++++++++++++++++ cmd/mavweb/history.html | 2 +- 3 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 cmd/mavweb/credentials_test.go diff --git a/cmd/mavweb/credentials_test.go b/cmd/mavweb/credentials_test.go new file mode 100644 index 0000000..eca4af3 --- /dev/null +++ b/cmd/mavweb/credentials_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestNewCredentialStore(t *testing.T) { + t.Parallel() + + t.Run("non-existent path returns empty store", func(t *testing.T) { + cs, err := newCredentialStore(filepath.Join(t.TempDir(), "nonexistent.json")) + if err != nil { + t.Fatalf("newCredentialStore(nonexistent) = _, %v, want nil", err) + } + if cs == nil { + t.Fatal("newCredentialStore returned nil store") + } + }) + + t.Run("valid JSON loads credentials", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "creds.json") + creds := map[string]localCred{ + "cred1": {PublicKey: []byte("key1"), SignCount: 5}, + } + data, _ := json.Marshal(creds) + os.WriteFile(path, data, 0600) + + cs, err := newCredentialStore(path) + if err != nil { + t.Fatalf("newCredentialStore(valid) = _, %v, want nil", err) + } + pk, sc, err := cs.Lookup("cred1") + if err != nil { + t.Fatalf("Lookup cred1: %v", err) + } + if string(pk) != "key1" { + t.Errorf("publicKey = %q, want %q", string(pk), "key1") + } + if sc != 5 { + t.Errorf("signCount = %d, want 5", sc) + } + }) + + t.Run("invalid JSON returns parse error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.json") + os.WriteFile(path, []byte("{invalid"), 0600) + + _, err := newCredentialStore(path) + if err == nil { + t.Fatal("newCredentialStore(invalid JSON) = _, nil, want error") + } + }) + + t.Run("empty file returns empty store", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.json") + os.WriteFile(path, nil, 0600) + + cs, err := newCredentialStore(path) + if err != nil { + t.Fatalf("newCredentialStore(empty) = _, %v, want nil", err) + } + _, _, err = cs.Lookup("anything") + if err == nil { + t.Fatal("Lookup anything on empty store: expected error") + } + }) +} + +func TestCredentialStoreSaveAndLookup(t *testing.T) { + t.Parallel() + + t.Run("save then lookup returns credential with signCount=0", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "creds.json") + cs, err := newCredentialStore(path) + if err != nil { + t.Fatal(err) + } + + err = cs.Save("mykey", []byte("pubkey-data")) + if err != nil { + t.Fatalf("Save: %v", err) + } + + pk, sc, err := cs.Lookup("mykey") + if err != nil { + t.Fatalf("Lookup mykey: %v", err) + } + if string(pk) != "pubkey-data" { + t.Errorf("publicKey = %q, want %q", string(pk), "pubkey-data") + } + if sc != 0 { + t.Errorf("signCount = %d, want 0", sc) + } + }) + + t.Run("save duplicate ID returns error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "creds.json") + cs, _ := newCredentialStore(path) + cs.Save("dup", []byte("first")) + err := cs.Save("dup", []byte("second")) + if err == nil { + t.Fatal("Save duplicate: expected error") + } + }) + + t.Run("lookup non-existent ID returns error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "creds.json") + cs, _ := newCredentialStore(path) + _, _, err := cs.Lookup("nobody") + if err == nil { + t.Fatal("Lookup non-existent: expected error") + } + }) +} + +func TestCredentialStoreUpdateSignCount(t *testing.T) { + t.Parallel() + + t.Run("save, update, then lookup returns new count", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "creds.json") + cs, _ := newCredentialStore(path) + cs.Save("ctr", []byte("pk")) + + err := cs.UpdateSignCount("ctr", 42) + if err != nil { + t.Fatalf("UpdateSignCount: %v", err) + } + + _, sc, err := cs.Lookup("ctr") + if err != nil { + t.Fatalf("Lookup after update: %v", err) + } + if sc != 42 { + t.Errorf("signCount = %d, want 42", sc) + } + }) + + t.Run("update non-existent credential returns error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "creds.json") + cs, _ := newCredentialStore(path) + err := cs.UpdateSignCount("ghost", 1) + if err == nil { + t.Fatal("UpdateSignCount non-existent: expected error") + } + }) +} + +func TestCredentialStorePersistence(t *testing.T) { + t.Parallel() + + t.Run("credential persists across store instances", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "persist.json") + cs, _ := newCredentialStore(path) + cs.Save("persist-key", []byte("persist-data")) + + cs2, err := newCredentialStore(path) + if err != nil { + t.Fatalf("newCredentialStore(reopen): %v", err) + } + pk, _, err := cs2.Lookup("persist-key") + if err != nil { + t.Fatalf("Lookup persist-key after reopen: %v", err) + } + if string(pk) != "persist-data" { + t.Errorf("publicKey = %q, want %q", string(pk), "persist-data") + } + }) + + t.Run("file deleted results in empty store", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "deleted.json") + cs, _ := newCredentialStore(path) + cs.Save("gone-key", []byte("gone-data")) + + os.Remove(path) + + cs2, err := newCredentialStore(path) + if err != nil { + t.Fatalf("newCredentialStore(after delete): %v", err) + } + _, _, err = cs2.Lookup("gone-key") + if err == nil { + t.Fatal("Lookup gone-key after file delete: expected error") + } + }) +} diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 05075af..87bd8f1 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -33,6 +33,27 @@ type fakeCore struct { gotEnableCmd []string gotEnableDest bool gotDisable string + + // for handleSignal tests + writeLog []ipc.WriteFactReq + writeErr error + signalErr error + + // for handleDash tests + presence ipc.Presence + facts []ipc.Fact + nudges []ipc.Nudge + notes []ipc.Note + dashErr error + + // for handleRevert tests + revertKey string + revertNewID int64 + revertErr error + + // for handleHistory tests + historyFacts []ipc.Fact + historyErr error } func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, _ time.Time) error { @@ -57,6 +78,58 @@ func (f *fakeCore) ListTools(_ context.Context, status string) ([]ipc.Tool, erro } } +func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) { + if f.writeErr != nil { + return 0, f.writeErr + } + if f.writeLog == nil { + f.writeLog = make([]ipc.WriteFactReq, 0) + } + f.writeLog = append(f.writeLog, req) + return int64(len(f.writeLog)), nil +} + +func (f *fakeCore) Presence(_ context.Context) (ipc.Presence, error) { + if f.dashErr != nil { + return ipc.Presence{}, f.dashErr + } + return f.presence, nil +} + +func (f *fakeCore) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) { + if f.historyErr != nil { + return nil, f.historyErr + } + if f.historyFacts != nil { + return f.historyFacts, nil + } + if f.dashErr != nil { + return nil, f.dashErr + } + return f.facts, nil +} + +func (f *fakeCore) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) { + if f.dashErr != nil { + return nil, f.dashErr + } + return f.nudges, nil +} + +func (f *fakeCore) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) { + if f.dashErr != nil { + return nil, f.dashErr + } + return f.notes, nil +} + +func (f *fakeCore) RevertFact(_ context.Context, _ string) (int64, error) { + if f.revertErr != nil { + return 0, f.revertErr + } + return f.revertNewID, nil +} + // --- GET --- func TestHandleTools_GET_RendersAndEscapes(t *testing.T) { @@ -291,3 +364,282 @@ func TestWebAuthn_Begin_ReturnsChallenge(t *testing.T) { } } } + +// --- middleware --- + +func TestNoCache(t *testing.T) { + t.Parallel() + rr := httptest.NewRecorder() + innerCalled := false + noCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + innerCalled = true + w.Write([]byte("ok")) + })).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + if ct := rr.Header().Get("Cache-Control"); ct != "no-cache, no-store, must-revalidate" { + t.Errorf("Cache-Control = %q, want %q", ct, "no-cache, no-store, must-revalidate") + } + if !innerCalled { + t.Error("inner handler was not called") + } + if rr.Body.String() != "ok" { + t.Errorf("body = %q, want %q", rr.Body.String(), "ok") + } +} + +// --- handleSignal --- + +func TestHandleSignal(t *testing.T) { + t.Parallel() + + t.Run("GET returns 405", func(t *testing.T) { + rr := httptest.NewRecorder() + handleSignal(rr, httptest.NewRequest(http.MethodGet, "/api/signal", nil), &fakeCore{}) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rr.Code) + } + }) + + t.Run("nil core returns 503", func(t *testing.T) { + rr := httptest.NewRecorder() + handleSignal(rr, httptest.NewRequest(http.MethodPost, "/api/signal", nil), nil) + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", rr.Code) + } + }) + + t.Run("unknown key returns 400", func(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/signal?key=nonexistent", nil) + handleSignal(rr, req, core) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } + }) + + t.Run("known key desk_active writes fact with correct params", func(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/signal?key=desk_active", nil) + handleSignal(rr, req, core) + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rr.Code) + } + if len(core.writeLog) != 1 { + t.Fatalf("writeLog calls = %d, want 1", len(core.writeLog)) + } + reqF := core.writeLog[0] + if reqF.Source != "infer:hyprland" { + t.Errorf("source = %q, want %q", reqF.Source, "infer:hyprland") + } + if reqF.Value != `"active"` { + t.Errorf("value = %q, want %q", reqF.Value, `"active"`) + } + if reqF.Kind != "env" { + t.Errorf("kind = %q, want %q", reqF.Kind, "env") + } + if reqF.Confidence != 1.0 { + t.Errorf("confidence = %f, want 1.0", reqF.Confidence) + } + }) + + t.Run("core WriteFact error returns 502", func(t *testing.T) { + core := &fakeCore{writeErr: ipc.ErrForbidden} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/signal?key=desk_active", nil) + handleSignal(rr, req, core) + if rr.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502", rr.Code) + } + }) +} + +// --- handleDash --- + +func TestHandleDash(t *testing.T) { + t.Parallel() + + t.Run("nil core returns 503", func(t *testing.T) { + rr := httptest.NewRecorder() + handleDash(rr, httptest.NewRequest(http.MethodGet, "/dash", nil), nil) + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", rr.Code) + } + }) + + t.Run("core API error returns 502", func(t *testing.T) { + core := &fakeCore{dashErr: ipc.ErrNoFact} + rr := httptest.NewRecorder() + handleDash(rr, httptest.NewRequest(http.MethodGet, "/dash", nil), core) + if rr.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502", rr.Code) + } + }) + + t.Run("renders template with data", func(t *testing.T) { + now := time.Now() + core := &fakeCore{ + presence: ipc.Presence{Bucket: ipc.Present, Score: 0.75, Updated: now}, + facts: []ipc.Fact{ + {Kind: "env", Key: "test-key", Value: `"val"`, Source: "test", Confidence: 1.0}, + }, + nudges: []ipc.Nudge{ + {Rule: "test-rule", Channel: "test-chan", Message: "hello", Outcome: "pending"}, + }, + notes: []ipc.Note{ + {Text: "a note", Source: "user"}, + }, + } + rr := httptest.NewRecorder() + handleDash(rr, httptest.NewRequest(http.MethodGet, "/dash", nil), core) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + if !strings.Contains(body, "present") { + t.Error("rendered output missing presence bucket") + } + if !strings.Contains(body, "0.75") { + t.Error("rendered output missing presence score") + } + if !strings.Contains(body, "test-key") { + t.Error("rendered output missing fact key") + } + if !strings.Contains(body, "test-rule") { + t.Error("rendered output missing nudge rule") + } + if !strings.Contains(body, "a note") { + t.Error("rendered output missing note text") + } + }) +} + +// --- handleHistory --- + +func TestHandleHistory(t *testing.T) { + t.Parallel() + + t.Run("nil core returns 503", func(t *testing.T) { + rr := httptest.NewRecorder() + handleHistory(rr, httptest.NewRequest(http.MethodGet, "/history", nil), nil) + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", rr.Code) + } + }) + + t.Run("core RecentFacts error returns 502", func(t *testing.T) { + core := &fakeCore{historyErr: ipc.ErrNoFact} + rr := httptest.NewRecorder() + handleHistory(rr, httptest.NewRequest(http.MethodGet, "/history", nil), core) + if rr.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502", rr.Code) + } + }) + + t.Run("renders template with facts", func(t *testing.T) { + core := &fakeCore{ + historyFacts: []ipc.Fact{ + {Kind: "self", Key: "hist-key", Value: `"hist-val"`, Source: "test", Confidence: 0.5}, + }, + } + rr := httptest.NewRecorder() + handleHistory(rr, httptest.NewRequest(http.MethodGet, "/history", nil), core) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + if !strings.Contains(body, "hist-key") { + t.Error("rendered output missing fact key") + } + }) +} + +// --- handleRevert --- + +func TestHandleRevert(t *testing.T) { + t.Parallel() + + t.Run("GET returns 405", func(t *testing.T) { + rr := httptest.NewRecorder() + handleRevert(rr, httptest.NewRequest(http.MethodGet, "/api/revert", nil), &fakeCore{}) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rr.Code) + } + }) + + t.Run("nil core returns 503", func(t *testing.T) { + rr := httptest.NewRecorder() + handleRevert(rr, httptest.NewRequest(http.MethodPost, "/api/revert", nil), nil) + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", rr.Code) + } + }) + + t.Run("empty key returns 400", func(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleRevert(rr, req, core) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } + }) + + t.Run("core returns ErrNoFact returns 404", func(t *testing.T) { + core := &fakeCore{revertErr: ipc.ErrNoFact} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=missing")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleRevert(rr, req, core) + if rr.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rr.Code) + } + }) + + t.Run("core returns error returns 502", func(t *testing.T) { + core := &fakeCore{revertErr: ipc.ErrForbidden} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=somekey")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleRevert(rr, req, core) + if rr.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502", rr.Code) + } + }) + + t.Run("happy path returns JSON with reverted and new_id", func(t *testing.T) { + core := &fakeCore{revertNewID: 42} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=test-key")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleRevert(rr, req, core) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + body := rr.Body.String() + if !strings.Contains(body, `"reverted":true`) { + t.Errorf("body missing reverted:true: %s", body) + } + if !strings.Contains(body, `"new_id":42`) { + t.Errorf("body missing new_id:42: %s", body) + } + }) +} + +// --- handleTools ListTools error --- + +func TestHandleTools_ListToolsError_502(t *testing.T) { + t.Parallel() + core := &fakeCore{listErr: ipc.ErrForbidden} + rr := httptest.NewRecorder() + handleTools(rr, postForm("enable", url.Values{ + "name": {"svc"}, "cmd": {"systemctl restart"}, + }), core) + if rr.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String()) + } +} diff --git a/cmd/mavweb/history.html b/cmd/mavweb/history.html index 08dea56..b3283ad 100644 --- a/cmd/mavweb/history.html +++ b/cmd/mavweb/history.html @@ -28,7 +28,7 @@
-{{range .Facts}} +{{range .Facts}}{{if .VoidsID}}{{else}}{{end}}
whenkindkeyvaluesourceconf
{{if .VoidsID}}тип voided{{end}}{{.Ts.Format "2006-01-02 15:04"}} {{.Kind}} {{.Key}}