~bigbes/sr-ht-spec

ref: 6e59bf0ffc7d16f509dad40063d35bf8f6fb98a7 sr-ht-spec/web/inbox_test.go -rw-r--r-- 6.9 KiB
6e59bf0f — Eugene Blikh api: advertise an empty scope list, not null 8 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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
package web

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

	"github.com/stretchr/testify/assert"

	"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")
	}
}

// The open queue is drawn by ecore's "srht-repo-table", so the space and the
// agent ride in the item's Meta rather than in columns this package writes. This
// is the seam: that both still reach the page, since "who proposed this, and
// where" is what the queue is scanned for.
func TestInboxOpenQueueCarriesSpaceAndAgent(t *testing.T) {
	r := newFakeReader()
	seedProposal(r, service.Proposal{
		ID: 3, Space: demoSpace, Title: "Open one", State: core.StateOpen,
		Agent: "claude-code/a",
	}, nil)
	h, _, _ := testServerWith(t, r)

	body := get(t, h, "/inbox", "bigbes").Body.String()
	assert.Contains(t, body, "#3 — Open one")
	assert.Contains(t, body, demoSpace.String())
	assert.Contains(t, body, "claude-code/a")
}

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