Go · 9579 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package issues_test
4
5 import (
6 "context"
7 "encoding/json"
8 "io"
9 "log/slog"
10 "strings"
11 "sync"
12 "testing"
13
14 "github.com/jackc/pgx/v5/pgtype"
15 "github.com/jackc/pgx/v5/pgxpool"
16
17 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
18 "github.com/tenseleyFlow/shithub/internal/issues"
19 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
20 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
21 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
22 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
23 )
24
25 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
26 "AAAAAAAAAAAAAAAA$" +
27 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
28
29 // setup spins a fresh test DB, creates a user + repo, seeds the issue
30 // counter, and returns the things the issue tests need.
31 func setup(t *testing.T) (*pgxpool.Pool, issues.Deps, int64, int64) {
32 t.Helper()
33 pool := dbtest.NewTestDB(t)
34 ctx := context.Background()
35
36 uq := usersdb.New()
37 user, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
38 Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
39 })
40 if err != nil {
41 t.Fatalf("CreateUser: %v", err)
42 }
43
44 rq := reposdb.New()
45 repo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
46 OwnerUserID: pgtype.Int8{Int64: user.ID, Valid: true},
47 Name: "demo",
48 DefaultBranch: "trunk",
49 Visibility: reposdb.RepoVisibilityPublic,
50 })
51 if err != nil {
52 t.Fatalf("CreateRepo: %v", err)
53 }
54 iq := issuesdb.New()
55 if err := iq.EnsureRepoIssueCounter(ctx, pool, repo.ID); err != nil {
56 t.Fatalf("EnsureRepoIssueCounter: %v", err)
57 }
58
59 deps := issues.Deps{
60 Pool: pool,
61 Limiter: throttle.NewLimiter(),
62 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
63 }
64 return pool, deps, user.ID, repo.ID
65 }
66
67 func TestCreate_AllocatesSequentialNumbers(t *testing.T) {
68 _, deps, uid, rid := setup(t)
69 ctx := context.Background()
70 for i := int64(1); i <= 3; i++ {
71 got, err := issues.Create(ctx, deps, issues.CreateParams{
72 RepoID: rid, AuthorUserID: uid, Title: "title", Body: "body",
73 })
74 if err != nil {
75 t.Fatalf("Create #%d: %v", i, err)
76 }
77 if got.Number != i {
78 t.Errorf("issue %d: got number %d, want %d", i, got.Number, i)
79 }
80 }
81 }
82
83 func TestCreate_ConcurrentRaceForUniqueNumbers(t *testing.T) {
84 _, deps, uid, rid := setup(t)
85 ctx := context.Background()
86 const N = 8
87 got := make([]int64, N)
88 var wg sync.WaitGroup
89 wg.Add(N)
90 for i := 0; i < N; i++ {
91 go func(i int) {
92 defer wg.Done()
93 row, err := issues.Create(ctx, deps, issues.CreateParams{
94 RepoID: rid, AuthorUserID: uid, Title: "race", Body: "body",
95 })
96 if err != nil {
97 t.Errorf("create %d: %v", i, err)
98 return
99 }
100 got[i] = row.Number
101 }(i)
102 }
103 wg.Wait()
104 seen := map[int64]bool{}
105 for _, n := range got {
106 if seen[n] {
107 t.Errorf("duplicate number %d in %v", n, got)
108 }
109 seen[n] = true
110 if n < 1 || n > N {
111 t.Errorf("number %d out of [1,%d] range", n, N)
112 }
113 }
114 }
115
116 func TestCreate_RejectsEmptyTitle(t *testing.T) {
117 _, deps, uid, rid := setup(t)
118 ctx := context.Background()
119 _, err := issues.Create(ctx, deps, issues.CreateParams{
120 RepoID: rid, AuthorUserID: uid, Title: " ", Body: "body",
121 })
122 if err == nil || err.Error() == "" {
123 t.Fatalf("expected ErrEmptyTitle, got %v", err)
124 }
125 }
126
127 func TestCreate_RendersHTMLAndSanitizesScripts(t *testing.T) {
128 pool, deps, uid, rid := setup(t)
129 ctx := context.Background()
130 row, err := issues.Create(ctx, deps, issues.CreateParams{
131 RepoID: rid,
132 AuthorUserID: uid,
133 Title: "xss",
134 Body: `before <script>alert("xss")</script> after **bold**`,
135 })
136 if err != nil {
137 t.Fatalf("Create: %v", err)
138 }
139 iq := issuesdb.New()
140 got, err := iq.GetIssueByID(ctx, pool, row.ID)
141 if err != nil {
142 t.Fatalf("GetIssueByID: %v", err)
143 }
144 if !got.BodyHtmlCached.Valid {
145 t.Fatalf("expected cached body html")
146 }
147 html := got.BodyHtmlCached.String
148 if strings.Contains(strings.ToLower(html), "<script") {
149 t.Errorf("sanitized html should not contain <script>: %q", html)
150 }
151 if !strings.Contains(html, "<strong>") {
152 t.Errorf("markdown should render bold: %q", html)
153 }
154 }
155
156 func TestAddComment_LockedRejectsNonCollab(t *testing.T) {
157 pool, deps, uid, rid := setup(t)
158 ctx := context.Background()
159 row, err := issues.Create(ctx, deps, issues.CreateParams{
160 RepoID: rid, AuthorUserID: uid, Title: "lock-me", Body: "",
161 })
162 if err != nil {
163 t.Fatalf("Create: %v", err)
164 }
165 if err := issues.SetLock(ctx, deps, uid, row.ID, true, "spam"); err != nil {
166 t.Fatalf("SetLock: %v", err)
167 }
168 uq := usersdb.New()
169 other, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
170 Username: "carol", DisplayName: "Carol", PasswordHash: fixtureHash,
171 })
172 if err != nil {
173 t.Fatalf("CreateUser: %v", err)
174 }
175 _, err = issues.AddComment(ctx, deps, issues.CommentCreateParams{
176 IssueID: row.ID,
177 AuthorUserID: other.ID,
178 Body: "let me in",
179 IsCollab: false,
180 })
181 if err == nil {
182 t.Fatalf("expected ErrIssueLocked, got nil")
183 }
184 }
185
186 // TestAddComment_LockedAllowsCollab is the positive companion to the
187 // previous test: when the orchestrator is told `IsCollab=true` the
188 // locked gate must yield. This guards the policy contract — a triage+
189 // collaborator is allowed to post past a lock so they can wrap up
190 // drive-by spam threads. (S00-S25 audit, finding C3.)
191 func TestAddComment_LockedAllowsCollab(t *testing.T) {
192 pool, deps, uid, rid := setup(t)
193 ctx := context.Background()
194 row, err := issues.Create(ctx, deps, issues.CreateParams{
195 RepoID: rid, AuthorUserID: uid, Title: "lock-me", Body: "",
196 })
197 if err != nil {
198 t.Fatalf("Create: %v", err)
199 }
200 if err := issues.SetLock(ctx, deps, uid, row.ID, true, "spam"); err != nil {
201 t.Fatalf("SetLock: %v", err)
202 }
203 uq := usersdb.New()
204 collab, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
205 Username: "tessie", DisplayName: "Tessie", PasswordHash: fixtureHash,
206 })
207 if err != nil {
208 t.Fatalf("CreateUser: %v", err)
209 }
210 c, err := issues.AddComment(ctx, deps, issues.CommentCreateParams{
211 IssueID: row.ID,
212 AuthorUserID: collab.ID,
213 Body: "wrapping up the thread",
214 IsCollab: true,
215 })
216 if err != nil {
217 t.Fatalf("expected lock bypass for collab, got %v", err)
218 }
219 if c.ID == 0 {
220 t.Errorf("returned comment had zero ID")
221 }
222 }
223
224 func TestSetState_EmitsEvent(t *testing.T) {
225 pool, deps, uid, rid := setup(t)
226 ctx := context.Background()
227 row, err := issues.Create(ctx, deps, issues.CreateParams{
228 RepoID: rid, AuthorUserID: uid, Title: "close-me", Body: "",
229 })
230 if err != nil {
231 t.Fatalf("Create: %v", err)
232 }
233 if err := issues.SetState(ctx, deps, uid, row.ID, "closed", "completed"); err != nil {
234 t.Fatalf("SetState: %v", err)
235 }
236 if err := issues.SetState(ctx, deps, uid, row.ID, "open", ""); err != nil {
237 t.Fatalf("SetState reopen: %v", err)
238 }
239 iq := issuesdb.New()
240 events, err := iq.ListIssueEvents(ctx, pool, row.ID)
241 if err != nil {
242 t.Fatalf("ListIssueEvents: %v", err)
243 }
244 kinds := []string{}
245 for _, e := range events {
246 kinds = append(kinds, e.Kind)
247 }
248 want := []string{"closed", "reopened"}
249 if len(kinds) != 2 || kinds[0] != want[0] || kinds[1] != want[1] {
250 t.Errorf("got events %v, want %v", kinds, want)
251 }
252 }
253
254 func TestSetStateWithComment_LinksTimelineEvent(t *testing.T) {
255 pool, deps, uid, rid := setup(t)
256 ctx := context.Background()
257 row, err := issues.Create(ctx, deps, issues.CreateParams{
258 RepoID: rid, AuthorUserID: uid, Title: "close-with-comment", Body: "",
259 })
260 if err != nil {
261 t.Fatalf("Create: %v", err)
262 }
263 comment, err := issues.AddComment(ctx, deps, issues.CommentCreateParams{
264 IssueID: row.ID, AuthorUserID: uid, Body: "closing this out", IsCollab: true,
265 })
266 if err != nil {
267 t.Fatalf("AddComment: %v", err)
268 }
269 if err := issues.SetStateWithComment(ctx, deps, uid, row.ID, "closed", "", comment.ID); err != nil {
270 t.Fatalf("SetStateWithComment: %v", err)
271 }
272 iq := issuesdb.New()
273 events, err := iq.ListIssueEvents(ctx, pool, row.ID)
274 if err != nil {
275 t.Fatalf("ListIssueEvents: %v", err)
276 }
277 if len(events) != 1 {
278 t.Fatalf("got %d events, want 1", len(events))
279 }
280 if events[0].Kind != "closed" {
281 t.Fatalf("kind = %q, want closed", events[0].Kind)
282 }
283 var meta map[string]int64
284 if err := json.Unmarshal(events[0].Meta, &meta); err != nil {
285 t.Fatalf("meta json: %v", err)
286 }
287 if meta["comment_id"] != comment.ID {
288 t.Fatalf("comment_id = %d, want %d", meta["comment_id"], comment.ID)
289 }
290 }
291
292 func TestCreate_CrossReferenceCreatesEventOnTarget(t *testing.T) {
293 pool, deps, uid, rid := setup(t)
294 ctx := context.Background()
295 first, err := issues.Create(ctx, deps, issues.CreateParams{
296 RepoID: rid, AuthorUserID: uid, Title: "first", Body: "no refs",
297 })
298 if err != nil {
299 t.Fatalf("Create first: %v", err)
300 }
301 if _, err := issues.Create(ctx, deps, issues.CreateParams{
302 RepoID: rid, AuthorUserID: uid, Title: "second", Body: "fixes #" + itoa(first.Number),
303 }); err != nil {
304 t.Fatalf("Create second: %v", err)
305 }
306 iq := issuesdb.New()
307 events, err := iq.ListIssueEvents(ctx, pool, first.ID)
308 if err != nil {
309 t.Fatalf("ListIssueEvents: %v", err)
310 }
311 found := false
312 for _, e := range events {
313 if e.Kind == "referenced" {
314 found = true
315 }
316 }
317 if !found {
318 t.Errorf("expected `referenced` event on target issue, got %#v", events)
319 }
320 }
321
322 // itoa minimizes deps in tests.
323 func itoa(v int64) string {
324 if v == 0 {
325 return "0"
326 }
327 out := make([]byte, 0, 4)
328 neg := v < 0
329 if neg {
330 v = -v
331 }
332 for v > 0 {
333 out = append([]byte{byte('0' + v%10)}, out...)
334 v /= 10
335 }
336 if neg {
337 out = append([]byte{'-'}, out...)
338 }
339 return string(out)
340 }
341