package api import ( "bytes" "compress/gzip" "context" "encoding/json" "errors" "fmt" "net/http" "testing" "time" "felis.lolicon.best/internal/build" ) // fakeBuilder is an in-memory ImageBuilder for the handler tests. Each field is // the canned outcome of the matching call; the recorders let a test assert what // the handler forwarded. type fakeBuilder struct { submitted *build.Request submitErr error getBuilds map[string]*build.Build getErr error getManyIDs [][]string // one entry per GetMany call syncErr error cancelErr error addedRef string addedBy string addErr error removedRef string removeErr error images []build.Image listErr error lastBuildID string admitted map[string]bool admitErr error builds []build.Build buildsTotal int buildsErr error listOpts build.ListOpts scans map[string]*build.Scan scanErr error } func (f *fakeBuilder) Submit(_ context.Context, req build.Request) (*build.Build, error) { if f.submitErr != nil { return nil, f.submitErr } cp := req f.submitted = &cp return &build.Build{ID: "bld-1", ImageRef: req.ImageRef, Status: build.StatusBuilding, RequestedBy: req.RequestedBy}, nil } func (f *fakeBuilder) GetMany(_ context.Context, ids []string) (map[string]build.Build, error) { f.getManyIDs = append(f.getManyIDs, ids) if f.getErr != nil { return nil, f.getErr } out := map[string]build.Build{} for _, id := range ids { if b, ok := f.getBuilds[id]; ok { out[id] = *b } } return out, nil } func (f *fakeBuilder) Sync(_ context.Context, id string) (*build.Build, error) { f.lastBuildID = id if f.syncErr != nil { return nil, f.syncErr } return &build.Build{ID: id, ImageRef: "registry.felis.svc:5000/x:1", Status: build.StatusSucceeded}, nil } func (f *fakeBuilder) Cancel(_ context.Context, id string) (*build.Build, error) { f.lastBuildID = id if f.cancelErr != nil { return nil, f.cancelErr } return &build.Build{ID: id, ImageRef: "registry.felis.svc:5000/x:1", Status: build.StatusCancelled}, nil } func (f *fakeBuilder) ListBuilds(_ context.Context, opts build.ListOpts) ([]build.Build, int, error) { f.listOpts = opts return f.builds, f.buildsTotal, f.buildsErr } func (f *fakeBuilder) ListImages(context.Context) ([]build.Image, error) { return f.images, f.listErr } func (f *fakeBuilder) AddExternalImage(_ context.Context, ref, addedBy string) (*build.Image, error) { if f.addErr != nil { return nil, f.addErr } f.addedRef, f.addedBy = ref, addedBy return &build.Image{ImageRef: ref, Source: build.SourceExternal, AddedBy: addedBy, Enabled: true}, nil } func (f *fakeBuilder) RemoveImage(_ context.Context, ref string) error { if f.removeErr != nil { return f.removeErr } f.removedRef = ref return nil } func (f *fakeBuilder) ImageAdmitted(_ context.Context, ref string) (bool, error) { if f.admitErr != nil { return false, f.admitErr } return f.admitted[ref], nil } func (f *fakeBuilder) Scan(_ context.Context, id string) (*build.Scan, error) { if f.scanErr != nil { return nil, f.scanErr } if s, ok := f.scans[id]; ok { return s, nil } return nil, build.ErrNotFound } func adminAPI(b ImageBuilder) *API { api := newTestAPI(newFakeRepo(), newFakeCluster()) api.Builder = b api.External = staticExternal{p: &Principal{UserID: "a", Email: "admin@example.net", Role: "admin", ViaAdminAccess: true}} return api } // Every image route is admin-tier: a non-admin is rejected before the handler. func TestImageRoutesAreAdminOnly(t *testing.T) { cases := []struct { method, target, body string }{ {"POST", "/api/v1/images/build", `{"image_ref":"registry.felis.svc:5000/x:1","dockerfile":"FROM x","context_ref":"c"}`}, {"GET", "/api/v1/images/build", ""}, {"GET", "/api/v1/images/build/bld-1", ""}, {"GET", "/api/v1/images/build/bld-1/logs", ""}, {"POST", "/api/v1/images/build/bld-1/cancel", ""}, {"GET", "/api/v1/images/build/bld-1/scan", ""}, {"GET", "/api/v1/images/build/bld-1/scan/report", ""}, {"GET", "/api/v1/images/build/bld-1/sbom", ""}, {"GET", "/api/v1/images", ""}, {"POST", "/api/v1/images", `{"image_ref":"registry.felis.svc:5000/x:1"}`}, {"DELETE", "/api/v1/images?ref=registry.felis.svc:5000/x:1", ""}, } for _, c := range cases { api := adminAPI(&fakeBuilder{}) // downgrade to a plain user api.External = staticExternal{p: &Principal{UserID: "u", Role: "user"}} w := do(api.ExternalHandler(), c.method, c.target, c.body, nil) if w.Code != http.StatusForbidden { t.Errorf("%s %s: code = %d, want 403", c.method, c.target, w.Code) } } } func TestBuildImageSubmits(t *testing.T) { fb := &fakeBuilder{} api := adminAPI(fb) body := `{"image_ref":"registry.felis.svc:5000/mc:1","dockerfile":"FROM eclipse-temurin:21","context_ref":"tar://c.tgz"}` w := do(api.ExternalHandler(), "POST", "/api/v1/images/build", body, nil) if w.Code != http.StatusAccepted { t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) } if fb.submitted == nil { t.Fatal("Submit was not called") } // The requester identity must come from the Access principal, never the body. if fb.submitted.RequestedBy != "admin@example.net" { t.Errorf("requested_by = %q, want the admin email", fb.submitted.RequestedBy) } if fb.submitted.ImageRef != "registry.felis.svc:5000/mc:1" { t.Errorf("image_ref = %q", fb.submitted.ImageRef) } } func TestBuildImageValidationIs400(t *testing.T) { fb := &fakeBuilder{submitErr: fmt.Errorf("%w: bad ref", build.ErrInvalid)} api := adminAPI(fb) body := `{"image_ref":"docker.io/evil:1","dockerfile":"FROM x","context_ref":"c"}` w := do(api.ExternalHandler(), "POST", "/api/v1/images/build", body, nil) if w.Code != http.StatusBadRequest { t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String()) } if decodeErr(t, w) != "bad_request" { t.Errorf("error code = %q, want bad_request", decodeErr(t, w)) } } func TestGetBuildReconcilesOnRead(t *testing.T) { fb := &fakeBuilder{} api := adminAPI(fb) w := do(api.ExternalHandler(), "GET", "/api/v1/images/build/bld-9", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) } if fb.lastBuildID != "bld-9" { t.Errorf("Sync called with %q, want bld-9", fb.lastBuildID) } var bld build.Build if err := json.Unmarshal(w.Body.Bytes(), &bld); err != nil || bld.Status != build.StatusSucceeded { t.Fatalf("unexpected body %s err %v", w.Body.String(), err) } } func TestGetBuildNotFoundIs404(t *testing.T) { fb := &fakeBuilder{syncErr: build.ErrNotFound} api := adminAPI(fb) w := do(api.ExternalHandler(), "GET", "/api/v1/images/build/missing", "", nil) if w.Code != http.StatusNotFound { t.Fatalf("code = %d, want 404", w.Code) } } func TestCancelBuild(t *testing.T) { fb := &fakeBuilder{} api := adminAPI(fb) w := do(api.ExternalHandler(), "POST", "/api/v1/images/build/bld-1/cancel", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) } if fb.lastBuildID != "bld-1" { t.Errorf("Cancel called with %q", fb.lastBuildID) } } func TestCancelTerminalBuildIs409(t *testing.T) { fb := &fakeBuilder{cancelErr: build.ErrAlreadyTerminal} api := adminAPI(fb) w := do(api.ExternalHandler(), "POST", "/api/v1/images/build/bld-1/cancel", "", nil) if w.Code != http.StatusConflict { t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String()) } } func TestListBuilds(t *testing.T) { fb := &fakeBuilder{ builds: []build.Build{{ID: "bld-2", ImageRef: "registry.felis.svc:5000/x:2", Status: build.StatusBuilding}}, buildsTotal: 41, } api := adminAPI(fb) w := do(api.ExternalHandler(), "GET", "/api/v1/images/build?query=paper&limit=20&offset=40", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) } if want := (build.ListOpts{Query: "paper", Limit: 20, Offset: 40}); fb.listOpts != want { t.Errorf("forwarded %+v, want %+v", fb.listOpts, want) } var got struct { Builds []build.Build `json:"builds"` Total int `json:"total"` } if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("body not JSON: %v", err) } if got.Total != 41 || len(got.Builds) != 1 || got.Builds[0].ID != "bld-2" { t.Fatalf("body = %s", w.Body.String()) } } // An empty history is an empty list, so the panel never has to handle null. func TestListBuildsEmptyIsAnArray(t *testing.T) { api := adminAPI(&fakeBuilder{}) w := do(api.ExternalHandler(), "GET", "/api/v1/images/build", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) } if body := w.Body.String(); body != `{"builds":[],"total":0}`+"\n" { t.Fatalf("body = %q", body) } } func TestListImages(t *testing.T) { fb := &fakeBuilder{images: []build.Image{ {ImageRef: "registry.felis.svc:5000/a:1", Source: build.SourceBuilt, Enabled: true}, }} api := adminAPI(fb) w := do(api.ExternalHandler(), "GET", "/api/v1/images", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) } var got map[string][]build.Image if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("body not JSON: %v", err) } if len(got["images"]) != 1 { t.Fatalf("images = %d, want 1", len(got["images"])) } } func TestAddExternalImage(t *testing.T) { fb := &fakeBuilder{} api := adminAPI(fb) w := do(api.ExternalHandler(), "POST", "/api/v1/images", `{"image_ref":"registry.felis.svc:5000/ext:1"}`, nil) if w.Code != http.StatusCreated { t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String()) } if fb.addedRef != "registry.felis.svc:5000/ext:1" { t.Errorf("added ref = %q", fb.addedRef) } if fb.addedBy != "admin@example.net" { t.Errorf("added_by = %q, want the admin email", fb.addedBy) } } func TestRemoveImage(t *testing.T) { fb := &fakeBuilder{} api := adminAPI(fb) w := do(api.ExternalHandler(), "DELETE", "/api/v1/images?ref=registry.felis.svc:5000/x:1", "", nil) if w.Code != http.StatusNoContent { t.Fatalf("code = %d, want 204 (%s)", w.Code, w.Body.String()) } if fb.removedRef != "registry.felis.svc:5000/x:1" { t.Errorf("removed ref = %q", fb.removedRef) } } func TestRemoveImageRequiresRef(t *testing.T) { api := adminAPI(&fakeBuilder{}) w := do(api.ExternalHandler(), "DELETE", "/api/v1/images", "", nil) if w.Code != http.StatusBadRequest { t.Fatalf("code = %d, want 400", w.Code) } } // When no Builder is configured, image routes report 503 — but only after the // admin gate, so the boundary is still enforced. func TestImageRoutesWithoutBuilderAre503(t *testing.T) { api := newTestAPI(newFakeRepo(), newFakeCluster()) api.Builder = nil api.External = staticExternal{p: &Principal{UserID: "a", Email: "admin@example.net", Role: "admin", ViaAdminAccess: true}} w := do(api.ExternalHandler(), "GET", "/api/v1/images", "", nil) if w.Code != http.StatusServiceUnavailable { t.Fatalf("code = %d, want 503", w.Code) } } // Compile-time proof that the production Builder satisfies the API interface. var _ ImageBuilder = (*build.Builder)(nil) func gzipped(t *testing.T, s string) []byte { t.Helper() var buf bytes.Buffer zw := gzip.NewWriter(&buf) if _, err := zw.Write([]byte(s)); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } // scannedBuilder holds one blocked scan of bld-7 that kept its report but no // SBOM. func scannedBuilder(t *testing.T) *fakeBuilder { return &fakeBuilder{scans: map[string]*build.Scan{"bld-7": { BuildID: "bld-7", ScannedAt: time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC), Summary: build.ScanSummary{ Policy: build.ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}}, Blocked: true, Packages: 12, Counts: map[string]int{"CRITICAL": 1, "MEDIUM": 2}, BlockingCounts: map[string]int{"CRITICAL": 1}, Findings: []build.ScanFinding{{ID: "CVE-2024-0001", Kind: "vulnerability", Severity: "CRITICAL", Package: "log4j-core", Installed: "2.14.1", Fixed: "2.17.1", Target: "mods/core.jar", Blocking: true}}, }, ReportGz: gzipped(t, `{"SchemaVersion":2,"Results":[]}`), }}} } func TestBuildScanReturnsTheGateVerdict(t *testing.T) { w := do(adminAPI(scannedBuilder(t)).ExternalHandler(), "GET", "/api/v1/images/build/bld-7/scan", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) } var got map[string]any if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatal(err) } summary, _ := got["summary"].(map[string]any) findings, _ := summary["findings"].([]any) if got["build_id"] != "bld-7" || got["scanned_at"] != "2026-09-20T10:00:00Z" || got["has_report"] != true || got["has_sbom"] != false { t.Errorf("view = %s", w.Body.String()) } if summary["blocked"] != true || summary["packages"] != float64(12) || len(findings) != 1 { t.Errorf("summary = %s", w.Body.String()) } if f, _ := findings[0].(map[string]any); f["id"] != "CVE-2024-0001" || f["fixed"] != "2.17.1" || f["blocking"] != true { t.Errorf("finding = %v", findings[0]) } if _, leaked := got["report_gz"]; leaked { t.Error("the scan view must not inline the report bytes") } } func TestBuildScanMissingOrUnreadable(t *testing.T) { w := do(adminAPI(scannedBuilder(t)).ExternalHandler(), "GET", "/api/v1/images/build/bld-8/scan", "", nil) if w.Code != http.StatusNotFound || decodeErr(t, w) != "scan_not_found" { t.Errorf("no scan: code %d, %s", w.Code, w.Body.String()) } fb := scannedBuilder(t) fb.scanErr = errors.New("database is down") w = do(adminAPI(fb).ExternalHandler(), "GET", "/api/v1/images/build/bld-7/scan/report", "", nil) if w.Code != http.StatusInternalServerError { t.Errorf("store error: code %d, %s", w.Code, w.Body.String()) } api := adminAPI(nil) api.Builder = nil w = do(api.ExternalHandler(), "GET", "/api/v1/images/build/bld-7/sbom", "", nil) if w.Code != http.StatusServiceUnavailable { t.Errorf("no builder: code %d, %s", w.Code, w.Body.String()) } } func TestBuildScanDocumentsDownload(t *testing.T) { fb := scannedBuilder(t) api := adminAPI(fb) w := do(api.ExternalHandler(), "GET", "/api/v1/images/build/bld-7/scan/report", "", nil) if w.Code != http.StatusOK || w.Body.String() != `{"SchemaVersion":2,"Results":[]}` { t.Fatalf("report: code %d, body %q", w.Code, w.Body.String()) } for h, want := range map[string]string{ "Content-Type": "application/json", "Content-Disposition": `attachment; filename="bld-7-trivy.json"`, "X-Content-Type-Options": "nosniff", } { if got := w.Header().Get(h); got != want { t.Errorf("report %s = %q, want %q", h, got, want) } } audits := api.Repo.(*fakeRepo).audits if len(audits) != 1 || audits[0].Action != "image.build.scan.report" || audits[0].ServerName != "bld-7" { t.Errorf("audits = %+v", audits) } w = do(api.ExternalHandler(), "GET", "/api/v1/images/build/bld-7/sbom", "", nil) if w.Code != http.StatusNotFound || decodeErr(t, w) != "scan_document_not_kept" { t.Errorf("SBOM not kept: code %d, %s", w.Code, w.Body.String()) } if len(api.Repo.(*fakeRepo).audits) != 1 { t.Error("a download that sent nothing was audited") } fb.scans["bld-7"].SBOMGz = gzipped(t, `{"bomFormat":"CycloneDX","specVersion":"1.6"}`) w = do(api.ExternalHandler(), "GET", "/api/v1/images/build/bld-7/sbom", "", nil) if w.Code != http.StatusOK || w.Body.String() != `{"bomFormat":"CycloneDX","specVersion":"1.6"}` { t.Fatalf("SBOM: code %d, body %q", w.Code, w.Body.String()) } if w.Header().Get("Content-Type") != "application/vnd.cyclonedx+json" || w.Header().Get("Content-Disposition") != `attachment; filename="bld-7.cdx.json"` { t.Errorf("SBOM headers = %v", w.Header()) } if audits := api.Repo.(*fakeRepo).audits; audits[len(audits)-1].Action != "image.build.sbom" { t.Errorf("audits = %+v", audits) } }