Commit 04879dbcf6

04879dbcf65b4d7890890c5074fde6e24027251d

parent: 6194895054

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:36 UTC

e2e: saved queries over ssh and the web; query reads in the read-only sweep

Ref #292

Layout: unified · split

e2e/readonly_test.go +4
@@ -80,6 +80,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
80 must("", "org", "create", "theorg") 80 must("", "org", "create", "theorg")
81 must("", "org", "team", "create", "theorg", "core") 81 must("", "org", "team", "create", "theorg", "core")
82 must("", "token", "create", "--name", "t") 82 must("", "token", "create", "--name", "t")
83 must("", "query", "save", "q", "is:open")
83 must("", "web", "login") 84 must("", "web", "login")
84 pub, _ := os.ReadFile(deployKey + ".pub") 85 pub, _ := os.ReadFile(deployKey + ".pub")
85 must(string(pub), "repo", "deploy-key", "add", "alice/app") 86 must(string(pub), "repo", "deploy-key", "add", "alice/app")
@@ -163,6 +164,9 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
163 "notifications device list": nil, 164 "notifications device list": nil,
164 "repo bookmarks": nil, 165 "repo bookmarks": nil,
165 "search": {"app"}, 166 "search": {"app"},
167 "query list": {},
168 "query show": {"q"},
169 "query run": {"q"},
166 "mr revisions": {"alice/app", "1"}, 170 "mr revisions": {"alice/app", "1"},
167 "mr range-diff": {"alice/app", "1"}, 171 "mr range-diff": {"alice/app", "1"},
168 "webhook list": {"alice/app"}, 172 "webhook list": {"alice/app"},
e2e/savedqueries_test.go added +156
@@ -0,0 +1,156 @@
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// Saved queries over stock ssh: a query spans the repositories the
12// caller reads, never another user's private one, pages with the
13// cursor, and a pinned one reaches the dashboard and its web page (#292).
14func TestSavedQueries(t *testing.T) {
15 t.Parallel()
16 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
17 aliceKey := inst.newKey(t, "alice")
18 bobKey := inst.newKey(t, "bob")
19 inst.admin(t, "admin", "user", "create", "alice",
20 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
21 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
22 ssh := func(key string, want int, args ...string) string {
23 t.Helper()
24 out, errOut, code := inst.ssh(t, key, "", args...)
25 if code != want {
26 t.Fatalf("%v: exit %d, want %d\n%s%s", args, code, want, out, errOut)
27 }
28 return out + errOut
29 }
30
31 ssh(aliceKey, 0, "repo", "create", "alice/app")
32 ssh(aliceKey, 0, "repo", "create", "alice/lib")
33 ssh(bobKey, 0, "repo", "create", "bob/secret", "--private")
34 work := t.TempDir()
35 env := inst.gitEnv(aliceKey)
36 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
37 dir := filepath.Join(work, "w")
38 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
39 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
40 mustGit(t, dir, env, "add", ".")
41 mustGit(t, dir, env, "commit", "-q", "-m", "base")
42 mustGit(t, dir, env, "push", "-q", "origin", "main")
43 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
44 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\nb\n"), 0o644)
45 mustGit(t, dir, env, "commit", "-q", "-am", "feat")
46 mustGit(t, dir, env, "push", "-q", "origin", "feat")
47 ssh(aliceKey, 0, "mr", "create", "alice/app", "--source", "feat", "--target", "main", "--title", "feature")
48 ssh(aliceKey, 0, "issue", "create", "alice/app", "--title", "app-bug", "--label", "bug")
49 ssh(aliceKey, 0, "issue", "create", "alice/lib", "--title", "lib-bug", "--label", "bug")
50 ssh(bobKey, 0, "issue", "create", "bob/secret", "--title", "hidden-bug", "--label", "bug")
51
52 if out := ssh(aliceKey, 2, "query", "save", "bad", "is:open", "bogus:x"); !strings.Contains(out, "bogus:x") {
53 t.Errorf("a bad term is not named: %s", out)
54 }
55 ssh(aliceKey, 0, "query", "save", "bugs", "'repo:alice/*'", "label:bug", "is:open")
56 ssh(aliceKey, 1, "query", "save", "bugs", "is:open")
57 ssh(aliceKey, 0, "query", "save", "all", "is:open")
58 ssh(bobKey, 0, "query", "save", "all", "is:open")
59
60 type page struct {
61 Data struct {
62 Items []struct {
63 Kind string `json:"kind"`
64 Repo string `json:"repo"`
65 Title string `json:"title"`
66 } `json:"items"`
67 Next string `json:"next"`
68 } `json:"data"`
69 }
70 run := func(key string, args ...string) page {
71 t.Helper()
72 var p page
73 if err := json.Unmarshal([]byte(ssh(key, 0, append(args, "--json")...)), &p); err != nil {
74 t.Fatal(err)
75 }
76 return p
77 }
78 titles := func(p page) string {
79 var out []string
80 for _, it := range p.Data.Items {
81 out = append(out, it.Repo+":"+it.Title)
82 }
83 return strings.Join(out, " ")
84 }
85
86 if got := titles(run(aliceKey, "issue", "list", "--query", "bugs")); got != "alice/lib:lib-bug alice/app:app-bug" {
87 t.Errorf("issue list --query bugs = %s", got)
88 }
89 if got := titles(run(aliceKey, "mr", "list", "--query", "all")); got != "alice/app:feature" {
90 t.Errorf("mr list --query all = %s", got)
91 }
92 if got := titles(run(bobKey, "query", "run", "all")); !strings.Contains(got, "hidden-bug") {
93 t.Errorf("bob's own private issue is missing from his query: %s", got)
94 }
95
96 // alice pages through everything she reads, one row at a time.
97 var seen []string
98 cursor := ""
99 for i := 0; i < 6; i++ {
100 args := []string{"query", "run", "all", "--limit", "1"}
101 if cursor != "" {
102 args = append(args, "--cursor", cursor)
103 }
104 p := run(aliceKey, args...)
105 seen = append(seen, titles(p))
106 if cursor = p.Data.Next; cursor == "" {
107 break
108 }
109 }
110 if got := strings.Join(seen, " "); got != "alice/lib:lib-bug alice/app:app-bug alice/app:feature" {
111 t.Errorf("paged run = %s", got)
112 }
113
114 ssh(aliceKey, 0, "query", "pin", "all")
115 var dash struct {
116 Data struct {
117 Queries []struct {
118 Name string `json:"name"`
119 Count int `json:"count"`
120 } `json:"queries"`
121 } `json:"data"`
122 }
123 json.Unmarshal([]byte(ssh(aliceKey, 0, "dashboard", "--json")), &dash)
124 if len(dash.Data.Queries) != 1 || dash.Data.Queries[0].Name != "all" || dash.Data.Queries[0].Count != 3 {
125 t.Errorf("dashboard queries = %+v; want all with 3, bob's private issue uncounted", dash.Data.Queries)
126 }
127
128 out := ssh(aliceKey, 0, "web", "login", "--json")
129 var login struct {
130 Data struct {
131 URL string `json:"url"`
132 } `json:"data"`
133 }
134 json.Unmarshal([]byte(out), &login)
135 browser := newBrowser(t)
136 if status, _ := browserGet(t, browser, inst.base()+login.Data.URL[strings.Index(login.Data.URL, "/login"):]); status != 200 {
137 t.Fatalf("login: %d", status)
138 }
139 _, body := browserGet(t, browser, inst.base()+"/")
140 if !strings.Contains(body, `<a href="/alice/-/queries/all">all</a> <span class="count">3</span>`) {
141 t.Errorf("dashboard lacks the pinned query:\n%s", body)
142 }
143 status, body := browserGet(t, browser, inst.base()+"/alice/-/queries/bugs")
144 if status != 200 || !strings.Contains(body, "lib-bug") || strings.Contains(body, "hidden-bug") {
145 t.Errorf("/alice/-/queries/bugs: %d\n%s", status, body)
146 }
147 if status, _ := browserGet(t, browser, inst.base()+"/alice/-/queries/nosuch"); status != 404 {
148 t.Errorf("/alice/-/queries/nosuch: %d, want 404", status)
149 }
150 if status, _ := browserGet(t, browser, inst.base()+"/bob/-/queries/all"); status != 404 {
151 t.Errorf("alice reached bob's query page: %d", status)
152 }
153 if _, body := inst.get(t, "/alice/-/queries/bugs"); strings.Contains(body, "lib-bug") {
154 t.Error("an anonymous visitor reached a saved query page")
155 }
156}