830 lines
33 KiB
Go
830 lines
33 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/build"
|
|
"felis.lolicon.best/internal/submit"
|
|
)
|
|
|
|
// fakeSubmissions is an in-memory SubmissionService for the handler tests. Each
|
|
// *Err field injects a canned outcome; the recorders let a test assert exactly
|
|
// what the handler forwarded (the point of the owner-scoping checks: the
|
|
// submitter and reviewer must come from the principal, never the body).
|
|
type fakeSubmissions struct {
|
|
created *submit.CreateRequest
|
|
createErr error
|
|
uploadedID string
|
|
uploadedBy string
|
|
uploadedN int64
|
|
uploadErr error
|
|
listedBy string
|
|
byResult []submit.Submission
|
|
byErr error
|
|
listed []submit.Submission
|
|
listErr error
|
|
approvedID string
|
|
approvedBy string
|
|
approveErr error
|
|
rejectedID string
|
|
rejectedBy string
|
|
rejectReas string
|
|
rejectErr error
|
|
withdrawnID string
|
|
withdrawBy string
|
|
withdrawErr error
|
|
deletedID string
|
|
deleteErr error
|
|
openedID string
|
|
openBody string
|
|
openErr error
|
|
|
|
approvedDigest string
|
|
openDigest string
|
|
}
|
|
|
|
func (f *fakeSubmissions) Create(_ context.Context, req submit.CreateRequest) (*submit.Submission, error) {
|
|
if f.createErr != nil {
|
|
return nil, f.createErr
|
|
}
|
|
cp := req
|
|
f.created = &cp
|
|
return &submit.Submission{ID: "sub-1", SubmittedBy: req.SubmittedBy,
|
|
DisplayName: req.DisplayName, Status: submit.StatusPendingReview}, nil
|
|
}
|
|
|
|
func (f *fakeSubmissions) UploadContext(_ context.Context, id, submittedBy string, r io.Reader) (*submit.Submission, error) {
|
|
f.uploadedID, f.uploadedBy = id, submittedBy
|
|
if f.uploadErr != nil {
|
|
return nil, f.uploadErr
|
|
}
|
|
n, _ := io.Copy(io.Discard, r)
|
|
f.uploadedN = n
|
|
return &submit.Submission{ID: id, SubmittedBy: submittedBy, Status: submit.StatusPendingReview}, nil
|
|
}
|
|
|
|
func (f *fakeSubmissions) ListBy(_ context.Context, submittedBy string) ([]submit.Submission, error) {
|
|
f.listedBy = submittedBy
|
|
return f.byResult, f.byErr
|
|
}
|
|
|
|
func (f *fakeSubmissions) List(_ context.Context) ([]submit.Submission, error) {
|
|
return f.listed, f.listErr
|
|
}
|
|
|
|
func (f *fakeSubmissions) Approve(_ context.Context, id, reviewedBy, expectedDigest string) (*submit.Submission, error) {
|
|
f.approvedID, f.approvedBy, f.approvedDigest = id, reviewedBy, expectedDigest
|
|
if f.approveErr != nil {
|
|
return nil, f.approveErr
|
|
}
|
|
return &submit.Submission{ID: id, Status: submit.StatusApproved, ReviewedBy: reviewedBy, BuildID: "bld-1"}, nil
|
|
}
|
|
|
|
func (f *fakeSubmissions) Reject(_ context.Context, id, reviewedBy, reason string) (*submit.Submission, error) {
|
|
f.rejectedID, f.rejectedBy, f.rejectReas = id, reviewedBy, reason
|
|
if f.rejectErr != nil {
|
|
return nil, f.rejectErr
|
|
}
|
|
return &submit.Submission{ID: id, Status: submit.StatusRejected, ReviewedBy: reviewedBy, RejectReason: reason}, nil
|
|
}
|
|
|
|
func (f *fakeSubmissions) Withdraw(_ context.Context, id, submittedBy string) (*submit.Submission, error) {
|
|
f.withdrawnID, f.withdrawBy = id, submittedBy
|
|
if f.withdrawErr != nil {
|
|
return nil, f.withdrawErr
|
|
}
|
|
return &submit.Submission{ID: id, SubmittedBy: submittedBy, Status: submit.StatusPendingReview}, nil
|
|
}
|
|
|
|
func (f *fakeSubmissions) Delete(_ context.Context, id string) (*submit.Submission, error) {
|
|
f.deletedID = id
|
|
if f.deleteErr != nil {
|
|
return nil, f.deleteErr
|
|
}
|
|
return &submit.Submission{ID: id, Status: submit.StatusRejected}, nil
|
|
}
|
|
|
|
// openErr injects the OpenContext outcome; the body recorder lets the internal
|
|
// route test assert byte-exact streaming and the 404 mapping.
|
|
func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, string, error) {
|
|
f.openedID = id
|
|
if f.openErr != nil {
|
|
return nil, "", f.openErr
|
|
}
|
|
return io.NopCloser(strings.NewReader(f.openBody)), f.openDigest, nil
|
|
}
|
|
|
|
// appSubAPI wires a submissions service behind an ordinary user principal (the
|
|
// app tier — /me/submissions). [email protected] / .test are deliberately not the
|
|
// deployment domain.
|
|
func appSubAPI(s SubmissionService) *API {
|
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
api.Submissions = s
|
|
api.External = staticExternal{p: &Principal{UserID: "user-7", Email: "[email protected]", Role: "user"}}
|
|
return api
|
|
}
|
|
|
|
// adminSubAPI wires a submissions service behind an admin principal (the admin
|
|
// tier — /submissions approve/reject/list).
|
|
func adminSubAPI(s SubmissionService) *API {
|
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
api.Submissions = s
|
|
api.External = staticExternal{p: &Principal{UserID: "admin-1", Email: "[email protected]",
|
|
Role: "admin", ViaAdminAccess: true}}
|
|
return api
|
|
}
|
|
|
|
// The submitter is the principal, never the body: a valid create stamps the
|
|
// authenticated user's id onto the submission.
|
|
func TestCreateSubmissionStampsPrincipal(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
api := appSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions", `{"display_name":"My Pack"}`, nil)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if fs.created == nil {
|
|
t.Fatal("Create was not called")
|
|
}
|
|
if fs.created.SubmittedBy != "user-7" {
|
|
t.Errorf("submitted_by = %q, want the principal id user-7", fs.created.SubmittedBy)
|
|
}
|
|
if fs.created.DisplayName != "My Pack" {
|
|
t.Errorf("display_name = %q", fs.created.DisplayName)
|
|
}
|
|
}
|
|
|
|
// A client cannot smuggle a submitted_by through the body: decodeJSON rejects the
|
|
// unknown field with 400 and the handler never reaches Create.
|
|
func TestCreateSubmissionRejectsBodySubmittedBy(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
api := appSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions",
|
|
`{"display_name":"X","submitted_by":"someone-else"}`, nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if fs.created != nil {
|
|
t.Errorf("Create must not be called for an unknown-field body; got %+v", fs.created)
|
|
}
|
|
}
|
|
|
|
// A validation failure from the submit layer surfaces as 400 bad_request.
|
|
func TestCreateSubmissionValidationIs400(t *testing.T) {
|
|
fs := &fakeSubmissions{createErr: fmt.Errorf("%w: display name is required", submit.ErrInvalid)}
|
|
api := appSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions", `{"display_name":""}`, nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := decodeErr(t, w); got != "bad_request" {
|
|
t.Errorf("error code = %q, want bad_request", got)
|
|
}
|
|
}
|
|
|
|
// The upload endpoint forwards the raw body to the transport and stamps the
|
|
// submitter from the principal, never the body — a user can only upload to a
|
|
// submission under their own identity.
|
|
func TestUploadSubmissionContextStreamsBody(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
api := appSubAPI(fs)
|
|
// A tiny gzip-magic-prefixed body stands in for a real context.tar.gz.
|
|
body := "\x1f\x8b\x08\x00 the modpack bytes"
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", body,
|
|
map[string]string{"Content-Type": "application/gzip"})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if fs.uploadedID != "sub-9" {
|
|
t.Errorf("uploaded id = %q, want sub-9", fs.uploadedID)
|
|
}
|
|
if fs.uploadedBy != "user-7" {
|
|
t.Errorf("submitter = %q, want the principal id user-7", fs.uploadedBy)
|
|
}
|
|
if fs.uploadedN != int64(len(body)) {
|
|
t.Errorf("streamed %d bytes, want %d", fs.uploadedN, len(body))
|
|
}
|
|
}
|
|
|
|
// A submission the caller does not own reads back as 404 (the transport reports
|
|
// ErrNotFound), so the endpoint cannot probe another user's submission.
|
|
func TestUploadSubmissionContextNotOwnedIs404(t *testing.T) {
|
|
fs := &fakeSubmissions{uploadErr: submit.ErrNotFound}
|
|
api := appSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-x/context", "\x1f\x8bdata", nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Uploading to an already-reviewed submission is a 409.
|
|
func TestUploadSubmissionContextAlreadyReviewedIs409(t *testing.T) {
|
|
fs := &fakeSubmissions{uploadErr: submit.ErrAlreadyReviewed}
|
|
api := appSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// A wrong-format / oversize body surfaces as 400 (the transport wraps ErrInvalid).
|
|
func TestUploadSubmissionContextBadFormatIs400(t *testing.T) {
|
|
fs := &fakeSubmissions{uploadErr: fmt.Errorf("%w: build context must be a gzip-compressed tarball (.tar.gz)", submit.ErrInvalid)}
|
|
api := appSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "not gzip", nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := decodeErr(t, w); got != "bad_request" {
|
|
t.Errorf("error code = %q, want bad_request", got)
|
|
}
|
|
}
|
|
|
|
// When the deployment's store has no upload transport, the endpoint reports 503
|
|
// (ErrUploadsUnavailable) — an honest "not available here", not a 500.
|
|
func TestUploadSubmissionContextNoTransportIs503(t *testing.T) {
|
|
fs := &fakeSubmissions{uploadErr: submit.ErrUploadsUnavailable}
|
|
api := appSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := decodeErr(t, w); got != "uploads_unavailable" {
|
|
t.Errorf("error code = %q, want uploads_unavailable", got)
|
|
}
|
|
}
|
|
|
|
// The upload route is app-tier: with no service configured it is 503, exactly
|
|
// like the other /me/submissions routes.
|
|
func TestUploadSubmissionContextWithoutServiceIs503(t *testing.T) {
|
|
app := appSubAPI(nil)
|
|
app.Submissions = nil
|
|
w := do(app.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Withdraw retracts the caller's OWN pending submission: the submitter is the
|
|
// principal (never the body), a reviewed submission is 409, and a foreign id is
|
|
// 404 — the same posture as the upload route.
|
|
func TestWithdrawSubmission(t *testing.T) {
|
|
t.Run("withdraws as the principal", func(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if fs.withdrawnID != "sub-3" || fs.withdrawBy != "user-7" {
|
|
t.Fatalf("withdraw forwarded (%q, %q), want (sub-3, user-7)", fs.withdrawnID, fs.withdrawBy)
|
|
}
|
|
})
|
|
t.Run("reviewed submission is 409", func(t *testing.T) {
|
|
fs := &fakeSubmissions{withdrawErr: submit.ErrAlreadyReviewed}
|
|
w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := decodeErr(t, w); got != "already_reviewed" {
|
|
t.Errorf("error code = %q, want already_reviewed", got)
|
|
}
|
|
})
|
|
t.Run("foreign or unknown id is 404", func(t *testing.T) {
|
|
fs := &fakeSubmissions{withdrawErr: submit.ErrNotFound}
|
|
w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-x", "", nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
t.Run("no service is 503", func(t *testing.T) {
|
|
app := appSubAPI(nil)
|
|
app.Submissions = nil
|
|
w := do(app.ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// The admin delete retires any submission and maps the lane's 404; the route's
|
|
// admin gate itself is pinned by TestSubmissionAdminRoutesAreAdminOnly.
|
|
func TestDeleteSubmissionAdmin(t *testing.T) {
|
|
t.Run("deletes the named row", func(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
w := do(adminSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/submissions/sub-8", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if fs.deletedID != "sub-8" {
|
|
t.Fatalf("delete forwarded id %q, want sub-8", fs.deletedID)
|
|
}
|
|
})
|
|
t.Run("unknown is 404", func(t *testing.T) {
|
|
fs := &fakeSubmissions{deleteErr: submit.ErrNotFound}
|
|
w := do(adminSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/submissions/sub-x", "", nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// The "my uploads" list scopes strictly to the principal's id — there is no
|
|
// parameter that could widen it to another user's submissions.
|
|
func TestMySubmissionsScopesToPrincipal(t *testing.T) {
|
|
fs := &fakeSubmissions{byResult: []submit.Submission{
|
|
{ID: "sub-1", SubmittedBy: "user-7", DisplayName: "p", Status: submit.StatusPendingReview},
|
|
}}
|
|
api := appSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/me/submissions", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if fs.listedBy != "user-7" {
|
|
t.Errorf("ListBy scoped to %q, want the principal id user-7", fs.listedBy)
|
|
}
|
|
var got map[string][]submit.Submission
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("body not JSON: %v", err)
|
|
}
|
|
if len(got["submissions"]) != 1 {
|
|
t.Fatalf("submissions = %d, want 1", len(got["submissions"]))
|
|
}
|
|
}
|
|
|
|
// The "my uploads" list carries the linked build's outcome — for a submitter it
|
|
// is the only visible outlet for a failed build (the /images/build routes are
|
|
// admin-tier). A row with no linked build gains no build fields.
|
|
func TestMySubmissionsCarriesBuildOutcome(t *testing.T) {
|
|
fs := &fakeSubmissions{byResult: []submit.Submission{
|
|
{ID: "sub-1", SubmittedBy: "user-7", Status: submit.StatusApproved, BuildID: "bld-9"},
|
|
{ID: "sub-2", SubmittedBy: "user-7", Status: submit.StatusPendingReview},
|
|
}}
|
|
api := appSubAPI(fs)
|
|
api.Builder = &fakeBuilder{getBuilds: map[string]*build.Build{
|
|
"bld-9": {ID: "bld-9", Status: build.StatusFailed, Error: "build job failed or scan found a CRITICAL CVE"},
|
|
}}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/me/submissions", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
var got struct {
|
|
Submissions []struct {
|
|
ID string `json:"id"`
|
|
BuildStatus string `json:"build_status"`
|
|
BuildError string `json:"build_error"`
|
|
} `json:"submissions"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("body not JSON: %v", err)
|
|
}
|
|
if len(got.Submissions) != 2 {
|
|
t.Fatalf("submissions = %d, want 2", len(got.Submissions))
|
|
}
|
|
if got.Submissions[0].BuildStatus != "failed" || got.Submissions[0].BuildError == "" {
|
|
t.Errorf("sub-1 outcome = %+v, want failed with the error text", got.Submissions[0])
|
|
}
|
|
if got.Submissions[1].BuildStatus != "" || got.Submissions[1].BuildError != "" {
|
|
t.Errorf("sub-2 outcome = %+v, want no build fields without a linked build", got.Submissions[1])
|
|
}
|
|
}
|
|
|
|
// A linked build whose row is gone renders as "no outcome" rather than failing
|
|
// the whole list; any other lookup failure must surface, never be swallowed.
|
|
func TestMySubmissionsBuildLookupSemantics(t *testing.T) {
|
|
// Missing row (ErrNotFound): 200 with no build fields.
|
|
fs := &fakeSubmissions{byResult: []submit.Submission{{ID: "sub-1", BuildID: "bld-gone"}}}
|
|
api := appSubAPI(fs)
|
|
api.Builder = &fakeBuilder{}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/me/submissions", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("missing build row: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if strings.Contains(w.Body.String(), "build_status") {
|
|
t.Errorf("missing build row: body carries build fields: %s", w.Body.String())
|
|
}
|
|
|
|
// Store fault: the failure is reported, not hidden behind a 200.
|
|
fs = &fakeSubmissions{byResult: []submit.Submission{{ID: "sub-1", BuildID: "bld-1"}}}
|
|
api = appSubAPI(fs)
|
|
api.Builder = &fakeBuilder{getErr: errors.New("db down")}
|
|
w = do(api.ExternalHandler(), "GET", "/api/v1/me/submissions", "", nil)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("store fault: code = %d, want 500 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Every /submissions route is admin-tier: a plain user is rejected before the
|
|
// handler runs.
|
|
func TestSubmissionAdminRoutesAreAdminOnly(t *testing.T) {
|
|
cases := []struct {
|
|
method, target, body string
|
|
}{
|
|
{"GET", "/api/v1/submissions", ""},
|
|
{"POST", "/api/v1/submissions/sub-1/approve", ""},
|
|
{"POST", "/api/v1/submissions/sub-1/reject", `{"reason":"no"}`},
|
|
{"DELETE", "/api/v1/submissions/sub-1", ""},
|
|
{"GET", "/api/v1/submissions/sub-1/context", ""},
|
|
}
|
|
for _, c := range cases {
|
|
api := adminSubAPI(&fakeSubmissions{})
|
|
// 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 TestListSubmissionsAdmin(t *testing.T) {
|
|
fs := &fakeSubmissions{listed: []submit.Submission{
|
|
{ID: "sub-1", SubmittedBy: "user-7", Status: submit.StatusPendingReview},
|
|
{ID: "sub-2", SubmittedBy: "user-9", Status: submit.StatusApproved, BuildID: "bld-2"},
|
|
}}
|
|
api := adminSubAPI(fs)
|
|
api.Builder = &fakeBuilder{getBuilds: map[string]*build.Build{
|
|
"bld-2": {ID: "bld-2", Status: build.StatusSucceeded},
|
|
}}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/submissions", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
var got map[string][]submit.Submission
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("body not JSON: %v", err)
|
|
}
|
|
if len(got["submissions"]) != 2 {
|
|
t.Fatalf("submissions = %d, want 2", len(got["submissions"]))
|
|
}
|
|
// The admin queue carries the same build outcome enrichment.
|
|
if !strings.Contains(w.Body.String(), `"build_status":"succeeded"`) {
|
|
t.Errorf("admin queue lacks the linked build outcome: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// The reviewer is the admin principal's email, never client input.
|
|
func TestApproveSubmission(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
api := adminSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/submissions/sub-9/approve", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if fs.approvedID != "sub-9" {
|
|
t.Errorf("approved id = %q, want sub-9", fs.approvedID)
|
|
}
|
|
if fs.approvedBy != "[email protected]" {
|
|
t.Errorf("reviewer = %q, want the admin email", fs.approvedBy)
|
|
}
|
|
}
|
|
|
|
// The digest the reviewer inspected travels in the body, and a context replaced
|
|
// since then is a 409 the panel can act on.
|
|
func TestApproveSubmissionForwardsTheReviewedDigest(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
digest := strings.Repeat("ab", 32)
|
|
w := do(adminSubAPI(fs).ExternalHandler(), "POST", "/api/v1/submissions/sub-9/approve",
|
|
`{"expected_digest":"`+digest+`"}`, nil)
|
|
if w.Code != http.StatusOK || fs.approvedDigest != digest {
|
|
t.Fatalf("code = %d, forwarded digest %q (%s)", w.Code, fs.approvedDigest, w.Body.String())
|
|
}
|
|
|
|
fs = &fakeSubmissions{approveErr: submit.ErrContextChanged}
|
|
w = do(adminSubAPI(fs).ExternalHandler(), "POST", "/api/v1/submissions/sub-9/approve",
|
|
`{"expected_digest":"`+digest+`"}`, nil)
|
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "context_changed" {
|
|
t.Fatalf("code = %d body %s, want 409 context_changed", w.Code, w.Body.String())
|
|
}
|
|
|
|
w = do(adminSubAPI(&fakeSubmissions{}).ExternalHandler(), "POST", "/api/v1/submissions/sub-9/approve",
|
|
`{"digest":"x"}`, nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("unknown field code = %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestApproveSubmissionAlreadyReviewedIs409(t *testing.T) {
|
|
fs := &fakeSubmissions{approveErr: submit.ErrAlreadyReviewed}
|
|
api := adminSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/submissions/sub-9/approve", "", nil)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := decodeErr(t, w); got != "already_reviewed" {
|
|
t.Errorf("error code = %q, want already_reviewed", got)
|
|
}
|
|
}
|
|
|
|
func TestApproveSubmissionNotFoundIs404(t *testing.T) {
|
|
fs := &fakeSubmissions{approveErr: submit.ErrNotFound}
|
|
api := adminSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/submissions/missing/approve", "", nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// A build hand-off failure post-approval is a server-side fault (the client did
|
|
// nothing wrong), so it collapses to 500 — never a 4xx.
|
|
func TestApproveBuildHandoffFailureIs500(t *testing.T) {
|
|
fs := &fakeSubmissions{approveErr: fmt.Errorf("submit: approved but build hand-off failed: %w",
|
|
errors.New("cluster unreachable"))}
|
|
api := adminSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/submissions/sub-9/approve", "", nil)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("code = %d, want 500 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := decodeErr(t, w); got != "internal" {
|
|
t.Errorf("error code = %q, want internal", got)
|
|
}
|
|
}
|
|
|
|
func TestRejectSubmission(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
api := adminSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/submissions/sub-3/reject",
|
|
`{"reason":"contains malware"}`, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if fs.rejectedID != "sub-3" {
|
|
t.Errorf("rejected id = %q, want sub-3", fs.rejectedID)
|
|
}
|
|
if fs.rejectedBy != "[email protected]" {
|
|
t.Errorf("reviewer = %q, want the admin email", fs.rejectedBy)
|
|
}
|
|
if fs.rejectReas != "contains malware" {
|
|
t.Errorf("reason = %q", fs.rejectReas)
|
|
}
|
|
}
|
|
|
|
func TestRejectSubmissionValidationIs400(t *testing.T) {
|
|
fs := &fakeSubmissions{rejectErr: fmt.Errorf("%w: a reject reason is required", submit.ErrInvalid)}
|
|
api := adminSubAPI(fs)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/submissions/sub-3/reject", `{"reason":""}`, nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := decodeErr(t, w); got != "bad_request" {
|
|
t.Errorf("error code = %q, want bad_request", got)
|
|
}
|
|
}
|
|
|
|
// When no Submissions service is configured, the routes report 503 — the app
|
|
// route directly, and the admin route only AFTER the admin gate, so the boundary
|
|
// is still enforced first.
|
|
func TestSubmissionRoutesWithoutServiceAre503(t *testing.T) {
|
|
// app tier
|
|
app := appSubAPI(nil)
|
|
app.Submissions = nil
|
|
if w := do(app.ExternalHandler(), "GET", "/api/v1/me/submissions", "", nil); w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("app route: code = %d, want 503", w.Code)
|
|
}
|
|
// admin tier
|
|
adm := adminSubAPI(nil)
|
|
adm.Submissions = nil
|
|
if w := do(adm.ExternalHandler(), "GET", "/api/v1/submissions", "", nil); w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("admin route: code = %d, want 503", w.Code)
|
|
}
|
|
}
|
|
|
|
// The internal context route is the build Pod's only read path to a submission's
|
|
// blob: it streams the bytes verbatim, and its error mapping distinguishes a
|
|
// missing blob (404) from an unwired transport (503).
|
|
func TestInternalSubmissionContextRoute(t *testing.T) {
|
|
newAPI := func(s SubmissionService) *API {
|
|
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
api.Submissions = s
|
|
return api
|
|
}
|
|
|
|
t.Run("streams the blob", func(t *testing.T) {
|
|
fs := &fakeSubmissions{openBody: "\x1f\x8b\x08\x00blob"}
|
|
w := do(newAPI(fs).InternalHandler(), "GET", "/api/v1/internal/submissions/sub-7/context", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if w.Body.String() != fs.openBody {
|
|
t.Fatalf("body = %q, want the stored blob %q", w.Body.String(), fs.openBody)
|
|
}
|
|
if fs.openedID != "sub-7" {
|
|
t.Fatalf("opened id = %q, want the path id", fs.openedID)
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/gzip" {
|
|
t.Fatalf("content-type = %q, want application/gzip", ct)
|
|
}
|
|
})
|
|
|
|
t.Run("missing blob is 404", func(t *testing.T) {
|
|
fs := &fakeSubmissions{openErr: fmt.Errorf("%w: gone", submit.ErrBlobNotFound)}
|
|
w := do(newAPI(fs).InternalHandler(), "GET", "/api/v1/internal/submissions/sub-7/context", "", nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("unwired transport is 503", func(t *testing.T) {
|
|
w := do(newAPI(nil).InternalHandler(), "GET", "/api/v1/internal/submissions/sub-7/context", "", nil)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("code = %d, want 503", w.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The admin context route is the reviewer's read path to the blob they are
|
|
// approving: an admin streams the stored bytes with a download disposition,
|
|
// and the error mapping matches the internal route (404 missing, 503 unwired).
|
|
// The admin-only gate itself is pinned by TestSubmissionAdminRoutesAreAdminOnly.
|
|
func TestAdminSubmissionContextRoute(t *testing.T) {
|
|
t.Run("streams the blob with a download disposition", func(t *testing.T) {
|
|
fs := &fakeSubmissions{openBody: "\x1f\x8b\x08\x00blob"}
|
|
w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if w.Body.String() != fs.openBody {
|
|
t.Fatalf("body = %q, want the stored blob %q", w.Body.String(), fs.openBody)
|
|
}
|
|
if fs.openedID != "sub-7" {
|
|
t.Fatalf("opened id = %q, want the path id", fs.openedID)
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/gzip" {
|
|
t.Fatalf("content-type = %q, want application/gzip", ct)
|
|
}
|
|
if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="context.tar.gz"` {
|
|
t.Fatalf("content-disposition = %q", cd)
|
|
}
|
|
})
|
|
|
|
t.Run("names the recorded digest", func(t *testing.T) {
|
|
body := "\x1f\x8b\x08\x00blob"
|
|
sum := sha256.Sum256([]byte(body))
|
|
fs := &fakeSubmissions{openBody: body, openDigest: hex.EncodeToString(sum[:])}
|
|
w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
|
|
if w.Code != http.StatusOK || w.Body.String() != body {
|
|
t.Fatalf("code = %d body %q", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Header().Get("X-Felis-Context-Sha256"); got != fs.openDigest {
|
|
t.Fatalf("digest header = %q, want %q", got, fs.openDigest)
|
|
}
|
|
})
|
|
|
|
// Bytes that are not the recorded ones must not arrive as a complete download:
|
|
// the reviewer would inspect a blob the approval does not name.
|
|
t.Run("aborts a blob that does not match", func(t *testing.T) {
|
|
big := "\x1f\x8b" + strings.Repeat("x", 100<<10)
|
|
fs := &fakeSubmissions{openBody: big, openDigest: strings.Repeat("0", 64)}
|
|
srv := httptest.NewServer(adminSubAPI(fs).ExternalHandler())
|
|
defer srv.Close()
|
|
resp, err := http.Get(srv.URL + "/api/v1/submissions/sub-7/context")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
got, err := io.ReadAll(resp.Body)
|
|
if err == nil {
|
|
t.Fatalf("read %d bytes cleanly; want the response aborted", len(got))
|
|
}
|
|
if len(got) >= len(big) {
|
|
t.Fatalf("received all %d bytes before the abort", len(got))
|
|
}
|
|
})
|
|
|
|
t.Run("missing blob is 404", func(t *testing.T) {
|
|
fs := &fakeSubmissions{openErr: fmt.Errorf("%w: gone", submit.ErrBlobNotFound)}
|
|
w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("code = %d, want 404", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("unwired transport is 503", func(t *testing.T) {
|
|
api := adminSubAPI(nil)
|
|
api.Submissions = nil
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("code = %d, want 503", w.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A spent per-user allowance is 403 submission_quota_exceeded on both the create
|
|
// and the upload path — distinctly NOT the 400 a malformed request gets, and not
|
|
// the 429 the cooldown answers with.
|
|
func TestSubmissionQuotaIs403(t *testing.T) {
|
|
t.Run("create", func(t *testing.T) {
|
|
fs := &fakeSubmissions{createErr: fmt.Errorf("%w: 5 submissions are already awaiting review", submit.ErrQuotaExceeded)}
|
|
w := do(appSubAPI(fs).ExternalHandler(), "POST", "/api/v1/me/submissions", `{"display_name":"Pack"}`, nil)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := decodeErr(t, w); got != "submission_quota_exceeded" {
|
|
t.Errorf("error code = %q, want submission_quota_exceeded", got)
|
|
}
|
|
})
|
|
t.Run("upload", func(t *testing.T) {
|
|
fs := &fakeSubmissions{uploadErr: fmt.Errorf("%w: exceeds your remaining storage allowance", submit.ErrQuotaExceeded)}
|
|
w := do(appSubAPI(fs).ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if got := decodeErr(t, w); got != "submission_quota_exceeded" {
|
|
t.Errorf("error code = %q, want submission_quota_exceeded", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The per-user create cooldown bounds review-queue growth: a second create in
|
|
// the same window is 429 submission_cooldown and never reaches the service; the
|
|
// window recovers afterwards.
|
|
func TestCreateSubmissionRateLimited(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
api := appSubAPI(fs)
|
|
clock := time.Unix(1_700_000_000, 0)
|
|
api.Now = func() time.Time { return clock }
|
|
api.SubmitCreateCooldown = time.Minute
|
|
eh := api.ExternalHandler()
|
|
|
|
if w := do(eh, "POST", "/api/v1/me/submissions", `{"display_name":"First"}`, nil); w.Code != http.StatusCreated {
|
|
t.Fatalf("first create: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
|
}
|
|
w := do(eh, "POST", "/api/v1/me/submissions", `{"display_name":"Second"}`, nil)
|
|
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "submission_cooldown" {
|
|
t.Fatalf("immediate second create: code = %d body %s, want 429 submission_cooldown", w.Code, w.Body.String())
|
|
}
|
|
// The gate sits before the body handling: even a malformed request is
|
|
// refused while the window is closed, so it cannot be used to probe.
|
|
if w := do(eh, "POST", "/api/v1/me/submissions", `{`, nil); w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("malformed create during cooldown: code = %d, want 429", w.Code)
|
|
}
|
|
clock = clock.Add(time.Minute + time.Second)
|
|
if w := do(eh, "POST", "/api/v1/me/submissions", `{"display_name":"Third"}`, nil); w.Code != http.StatusCreated {
|
|
t.Fatalf("post-cooldown create: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// A failed create frees the window: only a row that was actually recorded burns
|
|
// the cooldown, so a validation typo is not punished with a wait.
|
|
func TestCreateSubmissionFailureDoesNotBurnCooldown(t *testing.T) {
|
|
fs := &fakeSubmissions{createErr: fmt.Errorf("%w: display name is required", submit.ErrInvalid)}
|
|
api := appSubAPI(fs)
|
|
api.Now = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
|
api.SubmitCreateCooldown = time.Minute
|
|
eh := api.ExternalHandler()
|
|
|
|
if w := do(eh, "POST", "/api/v1/me/submissions", `{"display_name":""}`, nil); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("failed create: code = %d, want 400", w.Code)
|
|
}
|
|
fs.createErr = nil
|
|
if w := do(eh, "POST", "/api/v1/me/submissions", `{"display_name":"Fixed"}`, nil); w.Code != http.StatusCreated {
|
|
t.Fatalf("retry at the same instant: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// The per-user upload cooldown bounds context streaming: a second upload in the
|
|
// same window is 429 submission_cooldown, and a FAILED upload frees the window
|
|
// for an immediate retry.
|
|
func TestUploadSubmissionContextRateLimited(t *testing.T) {
|
|
fs := &fakeSubmissions{}
|
|
api := appSubAPI(fs)
|
|
clock := time.Unix(1_700_000_000, 0)
|
|
api.Now = func() time.Time { return clock }
|
|
api.SubmitUploadCooldown = time.Minute
|
|
eh := api.ExternalHandler()
|
|
body := "\x1f\x8b\x08\x00 the modpack bytes"
|
|
|
|
if w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, nil); w.Code != http.StatusOK {
|
|
t.Fatalf("first upload: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, nil)
|
|
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "submission_cooldown" {
|
|
t.Fatalf("immediate second upload: code = %d body %s, want 429 submission_cooldown", w.Code, w.Body.String())
|
|
}
|
|
|
|
// A failed upload releases its reservation, so the user is not punished for
|
|
// a genuine failure (aborted transfer, spent quota) with a cooldown wait.
|
|
fs2 := &fakeSubmissions{uploadErr: submit.ErrUploadsUnavailable}
|
|
api2 := appSubAPI(fs2)
|
|
api2.Now = func() time.Time { return clock }
|
|
api2.SubmitUploadCooldown = time.Minute
|
|
eh2 := api2.ExternalHandler()
|
|
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, nil); w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("failed upload: code = %d, want 503", w.Code)
|
|
}
|
|
fs2.uploadErr = nil
|
|
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, nil); w.Code != http.StatusOK {
|
|
t.Fatalf("retry at the same instant after failure: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|