Files
alexei.dolgolyov 3071cda512 feat(deploy): commit-status reporting to Git providers
Report deploy status back to the Git provider as a commit status
(pending/success/failure) for git-sourced workloads (static + dockerfile).

- GitProvider.SetCommitStatus on gitea/github/gitlab over the existing
  SSRF-safe client; fixed "tinyforge" context so redeploys update one row.
  postJSON returns status-code-only errors (never echoes the upstream body,
  which a hostile provider could use to reflect the auth token into the
  best-effort log line).
- Best-effort deploy hook: pending on deploy start, success/failure on
  outcome, gated on a per-workload report_commit_status flag. Never fails or
  blocks a deploy; emits nothing on the unchanged-SHA short-circuit.
- UI ToggleSwitch (create + edit) + reportCommitStatus in sourceForms.ts
  + en/ru i18n.
- Tests: per-provider state mapping + request shape; reporter gating
  (enabled/disabled/empty-SHA/nil/error-swallow).

Reviewed via go-reviewer + security-reviewer (0 CRITICAL/HIGH; one MEDIUM
body-echo log-leak fixed).
2026-05-29 11:37:56 +03:00

332 lines
9.6 KiB
Go

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")
}
}