package handlers import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "polar/internal/auth" "polar/internal/repo" ) func postForm(t *testing.T, app interface { Test(*http.Request, ...int) (*http.Response, error) }, path, body string) *http.Response { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("POST %s: %v", path, err) } return resp } func getPage(t *testing.T, app interface { Test(*http.Request, ...int) (*http.Response, error) }, path string) (int, string) { t.Helper() resp, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil), 5000) if err != nil { t.Fatalf("GET %s: %v", path, err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp.StatusCode, string(b) } // TestDeviceFlowAPI walks through the full RFC 8628 exchange. func TestDeviceFlowAPI(t *testing.T) { app, authSvc, userSvc, _ := setupTestApp(t) ctx := context.Background() u, err := userSvc.Register(ctx, "dev", "dev@example.com", "password123", true) if err != nil { t.Fatalf("register: %v", err) } resp := postForm(t, app, "/api/v1/device/code", "client_id=git-credential-polar") if resp.StatusCode != http.StatusOK { t.Fatalf("device/code status %d", resp.StatusCode) } var code struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` VerificationURI string `json:"verification_uri"` Interval int `json:"interval"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&code); err != nil { t.Fatalf("decode device/code: %v", err) } if code.DeviceCode == "" || code.UserCode == "" || code.Interval <= 0 || code.ExpiresIn <= 0 { t.Fatalf("incomplete device code response: %+v", code) } if !strings.Contains(code.VerificationURI, "/login/device") { t.Fatalf("unexpected verification uri %q", code.VerificationURI) } // Pending exchange. resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode) if resp.StatusCode != http.StatusTooManyRequests { t.Fatalf("expected 429 while pending, got %d", resp.StatusCode) } pendingBody, _ := io.ReadAll(resp.Body) if !strings.Contains(string(pendingBody), "authorization_pending") { t.Fatalf("expected authorization_pending, got %s", string(pendingBody)) } // The verification page shows the pending grant. status, page := getPage(t, app, "/login/device?user_code="+code.UserCode) if status != http.StatusOK { t.Fatalf("device page status %d", status) } if !strings.Contains(page, code.UserCode) { t.Fatalf("device page missing user code") } // Approve and exchange. if err := authSvc.SetDeviceCodeStatus(ctx, code.UserCode, u.ID, auth.DeviceStatusApproved); err != nil { t.Fatalf("approve: %v", err) } resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("token exchange status %d: %s", resp.StatusCode, string(body)) } var token struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Username string `json:"username"` } if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { t.Fatalf("decode token: %v", err) } if !strings.HasPrefix(token.AccessToken, "polar_") || token.Username != "dev" { t.Fatalf("unexpected token payload: %+v", token) } userID, err := authSvc.VerifyToken(ctx, token.AccessToken) if err != nil || userID != u.ID { t.Fatalf("issued token does not verify: id=%d err=%v", userID, err) } // The device code is single use. resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode) if resp.StatusCode == http.StatusOK { t.Fatalf("device code must not be exchangeable twice") } } func gitRun(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.CommandContext(context.Background(), "git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v (%s)", args, err, string(out)) } } // pushCommit seeds the bare repository with a single commit on the given branch. func pushCommit(t *testing.T, barePath, branch, filename, content string) { t.Helper() work := t.TempDir() gitRun(t, work, "init", "-b", "main") gitRun(t, work, "config", "user.name", "Tester") gitRun(t, work, "config", "user.email", "tester@example.com") if err := os.WriteFile(filepath.Join(work, filename), []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", filename, err) } gitRun(t, work, "add", ".") gitRun(t, work, "commit", "-m", "add "+filename) pushRef := "HEAD:refs/heads/" + branch gitRun(t, work, "push", barePath, pushRef) } // pushBranchFromClone adds a branch with a new commit on top of the cloned // history of the given bare repository. func pushBranchFromClone(t *testing.T, barePath, branch, filename, content string) { t.Helper() work := filepath.Join(t.TempDir(), "clone") gitRun(t, t.TempDir(), "clone", barePath, work) gitRun(t, work, "config", "user.name", "Tester") gitRun(t, work, "config", "user.email", "tester@example.com") gitRun(t, work, "checkout", "-b", branch) if err := os.WriteFile(filepath.Join(work, filename), []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", filename, err) } gitRun(t, work, "add", ".") gitRun(t, work, "commit", "-m", "add "+filename) gitRun(t, work, "push", "origin", branch) } // TestForksCrossRepoPRAndReviews exercises the social features end to end. func TestForksCrossRepoPRAndReviews(t *testing.T) { app, _, userSvc, repoSvc := setupTestApp(t) ctx := context.Background() alice, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true) if err != nil { t.Fatalf("register alice: %v", err) } bob, err := userSvc.Register(ctx, "bob", "bob@example.com", "strongpassword", true) if err != nil { t.Fatalf("register bob: %v", err) } r, err := repoSvc.Create(ctx, alice, "engine", "Analytical Engine", false) if err != nil { t.Fatalf("create repo: %v", err) } basePath, _ := repoSvc.DiskPath("alice", "engine") pushCommit(t, basePath, "main", "file.txt", "base\n") // Fork and verify both the record and the on-disk copy. fork, err := repoSvc.Fork(ctx, r, bob, "") if err != nil { t.Fatalf("fork: %v", err) } if fork.ForkedFromRepoID == nil || *fork.ForkedFromRepoID != r.ID { t.Fatalf("fork missing origin link") } if fork.ForkedFromOwner != "alice" || fork.ForkedFromName != "engine" { t.Fatalf("fork origin metadata wrong: %+v", fork) } forkPath, _ := repoSvc.DiskPath("bob", "engine") if _, err := os.Stat(filepath.Join(forkPath, "HEAD")); err != nil { t.Fatalf("fork bare repository missing on disk: %v", err) } found, err := repoSvc.FindForkByOwner(ctx, r.ID, bob.ID) if err != nil || found == nil || found.ID != fork.ID { t.Fatalf("FindForkByOwner failed: %+v err=%v", found, err) } forks, err := repoSvc.ListForks(ctx, r.ID, 10) if err != nil || len(forks) != 1 { t.Fatalf("ListForks expected 1, got %d err=%v", len(forks), err) } // Repository pages expose the fork relationship. status, page := getPage(t, app, "/bob/engine") if status != http.StatusOK || !strings.Contains(page, "/alice/engine") { t.Fatalf("fork page should link to the origin repository (status %d)", status) } status, page = getPage(t, app, "/alice/engine/forks") if status != http.StatusOK || !strings.Contains(page, "bob/engine") { t.Fatalf("forks page should list bob/engine (status %d)", status) } // Bob pushes a feature branch into his fork and opens a cross-fork PR. pushBranchFromClone(t, forkPath, "feature", "feature.txt", "feature") pr, err := repoSvc.CreatePullRequest(ctx, r.ID, bob.ID, "Add feature", "Implements feature", "main", "feature", &fork.ID) if err != nil { t.Fatalf("create cross-fork PR: %v", err) } if pr.HeadRepoID == nil || *pr.HeadRepoID != fork.ID || pr.HeadRepoOwner != "bob" { t.Fatalf("cross-fork PR metadata wrong: %+v", pr) } status, page = getPage(t, app, "/alice/engine/pulls/1") if status != http.StatusOK { t.Fatalf("pull detail status %d", status) } if !strings.Contains(page, "bob/engine:feature") { t.Fatalf("pull detail missing cross-fork head label") } if !strings.Contains(page, "Add feature") { t.Fatalf("pull detail missing title") } // Reviews: alice approves, the verdict shows up on the page. if err := repoSvc.UpsertReview(ctx, pr.ID, alice.ID, repo.ReviewApproved, "looks good"); err != nil { t.Fatalf("upsert review: %v", err) } reviews, err := repoSvc.ListReviews(ctx, pr.ID) if err != nil || len(reviews) != 1 || reviews[0].State != repo.ReviewApproved { t.Fatalf("ListReviews unexpected: %+v err=%v", reviews, err) } approved, changesRequested := repoSvc.ReviewCounts(ctx, pr.ID) if approved != 1 || changesRequested != 0 { t.Fatalf("ReviewCounts = %d/%d", approved, changesRequested) } // Changing the verdict updates the same review row. if err := repoSvc.UpsertReview(ctx, pr.ID, alice.ID, repo.ReviewChangesRequested, "needs tests"); err != nil { t.Fatalf("update review: %v", err) } reviews, _ = repoSvc.ListReviews(ctx, pr.ID) if len(reviews) != 1 || reviews[0].State != repo.ReviewChangesRequested || reviews[0].Body != "needs tests" { t.Fatalf("review update failed: %+v", reviews) } status, page = getPage(t, app, "/alice/engine/pulls/1") if status != http.StatusOK || !strings.Contains(page, "needs tests") { t.Fatalf("pull page should render reviews") } // Cross-fork merge produces a merge commit on the base repository. if err := repoSvc.MergePullRequest(ctx, r, pr, "Alice", "alice@example.com", alice.ID); err != nil { t.Fatalf("cross-fork merge: %v", err) } merged, err := repoSvc.GetPullRequest(ctx, r.ID, 1) if err != nil || merged.Status != "merged" { t.Fatalf("expected merged PR, got %+v err=%v", merged, err) } // Collaborators on a private repository. priv, err := repoSvc.Create(ctx, alice, "vault", "Secret", true) if err != nil { t.Fatalf("create private repo: %v", err) } if repoSvc.CanRead(ctx, priv, bob) { t.Fatalf("bob must not read the private repo before being added") } if err := repoSvc.AddCollaborator(ctx, priv, "bob", repo.RoleRead); err != nil { t.Fatalf("add collaborator: %v", err) } if !repoSvc.CanRead(ctx, priv, bob) { t.Fatalf("bob should read the private repo with the read role") } if repoSvc.CanWrite(ctx, priv, bob) { t.Fatalf("bob must not write with the read role") } if err := repoSvc.AddCollaborator(ctx, priv, "bob", repo.RoleWrite); err != nil { t.Fatalf("upgrade collaborator: %v", err) } if !repoSvc.CanWrite(ctx, priv, bob) { t.Fatalf("bob should write with the write role") } if err := repoSvc.AddCollaborator(ctx, priv, "alice", repo.RoleWrite); err != repo.ErrCannotAddOwner { t.Fatalf("adding the owner should fail, got %v", err) } if err := repoSvc.AddCollaborator(ctx, priv, "ghost", repo.RoleRead); err != repo.ErrCollabUserNotFound { t.Fatalf("unknown user should fail, got %v", err) } collabs, err := repoSvc.ListCollaborators(ctx, priv.ID) if err != nil || len(collabs) != 1 || collabs[0].Username != "bob" || collabs[0].Role != repo.RoleWrite { t.Fatalf("ListCollaborators unexpected: %+v err=%v", collabs, err) } if err := repoSvc.RemoveCollaborator(ctx, priv.ID, bob.ID); err != nil { t.Fatalf("remove collaborator: %v", err) } if repoSvc.CanRead(ctx, priv, bob) { t.Fatalf("bob must lose access after removal") } }