The create-server form has no way to tell a user which of the whitelisted images is a sensible starting point. Add 'recommended' as a third image_whitelist.source alongside 'built' and 'external', and seed it with the one image that has earned it. The marker is presentation only. ImageAdmitted still turns solely on enabled, so a recommended row is admitted by exactly the rule that governs every other row and carries no extra privilege; a test pins both halves, because the failure modes are silent and opposite — make admission source-aware and the curated images vanish from the form, or let curation bypass the disable switch and an admin who pulled an image finds it still creatable. Only one image is seeded, and the restraint is the point. Velocity runs proxy-wide modern forwarding, so a backend that cannot verify the signed handshake rejects every login the proxy sends it. The operator injects FELIS_FORWARDING_SECRET into every backend but cannot make an image consume it. An arbitrary public Minecraft image therefore passes admission, builds, schedules, reports Ready — and then refuses every join, with nothing in the server's status explaining why. Exactly two images read that variable, deploy/limbo and deploy/lobby; limbo is the login gate and is nonsense as a base for a user's server, which leaves lobby. The list grows when Felis ships another forwarding-aware image, not before. AdmitBuiltImage now preserves a 'recommended' source through its ON CONFLICT path. Rebuilding a curated tag is the expected way to patch it, and that rebuild arrives through this exact path, so a blind SET source = 'built' would demote the curation on the first rebuild with nothing in the request saying so. AddExternalImage deliberately does not preserve it: an admin POSTing the ref is an explicit, named re-admission, and the 201 body reports the Image it constructed without re-reading the row, so a sticky source there would report a value the database does not hold. The migration is idempotent via ON CONFLICT DO NOTHING, so an admin who disabled or re-pointed the row does not have that decision undone on the next apply.
564 lines
17 KiB
Go
564 lines
17 KiB
Go
package build
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/metrics"
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
)
|
|
|
|
var testNow = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
// ---- in-memory fakes ----
|
|
|
|
type fakeStore struct {
|
|
builds map[string]*Build
|
|
images map[string]Image
|
|
// call recorders
|
|
admitted []Image
|
|
finished []string // "id:status"
|
|
removeErr error
|
|
createErr error
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{builds: map[string]*Build{}, images: map[string]Image{}}
|
|
}
|
|
|
|
func (f *fakeStore) CreateBuild(_ context.Context, b *Build) error {
|
|
if f.createErr != nil {
|
|
return f.createErr
|
|
}
|
|
cp := *b
|
|
f.builds[b.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) GetBuild(_ context.Context, id string) (*Build, error) {
|
|
b, ok := f.builds[id]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
cp := *b
|
|
return &cp, nil
|
|
}
|
|
|
|
func (f *fakeStore) SetBuildJob(_ context.Context, id, jobName string) error {
|
|
b, ok := f.builds[id]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
b.JobName = jobName
|
|
b.Status = StatusBuilding
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) FinishBuild(_ context.Context, id string, status Status, errMsg string, at time.Time) error {
|
|
b, ok := f.builds[id]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
b.Status = status
|
|
b.Error = errMsg
|
|
finished := at
|
|
b.FinishedAt = &finished
|
|
f.finished = append(f.finished, id+":"+string(status))
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) ListUnfinishedBuilds(_ context.Context) ([]Build, error) {
|
|
var out []Build
|
|
for _, b := range f.builds {
|
|
if !b.Status.terminal() {
|
|
out = append(out, *b)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) AdmitBuiltImage(_ context.Context, img Image) error {
|
|
f.images[img.ImageRef] = img
|
|
f.admitted = append(f.admitted, img)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) ListImages(_ context.Context) ([]Image, error) {
|
|
var out []Image
|
|
for _, img := range f.images {
|
|
out = append(out, img)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) AddExternalImage(_ context.Context, img Image) error {
|
|
f.images[img.ImageRef] = img
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) RemoveImage(_ context.Context, ref string) error {
|
|
if f.removeErr != nil {
|
|
return f.removeErr
|
|
}
|
|
if _, ok := f.images[ref]; !ok {
|
|
return ErrNotFound
|
|
}
|
|
delete(f.images, ref)
|
|
return nil
|
|
}
|
|
|
|
type fakeJobs struct {
|
|
phase JobPhase
|
|
phaseErr error
|
|
createErr error
|
|
|
|
created []JobParams
|
|
cancelled []string
|
|
}
|
|
|
|
func (f *fakeJobs) CreateBuildJob(_ context.Context, p JobParams) (string, error) {
|
|
if f.createErr != nil {
|
|
return "", f.createErr
|
|
}
|
|
f.created = append(f.created, p)
|
|
return BuildJobName(p.BuildID), nil
|
|
}
|
|
|
|
func (f *fakeJobs) JobPhase(_ context.Context, _ string) (JobPhase, error) {
|
|
return f.phase, f.phaseErr
|
|
}
|
|
|
|
func (f *fakeJobs) CancelBuildJob(_ context.Context, jobName string) error {
|
|
f.cancelled = append(f.cancelled, jobName)
|
|
return nil
|
|
}
|
|
|
|
// newBuilder wires a Builder over fresh fakes with a frozen clock and a
|
|
// deterministic id generator.
|
|
func newBuilder() (*Builder, *fakeStore, *fakeJobs) {
|
|
st := newFakeStore()
|
|
jb := &fakeJobs{phase: JobPending}
|
|
n := 0
|
|
b := &Builder{
|
|
Store: st,
|
|
Jobs: jb,
|
|
Config: Config{
|
|
RegistryURL: "registry.felis.svc:5000",
|
|
},
|
|
Now: func() time.Time { return testNow },
|
|
IDGen: func() string {
|
|
n++
|
|
return "bld-" + string(rune('0'+n))
|
|
},
|
|
}
|
|
return b, st, jb
|
|
}
|
|
|
|
func goodRequest() Request {
|
|
return Request{
|
|
ImageRef: "registry.felis.svc:5000/mc-paper:1.0",
|
|
Dockerfile: "FROM eclipse-temurin:21\nCOPY . /data\n",
|
|
ContextRef: "tar://contexts/abc.tar.gz",
|
|
RequestedBy: "[email protected]",
|
|
}
|
|
}
|
|
|
|
// ---- submission ----
|
|
|
|
func TestSubmitCreatesPendingBuildAndStartsJob(t *testing.T) {
|
|
b, st, jb := newBuilder()
|
|
bld, err := b.Submit(context.Background(), goodRequest())
|
|
if err != nil {
|
|
t.Fatalf("Submit: %v", err)
|
|
}
|
|
if bld.Status != StatusBuilding {
|
|
t.Errorf("status = %q, want building", bld.Status)
|
|
}
|
|
if bld.JobName == "" {
|
|
t.Error("job name not recorded")
|
|
}
|
|
if got := st.builds[bld.ID]; got == nil || got.Dockerfile == "" {
|
|
t.Error("build row not persisted with dockerfile")
|
|
}
|
|
if len(jb.created) != 1 {
|
|
t.Fatalf("CreateBuildJob calls = %d, want 1", len(jb.created))
|
|
}
|
|
// The Job must be parameterised with the weak build SA and the namespace,
|
|
// never the api identity — this is the §16 red line, asserted at the seam.
|
|
p := jb.created[0]
|
|
if p.ServiceAccount != defaultServiceAccount {
|
|
t.Errorf("job SA = %q, want %q", p.ServiceAccount, defaultServiceAccount)
|
|
}
|
|
if p.Namespace != defaultNamespace {
|
|
t.Errorf("job namespace = %q, want %q", p.Namespace, defaultNamespace)
|
|
}
|
|
if p.RegistryURL != "registry.felis.svc:5000" {
|
|
t.Errorf("job registry = %q", p.RegistryURL)
|
|
}
|
|
if p.Deadline <= 0 {
|
|
t.Error("job deadline not set")
|
|
}
|
|
}
|
|
|
|
func TestSubmitRejectsExternalRegistryTarget(t *testing.T) {
|
|
b, st, jb := newBuilder()
|
|
req := goodRequest()
|
|
req.ImageRef = "docker.io/library/evil:latest"
|
|
if _, err := b.Submit(context.Background(), req); err == nil {
|
|
t.Fatal("expected rejection of non-internal registry target")
|
|
}
|
|
if len(st.builds) != 0 {
|
|
t.Error("a rejected build must not be persisted")
|
|
}
|
|
if len(jb.created) != 0 {
|
|
t.Error("a rejected build must not start a job")
|
|
}
|
|
}
|
|
|
|
func TestSubmitRejectsEmptyAndOversizeDockerfile(t *testing.T) {
|
|
b, _, _ := newBuilder()
|
|
req := goodRequest()
|
|
req.Dockerfile = " "
|
|
if _, err := b.Submit(context.Background(), req); err == nil {
|
|
t.Error("expected rejection of empty dockerfile")
|
|
}
|
|
|
|
b2, _, _ := newBuilder()
|
|
b2.Config.MaxDockerfileBytes = 16
|
|
req2 := goodRequest()
|
|
req2.Dockerfile = "FROM scratch\n# padding padding padding padding\n"
|
|
if _, err := b2.Submit(context.Background(), req2); err == nil {
|
|
t.Error("expected rejection of oversize dockerfile")
|
|
}
|
|
}
|
|
|
|
func TestSubmitRequiresContext(t *testing.T) {
|
|
b, _, _ := newBuilder()
|
|
req := goodRequest()
|
|
req.ContextRef = ""
|
|
if _, err := b.Submit(context.Background(), req); err == nil {
|
|
t.Error("expected rejection when context reference is missing")
|
|
}
|
|
}
|
|
|
|
func TestSubmitMarksBuildFailedWhenJobCreationFails(t *testing.T) {
|
|
b, st, jb := newBuilder()
|
|
jb.createErr = errors.New("apiserver down")
|
|
bld, err := b.Submit(context.Background(), goodRequest())
|
|
if err == nil {
|
|
t.Fatal("expected error when job creation fails")
|
|
}
|
|
if bld == nil || bld.Status != StatusFailed {
|
|
t.Fatalf("build should be marked failed, got %+v", bld)
|
|
}
|
|
// The persisted row must not be left pending forever.
|
|
if got := st.builds[bld.ID]; got == nil || got.Status != StatusFailed {
|
|
t.Errorf("persisted status = %v, want failed", got)
|
|
}
|
|
}
|
|
|
|
// ---- the scan gate (Job phase -> DB) ----
|
|
|
|
func TestSyncSucceededAdmitsImageWithAddedBy(t *testing.T) {
|
|
b, st, jb := newBuilder()
|
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
|
|
|
jb.phase = JobSucceeded
|
|
got, err := b.Sync(context.Background(), bld.ID)
|
|
if err != nil {
|
|
t.Fatalf("Sync: %v", err)
|
|
}
|
|
if got.Status != StatusSucceeded {
|
|
t.Errorf("status = %q, want succeeded", got.Status)
|
|
}
|
|
img, ok := st.images[bld.ImageRef]
|
|
if !ok {
|
|
t.Fatal("succeeded build did not admit its image to the whitelist")
|
|
}
|
|
if !img.Enabled {
|
|
t.Error("admitted image must be enabled=true")
|
|
}
|
|
if img.Source != SourceBuilt {
|
|
t.Errorf("source = %q, want built", img.Source)
|
|
}
|
|
if img.AddedBy != "[email protected]" {
|
|
t.Errorf("added_by = %q, want the requester", img.AddedBy)
|
|
}
|
|
if img.BuildID != bld.ID {
|
|
t.Errorf("build_id = %q, want %q", img.BuildID, bld.ID)
|
|
}
|
|
}
|
|
|
|
func TestSyncFailedDoesNotAdmitImage(t *testing.T) {
|
|
// A failed Job is exactly the CRITICAL-CVE case: trivy --exit-code 1 fails
|
|
// the Pod, so the gate is enforced as the Job verdict (spec §16).
|
|
b, st, jb := newBuilder()
|
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
|
|
|
jb.phase = JobFailed
|
|
got, err := b.Sync(context.Background(), bld.ID)
|
|
if err != nil {
|
|
t.Fatalf("Sync: %v", err)
|
|
}
|
|
if got.Status != StatusFailed {
|
|
t.Errorf("status = %q, want failed", got.Status)
|
|
}
|
|
if len(st.images) != 0 {
|
|
t.Error("a failed/CRITICAL build must NOT admit any image")
|
|
}
|
|
if len(st.admitted) != 0 {
|
|
t.Error("AdmitBuiltImage must not be called on failure")
|
|
}
|
|
}
|
|
|
|
func TestSyncRunningIsNoOp(t *testing.T) {
|
|
b, _, jb := newBuilder()
|
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
|
jb.phase = JobRunning
|
|
got, err := b.Sync(context.Background(), bld.ID)
|
|
if err != nil {
|
|
t.Fatalf("Sync: %v", err)
|
|
}
|
|
if got.Status != StatusBuilding {
|
|
t.Errorf("status = %q, want building (unchanged)", got.Status)
|
|
}
|
|
}
|
|
|
|
func TestSyncIsIdempotentOnTerminalBuild(t *testing.T) {
|
|
b, st, jb := newBuilder()
|
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
|
jb.phase = JobSucceeded
|
|
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
|
|
t.Fatalf("first Sync: %v", err)
|
|
}
|
|
// Flip the phase to a value that would admit again; a terminal build must
|
|
// not be re-reconciled.
|
|
before := len(st.admitted)
|
|
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
|
|
t.Fatalf("second Sync: %v", err)
|
|
}
|
|
if len(st.admitted) != before {
|
|
t.Error("terminal build was re-admitted on a second Sync")
|
|
}
|
|
}
|
|
|
|
func TestSyncAllAdvancesUnfinishedBuilds(t *testing.T) {
|
|
b, _, jb := newBuilder()
|
|
if _, err := b.Submit(context.Background(), goodRequest()); err != nil {
|
|
t.Fatalf("submit: %v", err)
|
|
}
|
|
jb.phase = JobSucceeded
|
|
n, err := b.SyncAll(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("SyncAll: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("advanced = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// TestImageBuildFailuresMetricCountsFailedBuilds asserts felis_image_build_failures_total
|
|
// (spec §23) advances exactly once per failed build from BOTH terminal-failure
|
|
// producers, and never on a successful build. There are two distinct Inc sites —
|
|
// finishAt (the Sync verdict) and Submit's job-creation bypass — so each is
|
|
// exercised separately; the success case is the negative control proving the
|
|
// StatusFailed guard discriminates rather than firing on every terminal write.
|
|
// Deltas are read around each action because the counter is a process-global
|
|
// singleton these package tests share (they run sequentially).
|
|
func TestImageBuildFailuresMetricCountsFailedBuilds(t *testing.T) {
|
|
read := func() float64 { return testutil.ToFloat64(metrics.ImageBuildFailuresTotal) }
|
|
|
|
t.Run("sync job-failed verdict increments via finishAt", func(t *testing.T) {
|
|
b, _, jb := newBuilder()
|
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
|
before := read()
|
|
jb.phase = JobFailed
|
|
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
|
|
t.Fatalf("Sync: %v", err)
|
|
}
|
|
if got := read() - before; got != 1 {
|
|
t.Errorf("failures delta = %v, want 1", got)
|
|
}
|
|
})
|
|
|
|
t.Run("submit job-create failure increments via bypass", func(t *testing.T) {
|
|
b, _, jb := newBuilder()
|
|
jb.createErr = errors.New("apiserver down")
|
|
before := read()
|
|
if _, err := b.Submit(context.Background(), goodRequest()); err == nil {
|
|
t.Fatal("expected Submit error when job creation fails")
|
|
}
|
|
if got := read() - before; got != 1 {
|
|
t.Errorf("failures delta = %v, want 1", got)
|
|
}
|
|
})
|
|
|
|
t.Run("successful build does not increment", func(t *testing.T) {
|
|
b, _, jb := newBuilder()
|
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
|
before := read()
|
|
jb.phase = JobSucceeded
|
|
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
|
|
t.Fatalf("Sync: %v", err)
|
|
}
|
|
if got := read() - before; got != 0 {
|
|
t.Errorf("failures delta on success = %v, want 0", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---- cancellation ----
|
|
|
|
func TestCancelDeletesJobAndMarksCancelled(t *testing.T) {
|
|
b, _, jb := newBuilder()
|
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
|
got, err := b.Cancel(context.Background(), bld.ID)
|
|
if err != nil {
|
|
t.Fatalf("Cancel: %v", err)
|
|
}
|
|
if got.Status != StatusCancelled {
|
|
t.Errorf("status = %q, want cancelled", got.Status)
|
|
}
|
|
if len(jb.cancelled) != 1 {
|
|
t.Errorf("CancelBuildJob calls = %d, want 1", len(jb.cancelled))
|
|
}
|
|
}
|
|
|
|
func TestCancelTerminalBuildFails(t *testing.T) {
|
|
b, _, jb := newBuilder()
|
|
bld, _ := b.Submit(context.Background(), goodRequest())
|
|
jb.phase = JobSucceeded
|
|
if _, err := b.Sync(context.Background(), bld.ID); err != nil {
|
|
t.Fatalf("Sync: %v", err)
|
|
}
|
|
if _, err := b.Cancel(context.Background(), bld.ID); !errors.Is(err, ErrAlreadyTerminal) {
|
|
t.Errorf("Cancel on terminal build err = %v, want ErrAlreadyTerminal", err)
|
|
}
|
|
}
|
|
|
|
// ---- external admission ----
|
|
|
|
func TestAddExternalImageIsEnabledAndRecorded(t *testing.T) {
|
|
b, st, _ := newBuilder()
|
|
img, err := b.AddExternalImage(context.Background(), "registry.felis.svc:5000/ext:1", "[email protected]")
|
|
if err != nil {
|
|
t.Fatalf("AddExternalImage: %v", err)
|
|
}
|
|
if img.Source != SourceExternal || !img.Enabled {
|
|
t.Errorf("external image = %+v, want enabled external", img)
|
|
}
|
|
if _, ok := st.images["registry.felis.svc:5000/ext:1"]; !ok {
|
|
t.Error("external image not persisted")
|
|
}
|
|
}
|
|
|
|
func TestAddExternalImageRejectsMalformedRef(t *testing.T) {
|
|
b, _, _ := newBuilder()
|
|
if _, err := b.AddExternalImage(context.Background(), "not a ref!!", "[email protected]"); err == nil {
|
|
t.Error("expected rejection of malformed image ref")
|
|
}
|
|
}
|
|
|
|
func TestRemoveImage(t *testing.T) {
|
|
b, st, _ := newBuilder()
|
|
st.images["registry.felis.svc:5000/x:1"] = Image{ImageRef: "registry.felis.svc:5000/x:1"}
|
|
if err := b.RemoveImage(context.Background(), "registry.felis.svc:5000/x:1"); err != nil {
|
|
t.Fatalf("RemoveImage: %v", err)
|
|
}
|
|
if _, ok := st.images["registry.felis.svc:5000/x:1"]; ok {
|
|
t.Error("image not removed")
|
|
}
|
|
}
|
|
|
|
// ---- whitelist admission for the create-server form (spec §15) ----
|
|
|
|
func TestImageMatches(t *testing.T) {
|
|
cases := []struct {
|
|
ref, pattern string
|
|
want bool
|
|
}{
|
|
// exact match
|
|
{"registry.felis.svc:5000/mc:1.0", "registry.felis.svc:5000/mc:1.0", true},
|
|
// exact mismatch on tag
|
|
{"registry.felis.svc:5000/mc:1.0", "registry.felis.svc:5000/mc:2.0", false},
|
|
// tag wildcard admits any concrete tag on the repo
|
|
{"registry.felis.svc:5000/mc:1.0", "registry.felis.svc:5000/mc:*", true},
|
|
{"registry.felis.svc:5000/mc:anything", "registry.felis.svc:5000/mc:*", true},
|
|
// wildcard does not cross repos
|
|
{"registry.felis.svc:5000/other:1", "registry.felis.svc:5000/mc:*", false},
|
|
// a registry-port colon is not a tag separator: an untagged ref under a
|
|
// ported host has no tag, so a wildcard (which needs a non-empty tag) misses
|
|
{"registry.felis.svc:5000/mc", "registry.felis.svc:5000/mc:*", false},
|
|
// a non-wildcard pattern never matches via the prefix branch
|
|
{"registry.felis.svc:5000/mc:1", "registry.felis.svc:5000/mc", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := imageMatches(c.ref, c.pattern); got != c.want {
|
|
t.Errorf("imageMatches(%q, %q) = %v, want %v", c.ref, c.pattern, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImageAdmitted(t *testing.T) {
|
|
b, st, _ := newBuilder()
|
|
st.images["registry.felis.svc:5000/exact:1"] = Image{ImageRef: "registry.felis.svc:5000/exact:1", Enabled: true}
|
|
st.images["registry.felis.svc:5000/wild:*"] = Image{ImageRef: "registry.felis.svc:5000/wild:*", Enabled: true}
|
|
st.images["registry.felis.svc:5000/off:1"] = Image{ImageRef: "registry.felis.svc:5000/off:1", Enabled: false}
|
|
|
|
cases := []struct {
|
|
ref string
|
|
want bool
|
|
}{
|
|
{"registry.felis.svc:5000/exact:1", true}, // exact, enabled
|
|
{"registry.felis.svc:5000/exact:2", false}, // wrong tag
|
|
{"registry.felis.svc:5000/wild:99", true}, // wildcard, enabled, port-colon safe
|
|
{"registry.felis.svc:5000/off:1", false}, // present but disabled
|
|
{"registry.felis.svc:5000/unknown:1", false}, // not on the list
|
|
{"", false}, // empty ref
|
|
{" ", false}, // blank ref
|
|
}
|
|
for _, c := range cases {
|
|
got, err := b.ImageAdmitted(context.Background(), c.ref)
|
|
if err != nil {
|
|
t.Fatalf("ImageAdmitted(%q): %v", c.ref, err)
|
|
}
|
|
if got != c.want {
|
|
t.Errorf("ImageAdmitted(%q) = %v, want %v", c.ref, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A 'recommended' row (0018) is curation, not capability: it must be admitted by
|
|
// exactly the rule that governs every other source, and it must not become a way
|
|
// to bypass the disable switch. Both halves are pinned here because the failure
|
|
// modes are silent and opposite — make admission source-aware in one direction
|
|
// and the curated images quietly vanish from the create-server form; in the
|
|
// other, a disabled recommendation stays creatable after an admin pulled it.
|
|
func TestRecommendedImageAdmittedLikeAnyOtherSource(t *testing.T) {
|
|
b, st, _ := newBuilder()
|
|
st.images["felis-lobby:demo"] = Image{
|
|
ImageRef: "felis-lobby:demo", Source: SourceRecommended, Enabled: true,
|
|
}
|
|
st.images["felis-lobby:pulled"] = Image{
|
|
ImageRef: "felis-lobby:pulled", Source: SourceRecommended, Enabled: false,
|
|
}
|
|
|
|
admitted, err := b.ImageAdmitted(context.Background(), "felis-lobby:demo")
|
|
if err != nil {
|
|
t.Fatalf("ImageAdmitted: %v", err)
|
|
}
|
|
if !admitted {
|
|
t.Error("an enabled recommended image must be admitted; curation must not cost admission")
|
|
}
|
|
|
|
admitted, err = b.ImageAdmitted(context.Background(), "felis-lobby:pulled")
|
|
if err != nil {
|
|
t.Fatalf("ImageAdmitted: %v", err)
|
|
}
|
|
if admitted {
|
|
t.Error("a disabled recommended image must not be admitted; curation is not a disable bypass")
|
|
}
|
|
}
|