Files
Felis/internal/api/images_test.go

469 lines
16 KiB
Go

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: "[email protected]",
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 != "[email protected]" {
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 != "[email protected]" {
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: "[email protected]",
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)
}
}