| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package handlers |
| 4 | |
| 5 | import ( |
| 6 | "io" |
| 7 | "log/slog" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/go-chi/chi/v5" |
| 14 | ) |
| 15 | |
| 16 | func TestHandlers(t *testing.T) { |
| 17 | t.Parallel() |
| 18 | |
| 19 | mux := http.NewServeMux() |
| 20 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 21 | if err := Register(mux, Deps{ |
| 22 | Logger: logger, |
| 23 | TemplatesFS: testTemplatesFS(t), |
| 24 | StaticFS: testStaticFS(t), |
| 25 | LogoSVG: `<svg xmlns="http://www.w3.org/2000/svg"><title>shithub</title></svg>`, |
| 26 | }); err != nil { |
| 27 | t.Fatalf("Register: %v", err) |
| 28 | } |
| 29 | |
| 30 | tests := []struct { |
| 31 | name string |
| 32 | path string |
| 33 | wantStatus int |
| 34 | wantBodyAny []string |
| 35 | wantHeader map[string]string |
| 36 | }{ |
| 37 | { |
| 38 | name: "hello page", |
| 39 | path: "/", |
| 40 | wantStatus: http.StatusOK, |
| 41 | wantBodyAny: []string{"shithub", "GitHub. Open source. Without Copilot.", "Sprint 00", `<meta name="description"`, `<link rel="canonical"`}, |
| 42 | wantHeader: map[string]string{"Content-Type": "text/html; charset=utf-8"}, |
| 43 | }, |
| 44 | { |
| 45 | name: "about page", |
| 46 | path: "/about", |
| 47 | wantStatus: http.StatusOK, |
| 48 | wantBodyAny: []string{"No hard feelings to GitHub", "AI training on my code", `<meta name="description"`}, |
| 49 | wantHeader: map[string]string{"Content-Type": "text/html; charset=utf-8"}, |
| 50 | }, |
| 51 | { |
| 52 | name: "robots", |
| 53 | path: "/robots.txt", |
| 54 | wantStatus: http.StatusOK, |
| 55 | wantBodyAny: []string{"User-agent: *", "Allow: /", "Disallow: /admin", "Sitemap: http://example.com/sitemap.xml"}, |
| 56 | wantHeader: map[string]string{"Content-Type": "text/plain; charset=utf-8"}, |
| 57 | }, |
| 58 | { |
| 59 | name: "sitemap", |
| 60 | path: "/sitemap.xml", |
| 61 | wantStatus: http.StatusOK, |
| 62 | wantBodyAny: []string{`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`, "<loc>http://example.com/</loc>", "<loc>http://example.com/about</loc>"}, |
| 63 | wantHeader: map[string]string{"Content-Type": "application/xml; charset=utf-8"}, |
| 64 | }, |
| 65 | { |
| 66 | name: "healthz", |
| 67 | path: "/healthz", |
| 68 | wantStatus: http.StatusOK, |
| 69 | wantBodyAny: []string{"ok"}, |
| 70 | }, |
| 71 | { |
| 72 | name: "readyz", |
| 73 | path: "/readyz", |
| 74 | wantStatus: http.StatusOK, |
| 75 | wantBodyAny: []string{"ready"}, |
| 76 | }, |
| 77 | { |
| 78 | name: "logo svg", |
| 79 | path: "/static/logo/shithub.svg", |
| 80 | wantStatus: http.StatusOK, |
| 81 | wantBodyAny: []string{"<svg", "shithub"}, |
| 82 | }, |
| 83 | { |
| 84 | name: "unknown route 404", |
| 85 | path: "/this-path-does-not-exist", |
| 86 | wantStatus: http.StatusNotFound, |
| 87 | }, |
| 88 | } |
| 89 | |
| 90 | for _, tc := range tests { |
| 91 | t.Run(tc.name, func(t *testing.T) { |
| 92 | t.Parallel() |
| 93 | |
| 94 | req := httptest.NewRequest(http.MethodGet, tc.path, nil) |
| 95 | rec := httptest.NewRecorder() |
| 96 | mux.ServeHTTP(rec, req) |
| 97 | |
| 98 | if rec.Code != tc.wantStatus { |
| 99 | t.Fatalf("status: got %d, want %d (body=%q)", rec.Code, tc.wantStatus, rec.Body.String()) |
| 100 | } |
| 101 | body := rec.Body.String() |
| 102 | for _, want := range tc.wantBodyAny { |
| 103 | if !strings.Contains(body, want) { |
| 104 | t.Errorf("body missing %q\nbody=%q", want, body) |
| 105 | } |
| 106 | } |
| 107 | for k, want := range tc.wantHeader { |
| 108 | if got := rec.Header().Get(k); got != want { |
| 109 | t.Errorf("header %s: got %q, want %q", k, got, want) |
| 110 | } |
| 111 | } |
| 112 | }) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // TestHealthzHEAD pins SR2 L8: HEAD /healthz must return 200, not |
| 117 | // 405. Strict probes (some k8s livenessProbes, certain monitoring |
| 118 | // tools) issue HEAD-only requests; chi only registers the methods |
| 119 | // you ask for, so the GET-only registration would 405 HEAD probes. |
| 120 | func TestHealthzHEAD(t *testing.T) { |
| 121 | t.Parallel() |
| 122 | |
| 123 | mux := http.NewServeMux() |
| 124 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 125 | if err := Register(mux, Deps{ |
| 126 | Logger: logger, |
| 127 | TemplatesFS: testTemplatesFS(t), |
| 128 | StaticFS: testStaticFS(t), |
| 129 | LogoSVG: `<svg xmlns="http://www.w3.org/2000/svg"><title>shithub</title></svg>`, |
| 130 | }); err != nil { |
| 131 | t.Fatalf("Register: %v", err) |
| 132 | } |
| 133 | req := httptest.NewRequest(http.MethodHead, "/healthz", nil) |
| 134 | rec := httptest.NewRecorder() |
| 135 | mux.ServeHTTP(rec, req) |
| 136 | if rec.Code != http.StatusOK { |
| 137 | t.Fatalf("HEAD /healthz: status %d, want 200", rec.Code) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | func TestActionsLogStreamRouteBypassesCompressAndTimeout(t *testing.T) { |
| 142 | t.Parallel() |
| 143 | |
| 144 | mux := http.NewServeMux() |
| 145 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 146 | if err := Register(mux, Deps{ |
| 147 | Logger: logger, |
| 148 | TemplatesFS: testTemplatesFS(t), |
| 149 | StaticFS: testStaticFS(t), |
| 150 | LogoSVG: `<svg xmlns="http://www.w3.org/2000/svg"><title>shithub</title></svg>`, |
| 151 | RepoActionsStreamMounter: func(r chi.Router) { |
| 152 | r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}/log/stream", func(w http.ResponseWriter, r *http.Request) { |
| 153 | w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") |
| 154 | if _, ok := r.Context().Deadline(); ok { |
| 155 | _, _ = io.WriteString(w, "deadline") |
| 156 | return |
| 157 | } |
| 158 | _, _ = io.WriteString(w, "no-deadline") |
| 159 | }) |
| 160 | }, |
| 161 | }); err != nil { |
| 162 | t.Fatalf("Register: %v", err) |
| 163 | } |
| 164 | |
| 165 | req := httptest.NewRequest(http.MethodGet, "/octo/demo/actions/runs/1/jobs/0/steps/0/log/stream", nil) |
| 166 | req.Header.Set("Accept-Encoding", "gzip") |
| 167 | rec := httptest.NewRecorder() |
| 168 | mux.ServeHTTP(rec, req) |
| 169 | |
| 170 | if rec.Code != http.StatusOK { |
| 171 | t.Fatalf("status: got %d, want %d body=%q", rec.Code, http.StatusOK, rec.Body.String()) |
| 172 | } |
| 173 | if got := rec.Header().Get("Content-Encoding"); got != "" { |
| 174 | t.Fatalf("Content-Encoding: got %q, want empty", got) |
| 175 | } |
| 176 | if got := rec.Body.String(); got != "no-deadline" { |
| 177 | t.Fatalf("body: got %q, want no-deadline", got) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | func TestActionsManagementRoutesStayBeforeProfileCatchAll(t *testing.T) { |
| 182 | t.Parallel() |
| 183 | |
| 184 | mux := http.NewServeMux() |
| 185 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 186 | if err := Register(mux, Deps{ |
| 187 | Logger: logger, |
| 188 | TemplatesFS: testTemplatesFS(t), |
| 189 | StaticFS: testStaticFS(t), |
| 190 | LogoSVG: `<svg xmlns="http://www.w3.org/2000/svg"><title>shithub</title></svg>`, |
| 191 | RepoHomeMounter: func(r chi.Router) { |
| 192 | r.Get("/{owner}/{repo}/actions/caches", writeRouteName("actions:caches")) |
| 193 | r.Get("/{owner}/{repo}/actions/attestations", writeRouteName("actions:attestations")) |
| 194 | r.Get("/{owner}/{repo}/actions/runners", writeRouteName("actions:runners")) |
| 195 | r.Get("/{owner}/{repo}/actions/metrics/usage", writeRouteName("actions:usage")) |
| 196 | r.Get("/{owner}/{repo}/actions/metrics/performance", writeRouteName("actions:performance")) |
| 197 | }, |
| 198 | ProfileMounter: func(r chi.Router) { |
| 199 | r.Get("/{username}", writeRouteName("profile")) |
| 200 | }, |
| 201 | }); err != nil { |
| 202 | t.Fatalf("Register: %v", err) |
| 203 | } |
| 204 | |
| 205 | tests := map[string]string{ |
| 206 | "/octo/demo/actions/caches": "actions:caches", |
| 207 | "/octo/demo/actions/attestations": "actions:attestations", |
| 208 | "/octo/demo/actions/runners": "actions:runners", |
| 209 | "/octo/demo/actions/metrics/usage": "actions:usage", |
| 210 | "/octo/demo/actions/metrics/performance": "actions:performance", |
| 211 | } |
| 212 | for path, want := range tests { |
| 213 | req := httptest.NewRequest(http.MethodGet, path, nil) |
| 214 | rec := httptest.NewRecorder() |
| 215 | mux.ServeHTTP(rec, req) |
| 216 | if rec.Code != http.StatusOK { |
| 217 | t.Fatalf("%s: status got %d want 200 body=%q", path, rec.Code, rec.Body.String()) |
| 218 | } |
| 219 | if got := rec.Body.String(); got != want { |
| 220 | t.Fatalf("%s: body got %q want %q", path, got, want) |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func writeRouteName(name string) http.HandlerFunc { |
| 226 | return func(w http.ResponseWriter, _ *http.Request) { |
| 227 | _, _ = io.WriteString(w, name) |
| 228 | } |
| 229 | } |
| 230 |