rin
Публичный Open-source lightweight git hosting.
social_test.go · 322 строки · 11.5 KB
Raw
1 package handlers
2
3 import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "polar/internal/auth"
16 "polar/internal/repo"
17 )
18
19 func postForm(t *testing.T, app interface {
20 Test(*http.Request, ...int) (*http.Response, error)
21 }, path, body string) *http.Response {
22 t.Helper()
23 req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
24 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
25 resp, err := app.Test(req, 5000)
26 if err != nil {
27 t.Fatalf("POST %s: %v", path, err)
28 }
29 return resp
30 }
31
32 func getPage(t *testing.T, app interface {
33 Test(*http.Request, ...int) (*http.Response, error)
34 }, path string) (int, string) {
35 t.Helper()
36 resp, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil), 5000)
37 if err != nil {
38 t.Fatalf("GET %s: %v", path, err)
39 }
40 defer resp.Body.Close()
41 b, _ := io.ReadAll(resp.Body)
42 return resp.StatusCode, string(b)
43 }
44
45 // TestDeviceFlowAPI walks through the full RFC 8628 exchange.
46 func TestDeviceFlowAPI(t *testing.T) {
47 app, authSvc, userSvc, _ := setupTestApp(t)
48 ctx := context.Background()
49
50 u, err := userSvc.Register(ctx, "dev", "dev@example.com", "password123", true)
51 if err != nil {
52 t.Fatalf("register: %v", err)
53 }
54
55 resp := postForm(t, app, "/api/v1/device/code", "client_id=git-credential-polar")
56 if resp.StatusCode != http.StatusOK {
57 t.Fatalf("device/code status %d", resp.StatusCode)
58 }
59 var code struct {
60 DeviceCode string `json:"device_code"`
61 UserCode string `json:"user_code"`
62 VerificationURI string `json:"verification_uri"`
63 Interval int `json:"interval"`
64 ExpiresIn int `json:"expires_in"`
65 }
66 if err := json.NewDecoder(resp.Body).Decode(&code); err != nil {
67 t.Fatalf("decode device/code: %v", err)
68 }
69 if code.DeviceCode == "" || code.UserCode == "" || code.Interval <= 0 || code.ExpiresIn <= 0 {
70 t.Fatalf("incomplete device code response: %+v", code)
71 }
72 if !strings.Contains(code.VerificationURI, "/login/device") {
73 t.Fatalf("unexpected verification uri %q", code.VerificationURI)
74 }
75
76 // Pending exchange.
77 resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode)
78 if resp.StatusCode != http.StatusTooManyRequests {
79 t.Fatalf("expected 429 while pending, got %d", resp.StatusCode)
80 }
81 pendingBody, _ := io.ReadAll(resp.Body)
82 if !strings.Contains(string(pendingBody), "authorization_pending") {
83 t.Fatalf("expected authorization_pending, got %s", string(pendingBody))
84 }
85
86 // The verification page shows the pending grant.
87 status, page := getPage(t, app, "/login/device?user_code="+code.UserCode)
88 if status != http.StatusOK {
89 t.Fatalf("device page status %d", status)
90 }
91 if !strings.Contains(page, code.UserCode) {
92 t.Fatalf("device page missing user code")
93 }
94
95 // Approve and exchange.
96 if err := authSvc.SetDeviceCodeStatus(ctx, code.UserCode, u.ID, auth.DeviceStatusApproved); err != nil {
97 t.Fatalf("approve: %v", err)
98 }
99 resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode)
100 if resp.StatusCode != http.StatusOK {
101 body, _ := io.ReadAll(resp.Body)
102 t.Fatalf("token exchange status %d: %s", resp.StatusCode, string(body))
103 }
104 var token struct {
105 AccessToken string `json:"access_token"`
106 TokenType string `json:"token_type"`
107 Username string `json:"username"`
108 }
109 if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
110 t.Fatalf("decode token: %v", err)
111 }
112 if !strings.HasPrefix(token.AccessToken, "polar_") || token.Username != "dev" {
113 t.Fatalf("unexpected token payload: %+v", token)
114 }
115
116 userID, err := authSvc.VerifyToken(ctx, token.AccessToken)
117 if err != nil || userID != u.ID {
118 t.Fatalf("issued token does not verify: id=%d err=%v", userID, err)
119 }
120
121 // The device code is single use.
122 resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode)
123 if resp.StatusCode == http.StatusOK {
124 t.Fatalf("device code must not be exchangeable twice")
125 }
126 }
127
128 func gitRun(t *testing.T, dir string, args ...string) {
129 t.Helper()
130 cmd := exec.CommandContext(context.Background(), "git", args...)
131 cmd.Dir = dir
132 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
133 if out, err := cmd.CombinedOutput(); err != nil {
134 t.Fatalf("git %v: %v (%s)", args, err, string(out))
135 }
136 }
137
138 // pushCommit seeds the bare repository with a single commit on the given branch.
139 func pushCommit(t *testing.T, barePath, branch, filename, content string) {
140 t.Helper()
141 work := t.TempDir()
142 gitRun(t, work, "init", "-b", "main")
143 gitRun(t, work, "config", "user.name", "Tester")
144 gitRun(t, work, "config", "user.email", "tester@example.com")
145 if err := os.WriteFile(filepath.Join(work, filename), []byte(content), 0o644); err != nil {
146 t.Fatalf("write %s: %v", filename, err)
147 }
148 gitRun(t, work, "add", ".")
149 gitRun(t, work, "commit", "-m", "add "+filename)
150 pushRef := "HEAD:refs/heads/" + branch
151 gitRun(t, work, "push", barePath, pushRef)
152 }
153
154 // pushBranchFromClone adds a branch with a new commit on top of the cloned
155 // history of the given bare repository.
156 func pushBranchFromClone(t *testing.T, barePath, branch, filename, content string) {
157 t.Helper()
158 work := filepath.Join(t.TempDir(), "clone")
159 gitRun(t, t.TempDir(), "clone", barePath, work)
160 gitRun(t, work, "config", "user.name", "Tester")
161 gitRun(t, work, "config", "user.email", "tester@example.com")
162 gitRun(t, work, "checkout", "-b", branch)
163 if err := os.WriteFile(filepath.Join(work, filename), []byte(content), 0o644); err != nil {
164 t.Fatalf("write %s: %v", filename, err)
165 }
166 gitRun(t, work, "add", ".")
167 gitRun(t, work, "commit", "-m", "add "+filename)
168 gitRun(t, work, "push", "origin", branch)
169 }
170
171 // TestForksCrossRepoPRAndReviews exercises the social features end to end.
172 func TestForksCrossRepoPRAndReviews(t *testing.T) {
173 app, _, userSvc, repoSvc := setupTestApp(t)
174 ctx := context.Background()
175
176 alice, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true)
177 if err != nil {
178 t.Fatalf("register alice: %v", err)
179 }
180 bob, err := userSvc.Register(ctx, "bob", "bob@example.com", "strongpassword", true)
181 if err != nil {
182 t.Fatalf("register bob: %v", err)
183 }
184
185 r, err := repoSvc.Create(ctx, alice, "engine", "Analytical Engine", false)
186 if err != nil {
187 t.Fatalf("create repo: %v", err)
188 }
189 basePath, _ := repoSvc.DiskPath("alice", "engine")
190 pushCommit(t, basePath, "main", "file.txt", "base\n")
191
192 // Fork and verify both the record and the on-disk copy.
193 fork, err := repoSvc.Fork(ctx, r, bob, "")
194 if err != nil {
195 t.Fatalf("fork: %v", err)
196 }
197 if fork.ForkedFromRepoID == nil || *fork.ForkedFromRepoID != r.ID {
198 t.Fatalf("fork missing origin link")
199 }
200 if fork.ForkedFromOwner != "alice" || fork.ForkedFromName != "engine" {
201 t.Fatalf("fork origin metadata wrong: %+v", fork)
202 }
203 forkPath, _ := repoSvc.DiskPath("bob", "engine")
204 if _, err := os.Stat(filepath.Join(forkPath, "HEAD")); err != nil {
205 t.Fatalf("fork bare repository missing on disk: %v", err)
206 }
207
208 found, err := repoSvc.FindForkByOwner(ctx, r.ID, bob.ID)
209 if err != nil || found == nil || found.ID != fork.ID {
210 t.Fatalf("FindForkByOwner failed: %+v err=%v", found, err)
211 }
212 forks, err := repoSvc.ListForks(ctx, r.ID, 10)
213 if err != nil || len(forks) != 1 {
214 t.Fatalf("ListForks expected 1, got %d err=%v", len(forks), err)
215 }
216
217 // Repository pages expose the fork relationship.
218 status, page := getPage(t, app, "/bob/engine")
219 if status != http.StatusOK || !strings.Contains(page, "/alice/engine") {
220 t.Fatalf("fork page should link to the origin repository (status %d)", status)
221 }
222 status, page = getPage(t, app, "/alice/engine/forks")
223 if status != http.StatusOK || !strings.Contains(page, "bob/engine") {
224 t.Fatalf("forks page should list bob/engine (status %d)", status)
225 }
226
227 // Bob pushes a feature branch into his fork and opens a cross-fork PR.
228 pushBranchFromClone(t, forkPath, "feature", "feature.txt", "feature")
229
230 pr, err := repoSvc.CreatePullRequest(ctx, r.ID, bob.ID, "Add feature", "Implements feature", "main", "feature", &fork.ID)
231 if err != nil {
232 t.Fatalf("create cross-fork PR: %v", err)
233 }
234 if pr.HeadRepoID == nil || *pr.HeadRepoID != fork.ID || pr.HeadRepoOwner != "bob" {
235 t.Fatalf("cross-fork PR metadata wrong: %+v", pr)
236 }
237
238 status, page = getPage(t, app, "/alice/engine/pulls/1")
239 if status != http.StatusOK {
240 t.Fatalf("pull detail status %d", status)
241 }
242 if !strings.Contains(page, "bob/engine:feature") {
243 t.Fatalf("pull detail missing cross-fork head label")
244 }
245 if !strings.Contains(page, "Add feature") {
246 t.Fatalf("pull detail missing title")
247 }
248
249 // Reviews: alice approves, the verdict shows up on the page.
250 if err := repoSvc.UpsertReview(ctx, pr.ID, alice.ID, repo.ReviewApproved, "looks good"); err != nil {
251 t.Fatalf("upsert review: %v", err)
252 }
253 reviews, err := repoSvc.ListReviews(ctx, pr.ID)
254 if err != nil || len(reviews) != 1 || reviews[0].State != repo.ReviewApproved {
255 t.Fatalf("ListReviews unexpected: %+v err=%v", reviews, err)
256 }
257 approved, changesRequested := repoSvc.ReviewCounts(ctx, pr.ID)
258 if approved != 1 || changesRequested != 0 {
259 t.Fatalf("ReviewCounts = %d/%d", approved, changesRequested)
260 }
261 // Changing the verdict updates the same review row.
262 if err := repoSvc.UpsertReview(ctx, pr.ID, alice.ID, repo.ReviewChangesRequested, "needs tests"); err != nil {
263 t.Fatalf("update review: %v", err)
264 }
265 reviews, _ = repoSvc.ListReviews(ctx, pr.ID)
266 if len(reviews) != 1 || reviews[0].State != repo.ReviewChangesRequested || reviews[0].Body != "needs tests" {
267 t.Fatalf("review update failed: %+v", reviews)
268 }
269 status, page = getPage(t, app, "/alice/engine/pulls/1")
270 if status != http.StatusOK || !strings.Contains(page, "needs tests") {
271 t.Fatalf("pull page should render reviews")
272 }
273
274 // Cross-fork merge produces a merge commit on the base repository.
275 if err := repoSvc.MergePullRequest(ctx, r, pr, "Alice", "alice@example.com", alice.ID); err != nil {
276 t.Fatalf("cross-fork merge: %v", err)
277 }
278 merged, err := repoSvc.GetPullRequest(ctx, r.ID, 1)
279 if err != nil || merged.Status != "merged" {
280 t.Fatalf("expected merged PR, got %+v err=%v", merged, err)
281 }
282
283 // Collaborators on a private repository.
284 priv, err := repoSvc.Create(ctx, alice, "vault", "Secret", true)
285 if err != nil {
286 t.Fatalf("create private repo: %v", err)
287 }
288 if repoSvc.CanRead(ctx, priv, bob) {
289 t.Fatalf("bob must not read the private repo before being added")
290 }
291 if err := repoSvc.AddCollaborator(ctx, priv, "bob", repo.RoleRead); err != nil {
292 t.Fatalf("add collaborator: %v", err)
293 }
294 if !repoSvc.CanRead(ctx, priv, bob) {
295 t.Fatalf("bob should read the private repo with the read role")
296 }
297 if repoSvc.CanWrite(ctx, priv, bob) {
298 t.Fatalf("bob must not write with the read role")
299 }
300 if err := repoSvc.AddCollaborator(ctx, priv, "bob", repo.RoleWrite); err != nil {
301 t.Fatalf("upgrade collaborator: %v", err)
302 }
303 if !repoSvc.CanWrite(ctx, priv, bob) {
304 t.Fatalf("bob should write with the write role")
305 }
306 if err := repoSvc.AddCollaborator(ctx, priv, "alice", repo.RoleWrite); err != repo.ErrCannotAddOwner {
307 t.Fatalf("adding the owner should fail, got %v", err)
308 }
309 if err := repoSvc.AddCollaborator(ctx, priv, "ghost", repo.RoleRead); err != repo.ErrCollabUserNotFound {
310 t.Fatalf("unknown user should fail, got %v", err)
311 }
312 collabs, err := repoSvc.ListCollaborators(ctx, priv.ID)
313 if err != nil || len(collabs) != 1 || collabs[0].Username != "bob" || collabs[0].Role != repo.RoleWrite {
314 t.Fatalf("ListCollaborators unexpected: %+v err=%v", collabs, err)
315 }
316 if err := repoSvc.RemoveCollaborator(ctx, priv.ID, bob.ID); err != nil {
317 t.Fatalf("remove collaborator: %v", err)
318 }
319 if repoSvc.CanRead(ctx, priv, bob) {
320 t.Fatalf("bob must lose access after removal")
321 }
322 }
323