social_test.go
·
322 lines
·
11.5 KB
| 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 |