gitmote / internal / webui / ci_test.go · history · branches
8803 bytes · raw
package webui
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/atmin/gitmote/internal/meta"
)
// fakeLive is a fixed live-log buffer for exercising the live-tail endpoint.
type fakeLive struct {
data []byte
done bool
ok bool
}
func (f fakeLive) Read(_ int64, offset int) ([]byte, int, bool, bool) {
if !f.ok {
return nil, offset, false, false
}
if offset < 0 {
offset = 0
}
if offset > len(f.data) {
offset = len(f.data)
}
return f.data[offset:], len(f.data), f.done, true
}
// seedQueuedJob creates an ACL-granted repo, a run, and a queued (non-terminal)
// job with no log yet — the running-job state the live tail serves.
func (x *harness) seedQueuedJob(repoName, sha string) (runID, jobID int64) {
x.t.Helper()
ctx := context.Background()
r, err := x.md.CreateRepo(ctx, repoName, "main")
if err != nil {
x.t.Fatalf("CreateRepo: %v", err)
}
if err := x.md.SetACL(ctx, r.ID, x.admin.ID, meta.PermAdmin); err != nil {
x.t.Fatalf("SetACL: %v", err)
}
run, err := x.md.CreateRun(ctx, r.ID, "refs/heads/main", sha)
if err != nil {
x.t.Fatalf("CreateRun: %v", err)
}
job, err := x.md.CreateJob(ctx, run.ID, "ci.yml")
if err != nil {
x.t.Fatalf("CreateJob: %v", err)
}
return run.ID, job.ID
}
// seedRun creates a repo (if repoName is new), a run, and one job with a stored
// log blob, returning the ids. The log carries ANSI + HTML to exercise rendering.
func (x *harness) seedRun(repoName, sha, log string) (repoID, runID, jobID int64) {
x.t.Helper()
ctx := context.Background()
r, err := x.md.GetRepo(ctx, repoName)
if err != nil {
r, err = x.md.CreateRepo(ctx, repoName, "main")
if err != nil {
x.t.Fatalf("CreateRepo: %v", err)
}
}
// Browse (the CI run views) is gated on repo-read; grant the harness admin an
// ACL so it can view runs on this private-by-default repo.
if err := x.md.SetACL(ctx, r.ID, x.admin.ID, meta.PermAdmin); err != nil {
x.t.Fatalf("SetACL: %v", err)
}
run, err := x.md.CreateRun(ctx, r.ID, "refs/heads/main", sha)
if err != nil {
x.t.Fatalf("CreateRun: %v", err)
}
job, err := x.md.CreateJob(ctx, run.ID, "ci.yml")
if err != nil {
x.t.Fatalf("CreateJob: %v", err)
}
key := "ci/1/" + itoa(run.ID) + "/" + itoa(job.ID) + ".log"
if err := x.store.Put(ctx, key, bytes.NewReader([]byte(log))); err != nil {
x.t.Fatalf("store log: %v", err)
}
if err := x.md.SetJobResult(ctx, job.ID, meta.RunPassed, key); err != nil {
x.t.Fatalf("SetJobResult: %v", err)
}
return r.ID, run.ID, job.ID
}
func TestCIRunsListAndDetail(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
_, runID, jobID := x.seedRun("app", "deadbeefcafe1234", "ok\n")
// Runs list links to the run and shows the short SHA.
rec := x.do(http.MethodGet, "/app/runs", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("runs list: %d (%s)", rec.Code, rec.Body)
}
body := rec.Body.String()
if !strings.Contains(body, "/app/runs/"+itoa(runID)) || !strings.Contains(body, "deadbeef") {
t.Errorf("runs list missing run link or short sha: %s", body)
}
// Run detail shows the job and links to its log.
rec = x.do(http.MethodGet, "/app/runs/"+itoa(runID), nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("run detail: %d (%s)", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "ci.yml") ||
!strings.Contains(rec.Body.String(), "/runs/"+itoa(runID)+"/job/"+itoa(jobID)+"/log") {
t.Errorf("run detail missing job or log link: %s", rec.Body)
}
}
func TestCIJobLogRendersSafely(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
// Green "ok" via ANSI, then HTML that must be escaped.
_, runID, jobID := x.seedRun("app", "sha1", "\x1b[32mok\x1b[0m <b>x</b>\n")
rec := x.do(http.MethodGet, "/app/runs/"+itoa(runID)+"/job/"+itoa(jobID)+"/log", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("log view: %d (%s)", rec.Code, rec.Body)
}
body := rec.Body.String()
if !strings.Contains(body, "ok</span>") {
t.Errorf("ANSI color not rendered as a span: %s", body)
}
if strings.Contains(body, "<b>x</b>") {
t.Errorf("log HTML not escaped (injection): %s", body)
}
if !strings.Contains(body, "<b>x</b>") {
t.Errorf("expected escaped log HTML: %s", body)
}
}
func TestCIRunNotFound(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
r, err := x.md.CreateRepo(context.Background(), "app", "main")
if err != nil {
t.Fatal(err)
}
if err := x.md.SetACL(context.Background(), r.ID, x.admin.ID, meta.PermAdmin); err != nil {
t.Fatal(err)
}
// Unknown run id → 404.
if rec := x.do(http.MethodGet, "/app/runs/9999", nil, session); rec.Code != http.StatusNotFound {
t.Errorf("unknown run = %d, want 404", rec.Code)
}
}
func TestCIRunCrossRepoIsNotFound(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
_, runID, _ := x.seedRun("app", "sha1", "x")
other, err := x.md.CreateRepo(context.Background(), "other", "main")
if err != nil {
t.Fatal(err)
}
if err := x.md.SetACL(context.Background(), other.ID, x.admin.ID, meta.PermAdmin); err != nil {
t.Fatal(err)
}
// A run that belongs to app must not be reachable under other.
rec := x.do(http.MethodGet, "/other/runs/"+itoa(runID), nil, session)
if rec.Code != http.StatusNotFound {
t.Errorf("cross-repo run = %d, want 404", rec.Code)
}
}
func TestCIRunsRequireAdmin(t *testing.T) {
x := newHarness(t)
// Unauthenticated GET is redirected to login like the rest of browsing.
if rec := x.do(http.MethodGet, "/app/runs", nil, nil); rec.Code != http.StatusSeeOther {
t.Errorf("unauth runs = %d, want 303 redirect", rec.Code)
}
}
func TestCILiveLogStreamsBytes(t *testing.T) {
x := newHarness(t)
x.h.live = fakeLive{data: []byte("building...\n"), ok: true}
session := x.login(x.mintTokenFor(x.admin.ID))
runID, jobID := x.seedQueuedJob("app", "sha1")
rec := x.do(http.MethodGet, "/app/runs/"+itoa(runID)+"/job/"+itoa(jobID)+"/log/live?offset=0", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("live poll: %d (%s)", rec.Code, rec.Body)
}
var resp struct {
Bytes string `json:"bytes"`
Next int `json:"next"`
Done bool `json:"done"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Bytes != "building...\n" || resp.Next != 12 || resp.Done {
t.Errorf("live resp = %+v, want the buffered bytes, next 12, not done", resp)
}
}
func TestCILiveLogDoneWhenNoBufferAndTerminal(t *testing.T) {
// The fallback path: no live buffer + a finished job → done=true, so the page
// stops polling and reloads to the durable log.
x := newHarness(t)
x.h.live = fakeLive{ok: false}
session := x.login(x.mintTokenFor(x.admin.ID))
_, runID, jobID := x.seedRun("app", "sha1", "done\n") // seedRun marks the job passed
rec := x.do(http.MethodGet, "/app/runs/"+itoa(runID)+"/job/"+itoa(jobID)+"/log/live", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("live poll: %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"done":true`) {
t.Errorf("live resp = %s, want done=true for a finished job with no buffer", rec.Body)
}
}
func TestCIJobLogPageTailsRunningJob(t *testing.T) {
x := newHarness(t)
x.h.live = fakeLive{data: []byte("x"), ok: true}
session := x.login(x.mintTokenFor(x.admin.ID))
runID, jobID := x.seedQueuedJob("app", "sha1")
// A running job's log page renders the live-tail element + poll script instead
// of a durable log.
rec := x.do(http.MethodGet, "/app/runs/"+itoa(runID)+"/job/"+itoa(jobID)+"/log", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("log page: %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `id="livelog"`) || !strings.Contains(body, "/log/live") {
t.Errorf("running-job log page missing live tail: %s", body)
}
}
func TestCICommitBadge(t *testing.T) {
x := newHarness(t)
ctx := context.Background()
session := x.login(x.mintTokenFor(x.admin.ID))
head, first := x.seedBrowseRepo("repo", "main")
r, _ := x.md.GetRepo(ctx, "repo")
run, err := x.md.CreateRun(ctx, r.ID, "refs/heads/main", head)
if err != nil {
t.Fatal(err)
}
// The commit with a run shows a badge linking to it.
rec := x.do(http.MethodGet, "/repo/commit/"+head, nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("commit page: %d (%s)", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "/repo/runs/"+itoa(run.ID)) {
t.Errorf("commit with a run is missing the CI badge: %s", rec.Body)
}
// A commit without a run shows no badge.
rec = x.do(http.MethodGet, "/repo/commit/"+first, nil, session)
if strings.Contains(rec.Body.String(), "/repo/runs/") {
t.Errorf("commit without a run should have no badge: %s", rec.Body)
}
}