package webapp import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" ) // doHdr is do() with request headers (device identity on read reports). func doHdr(t *testing.T, h http.Handler, method, url string, body any, hdr map[string]string) *httptest.ResponseRecorder { t.Helper() data, err := json.Marshal(body) if err != nil { t.Fatal(err) } req := httptest.NewRequest(method, url, bytes.NewReader(data)) for k, v := range hdr { req.Header.Set(k, v) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func openTestLedger(t *testing.T, retentionDays int) (*ReadLedger, *fileReadRepo) { t.Helper() repo := newFileReadRepo(filepath.Join(t.TempDir(), "reads.json")) l, err := NewReadLedger(repo, retentionDays) if err != nil { t.Fatal(err) } return l, repo } func TestReadLedgerDebounce(t *testing.T) { l, _ := openTestLedger(t, 0) // A reload storm and the render-then-raw double fetch are one visit… l.Record("p-1", "a.md", ReadKindHuman, "alice@x.io") l.Record("p-1", "a.md", ReadKindHuman, "alice@x.io") l.Record("p-1", "a.md", ReadKindHuman, "alice@x.io") // …but a different actor, kind, or path counts on its own. l.Record("p-1", "a.md", ReadKindHuman, "bob@x.io") l.Record("p-1", "a.md", ReadKindAgent, "alice@x.io") l.Record("p-1", "b.md", ReadKindHuman, "alice@x.io") heat := l.Heat("p-1", "", time.Time{}) if e := heat["a.md"]; e.Human != 2 || e.Agent != 1 || e.Readers != 2 { t.Fatalf("a.md = %+v, want human 2, agent 1, readers 2", e) } if e := heat["b.md"]; e.Human != 1 || e.Readers != 1 { t.Fatalf("b.md = %+v", e) } if heat["a.md"].LastRead.IsZero() { t.Fatal("last_read not set") } // …and once the window has passed, the same actor counts again: the // debounce collapses a visit, it does not stop counting (BEA-15). visit := ReadStatKey{Project: "p-1", Path: "a.md", Kind: ReadKindHuman, Actor: "alice@x.io"} l.mu.Lock() l.seen[visit] = l.seen[visit].Add(-readDebounce - time.Minute) l.mu.Unlock() l.Record("p-1", "a.md", ReadKindHuman, "alice@x.io") if e := l.Heat("p-1", "", time.Time{})["a.md"]; e.Human != 3 || e.Readers != 2 { t.Fatalf("a.md after the window = %+v, want human 3, readers 2", e) } } func TestReadLedgerWindow(t *testing.T) { l, repo := openTestLedger(t, 0) l.Record("p-1", "a.md", ReadKindHuman, "alice@x.io") if err := l.Close(); err != nil { t.Fatal(err) } // An old bucket inside retention: counted all-time, outside a 7-day window. old := ReadStat{Project: "p-1", Path: "a.md", Day: "2026-01-01", Kind: ReadKindHuman, Actor: "carol@x.io", Count: 5, Last: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)} if err := repo.PutBatch([]ReadStat{old}); err != nil { t.Fatal(err) } l2, err := NewReadLedger(repo, 0) if err != nil { t.Fatal(err) } if e := l2.Heat("p-1", "", time.Time{})["a.md"]; e.Human != 6 || e.Readers != 2 { t.Fatalf("all-time = %+v, want human 6, readers 2", e) } week := time.Now().UTC().AddDate(0, 0, -7) if e := l2.Heat("p-1", "", week)["a.md"]; e.Human != 1 || e.Readers != 1 { t.Fatalf("windowed = %+v, want only today's read", e) } } func TestReadLedgerRetentionFold(t *testing.T) { repo := newFileReadRepo(filepath.Join(t.TempDir(), "reads.json")) seed := []ReadStat{ {Project: "p-1", Path: "a.md", Day: "2020-01-01", Kind: ReadKindHuman, Actor: "alice@x.io", Count: 3, Last: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}, {Project: "p-1", Path: "a.md", Day: "2020-01-02", Kind: ReadKindHuman, Actor: "alice@x.io", Count: 2, Last: time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)}, {Project: "p-1", Path: "a.md", Day: time.Now().UTC().Format("2006-01-02"), Kind: ReadKindHuman, Actor: "alice@x.io", Count: 1, Last: time.Now().UTC()}, } if err := repo.PutBatch(seed); err != nil { t.Fatal(err) } l, err := NewReadLedger(repo, 30) if err != nil { t.Fatal(err) } // All-time totals survive the fold; per-day resolution ages out. if e := l.Heat("p-1", "", time.Time{})["a.md"]; e.Human != 6 || e.Readers != 1 { t.Fatalf("after fold = %+v, want human 6, readers 1", e) } if err := l.Close(); err != nil { t.Fatal(err) } rows, err := repo.Load() if err != nil { t.Fatal(err) } var folds, dailies int for _, st := range rows { if st.Day == "" { folds++ if st.Count != 5 { t.Fatalf("fold count = %d, want 5", st.Count) } } else { dailies++ } } if folds != 1 || dailies != 1 { t.Fatalf("rows after fold: %d folds, %d dailies; want 1 and 1 (%+v)", folds, dailies, rows) } // Reloading must not double-count: the fold replaced the old rows. l2, err := NewReadLedger(repo, 30) if err != nil { t.Fatal(err) } if e := l2.Heat("p-1", "", time.Time{})["a.md"]; e.Human != 6 { t.Fatalf("after reload = %+v, want human still 6", e) } } // TestShareOpens pins the receipt accessor: share buckets only (so Last // really means *last opened*), all-time (a link's lifetime is the question), // and counts with no trace of the actor, which is token+"/"+IP. func TestShareOpens(t *testing.T) { repo := newFileReadRepo(filepath.Join(t.TempDir(), "reads.json")) old := time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC) newer := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) if err := repo.PutBatch([]ReadStat{ // Two share buckets on one path, one of them the retention fold: // all-time means both count, so no day filter may creep in. {Project: "p-1", Path: "a.md", Day: "", Kind: ReadKindShare, Actor: "tok/1.2.3.4", Count: 2, Last: old}, {Project: "p-1", Path: "a.md", Day: "2026-03-02", Kind: ReadKindShare, Actor: "tok/5.6.7.8", Count: 1, Last: old.Add(time.Hour)}, // A NEWER human read of the same path. It must move neither the // count nor the date — this is the assertion that pins the kind // filter, since HeatEntry.LastRead would happily report it. {Project: "p-1", Path: "a.md", Day: "2026-08-01", Kind: ReadKindHuman, Actor: "alice@x.io", Count: 40, Last: newer}, {Project: "p-1", Path: "a.md", Day: "2026-08-01", Kind: ReadKindAgent, Actor: "dev1", Count: 9, Last: newer}, // A share read in another project must not leak across. {Project: "p-2", Path: "a.md", Day: "2026-03-02", Kind: ReadKindShare, Actor: "tok/9.9.9.9", Count: 7, Last: newer}, // A path with no share reads at all is simply absent from the map. {Project: "p-1", Path: "b.md", Day: "2026-08-01", Kind: ReadKindHuman, Actor: "alice@x.io", Count: 3, Last: newer}, }); err != nil { t.Fatal(err) } l, err := NewReadLedger(repo, 0) if err != nil { t.Fatal(err) } opens := l.ShareOpens("p-1") if got := opens["a.md"]; got.Count != 3 { t.Fatalf("a.md opens = %d, want 3 (share buckets only, all-time)", got.Count) } if got := opens["a.md"].Last; !got.Equal(old.Add(time.Hour)) { t.Fatalf("a.md last opened = %s, want %s — a newer human read must not move it", got, old.Add(time.Hour)) } if _, ok := opens["b.md"]; ok { t.Fatal("a path read only by humans must not appear in the opens map") } if got := l.ShareOpens("p-2")["a.md"].Count; got != 7 { t.Fatalf("p-2 a.md opens = %d, want 7", got) } // Enabled-but-empty is an empty map, not nil: nil is reserved for "reads // are off", which is what the wire format turns into an absent key. if m := l.ShareOpens("p-nothing"); m == nil { t.Fatal("a project with no share reads must yield an empty map, not nil") } // Live recording lands in the same map, debounced to visits. l.Record("p-1", "c.md", ReadKindShare, "tok2/1.1.1.1") l.Record("p-1", "c.md", ReadKindShare, "tok2/1.1.1.1") // same opener, inside the window if got := l.ShareOpens("p-1")["c.md"].Count; got != 1 { t.Fatalf("c.md opens = %d, want 1 — repeat opens inside the debounce window are one visit", got) } } func TestReadLedgerNil(t *testing.T) { var l *ReadLedger l.Record("p-1", "a.md", ReadKindHuman, "x") // must not panic if l.Heat("p-1", "", time.Time{}) != nil { t.Fatal("nil ledger heat should be nil") } if l.ShareOpens("p-1") != nil { t.Fatal("nil ledger share opens should be nil — reads off means absent, not zero") } if err := l.Close(); err != nil { t.Fatal(err) } } // TestHeatAPI drives the recording sites and the heat/report endpoints // through the real handler: renders and downloads count (debounced), the // store sync proxy and history blob views never count, and device-reported // agent reads land as agent traffic. func TestHeatAPI(t *testing.T) { srv, p, root := newHub(t, false, nil) f := newFakeRemoteAt(t, filepath.Join(root, p.ID)) f.putAs("dev1", "alice@x.io", "Alice", "wiki/plan.md", "# plan") f.putAs("dev1", "alice@x.io", "Alice", "top.md", "top") var err error srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0) if err != nil { t.Fatal(err) } h := srv.Handler() base := "/api/p/" + p.ID + "/" // render + raw fetch of the same file = one visit (debounced) for _, u := range []string{base + "render?path=wiki/plan.md", base + "file?path=wiki/plan.md"} { if rec := do(t, h, "GET", u, nil); rec.Code != 200 { t.Fatalf("GET %s: %d %s", u, rec.Code, rec.Body) } } // store proxy traffic is replication, not reading do(t, h, "GET", base+"store/list?prefix=journal/", nil) do(t, h, "GET", base+"store/object?key=journal/dev1.jsonl", nil) rec := do(t, h, "GET", base+"heat", nil) if rec.Code != 200 { t.Fatalf("heat: %d %s", rec.Code, rec.Body) } var out struct { Entries map[string]HeatEntry `json:"entries"` Since string `json:"since"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } if e := out.Entries["wiki/plan.md"]; e.Human != 1 || e.Readers != 1 { t.Fatalf("plan.md heat = %+v, want one human visit", e) } if len(out.Entries) != 1 || out.Since == "" { t.Fatalf("heat = %+v; store traffic must not count", out) } // an agent device reports its local tool reads report := map[string]any{ "reads": []map[string]string{{"path": "wiki/plan.md"}, {"path": "top.md"}, {"path": "../evil"}}, } rec = doHdr(t, h, "POST", base+"reads", report, map[string]string{"X-Bdrive-Device": "dev1"}) if rec.Code != 200 { t.Fatalf("report: %d %s", rec.Code, rec.Body) } var acc struct { Accepted int `json:"accepted"` } if err := json.Unmarshal(rec.Body.Bytes(), &acc); err != nil { t.Fatal(err) } if acc.Accepted != 2 { t.Fatalf("accepted = %d, want 2 (traversal path dropped)", acc.Accepted) } // …and without a device identity the report is rejected if rec := doHdr(t, h, "POST", base+"reads", report, nil); rec.Code != 400 { t.Fatalf("device-less report: %d, want 400", rec.Code) } rec = do(t, h, "GET", base+"heat?prefix=wiki", nil) if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } if e := out.Entries["wiki/plan.md"]; e.Human != 1 || e.Agent != 1 { t.Fatalf("plan.md after report = %+v, want human 1 + agent 1", e) } if _, ok := out.Entries["top.md"]; ok { t.Fatal("prefix filter leaked top.md") } if rec := do(t, h, "GET", base+"heat?days=x", nil); rec.Code != 400 { t.Fatalf("bad days: %d, want 400", rec.Code) } // a hub without read tracking 404s cleanly srv.Reads = nil if rec := do(t, h, "GET", base+"heat", nil); rec.Code != 404 { t.Fatalf("disabled heat: %d, want 404", rec.Code) } } // TestHeatByDevice covers the coverage-matrix breakdown: agent reads per // device per top-level folder, device-registry joined — and, critically, // that human actor identities never appear in the response. func TestHeatByDevice(t *testing.T) { srv, p, root := newHub(t, false, nil) f := newFakeRemoteAt(t, filepath.Join(root, p.ID)) f.putAs("dev1", "alice@x.io", "Alice", "wiki/plan.md", "# plan") var err error srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0) if err != nil { t.Fatal(err) } srv.Devices, _ = OpenDeviceRegistry(filepath.Join(t.TempDir(), "devices.json")) srv.Devices.Observe(DeviceInfo{ID: "dev1", Name: "ci-agent", OS: "linux/amd64"}) // Agent reads from two devices, human reads carrying real emails. srv.Reads.Record(p.ID, "wiki/plan.md", ReadKindAgent, "dev1") srv.Reads.Record(p.ID, "wiki/deep.md", ReadKindAgent, "dev1") srv.Reads.Record(p.ID, "top.md", ReadKindAgent, "dev2") srv.Reads.Record(p.ID, "wiki/plan.md", ReadKindHuman, "alice@x.io") srv.Reads.Record(p.ID, "wiki/plan.md", ReadKindShare, "tok123/1.2.3.4") h := srv.Handler() base := "/api/p/" + p.ID + "/" rec := do(t, h, "GET", base+"heat?by=device&days=30", nil) if rec.Code != 200 { t.Fatalf("by=device: %d %s", rec.Code, rec.Body) } var out struct { Devices []deviceHeat `json:"devices"` Since string `json:"since"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } if len(out.Devices) != 2 || out.Since == "" { t.Fatalf("devices = %+v", out) } // Sorted by total: dev1 (2 reads, registry-joined) first. d1 := out.Devices[0] if d1.ID != "dev1" || d1.Name != "ci-agent" || d1.OS != "linux/amd64" || d1.Total != 2 { t.Fatalf("dev1 row = %+v", d1) } if d1.Folders["wiki"] != 2 { t.Fatalf("dev1 folders = %+v, want wiki:2", d1.Folders) } // Root files land under the "" folder. if d2 := out.Devices[1]; d2.ID != "dev2" || d2.Folders[""] != 1 { t.Fatalf("dev2 row = %+v", d2) } // The privacy line: human and share actors are invisible here. body := rec.Body.String() for _, leak := range []string{"alice@x.io", "tok123", "1.2.3.4", "human", "share"} { if strings.Contains(body, leak) { t.Fatalf("by=device leaked %q: %s", leak, body) } } if rec := do(t, h, "GET", base+"heat?by=path", nil); rec.Code != 400 { t.Fatalf("invalid by: %d, want 400", rec.Code) } } // readsHub builds an authenticated hub with one project owned by alice and // two seeded files: the markdown the viewer renders, and an HTML file the // viewer shows inside a sandboxed iframe. func readsHub(t *testing.T) (http.Handler, *Server, Project, map[string]*http.Cookie) { t.Helper() srv, _, root := newHub(t, true, nil) auth, err := OpenBuiltinAuth(filepath.Join(t.TempDir(), "auth.json"), true, nil) if err != nil { t.Fatal(err) } srv.Auth = auth orgs, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json")) if err != nil { t.Fatal(err) } srv.Dir = LocalDirectory{OrgDB: orgs} srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0) if err != nil { t.Fatal(err) } h := srv.Handler() cookies := map[string]*http.Cookie{ "alice": signupAndSession(t, h, "alice@x.io", "Alice", "password1"), "bob": signupAndSession(t, h, "bob@x.io", "Bob", "password1"), } rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "wiki"}, cookies["alice"]) if rec.Code != 200 { t.Fatalf("create project: %d %s", rec.Code, rec.Body) } var out struct { Project Project `json:"project"` } json.Unmarshal(rec.Body.Bytes(), &out) if err := orgs.AddMember(out.Project.Org, "bob@x.io", RoleMember); err != nil { t.Fatal(err) } f := newFakeRemoteAt(t, filepath.Join(root, out.Project.ID)) f.putAs("dev1", "alice@x.io", "Alice", "guide.md", "# guide") f.putAs("dev1", "alice@x.io", "Alice", "page.html", "
hi
") return h, srv, out.Project, cookies } func humanHeat(t *testing.T, h http.Handler, p Project, c *http.Cookie, path string) HeatEntry { t.Helper() rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/heat?days=30", nil, c) if rec.Code != 200 { t.Fatalf("heat: %d %s", rec.Code, rec.Body) } var out struct { Entries map[string]HeatEntry `json:"entries"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } return out.Entries[path] } // TestReadCountsOnePageOpenOnce is the BEA-15 regression: one signed-in // person opening one file, however many requests the page open makes and // whichever handler serves them, is exactly one read. func TestReadCountsOnePageOpenOnce(t *testing.T) { h, _, p, c := readsHub(t) base := "/api/p/" + p.ID + "/" // A markdown page open is a /render; reopening it five times in a // session is one visit. for range 5 { if rec := doAs(t, h, "GET", base+"render?path=guide.md", nil, c["alice"]); rec.Code != 200 { t.Fatalf("render: %d %s", rec.Code, rec.Body) } } // The ⋯ menu's Download and a raw view hit the same file by other handlers. doAs(t, h, "GET", base+"file?path=guide.md", nil, c["alice"]) doAs(t, h, "GET", base+"download?path=guide.md", nil, c["alice"]) if e := humanHeat(t, h, p, c["alice"], "guide.md"); e.Human != 1 || e.Readers != 1 { t.Fatalf("guide.md after one person's session = %+v, want human 1, readers 1", e) } // A second person opening it once adds exactly one, and one more reader. doAs(t, h, "GET", base+"render?path=guide.md", nil, c["bob"]) if e := humanHeat(t, h, p, c["alice"], "guide.md"); e.Human != 2 || e.Readers != 2 { t.Fatalf("guide.md after bob = %+v, want human 2, readers 2", e) } // An HTML file is served into a sandboxed iframe, which fetches it as an // opaque origin — no session cookie. That fetch is a subresource of an // already-counted page open, not a second reader. doAs(t, h, "GET", base+"file?path=page.html", nil, c["alice"]) do(t, h, "GET", base+"file?path=page.html", nil) // the sandboxed iframe if e := humanHeat(t, h, p, c["alice"], "page.html"); e.Human != 1 || e.Readers != 1 { t.Fatalf("page.html = %+v, want human 1, readers 1 (sandboxed fetch is not a reader)", e) } // Spelunking through history is not consumption: neither the feed nor an // old version fetched from it counts. rec := doAs(t, h, "GET", base+"history?path=guide.md", nil, c["bob"]) var hist struct { Entries []struct { Blob string `json:"blob"` } `json:"entries"` } if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil || len(hist.Entries) == 0 { t.Fatalf("history: %d %s (%v)", rec.Code, rec.Body, err) } doAs(t, h, "GET", base+"blob?sha="+hist.Entries[0].Blob, nil, c["bob"]) if e := humanHeat(t, h, p, c["alice"], "guide.md"); e.Human != 2 { t.Fatalf("guide.md after history spelunking = %+v, want human still 2", e) } // The privacy line: counts leave the server, identities never do. rec = doAs(t, h, "GET", base+"heat?days=30", nil, c["alice"]) for _, leak := range []string{"alice@x.io", "bob@x.io", "dev1", "actor"} { if strings.Contains(rec.Body.String(), leak) { t.Fatalf("heat leaked %q: %s", leak, rec.Body) } } }