package web import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "github.com/yuanyuanxiang/SimpleRemoter/server/go/logger" "github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth" ) // newAuditTestHubWithoutAuditFile builds a hub whose logger has no dedicated // audit file, mirroring a server started with YAMA_AUDIT_LOG=off. func newAuditTestHubWithoutAuditFile(t *testing.T, dir string) (*wsHub, string) { t.Helper() lg := logger.New(logger.Config{ Level: logger.LevelInfo, Console: false, File: filepath.Join(dir, "server.log"), }) t.Cleanup(func() { _ = lg.Close() }) wh := newWSHub(wsauth.New(), hub.New(), nil, lg) t.Cleanup(wh.stop) return wh, "" } func readQueuedJSON(t *testing.T, c *wsClient) map[string]any { t.Helper() select { case m := <-c.send: var v map[string]any if err := json.Unmarshal(m.data, &v); err != nil { t.Fatalf("queued reply is not JSON: %q: %v", m.data, err) } return v case <-time.After(2 * time.Second): t.Fatal("timed out waiting for a queued reply") return nil } } // writeAuditFixture writes JSON-lines audit records (oldest first) and returns // the file path. Times are UTC RFC3339 so the tests are timezone-independent. func writeAuditFixture(t *testing.T, records []map[string]any) string { t.Helper() path := filepath.Join(t.TempDir(), "audit.log") var b strings.Builder for _, rec := range records { line, err := json.Marshal(rec) if err != nil { t.Fatalf("marshal fixture: %v", err) } b.Write(line) b.WriteByte('\n') } if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { t.Fatalf("write fixture: %v", err) } return path } func auditFixtureRecords() []map[string]any { at := func(hhmm string) string { tm, err := time.Parse("2006-01-02T15:04", "2026-10-18T"+hhmm) if err != nil { panic(err) } return tm.Format(time.RFC3339) } return []map[string]any{ {"level": "info", "time": at("10:00"), "audit": true, "event": "web.login.success", "actor": "admin", "ip": "203.0.113.1"}, {"level": "warn", "time": at("10:01"), "audit": true, "event": "web.login.failure", "actor": "alice", "ip": "203.0.113.2", "reason": "invalid_credentials"}, {"level": "info", "time": at("10:02"), "audit": true, "event": "remote.screen.start", "actor": "admin", "role": "admin", "ip": "203.0.113.1", "device_id": "dev1"}, {"level": "info", "time": at("10:03"), "audit": true, "event": "admin.user.delete", "actor": "admin", "ip": "203.0.113.1", "target": "bob"}, } } func runAuditQuery(t *testing.T, path string, q auditQueryRequest) auditQueryResult { t.Helper() q.Limit = clampAuditLimit(q.Limit) q.Offset = clampAuditOffset(q.Offset) res, err := queryAuditLog(path, q) if err != nil { t.Fatalf("queryAuditLog: %v", err) } return res } func eventNames(res auditQueryResult) []string { out := make([]string, 0, len(res.Events)) for _, e := range res.Events { out = append(out, auditStringField(e, "event")) } return out } func TestAuditQueryNoFilterNewestFirst(t *testing.T) { path := writeAuditFixture(t, auditFixtureRecords()) res := runAuditQuery(t, path, auditQueryRequest{Limit: 100}) if res.Total != 4 || len(res.Events) != 4 { t.Fatalf("total=%d events=%d, want 4/4", res.Total, len(res.Events)) } got := eventNames(res) want := []string{"admin.user.delete", "remote.screen.start", "web.login.failure", "web.login.success"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("events = %v, want %v", got, want) } } func TestAuditQueryEventFilters(t *testing.T) { path := writeAuditFixture(t, auditFixtureRecords()) cases := []struct { filter string want []string }{ {"web.", []string{"web.login.failure", "web.login.success"}}, {"web.*", []string{"web.login.failure", "web.login.success"}}, {"web.login.failure", []string{"web.login.failure"}}, {"remote.*", []string{"remote.screen.start"}}, } for _, tc := range cases { res := runAuditQuery(t, path, auditQueryRequest{Event: tc.filter, Limit: 100}) got := eventNames(res) if strings.Join(got, ",") != strings.Join(tc.want, ",") { t.Errorf("filter %q → %v, want %v", tc.filter, got, tc.want) } } } func TestAuditQueryFieldFilters(t *testing.T) { path := writeAuditFixture(t, auditFixtureRecords()) from, _ := time.Parse(time.RFC3339, "2026-10-18T10:01:00Z") to, _ := time.Parse(time.RFC3339, "2026-10-18T10:02:00Z") cases := []struct { name string q auditQueryRequest want []string }{ {"actor case-insensitive", auditQueryRequest{Actor: "ALICE", Limit: 100}, []string{"web.login.failure"}}, {"ip", auditQueryRequest{IP: "203.0.113.2", Limit: 100}, []string{"web.login.failure"}}, {"device", auditQueryRequest{DeviceID: "dev1", Limit: 100}, []string{"remote.screen.start"}}, {"warn only", auditQueryRequest{WarnOnly: true, Limit: 100}, []string{"web.login.failure"}}, {"keyword target", auditQueryRequest{Keyword: "BOB", Limit: 100}, []string{"admin.user.delete"}}, {"time range", auditQueryRequest{ From: from.Format(time.RFC3339), To: to.Format(time.RFC3339), Limit: 100, }, []string{"remote.screen.start", "web.login.failure"}}, } for _, tc := range cases { res := runAuditQuery(t, path, tc.q) got := eventNames(res) if strings.Join(got, ",") != strings.Join(tc.want, ",") { t.Errorf("%s → %v, want %v", tc.name, got, tc.want) } } } func TestAuditQueryOffsetAndLimit(t *testing.T) { path := writeAuditFixture(t, auditFixtureRecords()) res := runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 1}) if res.Total != 4 { t.Fatalf("total = %d, want 4", res.Total) } got := eventNames(res) want := []string{"remote.screen.start", "web.login.failure"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("events = %v, want %v", got, want) } // Offsetting past the end yields no events but keeps the total. res = runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 4}) if res.Total != 4 || len(res.Events) != 0 { t.Fatalf("offset past end: total=%d events=%v", res.Total, eventNames(res)) } } func TestAuditQuerySkipsNonAuditAndTornLines(t *testing.T) { path := filepath.Join(t.TempDir(), "audit.log") content := `{"event":"device.online","actor":"x"} not json at all {"level":"info","time":"2026-10-18T10:00:00Z","audit":true,"event":"web.login.success","actor":"admin"}` if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } res := runAuditQuery(t, path, auditQueryRequest{Limit: 100}) if res.Total != 1 || len(res.Events) != 1 { t.Fatalf("total=%d events=%v, want the single valid record", res.Total, eventNames(res)) } } func TestAuditQueryMissingFile(t *testing.T) { res := runAuditQuery(t, filepath.Join(t.TempDir(), "does-not-exist.log"), auditQueryRequest{Limit: 10}) if res.FileExists { t.Error("FileExists = true for a missing file") } if res.Total != 0 || len(res.Events) != 0 { t.Errorf("total=%d events=%v, want empty", res.Total, eventNames(res)) } if res.Events == nil { t.Error("Events is nil; JSON would encode null instead of []") } } func TestAuditQueryClampsPaging(t *testing.T) { if got := clampAuditLimit(0); got != auditQueryDefaultLimit { t.Errorf("limit(0) = %d, want default %d", got, auditQueryDefaultLimit) } if got := clampAuditLimit(-3); got != auditQueryDefaultLimit { t.Errorf("limit(-3) = %d, want default %d", got, auditQueryDefaultLimit) } if got := clampAuditLimit(9999); got != auditQueryMaxLimit { t.Errorf("limit(9999) = %d, want max %d", got, auditQueryMaxLimit) } if got := clampAuditLimit(50); got != 50 { t.Errorf("limit(50) = %d, want 50", got) } if got := clampAuditOffset(-5); got != 0 { t.Errorf("offset(-5) = %d, want 0", got) } if got := clampAuditOffset(1 << 30); got != auditQueryMaxOffset { t.Errorf("offset(huge) = %d, want max %d", got, auditQueryMaxOffset) } } func TestQueryAuditHandlerAdminOnly(t *testing.T) { wh, _, _ := auditTestHub(t) viewer, tok := auditTestClient(wh, "viewer") raw, _ := json.Marshal(map[string]any{"token": tok}) wh.handleQueryAudit(viewer, raw) msg := readQueuedJSON(t, viewer) if msg["cmd"] != "audit_result" || msg["ok"] != false { t.Fatalf("viewer reply = %+v", msg) } if _, ok := msg["events"]; ok { t.Error("denied viewer got events") } } func TestQueryAuditHandlerReturnsEvents(t *testing.T) { wh, _, _ := auditTestHub(t) // Two records written directly through the same logger the hub queries. wh.log.Audit("web.login.success", map[string]any{"actor": "admin", "ip": "203.0.113.1"}) wh.log.Audit("remote.screen.start", map[string]any{"actor": "admin", "device_id": "dev1"}) admin, tok := auditTestClient(wh, "admin") raw, _ := json.Marshal(map[string]any{"token": tok, "limit": 10}) wh.handleQueryAudit(admin, raw) msg := readQueuedJSON(t, admin) if msg["cmd"] != "audit_result" || msg["ok"] != true { t.Fatalf("reply = %+v", msg) } events, ok := msg["events"].([]any) if !ok || len(events) < 2 { t.Fatalf("events = %#v, want at least the two written records", msg["events"]) } // requireAuth writes a web.session.resume record for this same request, so // that one legitimately appears newer. Assert the two written records are // present and still newest-first relative to each other. var names []string for _, e := range events { if m, ok := e.(map[string]any); ok { names = append(names, auditStringField(m, "event")) } } joined := strings.Join(names, ",") if !strings.Contains(joined, "remote.screen.start") || !strings.Contains(joined, "web.login.success") { t.Fatalf("events = %v, want both written records", names) } if strings.Index(joined, "remote.screen.start") > strings.Index(joined, "web.login.success") { t.Fatalf("events = %v, want newest-first order", names) } if msg["truncated"] != false || msg["file_exists"] != true { t.Errorf("meta = %+v", msg) } } func TestQueryAuditHandlerDisabledFile(t *testing.T) { dir := t.TempDir() wh, _ := newAuditTestHubWithoutAuditFile(t, dir) admin, tok := auditTestClient(wh, "admin") raw, _ := json.Marshal(map[string]any{"token": tok}) wh.handleQueryAudit(admin, raw) msg := readQueuedJSON(t, admin) if msg["ok"] != false { t.Fatalf("reply = %+v, want ok:false when the audit file is disabled", msg) } }