A logged-in user could file submissions without bound and stream a 1 GiB context per submission. The only limits were the single-blob size cap and the 5 GiB uploads PVC (platform/workloads.go); nothing counted a user's rows or bytes, so one account could fill the volume and every other user's upload would start failing. - Create: per-user pending_review cap (default 5) — the review queue cannot be parked full of one account's rows. Check-then-insert, documented soft. - UploadContext: per-user stored-context budget (default 2 GiB) charged against the blob store's REAL sizes (new Blobs.Size on local/S3 stores), so the sum cannot drift from the volume; the write is capped at the remaining budget, so the excess is refused before it is persisted, and a re-upload is charged only for its new bytes. - API: per-user create/upload throttles (30s/15s, cmd/felis-wired) on a dedicated cooldown keyspace, reserve→release so a failed attempt never burns the window and a burst collapses to one winner; ErrQuotaExceeded → 403 submission_quota_exceeded (distinct from the 400 an oversize blob gets), 429 submission_cooldown for the throttles. - Panel: zh/en copy for both codes; openapi documents 403/429 on the two user routes; pgint covers the pending-queue count. Unit tests: submit package (cap, budget boundary/exact-fit/replacement, oversize-vs-quota split) and api handlers (quota 403 both paths, throttle 429 + recovery + failure-release). go vet/go test/gofmt clean; panel vitest 118 + typecheck green.
871 lines
29 KiB
Go
871 lines
29 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
|
||
sizeErr 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
|
||
}
|
||
|
||
// Size mirrors the real stores: a missing blob is (0, false, nil).
|
||
func (f *fakeBlobs) Size(_ context.Context, id string) (int64, bool, error) {
|
||
if f.sizeErr != nil {
|
||
return 0, false, f.sizeErr
|
||
}
|
||
b, ok := f.stored[id]
|
||
if !ok {
|
||
return 0, false, nil
|
||
}
|
||
return int64(len(b)), true, 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
|
||
countErr 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) CountPendingSubmissionsBy(_ context.Context, by string) (int, error) {
|
||
if f.countErr != nil {
|
||
return 0, f.countErr
|
||
}
|
||
var n int
|
||
for _, s := range f.subs {
|
||
if s.SubmittedBy == by && s.Status == StatusPendingReview {
|
||
n++
|
||
}
|
||
}
|
||
return n, 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)
|
||
}
|
||
}
|
||
|
||
// The per-user pending cap bounds the review queue: at the limit a new create is
|
||
// refused with ErrQuotaExceeded (403), a reviewed row frees a slot, and another
|
||
// user's queue is unaffected.
|
||
func TestCreatePendingCapRejects(t *testing.T) {
|
||
m, _, _ := newManager()
|
||
m.MaxPendingPerUser = 2
|
||
ctx := context.Background()
|
||
|
||
for i := 0; i < 2; i++ {
|
||
if _, err := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"}); err != nil {
|
||
t.Fatalf("create %d: %v", i+1, err)
|
||
}
|
||
}
|
||
_, err := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
|
||
if !errors.Is(err, ErrQuotaExceeded) {
|
||
t.Fatalf("create past the cap = %v, want ErrQuotaExceeded", err)
|
||
}
|
||
|
||
// A verdict moves the row out of pending_review, so the slot frees up.
|
||
if _, err := m.Reject(ctx, "sub-1", "[email protected]", "out of scope"); err != nil {
|
||
t.Fatalf("Reject: %v", err)
|
||
}
|
||
if _, err := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"}); err != nil {
|
||
t.Fatalf("create after review: %v", err)
|
||
}
|
||
|
||
// The cap is per user, never global.
|
||
if _, err := m.Create(ctx, CreateRequest{DisplayName: "Other", SubmittedBy: "user-2"}); err != nil {
|
||
t.Fatalf("other user's first create: %v", err)
|
||
}
|
||
}
|
||
|
||
// The per-user storage budget bounds the sum of a user's stored contexts. It is
|
||
// charged against the blob store's actual sizes, refuses at the boundary with
|
||
// ErrQuotaExceeded (403 — not the 400 a single oversize blob gets), and does not
|
||
// double-charge a re-upload of the same submission.
|
||
func TestUploadContextStorageBudget(t *testing.T) {
|
||
m, _, _ := newManager()
|
||
fb := newFakeBlobs()
|
||
m.Blobs = fb
|
||
m.MaxStoredBytesPerUser = 10 // tiny budget; gzBody is 4 magic bytes + payload
|
||
ctx := context.Background()
|
||
|
||
a, err := m.Create(ctx, CreateRequest{DisplayName: "A", SubmittedBy: "user-1"})
|
||
if err != nil {
|
||
t.Fatalf("create A: %v", err)
|
||
}
|
||
b, err := m.Create(ctx, CreateRequest{DisplayName: "B", SubmittedBy: "user-1"})
|
||
if err != nil {
|
||
t.Fatalf("create B: %v", err)
|
||
}
|
||
|
||
if _, err := m.UploadContext(ctx, a.ID, "user-1", strings.NewReader(gzBody("a"))); err != nil {
|
||
t.Fatalf("first upload: %v", err)
|
||
}
|
||
// A's 5 bytes leave 5. B's exactly-5-byte blob must be accepted — the budget
|
||
// binds only past the limit, never at it.
|
||
if _, err := m.UploadContext(ctx, b.ID, "user-1", strings.NewReader(gzBody("b"))); err != nil {
|
||
t.Fatalf("upload exactly at the budget = %v, want accepted", err)
|
||
}
|
||
if _, err := m.UploadContext(ctx, a.ID, "user-1", strings.NewReader(gzBody("far too much"))); err != nil {
|
||
// A re-upload is charged only for its NEW bytes (its old blob is
|
||
// superseded), and 16 bytes exceed the 5 bytes left after B.
|
||
if !errors.Is(err, ErrQuotaExceeded) {
|
||
t.Fatalf("re-upload past the budget = %v, want ErrQuotaExceeded", err)
|
||
}
|
||
} else {
|
||
t.Fatal("re-upload past the budget was accepted")
|
||
}
|
||
// Nothing oversize persisted, and A's good blob was not clobbered.
|
||
if string(fb.stored[a.ID]) != gzBody("a") {
|
||
t.Fatalf("A's blob = %q, want the original (a failed re-upload must not replace it)", fb.stored[a.ID])
|
||
}
|
||
|
||
// B now holds 5 and the budget is full: a fresh submission's upload is
|
||
// refused up front, before reading any body.
|
||
c, err := m.Create(ctx, CreateRequest{DisplayName: "C", SubmittedBy: "user-1"})
|
||
if err != nil {
|
||
t.Fatalf("create C: %v", err)
|
||
}
|
||
_, err = m.UploadContext(ctx, c.ID, "user-1", strings.NewReader(gzBody("c")))
|
||
if !errors.Is(err, ErrQuotaExceeded) {
|
||
t.Fatalf("upload with no budget left = %v, want ErrQuotaExceeded", err)
|
||
}
|
||
if _, ok := fb.stored[c.ID]; ok {
|
||
t.Fatal("a budget-refused upload must persist nothing")
|
||
}
|
||
|
||
// A 5-byte replacement of A fits exactly (10 − B's 5), proving the
|
||
// replacement is not double-charged against its own old bytes.
|
||
if _, err := m.UploadContext(ctx, a.ID, "user-1", strings.NewReader(gzBody("z"))); err != nil {
|
||
t.Fatalf("budget-exact replacement = %v, want accepted", err)
|
||
}
|
||
}
|
||
|
||
// A single oversize blob stays a 400 (ErrInvalid), distinct from the 403 the
|
||
// per-user budget answers with — the two failure classes must not collapse.
|
||
func TestUploadContextOversizeIsNotQuotaError(t *testing.T) {
|
||
m, _, _ := newManager()
|
||
fb := newFakeBlobs()
|
||
m.Blobs = fb
|
||
m.MaxContextBytes = 4
|
||
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
|
||
|
||
_, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader(gzBody("too big")))
|
||
if !errors.Is(err, ErrInvalid) || errors.Is(err, ErrQuotaExceeded) {
|
||
t.Fatalf("err = %v, want ErrInvalid and NOT ErrQuotaExceeded", err)
|
||
}
|
||
}
|
||
|
||
// A store failure while counting the pending queue must surface as-is, never as
|
||
// a quota verdict that blames the user.
|
||
func TestCreatePendingCountFailureSurfaces(t *testing.T) {
|
||
m, st, _ := newManager()
|
||
st.countErr = errors.New("db is down")
|
||
_, err := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
|
||
if err == nil || errors.Is(err, ErrQuotaExceeded) {
|
||
t.Fatalf("err = %v, want the raw store failure", 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
|
||
}
|