e2e/issue_test.go
174 lines · 7199 bytes
1package e2e
2
3import (
4 "encoding/json"
5 "regexp"
6 "strings"
7 "testing"
8)
9
10// normalizeJSON parses JSON and blanks volatile timestamp fields so the
11// remainder can be compared as a golden value.
12var tsPat = regexp.MustCompile(`"\d{4}-\d{2}-\d{2}T[0-9:.]+Z?"`)
13
14func golden(t *testing.T, raw string) string {
15 t.Helper()
16 norm := tsPat.ReplaceAllString(strings.TrimSpace(raw), `"TS"`)
17 // Re-encode compactly for stable comparison.
18 var v any
19 if err := json.Unmarshal([]byte(norm), &v); err != nil {
20 t.Fatalf("not JSON: %v\n%s", err, raw)
21 }
22 out, _ := json.Marshal(v)
23 return string(out)
24}
25
26func TestIssueLifecycleOverBareSSH(t *testing.T) {
27 t.Parallel()
28 inst := startInstance(t)
29
30 aliceKey := inst.newKey(t, "alice")
31 bobKey := inst.newKey(t, "bob")
32 eveKey := inst.newKey(t, "eve")
33 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
34 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
35 inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub")
36
37 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj"); code != 0 {
38 t.Fatalf("repo create: %s", errOut)
39 }
40
41 // Create with inline body; numbering starts at 1.
42 out, errOut, code := inst.ssh(t, aliceKey, "",
43 "issue", "create", "alice/proj", "--title", "'first bug'", "--body", "'it is broken'", "--json")
44 if code != 0 {
45 t.Fatalf("issue create: %s", errOut)
46 }
47 if g := golden(t, out); g != `{"data":{"number":1},"protocol_version":1}` {
48 t.Fatalf("create output: %s", g)
49 }
50
51 // Bob (no grant, public repo) can file an issue too — body over stdin.
52 _, errOut, code = inst.ssh(t, bobKey, "long body\nfrom stdin\n",
53 "issue", "create", "alice/proj", "--title", "'from bob'", "--file", "-")
54 if code != 0 {
55 t.Fatalf("bob issue create: %s", errOut)
56 }
57
58 // Comment, label, assign, close.
59 if _, errOut, code = inst.ssh(t, bobKey, "", "issue", "comment", "alice/proj", "1", "--message", "'me too'"); code != 0 {
60 t.Fatalf("comment: %s", errOut)
61 }
62 if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "label", "alice/proj", "1", "--add", "bug", "--add", "urgent"); code != 0 {
63 t.Fatalf("label: %s", errOut)
64 }
65 if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "assign", "alice/proj", "1", "--add", "bob"); code != 0 {
66 t.Fatalf("assign: %s", errOut)
67 }
68
69 // Golden check on issue show --json.
70 out, _, code = inst.ssh(t, aliceKey, "", "issue", "show", "alice/proj", "1", "--json")
71 if code != 0 {
72 t.Fatal("issue show failed")
73 }
74 // A body reports the markup it was written in; "md" is what a body with no
75 // --format carries, and what everything written before formats existed has.
76 wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` +
77 `"body_format":"md",` +
78 `"comments":[{"author":"bob","body":"me too","body_format":"md","created_at":"TS","id":1}],` +
79 `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` +
80 `"title":"first bug"},"protocol_version":1}`
81 if g := golden(t, out); g != wantShow {
82 t.Fatalf("issue show golden mismatch:\ngot %s\nwant %s", g, wantShow)
83 }
84
85 // Permission edges: eve (read-only public) cannot label or close
86 // someone else's issue; bob can close his own.
87 _, errOut, code = inst.ssh(t, eveKey, "", "issue", "label", "alice/proj", "1", "--add", "spam")
88 if code != 4 {
89 t.Fatalf("eve label: exit %d (want 4), %s", code, errOut)
90 }
91 _, errOut, code = inst.ssh(t, eveKey, "", "issue", "close", "alice/proj", "1")
92 if code != 4 {
93 t.Fatalf("eve close: exit %d (want 4), %s", code, errOut)
94 }
95 if _, errOut, code = inst.ssh(t, bobKey, "", "issue", "close", "alice/proj", "2"); code != 0 {
96 t.Fatalf("bob closing own issue: %s", errOut)
97 }
98
99 // Close, list filters, reopen.
100 if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "close", "alice/proj", "1"); code != 0 {
101 t.Fatalf("close: %s", errOut)
102 }
103 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "list", "alice/proj", "--json")
104 if g := golden(t, out); g != `{"data":[],"protocol_version":1}` { // empty list is [], never null
105 t.Fatalf("open list after close: %s", g)
106 }
107 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "list", "alice/proj", "--state", "closed", "--json")
108 if !strings.Contains(out, `"number":2`) || !strings.Contains(out, `"number":1`) {
109 t.Fatalf("closed list: %s", out)
110 }
111 if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "reopen", "alice/proj", "1"); code != 0 {
112 t.Fatalf("reopen: %s", errOut)
113 }
114 // Double-reopen is a usage error.
115 if _, _, code = inst.ssh(t, aliceKey, "", "issue", "reopen", "alice/proj", "1"); code != 2 {
116 t.Fatalf("double reopen: exit %d, want 2", code)
117 }
118
119 // Label removal and assignee removal.
120 if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "label", "alice/proj", "1", "--remove", "urgent"); code != 0 {
121 t.Fatalf("label remove: %s", errOut)
122 }
123 if _, errOut, code = inst.ssh(t, aliceKey, "", "issue", "assign", "alice/proj", "1", "--remove", "bob"); code != 0 {
124 t.Fatalf("assign remove: %s", errOut)
125 }
126 out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/proj", "1", "--json")
127 if strings.Contains(out, "urgent") || strings.Contains(out, "assignees") {
128 t.Fatalf("removal not reflected: %s", out)
129 }
130
131 // Missing issue and missing repo produce not-found exit codes.
132 if _, _, code = inst.ssh(t, aliceKey, "", "issue", "show", "alice/proj", "99"); code != 3 {
133 t.Fatalf("missing issue: exit %d, want 3", code)
134 }
135 if _, _, code = inst.ssh(t, aliceKey, "", "issue", "list", "alice/nope"); code != 3 {
136 t.Fatalf("missing repo: exit %d, want 3", code)
137 }
138
139 // Web read views: list shows the issue, detail shows the comment, and
140 // bodies render as markdown (goldmark drops raw HTML).
141 status, body := inst.get(t, "/alice/proj/issues")
142 if status != 200 || !strings.Contains(body, "first bug") {
143 t.Fatalf("issues page: %d", status)
144 }
145 status, body = inst.get(t, "/alice/proj/issues/1")
146 if status != 200 || !strings.Contains(body, "me too") || !strings.Contains(body, "bug") {
147 t.Fatalf("issue detail: %d\n%s", status, body)
148 }
149 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj",
150 "--title", "'md body'", "--body", "'has **bold** and <script>x</script>'"); code != 0 {
151 t.Fatal("md issue create failed")
152 }
153 status, body = inst.get(t, "/alice/proj/issues/3")
154 if status != 200 || !strings.Contains(body, "<strong>bold</strong>") {
155 t.Fatalf("markdown body not rendered: %d\n%s", status, body)
156 }
157 if strings.Contains(body, "<script>x</script>") {
158 t.Fatal("raw HTML survived in issue body")
159 }
160
161 // Private repos hide their issues from non-readers, as not-found.
162 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
163 t.Fatal("create private failed")
164 }
165 if _, _, code = inst.ssh(t, aliceKey, "", "issue", "create", "alice/secret", "--title", "hidden"); code != 0 {
166 t.Fatal("issue in private repo failed")
167 }
168 if _, _, code = inst.ssh(t, eveKey, "", "issue", "show", "alice/secret", "1"); code != 3 {
169 t.Fatalf("eve sees private issue: exit %d, want 3", code)
170 }
171 if status, _ := inst.get(t, "/alice/secret/issues"); status != 404 {
172 t.Fatalf("anonymous issues page on private repo: %d, want 404", status)
173 }
174}