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