package staticsite import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) // ── State mapping (pure) ──────────────────────────────────────────── // // Each provider maps the provider-agnostic CommitStatus onto its own API // vocabulary. Gitea/GitHub accept the same four words; GitLab collapses // failure+error into "failed". func TestGiteaState_Mapping(t *testing.T) { cases := map[CommitStatus]string{ CommitStatusPending: "pending", CommitStatusSuccess: "success", CommitStatusFailure: "failure", CommitStatusError: "error", CommitStatus("???"): "pending", // unknown -> pending fallback } for in, want := range cases { if got := giteaState(in); got != want { t.Errorf("giteaState(%q) = %q, want %q", in, got, want) } } } func TestGitHubState_Mapping(t *testing.T) { cases := map[CommitStatus]string{ CommitStatusPending: "pending", CommitStatusSuccess: "success", CommitStatusFailure: "failure", CommitStatusError: "error", CommitStatus("???"): "pending", } for in, want := range cases { if got := githubState(in); got != want { t.Errorf("githubState(%q) = %q, want %q", in, got, want) } } } func TestGitLabState_Mapping(t *testing.T) { cases := map[CommitStatus]string{ CommitStatusPending: "pending", CommitStatusSuccess: "success", CommitStatusFailure: "failed", // GitLab has no "failure" CommitStatusError: "failed", // error also collapses to "failed" CommitStatus("???"): "pending", } for in, want := range cases { if got := gitlabState(in); got != want { t.Errorf("gitlabState(%q) = %q, want %q", in, got, want) } } } func TestTruncateDescription(t *testing.T) { short := "Tinyforge: deploying" if got := truncateDescription(short); got != short { t.Errorf("short description mutated: %q", got) } long := strings.Repeat("x", 200) got := truncateDescription(long) if len([]rune(got)) > maxCommitStatusDescription { t.Errorf("truncated length = %d runes, want <= %d", len([]rune(got)), maxCommitStatusDescription) } if !strings.HasSuffix(got, "…") { t.Errorf("missing ellipsis on truncation: %q", got) } } // ── Endpoint + body construction (httptest) ───────────────────────── // // The SSRF-safe client refuses loopback, so for these tests we swap the // provider's httpClient for a plain one pointed at httptest. This still // exercises the real URL/body construction inside each SetCommitStatus. type capturedRequest struct { method string path string // r.URL.EscapedPath() — preserves %2F so GitLab's encoded project path is observable rawQ string body map[string]string auth string token string // PRIVATE-TOKEN (GitLab) } func newCaptureServer(t *testing.T, capture *capturedRequest) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capture.method = r.Method capture.path = r.URL.EscapedPath() capture.rawQ = r.URL.RawQuery capture.auth = r.Header.Get("Authorization") capture.token = r.Header.Get("PRIVATE-TOKEN") raw, _ := io.ReadAll(r.Body) if len(raw) > 0 { _ = json.Unmarshal(raw, &capture.body) } w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{}`)) })) } func TestGitea_SetCommitStatus_Request(t *testing.T) { var cap capturedRequest srv := newCaptureServer(t, &cap) defer srv.Close() f := NewGiteaContentFetcher(srv.URL, "tok123") f.httpClient = srv.Client() // bypass SSRF guard for loopback test server err := f.SetCommitStatus(context.Background(), "owner", "repo", "abc123", CommitStatusSuccess, "https://app.example.com", "deployed") if err != nil { t.Fatalf("SetCommitStatus: %v", err) } if cap.method != http.MethodPost { t.Errorf("method = %q, want POST", cap.method) } if want := "/api/v1/repos/owner/repo/statuses/abc123"; cap.path != want { t.Errorf("path = %q, want %q", cap.path, want) } if cap.body["state"] != "success" { t.Errorf("state = %q, want success", cap.body["state"]) } if cap.body["context"] != "tinyforge" { t.Errorf("context = %q, want tinyforge", cap.body["context"]) } if cap.body["target_url"] != "https://app.example.com" { t.Errorf("target_url = %q", cap.body["target_url"]) } if cap.body["description"] != "deployed" { t.Errorf("description = %q, want deployed", cap.body["description"]) } if cap.auth != "token tok123" { t.Errorf("auth = %q, want 'token tok123'", cap.auth) } } func TestGitHub_SetCommitStatus_Request(t *testing.T) { var cap capturedRequest srv := newCaptureServer(t, &cap) defer srv.Close() // Force GHE-style apiBase so we hit the server's path; the github.com // branch hard-codes api.github.com which the SSRF client would block. g := NewGitHubProvider(srv.URL, "ghp_tok") g.apiBase = srv.URL + "/api/v3" g.httpClient = srv.Client() err := g.SetCommitStatus(context.Background(), "octo", "cat", "deadbeef", CommitStatusFailure, "", "failed") if err != nil { t.Fatalf("SetCommitStatus: %v", err) } if want := "/api/v3/repos/octo/cat/statuses/deadbeef"; cap.path != want { t.Errorf("path = %q, want %q", cap.path, want) } if cap.body["state"] != "failure" { t.Errorf("state = %q, want failure", cap.body["state"]) } if cap.body["context"] != "tinyforge" { t.Errorf("context = %q, want tinyforge", cap.body["context"]) } if cap.auth != "Bearer ghp_tok" { t.Errorf("auth = %q, want 'Bearer ghp_tok'", cap.auth) } } func TestGitLab_SetCommitStatus_Request(t *testing.T) { var cap capturedRequest srv := newCaptureServer(t, &cap) defer srv.Close() g := NewGitLabProvider(srv.URL, "glpat-xyz") g.httpClient = srv.Client() err := g.SetCommitStatus(context.Background(), "grp", "proj", "cafe01", CommitStatusError, "https://app.example.com", "boom") if err != nil { t.Fatalf("SetCommitStatus: %v", err) } // GitLab uses the URL-encoded project path + sha in the path, and the // status metadata as query params. if want := "/api/v4/projects/grp%2Fproj/statuses/cafe01"; cap.path != want { t.Errorf("path = %q, want %q", cap.path, want) } q, err := parseQuery(cap.rawQ) if err != nil { t.Fatalf("parse query %q: %v", cap.rawQ, err) } if q["state"] != "failed" { // error -> failed t.Errorf("state = %q, want failed", q["state"]) } if q["name"] != "tinyforge" { t.Errorf("name = %q, want tinyforge", q["name"]) } if q["target_url"] != "https://app.example.com" { t.Errorf("target_url = %q", q["target_url"]) } if q["description"] != "boom" { t.Errorf("description = %q, want boom", q["description"]) } if cap.token != "glpat-xyz" { t.Errorf("PRIVATE-TOKEN = %q, want glpat-xyz", cap.token) } } // parseQuery is a tiny wrapper so the test reads the first value of each // query key without dragging net/url into every assertion. func parseQuery(raw string) (map[string]string, error) { out := map[string]string{} if raw == "" { return out, nil } for _, pair := range strings.Split(raw, "&") { kv := strings.SplitN(pair, "=", 2) k := urlDecode(kv[0]) v := "" if len(kv) == 2 { v = urlDecode(kv[1]) } if _, ok := out[k]; !ok { out[k] = v } } return out, nil } func urlDecode(s string) string { dec, err := decodeQueryComponent(s) if err != nil { return s } return dec } // decodeQueryComponent decodes one application/x-www-form-urlencoded // component (handles %XX and '+'-as-space) without importing net/url here. func decodeQueryComponent(s string) (string, error) { var b strings.Builder for i := 0; i < len(s); i++ { switch s[i] { case '+': b.WriteByte(' ') case '%': if i+2 >= len(s) { return s, errPercent } hi, lo := fromHex(s[i+1]), fromHex(s[i+2]) if hi < 0 || lo < 0 { return s, errPercent } b.WriteByte(byte(hi<<4 | lo)) i += 2 default: b.WriteByte(s[i]) } } return b.String(), nil } var errPercent = &decodeErr{} type decodeErr struct{} func (*decodeErr) Error() string { return "bad percent-encoding" } func fromHex(c byte) int { switch { case c >= '0' && c <= '9': return int(c - '0') case c >= 'a' && c <= 'f': return int(c-'a') + 10 case c >= 'A' && c <= 'F': return int(c-'A') + 10 } return -1 } // TestSetCommitStatus_NonOK_ReturnsError verifies a non-2xx provider // response surfaces as an error (the deploy hook logs + swallows it, but // the provider method itself must report it). func TestSetCommitStatus_NonOK_ReturnsError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"message":"bad token"}`)) })) defer srv.Close() f := NewGiteaContentFetcher(srv.URL, "tok") f.httpClient = srv.Client() err := f.SetCommitStatus(context.Background(), "o", "r", "sha", CommitStatusPending, "", "x") if err == nil { t.Fatal("expected error on 401, got nil") } if !strings.Contains(err.Error(), "401") { t.Errorf("error missing status code: %v", err) } } // TestSetCommitStatus_RespectsContext ensures the call honours context // cancellation (defensive — the deploy hook passes the deploy ctx). func TestSetCommitStatus_RespectsContext(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(200 * time.Millisecond) w.WriteHeader(http.StatusCreated) })) defer srv.Close() f := NewGiteaContentFetcher(srv.URL, "") f.httpClient = srv.Client() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() if err := f.SetCommitStatus(ctx, "o", "r", "sha", CommitStatusPending, "", "x"); err == nil { t.Fatal("expected context-deadline error, got nil") } }