rin
Публичный Open-source lightweight git hosting.
handlers_test.go · 328 строк · 11.1 KB
Raw
1 package handlers
2
3 import (
4 "context"
5 "fmt"
6 "io"
7 "log/slog"
8 "net"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17
18 "polar/internal/auth"
19 "polar/internal/config"
20 "polar/internal/db"
21 "polar/internal/repo"
22 "polar/internal/user"
23 "polar/internal/web/views"
24
25 "github.com/gofiber/fiber/v2"
26 )
27
28 func setupTestApp(t *testing.T) (*fiber.App, *auth.Service, *user.Service, *repo.Service) {
29 t.Helper()
30 tmp := t.TempDir()
31
32 cfg := config.Default()
33 cfg.Storage.DataDir = tmp
34
35 ctx := context.Background()
36 database, err := db.Open(ctx, cfg.DBPath(), true)
37 if err != nil {
38 t.Fatalf("open test db: %v", err)
39 }
40 t.Cleanup(func() { _ = database.Close() })
41
42 authSvc := auth.NewService(database)
43 userSvc := user.NewService(database)
44 repoSvc := repo.NewService(database, cfg.ReposDir())
45
46 viewsEngine, err := views.NewEngine()
47 if err != nil {
48 t.Fatalf("new views engine: %v", err)
49 }
50
51 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
52 app := fiber.New(fiber.Config{
53 DisableStartupMessage: true,
54 BodyLimit: 50 * 1024 * 1024,
55 })
56
57 h := New(cfg, database, authSvc, userSvc, repoSvc, viewsEngine, logger)
58 h.RegisterRoutes(app)
59
60 return app, authSvc, userSvc, repoSvc
61 }
62
63 func TestHealthzLandingAndStatic(t *testing.T) {
64 app, _, _, _ := setupTestApp(t)
65
66 // 1. /healthz
67 req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
68 resp, err := app.Test(req, 5000)
69 if err != nil {
70 t.Fatalf("healthz request failed: %v", err)
71 }
72 if resp.StatusCode != http.StatusOK {
73 t.Fatalf("expected 200 on /healthz, got %d", resp.StatusCode)
74 }
75 if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
76 t.Fatalf("missing security header X-Content-Type-Options")
77 }
78
79 // 2. Landing page (RU & EN) + verify zero external asset requests
80 reqRU := httptest.NewRequest(http.MethodGet, "/?lang=ru", nil)
81 respRU, err := app.Test(reqRU, 5000)
82 if err != nil || respRU.StatusCode != http.StatusOK {
83 t.Fatalf("landing RU failed: %v", err)
84 }
85 bodyRU, _ := io.ReadAll(respRU.Body)
86 htmlRU := string(bodyRU)
87 if !strings.Contains(htmlRU, "Polar") || !strings.Contains(htmlRU, "hi-git-branch") {
88 t.Fatalf("landing RU missing expected content or Hugeicons sprite")
89 }
90 if strings.Contains(htmlRU, "https://fonts.googleapis.com") || strings.Contains(htmlRU, "https://unpkg.com") || strings.Contains(htmlRU, "https://cdn.") {
91 t.Fatalf("landing page must not make external CDN requests")
92 }
93
94 reqEN := httptest.NewRequest(http.MethodGet, "/?lang=en", nil)
95 respEN, err := app.Test(reqEN, 5000)
96 if err != nil || respEN.StatusCode != http.StatusOK {
97 t.Fatalf("landing EN failed: %v", err)
98 }
99 bodyEN, _ := io.ReadAll(respEN.Body)
100 if !strings.Contains(string(bodyEN), "Lightweight self-hosted Git hosting") {
101 t.Fatalf("landing EN missing translated hero title")
102 }
103
104 // 3. Static embedded CSS & JS
105 for _, asset := range []string{"/static/css/app.css", "/static/js/app.js", "/static/js/htmx.min.js"} {
106 r := httptest.NewRequest(http.MethodGet, asset, nil)
107 res, err := app.Test(r, 5000)
108 if err != nil || res.StatusCode != http.StatusOK {
109 t.Fatalf("embedded static asset %s failed: status=%d err=%v", asset, res.StatusCode, err)
110 }
111 }
112 }
113
114 func TestEndToEndGitSmartHTTPAndPrivateRepos(t *testing.T) {
115 app, authSvc, userSvc, repoSvc := setupTestApp(t)
116 ctx := context.Background()
117
118 // Start real TCP listener for system git CLI
119 ln, err := net.Listen("tcp", "127.0.0.1:0")
120 if err != nil {
121 t.Fatalf("listen tcp: %v", err)
122 }
123 defer ln.Close()
124
125 go func() {
126 _ = app.Listener(ln)
127 }()
128
129 baseURL := "http://" + ln.Addr().String()
130
131 // Create owner user + personal access token
132 owner, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true)
133 if err != nil {
134 t.Fatalf("register owner: %v", err)
135 }
136 rawToken, _, err := authSvc.CreateToken(ctx, owner.ID, "E2E Token")
137 if err != nil {
138 t.Fatalf("create token: %v", err)
139 }
140
141 // Create a public repo and a private repo
142 pubRepo, err := repoSvc.Create(ctx, owner, "engine", "Analytical Engine", false)
143 if err != nil {
144 t.Fatalf("create public repo: %v", err)
145 }
146 privRepo, err := repoSvc.Create(ctx, owner, "vault", "Secret Notes", true)
147 if err != nil {
148 t.Fatalf("create private repo: %v", err)
149 }
150
151 // 1. Verify private repo is blocked on Web UI and Git HTTP without credentials
152 webPrivResp, err := http.Get(baseURL + "/alice/vault")
153 if err != nil {
154 t.Fatalf("GET /alice/vault: %v", err)
155 }
156 _ = webPrivResp.Body.Close()
157 if webPrivResp.StatusCode != http.StatusNotFound {
158 t.Fatalf("expected 404 for unauthenticated private repo web request, got %d", webPrivResp.StatusCode)
159 }
160
161 gitPrivResp, err := http.Get(baseURL + "/alice/vault.git/info/refs?service=git-upload-pack")
162 if err != nil {
163 t.Fatalf("GET private info/refs: %v", err)
164 }
165 _ = gitPrivResp.Body.Close()
166 if gitPrivResp.StatusCode != http.StatusUnauthorized {
167 t.Fatalf("expected 401 for unauthenticated private git info/refs, got %d", gitPrivResp.StatusCode)
168 }
169
170 // 2. Push commits to public repo over HTTP using Personal Access Token
171 workDir := filepath.Join(t.TempDir(), "work")
172 if err := os.MkdirAll(workDir, 0o755); err != nil {
173 t.Fatalf("mkdir work: %v", err)
174 }
175
176 runGitCmd := func(dir string, args ...string) {
177 t.Helper()
178 cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
179 defer cancel()
180 cmd := exec.CommandContext(cctx, "git", args...)
181 cmd.Dir = dir
182 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
183 out, err := cmd.CombinedOutput()
184 if err != nil {
185 t.Fatalf("git %v failed: %v\nOutput: %s", args, err, string(out))
186 }
187 }
188
189 runGitCmd(workDir, "init", "-b", "main")
190 runGitCmd(workDir, "config", "user.name", "Alice Lovelace")
191 runGitCmd(workDir, "config", "user.email", "alice@example.com")
192
193 if err := os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Analytical Engine\n\nHello from **Alice**!\n"), 0o644); err != nil {
194 t.Fatalf("write README.md: %v", err)
195 }
196 if err := os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Alice\")\n}\n"), 0o644); err != nil {
197 t.Fatalf("write main.go: %v", err)
198 }
199
200 runGitCmd(workDir, "add", "README.md", "main.go")
201 runGitCmd(workDir, "commit", "-m", "Initial commit with README and main.go")
202
203 authedPubURL := fmt.Sprintf("http://%s:%s@%s/alice/%s.git", owner.Username, rawToken, ln.Addr().String(), pubRepo.Name)
204 runGitCmd(workDir, "push", "-u", authedPubURL, "main")
205
206 // Also push to private repo with token
207 authedPrivURL := fmt.Sprintf("http://%s:%s@%s/alice/%s.git", owner.Username, rawToken, ln.Addr().String(), privRepo.Name)
208 runGitCmd(workDir, "push", authedPrivURL, "main")
209
210 // 3. Clone public repo anonymously and private repo with token
211 clonePubDir := filepath.Join(t.TempDir(), "clone-pub")
212 runGitCmd(t.TempDir(), "clone", baseURL+"/alice/engine.git", clonePubDir)
213 if _, err := os.Stat(filepath.Join(clonePubDir, "main.go")); err != nil {
214 t.Fatalf("cloned public repo missing main.go: %v", err)
215 }
216
217 clonePrivDir := filepath.Join(t.TempDir(), "clone-priv")
218 runGitCmd(t.TempDir(), "clone", authedPrivURL, clonePrivDir)
219 if _, err := os.Stat(filepath.Join(clonePrivDir, "README.md")); err != nil {
220 t.Fatalf("cloned private repo missing README.md: %v", err)
221 }
222
223 // 4. Verify Web UI renders repository tree, README Markdown, syntax-highlighted blob, commits, and diff
224 checkEndpoint := func(urlPath, expectedSubstring string) {
225 t.Helper()
226 resp, err := http.Get(baseURL + urlPath)
227 if err != nil {
228 t.Fatalf("GET %s failed: %v", urlPath, err)
229 }
230 defer resp.Body.Close()
231 if resp.StatusCode != http.StatusOK {
232 t.Fatalf("GET %s returned status %d", urlPath, resp.StatusCode)
233 }
234 b, _ := io.ReadAll(resp.Body)
235 if !strings.Contains(string(b), expectedSubstring) {
236 t.Fatalf("GET %s missing expected %q", urlPath, expectedSubstring)
237 }
238 }
239
240 checkEndpoint("/alice/engine", "Analytical Engine")
241 checkEndpoint("/alice/engine/blob/main/main.go", "Println")
242 checkEndpoint("/alice/engine/raw/main/main.go", "package main")
243 checkEndpoint("/alice/engine/commits/main", "Initial commit with README and main.go")
244 checkEndpoint("/alice/engine/branches", "main")
245 checkEndpoint("/alice", "alice")
246
247 // 5. Verify ZIP archive download
248 zipResp, err := http.Get(baseURL + "/alice/engine/archive/main.zip")
249 if err != nil {
250 t.Fatalf("GET archive zip failed: %v", err)
251 }
252 defer zipResp.Body.Close()
253 if zipResp.StatusCode != http.StatusOK {
254 t.Fatalf("GET archive zip status %d", zipResp.StatusCode)
255 }
256 zipBytes, _ := io.ReadAll(zipResp.Body)
257 if len(zipBytes) < 4 || zipBytes[0] != 'P' || zipBytes[1] != 'K' {
258 t.Fatalf("expected valid ZIP magic bytes, got len=%d", len(zipBytes))
259 }
260 }
261
262 func TestFeaturesTogglesAndProfile(t *testing.T) {
263 app, _, userSvc, repoSvc := setupTestApp(t)
264 ctx := context.Background()
265
266 // 1. Create a user
267 u, err := userSvc.Register(ctx, "rin", "rin@example.com", "pass12345", true)
268 if err != nil {
269 t.Fatalf("register: %v", err)
270 }
271
272 // 2. Update user profile with avatar and details
273 avatarData := "data:image/webp;base64,UklGRkAAAABXRUJQVlA4IDQAAADwAQCdASoBAAEAAQAcJaACdLoAAP7/2wAA"
274 if err := userSvc.UpdateProfileDetails(ctx, u.ID, "rin@example.com", "Rin Tester", "Developer", "Tokyo", "https://example.com"); err != nil {
275 t.Fatalf("update profile details: %v", err)
276 }
277 if err := userSvc.UpdateAvatar(ctx, u.ID, avatarData); err != nil {
278 t.Fatalf("update avatar: %v", err)
279 }
280
281 // Check profile page
282 reqProfile := httptest.NewRequest(http.MethodGet, "/rin", nil)
283 respProfile, err := app.Test(reqProfile, 5000)
284 if err != nil || respProfile.StatusCode != http.StatusOK {
285 t.Fatalf("profile page failed: status=%d err=%v", respProfile.StatusCode, err)
286 }
287 profBody, _ := io.ReadAll(respProfile.Body)
288 profHTML := string(profBody)
289 if !strings.Contains(profHTML, "Rin Tester") || !strings.Contains(profHTML, "data:image/webp") {
290 t.Fatalf("profile page missing updated name or avatar")
291 }
292
293 // 3. Create a repo and an issue
294 r, err := repoSvc.Create(ctx, u, "test-repo", "A test repo", false)
295 if err != nil {
296 t.Fatalf("create repo: %v", err)
297 }
298 issue, err := repoSvc.CreateIssue(ctx, r.ID, u.ID, "First Bug", "Something went wrong")
299 if err != nil {
300 t.Fatalf("create issue: %v", err)
301 }
302 if issue.Number != 1 {
303 t.Fatalf("expected issue number 1, got %d", issue.Number)
304 }
305 if err := repoSvc.CreateIssueComment(ctx, issue.ID, u.ID, "Fixed in commit 123"); err != nil {
306 t.Fatalf("add issue comment: %v", err)
307 }
308
309 reqIssues := httptest.NewRequest(http.MethodGet, "/rin/test-repo/issues", nil)
310 respIssues, err := app.Test(reqIssues, 5000)
311 if err != nil || respIssues.StatusCode != http.StatusOK {
312 t.Fatalf("issues list failed: status=%d err=%v", respIssues.StatusCode, err)
313 }
314 issBody, _ := io.ReadAll(respIssues.Body)
315 if !strings.Contains(string(issBody), "First Bug") {
316 t.Fatalf("issues list missing issue title")
317 }
318
319 reqIssueDetail := httptest.NewRequest(http.MethodGet, "/rin/test-repo/issues/1", nil)
320 respIssueDetail, err := app.Test(reqIssueDetail, 5000)
321 if err != nil || respIssueDetail.StatusCode != http.StatusOK {
322 t.Fatalf("issue detail failed: status=%d err=%v", respIssueDetail.StatusCode, err)
323 }
324 issDetailBody, _ := io.ReadAll(respIssueDetail.Body)
325 if !strings.Contains(string(issDetailBody), "Fixed in commit 123") {
326 t.Fatalf("issue detail missing comment")
327 }
328 }
329