3071cda512
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).
332 lines
9.6 KiB
Go
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")
|
|
}
|
|
}
|