Files
Felis/internal/submit/submit_test.go
T
Lemon-miaow f79e5ebb5e feat(build): make the user-modpack build lane read its context (closes the last functional gap)
A submitted modpack was durable but unreadable: the uploads PVC cannot cross
namespaces (felis-api mounts it; Kaniko runs in felis-build) and the s3 lane
handed the sandboxed build Pod no credentials, so NO user build could ever
consume its context. The transport is now the API itself:

- submit: derived context refs become the internal-face URL
  /api/v1/internal/submissions/{id}/context (service-token gated), and Blobs
  gains Open (local + s3) with an ErrBlobNotFound sentinel for the route's 404.
- api: serves that route on the internal face only (openapi.yaml updated; the
  route-coverage test enforces it).
- build: an http(s) context renders a context-fetch initContainer (the felis
  image's new fetch-context entrypoint) that streams the blob with the
  namespace-local service-token Secret — never mounted into Kaniko — and
  extracts it under a zip-slip guard into a size-limited emptyDir that Kaniko
  reads read-only as --context=/context.
- platform/install: the api Deployment carries its own internal base URL; the
  build namespace gets the token Secret through the existing replica mechanism
  (bootstrap.sh + felis setup); the build egress lock opens exactly the control
  namespace on the internal port.
- cmd/felis: fetch-context entrypoint (registered, documented, unit-tested for
  escapes/symlinks/non-gzip).

Tests cover rendering, hardening, the s3/local Open paths, and the route's
404/503 mapping. Verified next on the real single-node cluster with Kaniko.
2026-09-22 22:45:09 +08:00

723 lines
24 KiB
Go

package submit
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/build"
)
// gzBody returns a minimal gzip-magic-prefixed blob standing in for a real
// context.tar.gz: UploadContext only sniffs the first two bytes, so the payload
// after the magic is opaque.
func gzBody(payload string) string { return "\x1f\x8b\x08\x00" + payload }
// fakeBlobs is an in-memory Blobs transport. It records what was stored so a test
// can assert the derived id was used, and can force Exists/Put outcomes.
type fakeBlobs struct {
stored map[string][]byte
putErr error
existsErr error
forceExists *bool // overrides the stored-map lookup for the approve-gate tests
}
func newFakeBlobs() *fakeBlobs { return &fakeBlobs{stored: map[string][]byte{}} }
func (f *fakeBlobs) Put(_ context.Context, id string, r io.Reader) (int64, error) {
if f.putErr != nil {
return 0, f.putErr
}
b, err := io.ReadAll(r)
if err != nil {
return 0, err // e.g. cappedReader tripping — persist nothing, mirror the real store
}
f.stored[id] = b
return int64(len(b)), nil
}
func (f *fakeBlobs) Exists(_ context.Context, id string) (bool, error) {
if f.existsErr != nil {
return false, f.existsErr
}
if f.forceExists != nil {
return *f.forceExists, nil
}
_, ok := f.stored[id]
return ok, nil
}
func (f *fakeBlobs) Open(_ context.Context, id string) (io.ReadCloser, error) {
b, ok := f.stored[id]
if !ok {
return nil, fmt.Errorf("%w: no blob for %s", ErrBlobNotFound, id)
}
return io.NopCloser(bytes.NewReader(b)), nil
}
// testNow is the frozen clock for hermetic assertions.
var testNow = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
const (
testRegistry = "registry.felis.svc:5000"
testContext = "s3://felis-user-uploads"
)
// fakeStore is an in-memory Store with CAS semantics and error injection.
type fakeStore struct {
subs map[string]*Submission
createErr error
approveErr error
rejectErr error
linkErr error
linked []string // "id=buildID" recorder
}
func newFakeStore() *fakeStore { return &fakeStore{subs: map[string]*Submission{}} }
func (f *fakeStore) CreateSubmission(_ context.Context, s *Submission) error {
if f.createErr != nil {
return f.createErr
}
cp := *s
f.subs[s.ID] = &cp
return nil
}
func (f *fakeStore) GetSubmission(_ context.Context, id string) (*Submission, error) {
s, ok := f.subs[id]
if !ok {
return nil, ErrNotFound
}
cp := *s
return &cp, nil
}
func (f *fakeStore) ListSubmissions(_ context.Context) ([]Submission, error) {
out := make([]Submission, 0, len(f.subs))
for _, s := range f.subs {
out = append(out, *s)
}
return out, nil
}
func (f *fakeStore) ListSubmissionsBy(_ context.Context, by string) ([]Submission, error) {
var out []Submission
for _, s := range f.subs {
if s.SubmittedBy == by {
out = append(out, *s)
}
}
return out, nil
}
func (f *fakeStore) ApproveSubmission(_ context.Context, id, reviewedBy, imageRef string, at time.Time) (bool, error) {
if f.approveErr != nil {
return false, f.approveErr
}
s, ok := f.subs[id]
if !ok || s.Status != StatusPendingReview {
return false, nil // CAS lost / nonexistent
}
s.Status = StatusApproved
s.ImageRef = imageRef
s.ReviewedBy = reviewedBy
t := at
s.ReviewedAt = &t
return true, nil
}
func (f *fakeStore) RejectSubmission(_ context.Context, id, reviewedBy, reason string, at time.Time) (bool, error) {
if f.rejectErr != nil {
return false, f.rejectErr
}
s, ok := f.subs[id]
if !ok || s.Status != StatusPendingReview {
return false, nil
}
s.Status = StatusRejected
s.ReviewedBy = reviewedBy
s.RejectReason = reason
t := at
s.ReviewedAt = &t
return true, nil
}
func (f *fakeStore) LinkBuild(_ context.Context, id, buildID string) error {
if f.linkErr != nil {
return f.linkErr
}
s, ok := f.subs[id]
if !ok {
return ErrNotFound
}
s.BuildID = buildID
f.linked = append(f.linked, id+"="+buildID)
return nil
}
// fakeBuilds records Submit calls and can inject a failure.
type fakeBuilds struct {
submitErr error
calls int
got []build.Request
}
func (f *fakeBuilds) Submit(_ context.Context, req build.Request) (*build.Build, error) {
f.calls++
f.got = append(f.got, req)
if f.submitErr != nil {
return nil, f.submitErr
}
return &build.Build{ID: "bld-x", ImageRef: req.ImageRef, Status: build.StatusBuilding}, nil
}
// newManager wires the fakes with a frozen clock and a deterministic lowercase
// id generator (sub-1, sub-2, ...).
func newManager() (*Manager, *fakeStore, *fakeBuilds) {
st := newFakeStore()
bl := &fakeBuilds{}
n := 0
m := &Manager{
Store: st,
Builds: bl,
Registry: testRegistry,
ContextStore: testContext,
Now: func() time.Time { return testNow },
IDGen: func() string { n++; return "sub-" + string(rune('0'+n)) },
}
return m, st, bl
}
func TestCreatePendingDoesNotBuild(t *testing.T) {
m, st, bl := newManager()
sub, err := m.Create(context.Background(), CreateRequest{
DisplayName: "My Modpack",
SubmittedBy: "user-1",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if sub.Status != StatusPendingReview {
t.Fatalf("status = %q, want pending_review", sub.Status)
}
if sub.ID != "sub-1" {
t.Fatalf("id = %q, want sub-1", sub.ID)
}
// Context ref is derived, never user-supplied.
if want := testContext + "/sub-1/context.tar.gz"; sub.ContextRef != want {
t.Fatalf("context_ref = %q, want %q", sub.ContextRef, want)
}
if sub.ImageRef != "" || sub.BuildID != "" {
t.Fatalf("image/build set before approval: %+v", sub)
}
if bl.calls != 0 {
t.Fatalf("Create started %d builds, want 0", bl.calls)
}
if _, ok := st.subs["sub-1"]; !ok {
t.Fatal("submission not persisted")
}
}
// With a ContextBaseURL the derived ref is the internal-face URL the build Pod's
// fetch initContainer dials — not a filesystem path it could never read across
// namespaces. OpenContext then serves whatever the Blobs transport stored.
func TestContextRefIsFetchURLAndOpenContextServesIt(t *testing.T) {
m, _, _ := newManager()
m.ContextBaseURL = "http://felis-api-internal.felis.svc.cluster.local:8081/"
m.Blobs = newFakeBlobs()
ctx := context.Background()
sub, err := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if err != nil {
t.Fatalf("Create: %v", err)
}
want := "http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context"
if sub.ContextRef != want {
t.Fatalf("context_ref = %q, want the internal fetch URL %q", sub.ContextRef, want)
}
// Before any upload the read path reports not-found (the route's 404).
if _, err := m.OpenContext(ctx, sub.ID); !errors.Is(err, ErrBlobNotFound) {
t.Fatalf("OpenContext before upload = %v, want ErrBlobNotFound", err)
}
payload := "\x1f\x8b\x08\x00payload"
if _, err := m.UploadContext(ctx, sub.ID, "user-1", strings.NewReader(payload)); err != nil {
t.Fatalf("UploadContext: %v", err)
}
rc, err := m.OpenContext(ctx, sub.ID)
if err != nil {
t.Fatalf("OpenContext: %v", err)
}
defer rc.Close()
got, _ := io.ReadAll(rc)
if string(got) != payload {
t.Fatalf("OpenContext served %q, want %q", got, payload)
}
}
// No upload transport ⇒ no readable blob: the route reports the same 503 the
// upload endpoint does, rather than a misleading 404.
func TestOpenContextWithoutTransportIsUnavailable(t *testing.T) {
m, _, _ := newManager()
if _, err := m.OpenContext(context.Background(), "sub-1"); !errors.Is(err, ErrUploadsUnavailable) {
t.Fatalf("OpenContext with nil Blobs = %v, want ErrUploadsUnavailable", err)
}
}
func TestCreateValidation(t *testing.T) {
cases := []struct {
name string
req CreateRequest
}{
{"empty name", CreateRequest{DisplayName: " ", SubmittedBy: "user-1"}},
{"oversize name", CreateRequest{DisplayName: strings.Repeat("a", maxDisplayName+1), SubmittedBy: "user-1"}},
{"control chars", CreateRequest{DisplayName: "bad\nname", SubmittedBy: "user-1"}},
{"no submitter", CreateRequest{DisplayName: "ok", SubmittedBy: ""}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, st, bl := newManager()
_, err := m.Create(context.Background(), tc.req)
if !errors.Is(err, ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid", err)
}
if len(st.subs) != 0 || bl.calls != 0 {
t.Fatal("invalid Create persisted or built")
}
})
}
}
func TestApproveStartsExactlyOneBuildAndLinks(t *testing.T) {
m, st, bl := newManager()
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
sub, err := m.Approve(context.Background(), seed.ID, "[email protected]")
if err != nil {
t.Fatalf("Approve: %v", err)
}
if sub.Status != StatusApproved {
t.Fatalf("status = %q, want approved", sub.Status)
}
wantImage := testRegistry + "/user-uploads/sub-1:latest"
if sub.ImageRef != wantImage {
t.Fatalf("image_ref = %q, want %q", sub.ImageRef, wantImage)
}
if sub.BuildID != "bld-x" {
t.Fatalf("build_id = %q, want bld-x", sub.BuildID)
}
if sub.ReviewedBy != "[email protected]" || sub.ReviewedAt == nil {
t.Fatalf("review metadata missing: %+v", sub)
}
// Exactly one build, carrying the derived refs + reviewer + audit Dockerfile.
if bl.calls != 1 {
t.Fatalf("builds started = %d, want 1", bl.calls)
}
req := bl.got[0]
if req.ImageRef != wantImage {
t.Fatalf("build ImageRef = %q, want %q", req.ImageRef, wantImage)
}
if req.ContextRef != seed.ContextRef {
t.Fatalf("build ContextRef = %q, want %q", req.ContextRef, seed.ContextRef)
}
if req.RequestedBy != "[email protected]" {
t.Fatalf("build RequestedBy = %q, want admin", req.RequestedBy)
}
if strings.TrimSpace(req.Dockerfile) == "" {
t.Fatal("audit Dockerfile must be non-empty (build.Validate requires it)")
}
// The persisted row links the build.
if got := st.subs[seed.ID]; got.BuildID != "bld-x" || got.Status != StatusApproved {
t.Fatalf("persisted row not linked/approved: %+v", got)
}
if len(st.linked) != 1 {
t.Fatalf("LinkBuild called %d times, want 1", len(st.linked))
}
}
func TestApproveDerivedRefValidates(t *testing.T) {
// The derived push target must satisfy the build subsystem's own admission
// rules, else Approve would CAS then fail at Submit. Prove it against the SAME
// validator the Builder uses.
m, _, _ := newManager()
ref := m.deriveImageRef("sub-1")
req := build.Request{
ImageRef: ref,
Dockerfile: auditDockerfile("sub-1", m.deriveContextRef("sub-1")),
ContextRef: m.deriveContextRef("sub-1"),
}
if err := build.Validate(req, build.Config{RegistryURL: testRegistry}); err != nil {
t.Fatalf("derived build request does not validate: %v", err)
}
if !strings.HasPrefix(ref, testRegistry+"/user-uploads/") {
t.Fatalf("derived ref %q is not in the user-uploads namespace", ref)
}
}
func TestApproveIsCASNoDoubleBuild(t *testing.T) {
m, _, bl := newManager()
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.Approve(context.Background(), seed.ID, "admin@x"); err != nil {
t.Fatalf("first Approve: %v", err)
}
// A second approve loses the CAS and must NOT start another build.
_, err := m.Approve(context.Background(), seed.ID, "admin@x")
if !errors.Is(err, ErrAlreadyReviewed) {
t.Fatalf("second Approve err = %v, want ErrAlreadyReviewed", err)
}
if bl.calls != 1 {
t.Fatalf("builds started = %d, want 1 (no double-build)", bl.calls)
}
}
func TestApproveRejectedSubmission(t *testing.T) {
m, _, bl := newManager()
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.Reject(context.Background(), seed.ID, "admin@x", "nope"); err != nil {
t.Fatalf("Reject: %v", err)
}
_, err := m.Approve(context.Background(), seed.ID, "admin@x")
if !errors.Is(err, ErrAlreadyReviewed) {
t.Fatalf("Approve after reject err = %v, want ErrAlreadyReviewed", err)
}
if bl.calls != 0 {
t.Fatalf("builds started = %d, want 0", bl.calls)
}
}
func TestApproveUnknown(t *testing.T) {
m, _, _ := newManager()
_, err := m.Approve(context.Background(), "sub-nope", "admin@x")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestApproveBuildFailureLeavesApprovedUnlinked(t *testing.T) {
// The post-CAS Submit failure is the documented KNOWN-LIMITATION: the row is
// left approved with build_id NULL, recoverable by an admin via the direct
// build path. It must NOT roll back to pending or double-count.
m, st, bl := newManager()
bl.submitErr = errors.New("apiserver unreachable")
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Approve(context.Background(), seed.ID, "admin@x")
if err == nil {
t.Fatal("Approve should surface the build hand-off failure")
}
if bl.calls != 1 {
t.Fatalf("builds attempted = %d, want 1", bl.calls)
}
got := st.subs[seed.ID]
if got.Status != StatusApproved {
t.Fatalf("status = %q, want approved (CAS already committed)", got.Status)
}
if got.BuildID != "" {
t.Fatalf("build_id = %q, want empty (Submit failed before LinkBuild)", got.BuildID)
}
if len(st.linked) != 0 {
t.Fatal("LinkBuild must not run when Submit failed")
}
}
func TestApproveLinkFailureNamesRunningBuild(t *testing.T) {
// The WORSE post-CAS partial state: Submit SUCCEEDS (a build is genuinely
// running and will push to image_ref) but the follow-up LinkBuild write fails,
// so the row is approved with build_id still NULL. On the row this is
// indistinguishable from the benign Submit-fail case above, so a blind re-drive
// would double-build/double-push to :latest. Approve must therefore return a
// DISTINCT error that names the running build id and forbids re-submission —
// this exercises that branch (the benign test above does not).
m, st, bl := newManager()
st.linkErr = errors.New("db write timeout")
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Approve(context.Background(), seed.ID, "admin@x")
if err == nil {
t.Fatal("Approve must surface the link-write failure (a build is running)")
}
// A build WAS started — that is precisely why re-driving is unsafe.
if bl.calls != 1 {
t.Fatalf("builds started = %d, want 1 (Submit succeeded, build is running)", bl.calls)
}
// The error must name the running build id and the do-not-resubmit warning, and
// wrap the underlying store error so callers can still inspect the cause.
if !strings.Contains(err.Error(), "bld-x") {
t.Fatalf("error must name the running build id bld-x: %v", err)
}
if !strings.Contains(err.Error(), "do NOT re-submit") {
t.Fatalf("error must warn against re-submission: %v", err)
}
if !errors.Is(err, st.linkErr) {
t.Fatalf("error must wrap the underlying link failure: %v", err)
}
// The row is approved (CAS committed) but build_id stays empty — the very
// ambiguity the distinct error compensates for.
got := st.subs[seed.ID]
if got.Status != StatusApproved {
t.Fatalf("status = %q, want approved (CAS already committed)", got.Status)
}
if got.BuildID != "" {
t.Fatalf("build_id = %q, want empty (LinkBuild failed to record it)", got.BuildID)
}
if len(st.linked) != 0 {
t.Fatal("LinkBuild errored before recording — must not appear linked")
}
}
func TestApproveValidatesBeforeCAS(t *testing.T) {
// A misconfigured registry makes the derived ref fail build.Validate. That
// deterministic failure must happen BEFORE the CAS, so the row stays pending
// and no build is attempted — never a stranded approved row.
m, st, bl := newManager()
m.Registry = "" // derived ref becomes "/user-uploads/...", not host-qualified
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Approve(context.Background(), seed.ID, "admin@x")
if !errors.Is(err, build.ErrInvalid) {
t.Fatalf("err = %v, want build.ErrInvalid (pre-CAS validation)", err)
}
if got := st.subs[seed.ID]; got.Status != StatusPendingReview {
t.Fatalf("status = %q, want still pending_review (CAS not reached)", got.Status)
}
if bl.calls != 0 {
t.Fatalf("builds started = %d, want 0", bl.calls)
}
}
func TestRejectDoesNotBuild(t *testing.T) {
m, st, bl := newManager()
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
sub, err := m.Reject(context.Background(), seed.ID, "admin@x", "ships a coin miner")
if err != nil {
t.Fatalf("Reject: %v", err)
}
if sub.Status != StatusRejected || sub.RejectReason != "ships a coin miner" {
t.Fatalf("reject verdict not recorded: %+v", sub)
}
if sub.ReviewedBy != "admin@x" || sub.ReviewedAt == nil {
t.Fatalf("review metadata missing: %+v", sub)
}
if bl.calls != 0 {
t.Fatalf("builds started = %d, want 0", bl.calls)
}
if st.subs[seed.ID].Status != StatusRejected {
t.Fatal("rejection not persisted")
}
}
func TestRejectRequiresReason(t *testing.T) {
m, st, _ := newManager()
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Reject(context.Background(), seed.ID, "admin@x", " ")
if !errors.Is(err, ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid", err)
}
if got := st.subs[seed.ID]; got.Status != StatusPendingReview {
t.Fatalf("status = %q, want pending_review", got.Status)
}
}
func TestListBy(t *testing.T) {
m, _, _ := newManager()
if _, err := m.Create(context.Background(), CreateRequest{DisplayName: "A", SubmittedBy: "user-1"}); err != nil {
t.Fatal(err)
}
if _, err := m.Create(context.Background(), CreateRequest{DisplayName: "B", SubmittedBy: "user-2"}); err != nil {
t.Fatal(err)
}
mine, err := m.ListBy(context.Background(), "user-1")
if err != nil {
t.Fatalf("ListBy: %v", err)
}
if len(mine) != 1 || mine[0].SubmittedBy != "user-1" {
t.Fatalf("ListBy scoped wrong: %+v", mine)
}
if _, err := m.ListBy(context.Background(), ""); !errors.Is(err, ErrInvalid) {
t.Fatalf("empty submitter err = %v, want ErrInvalid", err)
}
}
func TestUploadContextStoresUnderDerivedID(t *testing.T) {
m, _, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
payload := gzBody("the modpack bytes")
sub, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader(payload))
if err != nil {
t.Fatalf("UploadContext: %v", err)
}
if sub.ID != seed.ID {
t.Fatalf("returned submission %q, want %q", sub.ID, seed.ID)
}
// The blob is stored under the submission id (the pinned, id-namespaced key),
// never a caller-supplied path.
got, ok := fb.stored[seed.ID]
if !ok {
t.Fatalf("nothing stored under id %q; stored keys: %v", seed.ID, keysOf(fb.stored))
}
if string(got) != payload {
t.Fatalf("stored %q, want the uploaded bytes", got)
}
}
func TestUploadContextRejectsNonGzip(t *testing.T) {
m, _, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader("PK\x03\x04 a zip, not gzip"))
if !errors.Is(err, ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid", err)
}
if len(fb.stored) != 0 {
t.Fatal("a wrong-format upload must persist nothing")
}
}
func TestUploadContextOversizeRejectedAndNotPersisted(t *testing.T) {
m, _, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
m.MaxContextBytes = 8 // tiny cap
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
// gzBody's 4-byte magic + payload well over 8 bytes total.
_, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader(gzBody("this is far too large")))
if !errors.Is(err, ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid (too large)", err)
}
if len(fb.stored) != 0 {
t.Fatal("an oversize upload must persist nothing")
}
}
func TestUploadContextExactlyAtCapAccepted(t *testing.T) {
m, _, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
body := gzBody("payload") // measure and cap at exactly this length
m.MaxContextBytes = int64(len(body))
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader(body)); err != nil {
t.Fatalf("a blob of exactly the cap must be accepted, got %v", err)
}
if string(fb.stored[seed.ID]) != body {
t.Fatalf("stored %q, want the full body (no truncation at the cap)", fb.stored[seed.ID])
}
}
func TestUploadContextWrongOwnerIsNotFound(t *testing.T) {
m, _, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
// A different user uploading to user-1's submission sees 404, not 403: the id
// is invisible so it cannot be probed.
_, err := m.UploadContext(context.Background(), seed.ID, "user-2", strings.NewReader(gzBody("x")))
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
if len(fb.stored) != 0 {
t.Fatal("a non-owner upload must persist nothing")
}
}
func TestUploadContextNotPendingIsAlreadyReviewed(t *testing.T) {
m, _, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.Reject(context.Background(), seed.ID, "admin@x", "nope"); err != nil {
t.Fatalf("Reject: %v", err)
}
_, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader(gzBody("x")))
if !errors.Is(err, ErrAlreadyReviewed) {
t.Fatalf("err = %v, want ErrAlreadyReviewed (context is frozen once reviewed)", err)
}
}
func TestUploadContextUnknownSubmission(t *testing.T) {
m, _, _ := newManager()
m.Blobs = newFakeBlobs()
_, err := m.UploadContext(context.Background(), "sub-nope", "user-1", strings.NewReader(gzBody("x")))
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestUploadContextNoTransportUnavailable(t *testing.T) {
m, _, _ := newManager() // Blobs left nil
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader(gzBody("x")))
if !errors.Is(err, ErrUploadsUnavailable) {
t.Fatalf("err = %v, want ErrUploadsUnavailable", err)
}
}
func TestApproveRefusesMissingContext(t *testing.T) {
// With a transport wired, approving a submission whose context was never
// uploaded fails BEFORE the CAS: the row stays pending and no build starts.
m, st, bl := newManager()
m.Blobs = newFakeBlobs() // empty → Exists=false
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Approve(context.Background(), seed.ID, "admin@x")
if !errors.Is(err, ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid (no context uploaded)", err)
}
if got := st.subs[seed.ID]; got.Status != StatusPendingReview {
t.Fatalf("status = %q, want still pending_review (CAS not reached)", got.Status)
}
if bl.calls != 0 {
t.Fatalf("builds started = %d, want 0", bl.calls)
}
}
func TestApproveProceedsWithUploadedContext(t *testing.T) {
// The end-to-end user path: create -> upload -> admin approve -> exactly one
// build through the SAME gated Builder.
m, _, bl := newManager()
m.Blobs = newFakeBlobs()
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader(gzBody("mods"))); err != nil {
t.Fatalf("UploadContext: %v", err)
}
sub, err := m.Approve(context.Background(), seed.ID, "admin@x")
if err != nil {
t.Fatalf("Approve: %v", err)
}
if sub.Status != StatusApproved {
t.Fatalf("status = %q, want approved", sub.Status)
}
if bl.calls != 1 {
t.Fatalf("builds started = %d, want 1", bl.calls)
}
}
// keysOf lists a map's keys for test diagnostics.
func keysOf(m map[string][]byte) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}