~bigbes/sr-ht-spec

ref: c2dd1ef94a971405ff2ab1c048f9e74e0ef0b844 sr-ht-spec/web/inbox_test.go -rw-r--r-- 6.2 KiB
c2dd1ef9 — Eugene Blikh chore(beads): Phase 5b closed, spec-ar4 blocked on phoebe host access 24 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
package web

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// mergedAt is a digest proposal that auto-merged at t — the timestamp the "since
// you last looked" divider compares against the owner's mark.
func mergedAt(id int, title string, t time.Time) service.Proposal {
	return service.Proposal{
		ID: id, Space: demoSpace, Title: title, State: core.StateMerged,
		Approval: core.ApprovalPolicy, Agent: "claude-code/x", Resolved: &t,
	}
}

// TestInboxListsOpenAndDigest proves the review queue lists open proposals under
// "waiting on you" and policy-merged ones under the digest, each linking to its
// review page.
func TestInboxListsOpenAndDigest(t *testing.T) {
	r := newFakeReader()
	seedProposal(r, service.Proposal{
		ID: 3, Space: demoSpace, Title: "Open one", State: core.StateOpen,
		Agent: "claude-code/a",
	}, nil)
	seedProposal(r, service.Proposal{
		ID: 4, Space: demoSpace, Title: "Auto-merged one", State: core.StateMerged,
		Approval: core.ApprovalPolicy, Agent: "claude-code/b",
	}, nil)
	// A human-merged proposal is neither waiting nor part of the digest.
	seedProposal(r, service.Proposal{
		ID: 5, Space: demoSpace, Title: "Human-merged", State: core.StateMerged,
		Approval: core.ApprovalHuman,
	}, nil)
	h, _, _ := testServerWith(t, r)

	rec := get(t, h, "/inbox", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body:\n%s", rec.Code, rec.Body)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "/~bigbes/rfcs/p/3") || !strings.Contains(body, "Open one") {
		t.Errorf("open proposal missing from the queue; body:\n%s", body)
	}
	if !strings.Contains(body, "/~bigbes/rfcs/p/4") || !strings.Contains(body, "Auto-merged one") {
		t.Errorf("policy-merged proposal missing from the digest; body:\n%s", body)
	}
	if strings.Contains(body, "Human-merged") {
		t.Errorf("a human-merged proposal leaked into the review queue")
	}
}

// TestInboxEmpty proves the page renders with no proposals rather than erroring.
func TestInboxEmpty(t *testing.T) {
	h, _, _ := testServerWith(t, newFakeReader())
	rec := get(t, h, "/inbox", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "queue is clear") {
		t.Errorf("empty inbox does not say the queue is clear")
	}
}

// TestInboxAnonymousRedirected proves a viewer with no read authority is sent to
// login rather than shown the queue.
func TestInboxAnonymousRedirected(t *testing.T) {
	h, _, _ := testServerWith(t, newFakeReader())
	rec := get(t, h, "/inbox", "")
	if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
		t.Fatalf("status = %d, want a login redirect", rec.Code)
	}
}

// TestInboxDividesNewFromSeen proves the digest flags what auto-merged after the
// mark as new, counts it, offers the mark-as-seen action, and draws the divider
// before the already-seen rows.
func TestInboxDividesNewFromSeen(t *testing.T) {
	r := newFakeReader()
	base := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
	r.mark, r.marked = base, true
	// ID 9 merged after the mark (new); ID 8 before it (seen). The digest is
	// newest-first, so 9 leads.
	seedProposal(r, mergedAt(9, "Fresh merge", base.Add(time.Hour)), nil)
	seedProposal(r, mergedAt(8, "Old merge", base.Add(-time.Hour)), nil)
	h, _, _ := testServerWith(t, r)

	rec := get(t, h, "/inbox", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body:\n%s", rec.Code, rec.Body)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "1 new") {
		t.Errorf("digest does not report one new item; body:\n%s", body)
	}
	if !strings.Contains(body, "/inbox/seen") {
		t.Errorf("no mark-as-seen action offered when there is new content")
	}
	if !strings.Contains(body, "seen before your last visit") {
		t.Errorf("no divider drawn between new and seen; body:\n%s", body)
	}
}

// TestInboxAllNewWithoutMark proves that before the owner has ever cleared the
// digest, everything in it counts as new.
func TestInboxAllNewWithoutMark(t *testing.T) {
	r := newFakeReader() // marked is false: no mark yet.
	seedProposal(r, mergedAt(4, "First", time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)), nil)
	seedProposal(r, mergedAt(5, "Second", time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)), nil)
	h, _, _ := testServerWith(t, r)

	rec := get(t, h, "/inbox", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "2 new") {
		t.Errorf("with no mark, the whole digest should read as new; body:\n%s", rec.Body)
	}
}

// TestInboxSeenAdvancesMark proves the POST advances the owner's mark and
// redirects back, so the next render shows the just-seen items as no longer new.
func TestInboxSeenAdvancesMark(t *testing.T) {
	r := newFakeReader()
	h, reader, _ := testServerWith(t, r)

	rec := post(t, h, "/inbox/seen", "bigbes", "https://spec.example")
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("status = %d, want 303", rec.Code)
	}
	if !reader.marked {
		t.Errorf("the digest mark was not advanced by a valid POST")
	}
}

// TestInboxSeenForbiddenForAgent proves an agent — authenticated but not the
// owner — may not move the owner's mark.
func TestInboxSeenForbiddenForAgent(t *testing.T) {
	r := newFakeReader()
	h, reader, _ := testServerWith(t, r)

	req := httptest.NewRequest(http.MethodPost, "/inbox/seen", nil)
	req.Header.Set("Authorization", "Bearer "+agentTk)
	req.Header.Set("Origin", "https://spec.example")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusForbidden {
		t.Fatalf("status = %d, want 403 for an agent marking seen", rec.Code)
	}
	if reader.marked {
		t.Errorf("the mark moved despite the agent being refused")
	}
}

// TestInboxSeenRefusedCrossOrigin proves the mark-as-seen POST gets the same
// CSRF defense as approve/reject.
func TestInboxSeenRefusedCrossOrigin(t *testing.T) {
	r := newFakeReader()
	h, reader, _ := testServerWith(t, r)

	rec := post(t, h, "/inbox/seen", "bigbes", "https://evil.example")
	if rec.Code != http.StatusForbidden {
		t.Fatalf("status = %d, want 403 for a cross-origin POST", rec.Code)
	}
	if reader.marked {
		t.Errorf("the mark moved despite the cross-origin refusal")
	}
}