559 lines
21 KiB
Go
559 lines
21 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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: "[email protected]",
|
|
Holder: "Dcntrlized",
|
|
CommentMarker: "<!-- marker -->",
|
|
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<!-- marker --> <!-- dcntrlized-copyleft-bot:problem -->"}
|
|
]`)
|
|
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, "<!-- marker -->") {
|
|
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<!-- marker --> <!-- dcntrlized-copyleft-bot:problem -->"},
|
|
{"id":42,"body":"the bot added the headers\n\n<!-- marker -->"},
|
|
{"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)
|
|
}
|
|
}
|