Files
826d13795f fix(webapp): show how many times a public link has been opened (BEA-76) (#125)
Every /s/<token> hit was already recorded as a share-kind read, carrying
both a count and a timestamp — and then thrown away at the UI layer. The
Public links table showed only who shared a file and when, six inches
below a file header that already said "1 shared". Two personas filed it
independently on the same tour.

The number now rides the shares list:

  * ReadLedger.ShareOpens(project) aggregates share-kind buckets per path,
    all-time. Share-kind only is what makes Last mean *last opened* —
    HeatEntry.LastRead is cross-kind, so a member viewing the file in the
    hub would otherwise move the date.
  * shareJSON takes the project's opens map, built ONCE per project by the
    caller and indexed per row. Both callers — the project list and the
    org-wide audit — hoist it above their loops; a per-share call would be
    a full byKey scan per row.
  * Counts, never openers. The share actor is token+"/"+IP, a public
    credential joined to an IP, and it stays in the ledger. There is no
    distinct-openers field, deliberately.
  * Reads off means the keys are ABSENT, not zero: `0` would claim nobody
    has opened a link on a hub that never looked.

shareDetail() is the leverage — the settings table, the org-wide audit and
the file page's share banner all render through it, so one string function
covers three surfaces. Once the row carried the receipt it truncated to
"3 op…", so the detail cell wraps instead of ellipsizing; the path keeps
its ellipsis, since it is a link with a tooltip and the column that can be
arbitrarily long.

Counted per FILE, not per link: heat is keyed by path, so two tokens on one
file report the same number. Worded that way in the section copy, alongside
the other honesty — opens are debounced visits, not requests.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:38:44 +09:00

486 lines
18 KiB
Go

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", "<p>hi</p>")
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)
}
}
}