// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package bot import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" "git.dcntrlized.org/Dcntrlized/license-bot/internal/config" "git.dcntrlized.org/Dcntrlized/license-bot/internal/gitea" "git.dcntrlized.org/Dcntrlized/license-bot/internal/licenser" ) func newTestBot(t *testing.T, apiURL string) *Bot { t.Helper() return New(config.Config{ GiteaURL: apiURL, Token: "tok", WebhookSecret: "s3cret", Repos: []string{"Dcntrlized/Maestro"}, BotName: "Bot", BotEmail: "bot@example.local", Holder: "Dcntrlized", CommentMarker: "", StatusContext: "license-bot / addlicense", AddlicenseBin: "addlicense", WorkRoot: t.TempDir(), }, slog.New(slog.NewTextHandler(io.Discard, nil))) } func sign(secret string, body []byte) string { m := hmac.New(sha256.New, []byte(secret)) m.Write(body) return hex.EncodeToString(m.Sum(nil)) } func TestSignatureValid(t *testing.T) { b := newTestBot(t, "http://127.0.0.1:1") body := []byte(`{"action":"opened"}`) if !b.SignatureValid(sign("s3cret", body), body) { t.Fatal("correct signature rejected") } if !b.SignatureValid("sha256="+sign("s3cret", body), body) { t.Fatal("sha256= prefixed signature rejected") } if b.SignatureValid(sign("other", body), body) { t.Fatal("signature from the wrong secret accepted") } if b.SignatureValid("", body) { t.Fatal("empty signature accepted") } } func TestWebhookRejectsBadSignature(t *testing.T) { b := newTestBot(t, "http://127.0.0.1:1") req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(`{}`)) req.Header.Set("X-Gitea-Signature", "deadbeef") rec := httptest.NewRecorder() b.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rec.Code) } } // TestWebhookIgnores covers repositories outside the allowlist and non // pull-request actions: neither may touch the Gitea API. func TestWebhookIgnores(t *testing.T) { var hits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&hits, 1) })) defer srv.Close() b := newTestBot(t, srv.URL) cases := []string{ `{"action":"opened","number":1,"repository":{"full_name":"Dcntrlized/Other"},"pull_request":{"number":1}}`, `{"action":"closed","number":1,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":1}}`, } for _, body := range cases { req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body)) req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body))) req.Header.Set("X-Gitea-Event", "pull_request") rec := httptest.NewRecorder() b.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("status = %d, want 202", rec.Code) } } time.Sleep(150 * time.Millisecond) if n := atomic.LoadInt32(&hits); n != 0 { t.Fatalf("API was called %d times for ignored events", n) } } // TestWebhookDispatches verifies the full hop: valid signature, allowlisted // repo, pull_request/opened with a PR number, dispatched to the API. func TestWebhookDispatches(t *testing.T) { var hits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&hits, 1) w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"number":7,"state":"closed","head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`) })) defer srv.Close() b := newTestBot(t, srv.URL) body := `{"action":"opened","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}` req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body)) req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body))) req.Header.Set("X-Gitea-Event", "pull_request") rec := httptest.NewRecorder() b.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("status = %d, want 202", rec.Code) } deadline := time.Now().Add(2 * time.Second) for atomic.LoadInt32(&hits) == 0 && time.Now().Before(deadline) { time.Sleep(20 * time.Millisecond) } if n := atomic.LoadInt32(&hits); n == 0 { t.Fatal("webhook was not dispatched to the API") } } func TestShortSHA(t *testing.T) { if got := short("0123456789abcdef"); got != "0123456789ab" { t.Fatalf("short = %q", got) } if got := short("abc"); got != "abc" { t.Fatalf("short = %q", got) } } // TestCaughtCommentWording pins the comment text this project has always used, // so it cannot silently drift again. It must keep the original pretty markdown // (heading, bold warning, AGPL link, @Admin tag) and link the bot's commit. func TestCaughtCommentWording(t *testing.T) { b := newTestBot(t, "http://192.168.122.183:3000") pr := &gitea.Pull{ Number: 4, HTMLURL: "http://192.168.122.183:3000/Dcntrlized/license-bot-lab/pulls/4", } pr.Head.SHA = "78a4a2e1ad20ed316eaa58399901e3e37d7496b0" pr.Head.Repo.FullName = "admin/license-bot-lab" pr.Base.Repo.FullName = "Dcntrlized/license-bot-lab" got := b.caughtComment(pr, &licenser.Result{ Changed: []string{"src/adv-unlicensed.go"}, NewSHA: "78a4a2e1ad20ed316eaa58399901e3e37d7496b0", Pushed: true, }) for _, want := range []string{ "## Uh oh! Unlicensed code detected!", "You just tried to push **unlicensed code**!", "[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html)", "Thankfully, our helpful bot caught it and added the missing licensing", "([`78a4a2e1ad20`](http://192.168.122.183:3000/admin/license-bot-lab/commit/78a4a2e1ad20ed316eaa58399901e3e37d7496b0))", "tag **@Admin**", } { if !strings.Contains(got, want) { t.Fatalf("comment is missing %q:\n%s", want, got) } } // It must not claim the contributor's commit was rewritten. if strings.Contains(strings.ToLower(got), "amended") || strings.Contains(got, "force-push") { t.Fatalf("comment must not mention amending or force-pushing:\n%s", got) } if !strings.Contains(got, "your own commit was left untouched") { t.Fatalf("comment must state the contributor's commit is untouched:\n%s", got) } } func TestHealthRoute(t *testing.T) { b := newTestBot(t, "http://127.0.0.1:1") rec := httptest.NewRecorder() b.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") { t.Fatalf("health = %d %q", rec.Code, rec.Body.String()) } rec = httptest.NewRecorder() b.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/healthz", nil)) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("POST /healthz = %d, want 405", rec.Code) } } // TestWebhookDeliveryDedup verifies that Gitea redelivering the same webhook // (same X-Gitea-Delivery id) is accepted but only dispatched once. func TestWebhookDeliveryDedup(t *testing.T) { var fetches int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/pulls/7") { atomic.AddInt32(&fetches, 1) } w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"number":7,"state":"open","head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`) })) defer srv.Close() b := newTestBot(t, srv.URL) body := `{"action":"opened","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}` for i := 0; i < 3; i++ { req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body)) req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body))) req.Header.Set("X-Gitea-Event", "pull_request") req.Header.Set("X-Gitea-Delivery", "delivery-1") rec := httptest.NewRecorder() b.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("attempt %d: status = %d, want 202", i, rec.Code) } } time.Sleep(200 * time.Millisecond) if n := atomic.LoadInt32(&fetches); n != 1 { t.Fatalf("pull fetched %d times for one delivery id, want 1", n) } } // TestReconcileSkipsGreenAndProcessesOthers covers the startup scan contract: // a pull request whose head already carries the bot's success status is left // alone, while one without it is checked. func TestReconcileSkipsGreenAndProcessesOthers(t *testing.T) { var greenFiles, redFiles int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case strings.HasSuffix(r.URL.Path, "/pulls") && r.URL.Query().Get("state") == "open": io.WriteString(w, `[ {"number":1,"state":"open","head":{"ref":"green","sha":"green","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}, {"number":2,"state":"open","head":{"ref":"red","sha":"red","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}} ]`) case strings.Contains(r.URL.Path, "/commits/green/statuses"): io.WriteString(w, `[{"context":"license-bot / addlicense","status":"success"}]`) case strings.Contains(r.URL.Path, "/commits/red/statuses"): io.WriteString(w, `[]`) case strings.HasSuffix(r.URL.Path, "/pulls/2"): io.WriteString(w, `{"number":2,"state":"open","head":{"ref":"red","sha":"red","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`) case strings.HasSuffix(r.URL.Path, "/pulls/2/files"): atomic.AddInt32(&redFiles, 1) io.WriteString(w, `[]`) case strings.HasSuffix(r.URL.Path, "/pulls/1/files"): atomic.AddInt32(&greenFiles, 1) io.WriteString(w, `[]`) default: io.WriteString(w, `[]`) } })) defer srv.Close() b := newTestBot(t, srv.URL) if err := b.Reconcile(context.Background()); err != nil { t.Fatalf("reconcile: %v", err) } if n := atomic.LoadInt32(&greenFiles); n != 0 { t.Fatalf("green pull request was checked %d times, want 0", n) } if n := atomic.LoadInt32(&redFiles); n != 1 { t.Fatalf("red pull request files listed %d times, want 1", n) } } // TestWebhookSubEvents pins Gitea's real header/action behavior (measured on // 1.27.2): a push to a pull request head arrives as X-Gitea-Event: // pull_request with action "synchronized" -- NOT "synchronize" -- and every // other pull-request sub-event (comments, reviews, labels) carries an action // outside the allowlist and must not start a check. func TestWebhookSubEvents(t *testing.T) { var fetches int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/pulls/7") { atomic.AddInt32(&fetches, 1) } w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"number":7,"state":"open","head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`) })) defer srv.Close() b := newTestBot(t, srv.URL) post := func(event, body string) { req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body)) req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body))) req.Header.Set("X-Gitea-Event", event) req.Header.Set("X-Gitea-Delivery", event+body) rec := httptest.NewRecorder() b.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("event %q: status = %d, want 202", event, rec.Code) } } cases := []struct { event, body string checked bool }{ {"pull_request", `{"action":"synchronized","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, true}, {"pull_request_sync", `{"action":"synchronize","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, true}, {"pull_request", `{"action":"opened","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, true}, {"pull_request", `{"action":"created","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, false}, {"pull_request", `{"action":"closed","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, false}, {"pull_request_comment", `{"action":"created","number":7,"repository":{"full_name":"Dcntrlized/Maestro"}}`, false}, {"push", `{"action":"synchronized","number":7,"repository":{"full_name":"Dcntrlized/Maestro"}}`, false}, } for _, c := range cases { before := atomic.LoadInt32(&fetches) post(c.event, c.body) time.Sleep(150 * time.Millisecond) got := atomic.LoadInt32(&fetches) > before if got != c.checked { t.Fatalf("event %q action %s: checked = %v, want %v", c.event, c.body, got, c.checked) } } } // TestCommentLinksUsePublicURL keeps the browser-facing links in the bot's // comment on the public name even when the API is reached over the internal // Docker alias (GITEA_URL=http://gitea:3000 is not resolvable in a browser). func TestCommentLinksUsePublicURL(t *testing.T) { b := newTestBot(t, "http://gitea:3000") b.cfg.PublicURL = "https://git.example.org" var pr gitea.Pull if err := json.Unmarshal([]byte(`{"head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`), &pr); err != nil { t.Fatal(err) } if got := b.commitURL(&pr, "abc"); got != "https://git.example.org/Dcntrlized/Maestro/commit/abc" { t.Fatalf("commitURL = %q", got) } b.cfg.PublicURL = "" if got := b.commitURL(&pr, "abc"); got != "http://gitea:3000/Dcntrlized/Maestro/commit/abc" { t.Fatalf("fallback commitURL = %q", got) } } // TestCommentReplacesInsteadOfEditing pins the replacement semantics: the bot's // previous comment is deleted and a fresh one is posted, never edited in place. // Gitea keeps a comment's position, so editing left a resolved failure buried // near the top of the timeline while the status at the head was green. func TestCommentReplacesInsteadOfEditing(t *testing.T) { var mu sync.Mutex var calls []string var posted string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() calls = append(calls, r.Method+" "+r.URL.Path) mu.Unlock() w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodGet: io.WriteString(w, `[ {"id":41,"body":"stale failure\n\n "} ]`) case http.MethodPost: raw, _ := io.ReadAll(r.Body) mu.Lock() posted = string(raw) mu.Unlock() io.WriteString(w, `{"id":42,"body":"fresh"}`) default: w.WriteHeader(http.StatusNoContent) } })) defer srv.Close() b := newTestBot(t, srv.URL) b.comment(context.Background(), "Dcntrlized/Maestro", &gitea.Pull{Number: 7}, "fresh message", false) mu.Lock() got := append([]string(nil), calls...) raw := posted mu.Unlock() // The client JSON-encodes the request, so decode it before asserting on the // markdown the server would actually store. var sent struct { Body string `json:"body"` } if err := json.Unmarshal([]byte(raw), &sent); err != nil { t.Fatalf("posted body is not JSON: %v (%s)", err, raw) } body := sent.Body var deletes, posts, patches int for _, c := range got { switch { case strings.HasPrefix(c, http.MethodDelete+" ") && strings.HasSuffix(c, "/issues/comments/41"): deletes++ case strings.HasPrefix(c, http.MethodPost+" "): posts++ case strings.HasPrefix(c, http.MethodPatch+" "): patches++ } } if patches != 0 { t.Fatalf("comment was edited in place; calls: %v", got) } if deletes != 1 || posts != 1 { t.Fatalf("deletes=%d posts=%d, want 1 and 1; calls: %v", deletes, posts, got) } if !strings.Contains(body, "") { t.Fatalf("posted comment lost the marker: %s", body) } if strings.Contains(body, problemMarker) { t.Fatalf("informational comment must not carry the problem marker: %s", body) } } // TestClearProblemCommentKeepsInformational verifies that a pass finding // nothing left to fix removes a stale problem comment but leaves the // informational "the bot added the headers" comment in place. func TestClearProblemCommentKeepsInformational(t *testing.T) { var mu sync.Mutex var deletes []string var posts int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodGet: io.WriteString(w, `[ {"id":41,"body":"stale failure\n\n "}, {"id":42,"body":"the bot added the headers\n\n"}, {"id":43,"body":"a human's comment"} ]`) case http.MethodDelete: mu.Lock() deletes = append(deletes, r.URL.Path) mu.Unlock() w.WriteHeader(http.StatusNoContent) case http.MethodPost: mu.Lock() posts++ mu.Unlock() io.WriteString(w, `{"id":44,"body":""}`) } })) defer srv.Close() b := newTestBot(t, srv.URL) b.clearProblemComment(context.Background(), "Dcntrlized/Maestro", &gitea.Pull{Number: 7}) mu.Lock() got := append([]string(nil), deletes...) n := posts mu.Unlock() if n != 0 { t.Fatalf("clearProblemComment posted %d comments, want 0", n) } if len(got) != 1 || !strings.HasSuffix(got[0], "/issues/comments/41") { t.Fatalf("deleted %v, want only the stale problem comment 41", got) } } // TestFixableFiles pins the scope of the check: every changed file that still // exists at the head is fair game, so an existing unlicensed file gets its // header the first time a pull request touches it. A removal has no content left // to license, and "unchanged" is not part of the delta. func TestFixableFiles(t *testing.T) { files := []gitea.File{ {Filename: "added.go", Status: "added"}, {Filename: "modified.go", Status: "modified"}, {Filename: "renamed.go", Status: "renamed"}, {Filename: "copied.go", Status: "copied"}, {Filename: "gone.go", Status: "removed"}, {Filename: "same.go", Status: "unchanged"}, } got := strings.Join(fixableFiles(files), ",") if want := "added.go,modified.go,renamed.go,copied.go"; got != want { t.Fatalf("fixableFiles = %q, want %q", got, want) } if len(fixableFiles(nil)) != 0 { t.Fatalf("fixableFiles(nil) = %v, want empty", fixableFiles(nil)) } } // TestProcessPullChecksModifiedFiles is the regression test for "a +3 -1 pull // request that only edits an existing unlicensed file got no header". A modified // file is no longer short-circuited to success: the run moves on to clone the // head, which fails here (the fake API is not a git server), so the flow must // reach the "pending" status and must never post the "no changed files" success. func TestProcessPullChecksModifiedFiles(t *testing.T) { var mu sync.Mutex var statuses []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/api/v1/") { http.Error(w, "not a git server", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pulls/2/files"): io.WriteString(w, `[{"filename":"test.go","status":"modified"}]`) case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/issues/2/comments"): io.WriteString(w, `[]`) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/statuses/"): raw, _ := io.ReadAll(r.Body) mu.Lock() statuses = append(statuses, string(raw)) mu.Unlock() io.WriteString(w, `{"id":1}`) default: io.WriteString(w, `{}`) } })) defer srv.Close() b := newTestBot(t, srv.URL) var pr gitea.Pull if err := json.Unmarshal([]byte(`{"number":2,"state":"open",`+ `"head":{"ref":"licensebot-test","sha":"4ac2f5d","repo":{"full_name":"Dcntrlized/licensebot-test"}},`+ `"base":{"ref":"licensebot-test","repo":{"full_name":"Dcntrlized/licensebot-test"}}}`), &pr); err != nil { t.Fatal(err) } // The clone against this fake API cannot succeed; that error is expected and // is not what this test asserts. _ = b.ProcessPull(context.Background(), "Dcntrlized/licensebot-test", &pr) mu.Lock() defer mu.Unlock() var pending bool for _, s := range statuses { if strings.Contains(s, "no changed files need a license header") { t.Fatalf("a modified file was short-circuited: %s", s) } if strings.Contains(s, `"state":"pending"`) && strings.Contains(s, "checking changed files for AGPL headers") { pending = true } } if !pending { t.Fatalf("never posted the pending status, got %v", statuses) } }