Go · 14086 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package search_test
4
5 import (
6 "context"
7 "errors"
8 "io"
9 "log/slog"
10 "strings"
11 "testing"
12
13 "github.com/jackc/pgx/v5/pgtype"
14
15 "github.com/tenseleyFlow/shithub/internal/auth/policy"
16 policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
17 "github.com/tenseleyFlow/shithub/internal/issues"
18 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
19 "github.com/tenseleyFlow/shithub/internal/orgs"
20 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
21 "github.com/tenseleyFlow/shithub/internal/search"
22 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
23 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
24 )
25
26 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
27 "AAAAAAAAAAAAAAAA$" +
28 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
29
30 // TestParseQuery covers the operator parser end-to-end.
31 func TestParseQuery(t *testing.T) {
32 t.Parallel()
33 cases := []struct {
34 in string
35 want search.ParsedQuery
36 }{
37 {"", search.ParsedQuery{}},
38 {"hello world", search.ParsedQuery{Text: "hello world"}},
39 {`"quoted phrase"`, search.ParsedQuery{Phrase: "quoted phrase"}},
40 {"repo:alice/demo bug", search.ParsedQuery{
41 Text: "bug",
42 RepoFilter: &search.RepoFilter{Owner: "alice", Name: "demo"},
43 }},
44 {"repo:noslash bug", search.ParsedQuery{Text: "repo:noslash bug"}},
45 {"is:open broken", search.ParsedQuery{Text: "broken", StateFilter: "open"}},
46 {"state:closed bug", search.ParsedQuery{Text: "bug", StateFilter: "closed"}},
47 {"author:bob fix", search.ParsedQuery{Text: "fix", AuthorFilter: "bob"}},
48 {"language:Go x", search.ParsedQuery{Text: "language:Go x"}},
49 }
50 for _, c := range cases {
51 got := search.ParseQuery(c.in)
52 if got.Text != c.want.Text || got.Phrase != c.want.Phrase ||
53 got.StateFilter != c.want.StateFilter || got.AuthorFilter != c.want.AuthorFilter {
54 t.Errorf("ParseQuery(%q):\n got %+v\n want %+v", c.in, got, c.want)
55 continue
56 }
57 if (got.RepoFilter == nil) != (c.want.RepoFilter == nil) {
58 t.Errorf("ParseQuery(%q): repo-filter presence mismatch", c.in)
59 continue
60 }
61 if got.RepoFilter != nil && (*got.RepoFilter != *c.want.RepoFilter) {
62 t.Errorf("ParseQuery(%q): repo-filter %+v, want %+v",
63 c.in, *got.RepoFilter, *c.want.RepoFilter)
64 }
65 }
66 }
67
68 // TestParseQuery_TruncatesOverlong ensures the input cap fires.
69 func TestParseQuery_TruncatesOverlong(t *testing.T) {
70 t.Parallel()
71 long := strings.Repeat("x", search.MaxQueryBytes+50)
72 got := search.ParseQuery(long)
73 if len(got.Text) > search.MaxQueryBytes {
74 t.Errorf("Text len = %d, want ≤ %d", len(got.Text), search.MaxQueryBytes)
75 }
76 }
77
78 // fxs is a fixture for visibility tests: alice owns one public + one
79 // private repo, each with one issue. bob is a separate user, no
80 // access to the private side.
81 type fxs struct {
82 deps search.Deps
83 alice usersdb.User
84 bob usersdb.User
85 pubRepo reposdb.Repo
86 prvRepo reposdb.Repo
87 orgRepo reposdb.Repo
88 }
89
90 func setup(t *testing.T) fxs {
91 t.Helper()
92 pool := dbtest.NewTestDB(t)
93 ctx := context.Background()
94
95 uq := usersdb.New()
96 alice, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
97 Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
98 })
99 if err != nil {
100 t.Fatalf("CreateUser alice: %v", err)
101 }
102 bob, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
103 Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
104 })
105 if err != nil {
106 t.Fatalf("CreateUser bob: %v", err)
107 }
108
109 org, err := orgs.Create(ctx,
110 orgs.Deps{Pool: pool, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))},
111 orgs.CreateParams{
112 Slug: "tenseleyflow",
113 DisplayName: "tenseleyFlow",
114 Description: "workflow things",
115 BillingEmail: "org@example.test",
116 CreatedByUserID: alice.ID,
117 },
118 )
119 if err != nil {
120 t.Fatalf("CreateOrg tenseleyflow: %v", err)
121 }
122
123 rq := reposdb.New()
124 pubRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
125 OwnerUserID: pgtype.Int8{Int64: alice.ID, Valid: true},
126 Name: "publicrepo",
127 Description: "a public repo sample",
128 DefaultBranch: "trunk",
129 Visibility: reposdb.RepoVisibilityPublic,
130 })
131 if err != nil {
132 t.Fatalf("CreateRepo public: %v", err)
133 }
134 prvRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
135 OwnerUserID: pgtype.Int8{Int64: alice.ID, Valid: true},
136 Name: "privaterepo",
137 Description: "private repo secrets here",
138 DefaultBranch: "trunk",
139 Visibility: reposdb.RepoVisibilityPrivate,
140 })
141 if err != nil {
142 t.Fatalf("CreateRepo private: %v", err)
143 }
144 orgRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
145 OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
146 Name: "shithub",
147 Description: "A 1:1 reverse-engineering of GitHub. AGPLv3. Without Copilot.",
148 DefaultBranch: "trunk",
149 Visibility: reposdb.RepoVisibilityPublic,
150 })
151 if err != nil {
152 t.Fatalf("CreateRepo org public: %v", err)
153 }
154
155 iq := issuesdb.New()
156 for _, r := range []reposdb.Repo{pubRepo, prvRepo, orgRepo} {
157 if err := iq.EnsureRepoIssueCounter(ctx, pool, r.ID); err != nil {
158 t.Fatalf("EnsureRepoIssueCounter: %v", err)
159 }
160 }
161 idep := issues.Deps{Pool: pool, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
162 if _, err := issues.Create(ctx, idep, issues.CreateParams{
163 RepoID: pubRepo.ID, AuthorUserID: alice.ID,
164 Title: "public bug report", Body: "nothing sensitive",
165 }); err != nil {
166 t.Fatalf("Create issue pub: %v", err)
167 }
168 if _, err := issues.Create(ctx, idep, issues.CreateParams{
169 RepoID: prvRepo.ID, AuthorUserID: alice.ID,
170 Title: "private secret design", Body: "internal only",
171 }); err != nil {
172 t.Fatalf("Create issue prv: %v", err)
173 }
174 if _, err := issues.Create(ctx, idep, issues.CreateParams{
175 RepoID: orgRepo.ID, AuthorUserID: alice.ID,
176 Title: "org public bug report", Body: "shithub project issue",
177 }); err != nil {
178 t.Fatalf("Create issue org: %v", err)
179 }
180 if _, err := pool.Exec(ctx, `
181 INSERT INTO code_search_paths (repo_id, ref_name, path, tsv)
182 VALUES ($1, 'trunk', 'README.md', to_tsvector('shithub_search', 'README shithub'))
183 `, orgRepo.ID); err != nil {
184 t.Fatalf("seed org code path: %v", err)
185 }
186
187 return fxs{
188 deps: search.Deps{
189 Pool: pool,
190 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
191 },
192 alice: alice, bob: bob, pubRepo: pubRepo, prvRepo: prvRepo, orgRepo: orgRepo,
193 }
194 }
195
196 // TestSearchRepos_AnonymousSeesOnlyPublic guards the visibility
197 // boundary — the highest-stakes assertion in the search surface.
198 func TestSearchRepos_AnonymousSeesOnlyPublic(t *testing.T) {
199 f := setup(t)
200 got, _, err := search.SearchRepos(context.Background(), f.deps,
201 policy.AnonymousActor(),
202 search.ParseQuery("repo"),
203 20, 0)
204 if err != nil {
205 t.Fatalf("SearchRepos: %v", err)
206 }
207 for _, r := range got {
208 if r.Visibility == "private" {
209 t.Errorf("anonymous saw private repo %q — visibility leak!", r.Name)
210 }
211 }
212 // Sanity: public repo is in the results.
213 found := false
214 for _, r := range got {
215 if r.Name == "publicrepo" {
216 found = true
217 }
218 }
219 if !found {
220 t.Errorf("expected publicrepo in anon results, got %d rows", len(got))
221 }
222 }
223
224 // TestSearchRepos_NonCollabOnPrivate matches the spec's private-
225 // content-stays-private contract.
226 func TestSearchRepos_NonCollabOnPrivate(t *testing.T) {
227 f := setup(t)
228 bobActor := policy.UserActor(f.bob.ID, f.bob.Username, false, false)
229 got, _, err := search.SearchRepos(context.Background(), f.deps, bobActor,
230 search.ParseQuery("secrets"), 20, 0)
231 if err != nil {
232 t.Fatalf("SearchRepos: %v", err)
233 }
234 if len(got) != 0 {
235 t.Errorf("non-collab bob saw %d results for 'secrets', want 0", len(got))
236 }
237 }
238
239 // TestSearchRepos_OwnerSeesPrivate confirms the predicate's owner
240 // branch.
241 func TestSearchRepos_OwnerSeesPrivate(t *testing.T) {
242 f := setup(t)
243 alice := policy.UserActor(f.alice.ID, f.alice.Username, false, false)
244 got, _, err := search.SearchRepos(context.Background(), f.deps, alice,
245 search.ParseQuery("secrets"), 20, 0)
246 if err != nil {
247 t.Fatalf("SearchRepos: %v", err)
248 }
249 if len(got) == 0 {
250 t.Fatalf("owner alice should see her private repo for 'secrets'")
251 }
252 }
253
254 // TestSearchRepos_CollabSeesPrivate exercises the collaborator
255 // branch of the visibility predicate.
256 func TestSearchRepos_CollabSeesPrivate(t *testing.T) {
257 f := setup(t)
258 ctx := context.Background()
259 pq := policydb.New()
260 if err := pq.UpsertCollabRole(ctx, f.deps.Pool, policydb.UpsertCollabRoleParams{
261 RepoID: f.prvRepo.ID, UserID: f.bob.ID, Role: policydb.CollabRoleRead,
262 }); err != nil {
263 t.Fatalf("UpsertCollabRole: %v", err)
264 }
265 bobActor := policy.UserActor(f.bob.ID, f.bob.Username, false, false)
266 got, _, err := search.SearchRepos(ctx, f.deps, bobActor,
267 search.ParseQuery("secrets"), 20, 0)
268 if err != nil {
269 t.Fatalf("SearchRepos: %v", err)
270 }
271 if len(got) == 0 {
272 t.Errorf("collab bob should see private repo via 'secrets'")
273 }
274 }
275
276 func TestSearchRepos_AnonymousFindsPublicOrgRepoByName(t *testing.T) {
277 f := setup(t)
278 got, total, err := search.SearchRepos(context.Background(), f.deps,
279 policy.AnonymousActor(),
280 search.ParseQuery("shithub"),
281 20, 0)
282 if err != nil {
283 t.Fatalf("SearchRepos: %v", err)
284 }
285 if total == 0 {
286 t.Fatalf("SearchRepos total = 0, want org-owned shithub")
287 }
288 found := false
289 for _, r := range got {
290 if r.ID == f.orgRepo.ID && r.OwnerUsername == "tenseleyflow" && r.Name == "shithub" {
291 found = true
292 }
293 }
294 if !found {
295 t.Fatalf("org-owned tenseleyflow/shithub missing from %d repo results", len(got))
296 }
297 }
298
299 func TestSearchRepos_AnonymousFindsPublicOrgRepoByOwner(t *testing.T) {
300 f := setup(t)
301 got, _, err := search.SearchRepos(context.Background(), f.deps,
302 policy.AnonymousActor(),
303 search.ParseQuery("tenseleyFlow"),
304 20, 0)
305 if err != nil {
306 t.Fatalf("SearchRepos: %v", err)
307 }
308 for _, r := range got {
309 if r.ID == f.orgRepo.ID && r.OwnerUsername == "tenseleyflow" && r.Name == "shithub" {
310 return
311 }
312 }
313 t.Fatalf("owner query did not return org-owned tenseleyflow/shithub; got %d rows", len(got))
314 }
315
316 // TestSearchIssues_AnonymousSeesOnlyPublic mirrors the repo test
317 // for the issue surface — issues inherit visibility from their repo.
318 func TestSearchIssues_AnonymousSeesOnlyPublic(t *testing.T) {
319 f := setup(t)
320 got, _, err := search.SearchIssues(context.Background(), f.deps,
321 policy.AnonymousActor(),
322 search.ParseQuery("secret"),
323 "issue", 20, 0)
324 if err != nil {
325 t.Fatalf("SearchIssues: %v", err)
326 }
327 if len(got) != 0 {
328 t.Errorf("anonymous saw %d issues for 'secret', want 0 (private leak)", len(got))
329 }
330 }
331
332 func TestSearchIssues_StateFilter(t *testing.T) {
333 f := setup(t)
334 ctx := context.Background()
335 alice := policy.UserActor(f.alice.ID, f.alice.Username, false, false)
336
337 // Open a second issue and close it.
338 idep := issues.Deps{Pool: f.deps.Pool, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
339 closed, _ := issues.Create(ctx, idep, issues.CreateParams{
340 RepoID: f.pubRepo.ID, AuthorUserID: f.alice.ID,
341 Title: "closed bug", Body: "fixed",
342 })
343 if err := issues.SetState(ctx, idep, f.alice.ID, closed.ID, "closed", "completed"); err != nil {
344 t.Fatalf("SetState: %v", err)
345 }
346
347 openHits, _, _ := search.SearchIssues(ctx, f.deps, alice,
348 search.ParseQuery("is:open bug"), "", 20, 0)
349 for _, h := range openHits {
350 if h.State != "open" {
351 t.Errorf("is:open: got state=%s", h.State)
352 }
353 }
354 closedHits, _, _ := search.SearchIssues(ctx, f.deps, alice,
355 search.ParseQuery("is:closed bug"), "", 20, 0)
356 for _, h := range closedHits {
357 if h.State != "closed" {
358 t.Errorf("is:closed: got state=%s", h.State)
359 }
360 }
361 }
362
363 func TestSearchIssues_RepoFilter(t *testing.T) {
364 f := setup(t)
365 alice := policy.UserActor(f.alice.ID, f.alice.Username, false, false)
366 got, _, err := search.SearchIssues(context.Background(), f.deps, alice,
367 search.ParseQuery("repo:alice/publicrepo bug"), "", 20, 0)
368 if err != nil {
369 t.Fatalf("SearchIssues: %v", err)
370 }
371 for _, h := range got {
372 if h.OwnerUsername != "alice" || h.RepoName != "publicrepo" {
373 t.Errorf("repo: filter let through %s/%s", h.OwnerUsername, h.RepoName)
374 }
375 }
376 }
377
378 func TestSearchIssues_RepoFilterMatchesOrgOwner(t *testing.T) {
379 f := setup(t)
380 got, _, err := search.SearchIssues(context.Background(), f.deps,
381 policy.AnonymousActor(),
382 search.ParseQuery("repo:tenseleyFlow/shithub bug"), "", 20, 0)
383 if err != nil {
384 t.Fatalf("SearchIssues: %v", err)
385 }
386 if len(got) == 0 {
387 t.Fatalf("expected org repo issue results")
388 }
389 for _, h := range got {
390 if h.OwnerUsername != "tenseleyflow" || h.RepoName != "shithub" {
391 t.Errorf("repo: filter let through %s/%s", h.OwnerUsername, h.RepoName)
392 }
393 }
394 }
395
396 func TestSearchCode_RepoFilterMatchesOrgOwner(t *testing.T) {
397 f := setup(t)
398 got, total, err := search.SearchCode(context.Background(), f.deps,
399 policy.AnonymousActor(),
400 search.ParseQuery("repo:tenseleyFlow/shithub README"), 20, 0)
401 if err != nil {
402 t.Fatalf("SearchCode: %v", err)
403 }
404 if total == 0 {
405 t.Fatalf("SearchCode total = 0, want org-owned path hit")
406 }
407 for _, h := range got {
408 if h.RepoID == f.orgRepo.ID && h.OwnerUsername == "tenseleyflow" && h.RepoName == "shithub" {
409 return
410 }
411 }
412 t.Fatalf("org-owned code hit missing from %d results", len(got))
413 }
414
415 func TestSearchUsers_ExcludesSuspended(t *testing.T) {
416 f := setup(t)
417 ctx := context.Background()
418 if _, err := f.deps.Pool.Exec(ctx,
419 "UPDATE users SET suspended_at = now() WHERE id = $1", f.bob.ID); err != nil {
420 t.Fatalf("suspend: %v", err)
421 }
422 got, _, err := search.SearchUsers(ctx, f.deps, search.ParseQuery("bob"), 20, 0)
423 if err != nil {
424 t.Fatalf("SearchUsers: %v", err)
425 }
426 for _, u := range got {
427 if u.Username == "bob" {
428 t.Errorf("suspended bob in user search results")
429 }
430 }
431 }
432
433 // TestSearchRepos_EmptyQuery surfaces the typed error so handlers
434 // can render a friendly empty state rather than a SQL error.
435 func TestSearchRepos_EmptyQuery(t *testing.T) {
436 f := setup(t)
437 _, _, err := search.SearchRepos(context.Background(), f.deps,
438 policy.AnonymousActor(), search.ParsedQuery{}, 20, 0)
439 if !errors.Is(err, search.ErrEmptyQuery) {
440 t.Errorf("expected ErrEmptyQuery, got %v", err)
441 }
442 }
443