feat(submit): give the upload lane a lifecycle — withdraw + admin delete (#76)
Nothing ever removed a submission: users could not retract a pending row, no
route deleted blobs or rows, and the reaper never touches uploads — so every
upload accumulated on the 5 GiB PVC forever and the only cleanup was SQL or
kubectl against the store.
- Blobs.Delete on both transports (local: RemoveAll of the id-namespaced dir,
id re-validated at the boundary; S3: idempotent object DELETE).
- Store: DeleteSubmission (admin, any status) and DeletePendingSubmission
(owner+pending CAS — a reviewed row can never be withdrawn out from under
its build).
- Manager.Delete / Manager.Withdraw delete the ROW first (under the CAS for
withdraw) and the blob after, so a live row can never point at a reaped
blob; a cleanup failure names the orphan explicitly instead of failing mute.
- API: DELETE /me/submissions/{id} (withdraw, app tier) and
DELETE /api/v1/submissions/{id} (admin) both return the row as it was;
audit events submission.withdraw / submission.delete; openapi documents both
paths; admin route pinned in the admin-only table.
- Panel: two-step withdraw on a pending row (frees the pending slot and the
storage budget); two-step delete on every admin row; zh/en copy; wire tests.
Unit: submit (withdraw happy path / wrong owner / reviewed row / no transport /
blob-cleanup failure), local+S3 delete idempotence, api handlers (200/404/409/
503 + route tier); pgint: withdraw CAS + admin delete exactly-once.
go vet/go test/gofmt clean; panel vitest 120 + typecheck green.
This commit is contained in:
20 files changed
+802
-19
No files matched your search
@@ -4355,6 +4355,41 @@ paths:
|
|||||||
'503':
|
'503':
|
||||||
$ref: '#/components/responses/ServiceUnavailable'
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
|
/api/v1/me/submissions/{id}:
|
||||||
|
delete:
|
||||||
|
tags: [submissions]
|
||||||
|
operationId: withdrawSubmission
|
||||||
|
summary: Withdraw your own pending submission (user side; user-directed lane over §16).
|
||||||
|
description: >-
|
||||||
|
Retracts the caller's own submission while it is still pending review:
|
||||||
|
the row and its uploaded build context are deleted, freeing the pending
|
||||||
|
slot and the per-user storage budget for a fresh submission. A reviewed
|
||||||
|
submission is frozen (409 — its build may already be consuming the
|
||||||
|
context), and a submission the caller does not own reads back as 404, so
|
||||||
|
this endpoint cannot probe or clear another user's uploads.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The withdrawn submission, as it was before the deletion.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Submission' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
description: Submission has already been reviewed and cannot be withdrawn.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
# ------------------------------------------------------ external: admin ----
|
# ------------------------------------------------------ external: admin ----
|
||||||
/api/v1/servers/{name}:
|
/api/v1/servers/{name}:
|
||||||
patch:
|
patch:
|
||||||
@@ -4750,3 +4785,35 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'503':
|
'503':
|
||||||
$ref: '#/components/responses/ServiceUnavailable'
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
|
/api/v1/submissions/{id}:
|
||||||
|
delete:
|
||||||
|
tags: [submissions]
|
||||||
|
operationId: deleteSubmission
|
||||||
|
summary: Retire a submission outright — row and uploaded context (admin; user-directed lane over §16).
|
||||||
|
description: >-
|
||||||
|
Removes the submission and its uploaded build context, any status — the
|
||||||
|
lane's only lifecycle valve, and the path that reclaims a rejected or
|
||||||
|
consumed upload from the uploads PVC. The reviewer identity is recorded
|
||||||
|
in the audit event, not on the (now deleted) row. Deleting an approved
|
||||||
|
submission whose build is still running fails that build's context
|
||||||
|
fetch; the admin has explicitly chosen to retire the artifact.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: admin
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The deleted submission, as it was before the deletion.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Submission' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
@@ -518,6 +518,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
// App-tier and owner-scoped (the id must belong to the principal), exactly
|
// App-tier and owner-scoped (the id must belong to the principal), exactly
|
||||||
// like the create/list routes above.
|
// like the create/list routes above.
|
||||||
{Method: "POST", Pattern: "/api/v1/me/submissions/{id}/context", h: a.handleUploadSubmissionContext},
|
{Method: "POST", Pattern: "/api/v1/me/submissions/{id}/context", h: a.handleUploadSubmissionContext},
|
||||||
|
// Withdraw the caller's OWN pending submission: the row and its uploaded
|
||||||
|
// context are deleted, freeing the pending slot and storage budget. Same
|
||||||
|
// owner-scoping as the upload route — a reviewed submission is frozen (409)
|
||||||
|
// and another user's id is invisible (404).
|
||||||
|
{Method: "DELETE", Pattern: "/api/v1/me/submissions/{id}", h: a.handleWithdrawSubmission},
|
||||||
// Admin (Zero-Trust) tier: create / mutate spec / image admission. These gate
|
// Admin (Zero-Trust) tier: create / mutate spec / image admission. These gate
|
||||||
// on Principal.IsAdmin() inside the handler via the adminOnly wrapper, so the
|
// on Principal.IsAdmin() inside the handler via the adminOnly wrapper, so the
|
||||||
// boundary is exercised even where the body is a later-phase stub.
|
// boundary is exercised even where the body is a later-phase stub.
|
||||||
@@ -547,6 +552,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
{Method: "GET", Pattern: "/api/v1/submissions", Admin: true, h: a.handleListSubmissions},
|
{Method: "GET", Pattern: "/api/v1/submissions", Admin: true, h: a.handleListSubmissions},
|
||||||
{Method: "POST", Pattern: "/api/v1/submissions/{id}/approve", Admin: true, h: a.handleApproveSubmission},
|
{Method: "POST", Pattern: "/api/v1/submissions/{id}/approve", Admin: true, h: a.handleApproveSubmission},
|
||||||
{Method: "POST", Pattern: "/api/v1/submissions/{id}/reject", Admin: true, h: a.handleRejectSubmission},
|
{Method: "POST", Pattern: "/api/v1/submissions/{id}/reject", Admin: true, h: a.handleRejectSubmission},
|
||||||
|
// Retire a submission outright (row + uploaded context), any status. The
|
||||||
|
// lane's lifecycle valve: without it, rejected/consumed uploads accumulated
|
||||||
|
// on the uploads PVC forever — there is no other delete path.
|
||||||
|
{Method: "DELETE", Pattern: "/api/v1/submissions/{id}", Admin: true, h: a.handleDeleteSubmission},
|
||||||
// The reviewer's read path to the uploaded blob: the executed Dockerfile
|
// The reviewer's read path to the uploaded blob: the executed Dockerfile
|
||||||
// lives inside it, so approval would otherwise be blind.
|
// lives inside it, so approval would otherwise be blind.
|
||||||
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
|
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ type SubmissionService interface {
|
|||||||
// Reject is the admin's other verdict: pending_review -> rejected with a
|
// Reject is the admin's other verdict: pending_review -> rejected with a
|
||||||
// required reason; it starts no build.
|
// required reason; it starts no build.
|
||||||
Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error)
|
Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error)
|
||||||
|
// Withdraw retracts the caller's OWN pending submission: the row and its
|
||||||
|
// uploaded context are deleted. A reviewed submission is frozen (409) and a
|
||||||
|
// submission the caller does not own reads back as 404, like the upload route.
|
||||||
|
Withdraw(ctx context.Context, id, submittedBy string) (*submit.Submission, error)
|
||||||
|
// Delete retires any submission outright (the admin lifecycle valve): the row
|
||||||
|
// and its uploaded context are removed, any status.
|
||||||
|
Delete(ctx context.Context, id string) (*submit.Submission, error)
|
||||||
// OpenContext returns the stored build-context blob for the internal
|
// OpenContext returns the stored build-context blob for the internal
|
||||||
// context-fetch route: the build Pod's initContainer cannot mount the uploads
|
// context-fetch route: the build Pod's initContainer cannot mount the uploads
|
||||||
// PVC across namespaces and holds no object-store credentials, so it streams
|
// PVC across namespaces and holds no object-store credentials, so it streams
|
||||||
@@ -294,6 +301,48 @@ func (a *API) handleRejectSubmission(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, sub)
|
writeJSON(w, http.StatusOK, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleWithdrawSubmission retracts the caller's own pending submission
|
||||||
|
// (app-tier): the row and its uploaded context are deleted, freeing the pending
|
||||||
|
// slot and the storage budget for a fresh submission. The submitter is the
|
||||||
|
// principal, never the body; a submission the caller does not own is reported as
|
||||||
|
// 404, so this endpoint cannot probe or clear another user's uploads, and a
|
||||||
|
// reviewed submission is 409 (its build may already be consuming the context).
|
||||||
|
func (a *API) handleWithdrawSubmission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.Submissions == nil {
|
||||||
|
writeError(w, r, errSubmissionsUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
sub, err := a.Submissions.Withdraw(r.Context(), r.PathValue("id"), p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeSubmitError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, p.Email, "submission.withdraw", sub.ID)
|
||||||
|
writeJSON(w, http.StatusOK, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteSubmission retires any submission outright (admin-tier): the row
|
||||||
|
// and its uploaded context are removed, any status. This is the lane's lifecycle
|
||||||
|
// valve — the only path that reclaims a rejected or consumed upload from the
|
||||||
|
// uploads PVC. The reviewer identity goes to the audit event, not the (now
|
||||||
|
// nonexistent) row. Deleting an approved submission whose build is still running
|
||||||
|
// fails that build's context fetch; the admin has explicitly chosen to retire it.
|
||||||
|
func (a *API) handleDeleteSubmission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.Submissions == nil {
|
||||||
|
writeError(w, r, errSubmissionsUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
sub, err := a.Submissions.Delete(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeSubmitError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, p.Email, "submission.delete", sub.ID)
|
||||||
|
writeJSON(w, http.StatusOK, sub)
|
||||||
|
}
|
||||||
|
|
||||||
// errSubmissionsUnavailable is returned when the approval lane is not configured
|
// errSubmissionsUnavailable is returned when the approval lane is not configured
|
||||||
// on this api instance (a nil Submissions service), so the admin/app boundary is
|
// on this api instance (a nil Submissions service), so the admin/app boundary is
|
||||||
// still exercised before the subsystem is wired in.
|
// still exercised before the subsystem is wired in.
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ type fakeSubmissions struct {
|
|||||||
rejectedBy string
|
rejectedBy string
|
||||||
rejectReas string
|
rejectReas string
|
||||||
rejectErr error
|
rejectErr error
|
||||||
|
withdrawnID string
|
||||||
|
withdrawBy string
|
||||||
|
withdrawErr error
|
||||||
|
deletedID string
|
||||||
|
deleteErr error
|
||||||
openedID string
|
openedID string
|
||||||
openBody string
|
openBody string
|
||||||
openErr error
|
openErr error
|
||||||
@@ -88,6 +93,22 @@ func (f *fakeSubmissions) Reject(_ context.Context, id, reviewedBy, reason strin
|
|||||||
return &submit.Submission{ID: id, Status: submit.StatusRejected, ReviewedBy: reviewedBy, RejectReason: reason}, nil
|
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
|
// openErr injects the OpenContext outcome; the body recorder lets the internal
|
||||||
// route test assert byte-exact streaming and the 404 mapping.
|
// route test assert byte-exact streaming and the 404 mapping.
|
||||||
func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, error) {
|
func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, error) {
|
||||||
@@ -249,6 +270,69 @@ func TestUploadSubmissionContextWithoutServiceIs503(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// The "my uploads" list scopes strictly to the principal's id — there is no
|
||||||
// parameter that could widen it to another user's submissions.
|
// parameter that could widen it to another user's submissions.
|
||||||
func TestMySubmissionsScopesToPrincipal(t *testing.T) {
|
func TestMySubmissionsScopesToPrincipal(t *testing.T) {
|
||||||
@@ -343,6 +427,7 @@ func TestSubmissionAdminRoutesAreAdminOnly(t *testing.T) {
|
|||||||
{"GET", "/api/v1/submissions", ""},
|
{"GET", "/api/v1/submissions", ""},
|
||||||
{"POST", "/api/v1/submissions/sub-1/approve", ""},
|
{"POST", "/api/v1/submissions/sub-1/approve", ""},
|
||||||
{"POST", "/api/v1/submissions/sub-1/reject", `{"reason":"no"}`},
|
{"POST", "/api/v1/submissions/sub-1/reject", `{"reason":"no"}`},
|
||||||
|
{"DELETE", "/api/v1/submissions/sub-1", ""},
|
||||||
{"GET", "/api/v1/submissions/sub-1/context", ""},
|
{"GET", "/api/v1/submissions/sub-1/context", ""},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
@@ -1034,6 +1034,36 @@ func TestSubmitStoreContract(t *testing.T) {
|
|||||||
if got, _ = s.GetSubmission(ctx, id); got.BuildID == "" {
|
if got, _ = s.GetSubmission(ctx, id); got.BuildID == "" {
|
||||||
t.Fatal("LinkBuild must persist build_id")
|
t.Fatal("LinkBuild must persist build_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lifecycle: the withdraw CAS deletes ONLY the owner's still-pending row — a
|
||||||
|
// wrong owner or a reviewed row can never delete through it — and the admin
|
||||||
|
// path deletes any status, exactly once.
|
||||||
|
id2 := "sub-w-" + suffix(t)
|
||||||
|
if err := s.CreateSubmission(ctx, &submit.Submission{
|
||||||
|
ID: id2, SubmittedBy: u.ID, DisplayName: "withdraw me",
|
||||||
|
ContextRef: "s3://bucket/" + id2 + "/context.tar.gz",
|
||||||
|
Status: submit.StatusPendingReview, CreatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateSubmission(2): %v", err)
|
||||||
|
}
|
||||||
|
if ok, err := s.DeletePendingSubmission(ctx, id2, "someone-else"); err != nil || ok {
|
||||||
|
t.Fatalf("withdraw by a non-owner = (%v, %v), want (false, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if ok, err := s.DeletePendingSubmission(ctx, id, u.ID); err != nil || ok {
|
||||||
|
t.Fatalf("withdraw of a reviewed row = (%v, %v), want (false, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if ok, err := s.DeletePendingSubmission(ctx, id2, u.ID); err != nil || !ok {
|
||||||
|
t.Fatalf("withdraw by the owner = (%v, %v), want (true, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetSubmission(ctx, id2); !errors.Is(err, submit.ErrNotFound) {
|
||||||
|
t.Fatalf("withdrawn row still readable: %v", err)
|
||||||
|
}
|
||||||
|
if ok, err := s.DeleteSubmission(ctx, id); err != nil || !ok {
|
||||||
|
t.Fatalf("admin delete = (%v, %v), want (true, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if ok, err := s.DeleteSubmission(ctx, id); err != nil || ok {
|
||||||
|
t.Fatalf("second admin delete = (%v, %v), want (false, nil)", ok, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- builds --------------------------------------------------------------------
|
// ---- builds --------------------------------------------------------------------
|
||||||
|
|||||||
@@ -149,5 +149,19 @@ func (s *LocalContextStore) Size(_ context.Context, id string) (int64, bool, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes everything stored for id — the blob plus its id-namespaced
|
||||||
|
// directory (a stray temp file from an interrupted upload goes with it) — after
|
||||||
|
// the submission row is gone. Idempotent: nothing stored is success.
|
||||||
|
func (s *LocalContextStore) Delete(_ context.Context, id string) error {
|
||||||
|
dir, err := s.dir(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return fmt.Errorf("submit: remove context blob: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Compile-time proof that the filesystem store satisfies the Blobs transport.
|
// Compile-time proof that the filesystem store satisfies the Blobs transport.
|
||||||
var _ Blobs = (*LocalContextStore)(nil)
|
var _ Blobs = (*LocalContextStore)(nil)
|
||||||
@@ -109,6 +109,35 @@ func TestLocalContextStorePutOverwrites(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes the blob and its id-namespaced directory, and is idempotent —
|
||||||
|
// the retry-safety the withdraw/delete cleanup depends on.
|
||||||
|
func TestLocalContextStoreDelete(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
s := &LocalContextStore{Base: base}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := s.Put(ctx, "sub-abc", strings.NewReader("\x1f\x8bbytes")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Delete(ctx, "sub-abc"); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if ok, err := s.Exists(ctx, "sub-abc"); err != nil || ok {
|
||||||
|
t.Fatalf("Exists after Delete = (%v, %v), want (false, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(base, "sub-abc")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("per-submission dir still present after Delete (err=%v)", err)
|
||||||
|
}
|
||||||
|
// Idempotent: deleting nothing is success, so a retried cleanup cannot fail.
|
||||||
|
if err := s.Delete(ctx, "sub-abc"); err != nil {
|
||||||
|
t.Fatalf("second Delete = %v, want nil (idempotent)", err)
|
||||||
|
}
|
||||||
|
// The same path guard as Put/Open.
|
||||||
|
if err := s.Delete(ctx, "../etc"); err == nil {
|
||||||
|
t.Fatal("Delete must reject an unsafe id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLocalContextStoreRejectsUnsafeID(t *testing.T) {
|
func TestLocalContextStoreRejectsUnsafeID(t *testing.T) {
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
s := &LocalContextStore{Base: base}
|
s := &LocalContextStore{Base: base}
|
||||||
|
|||||||
@@ -57,14 +57,16 @@ func (s *PGStore) ListSubmissionsBy(ctx context.Context, submittedBy string) ([]
|
|||||||
return s.querySubmissions(ctx, q, submittedBy)
|
return s.querySubmissions(ctx, q, submittedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cas executes a compare-and-set UPDATE and reports whether THIS call moved the
|
// cas executes a single-statement compare-and-set — an UPDATE or DELETE whose
|
||||||
// row. The `status = 'pending_review'` guard is the actual CAS predicate and is
|
// WHERE clause is the predicate — and reports whether THIS call moved a row. The
|
||||||
// deliberately kept INLINE in each caller's query — it is security-visible, so a
|
// `status = 'pending_review'` guard is the actual CAS predicate in each caller's
|
||||||
// reader auditing "can a non-pending row be flipped?" must see it next to the SET.
|
// query and is deliberately kept INLINE — it is security-visible, so a reader
|
||||||
// cas only folds the shared ExecContext + RowsAffected tail so the two reviewers
|
// auditing "can a non-pending row be flipped or deleted?" must see it next to the
|
||||||
// (approve, reject) cannot drift in how they report a lost race or a RowsAffected
|
// SET or DELETE. cas only folds the shared ExecContext + RowsAffected tail so
|
||||||
// error. n == 0 means a concurrent review already won the row — reported as
|
// the reviewers and deleters cannot drift in how they report a lost race or a
|
||||||
// won=false (never an error), which the Manager maps to ErrAlreadyReviewed.
|
// RowsAffected error. n == 0 means a concurrent actor already won the row —
|
||||||
|
// reported as won=false (never an error), which the Manager maps to
|
||||||
|
// ErrAlreadyReviewed / ErrNotFound.
|
||||||
func (s *PGStore) cas(ctx context.Context, q string, args ...any) (bool, error) {
|
func (s *PGStore) cas(ctx context.Context, q string, args ...any) (bool, error) {
|
||||||
res, err := s.db.ExecContext(ctx, q, args...)
|
res, err := s.db.ExecContext(ctx, q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,6 +109,21 @@ func (s *PGStore) LinkBuild(ctx context.Context, id, buildID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteSubmission is the admin delete: any status, one row, no predicate beyond
|
||||||
|
// the id. False means the id was already gone (a concurrent delete won).
|
||||||
|
func (s *PGStore) DeleteSubmission(ctx context.Context, id string) (bool, error) {
|
||||||
|
const q = `DELETE FROM image_submissions WHERE id = $1`
|
||||||
|
return s.cas(ctx, q, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePendingSubmission is the withdraw CAS: owner + pending_review must both
|
||||||
|
// still hold, so a reviewed submission can never be deleted through this path.
|
||||||
|
func (s *PGStore) DeletePendingSubmission(ctx context.Context, id, submittedBy string) (bool, error) {
|
||||||
|
const q = `DELETE FROM image_submissions
|
||||||
|
WHERE id = $1 AND submitted_by = $2 AND status = 'pending_review'`
|
||||||
|
return s.cas(ctx, q, id, submittedBy)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PGStore) querySubmissions(ctx context.Context, q string, args ...any) ([]Submission, error) {
|
func (s *PGStore) querySubmissions(ctx context.Context, q string, args ...any) ([]Submission, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type s3Client interface {
|
|||||||
PutObject(ctx context.Context, bucket, object string, reader io.Reader, size int64, opts minio.PutObjectOptions) (minio.UploadInfo, error)
|
PutObject(ctx context.Context, bucket, object string, reader io.Reader, size int64, opts minio.PutObjectOptions) (minio.UploadInfo, error)
|
||||||
StatObject(ctx context.Context, bucket, object string, opts minio.StatObjectOptions) (minio.ObjectInfo, error)
|
StatObject(ctx context.Context, bucket, object string, opts minio.StatObjectOptions) (minio.ObjectInfo, error)
|
||||||
GetObject(ctx context.Context, bucket, object string, opts minio.GetObjectOptions) (s3Object, error)
|
GetObject(ctx context.Context, bucket, object string, opts minio.GetObjectOptions) (s3Object, error)
|
||||||
|
RemoveObject(ctx context.Context, bucket, object string, opts minio.RemoveObjectOptions) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// s3Object is the handle GetObject yields: a stream whose Stat performs the HEAD
|
// s3Object is the handle GetObject yields: a stream whose Stat performs the HEAD
|
||||||
@@ -205,6 +206,20 @@ func (s *S3ContextStore) Size(ctx context.Context, id string) (int64, bool, erro
|
|||||||
return info.Size, true, nil
|
return info.Size, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes the stored object for the withdrawn/deleted submission. S3's
|
||||||
|
// DELETE is idempotent — removing an absent key succeeds — which is exactly the
|
||||||
|
// contract the cleanup path needs on a retry.
|
||||||
|
func (s *S3ContextStore) Delete(ctx context.Context, id string) error {
|
||||||
|
key, err := s.keyFor(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("submit: remove context blob: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Open returns the stored context blob for id — the read side of the transport the
|
// Open returns the stored context blob for id — the read side of the transport the
|
||||||
// build Pod's fetch initContainer uses. minio's GetObject returns only once the
|
// build Pod's fetch initContainer uses. minio's GetObject returns only once the
|
||||||
// server answered with an object (it surfaces NoSuchKey up front), so a missing
|
// server answered with an object (it surfaces NoSuchKey up front), so a missing
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type fakeS3 struct {
|
|||||||
objects map[string][]byte
|
objects map[string][]byte
|
||||||
putErr error
|
putErr error
|
||||||
statErr error // when set, StatObject returns it (e.g. auth rejected / bucket missing)
|
statErr error // when set, StatObject returns it (e.g. auth rejected / bucket missing)
|
||||||
|
removeErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeS3) PutObject(_ context.Context, bucket, object string, r io.Reader, _ int64, _ minio.PutObjectOptions) (minio.UploadInfo, error) {
|
func (f *fakeS3) PutObject(_ context.Context, bucket, object string, r io.Reader, _ int64, _ minio.PutObjectOptions) (minio.UploadInfo, error) {
|
||||||
@@ -45,6 +46,16 @@ func (f *fakeS3) StatObject(_ context.Context, bucket, object string, _ minio.St
|
|||||||
return minio.ObjectInfo{}, minio.ErrorResponse{Code: "NoSuchKey", StatusCode: http.StatusNotFound}
|
return minio.ObjectInfo{}, minio.ErrorResponse{Code: "NoSuchKey", StatusCode: http.StatusNotFound}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveObject mirrors S3's idempotent DELETE: removing a key (absent or not)
|
||||||
|
// succeeds unless removeErr injects a failure.
|
||||||
|
func (f *fakeS3) RemoveObject(_ context.Context, bucket, object string, _ minio.RemoveObjectOptions) error {
|
||||||
|
if f.removeErr != nil {
|
||||||
|
return f.removeErr
|
||||||
|
}
|
||||||
|
delete(f.objects, bucket+"/"+object)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// fakeS3Object is the object handle fakeS3.GetObject yields: Stat mirrors
|
// fakeS3Object is the object handle fakeS3.GetObject yields: Stat mirrors
|
||||||
// StatObject's not-found behaviour, Read serves the stored bytes.
|
// StatObject's not-found behaviour, Read serves the stored bytes.
|
||||||
type fakeS3Object struct {
|
type fakeS3Object struct {
|
||||||
@@ -142,6 +153,32 @@ func TestS3ContextStorePutAndExists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes the derived object and is idempotent (S3 DELETE of an absent
|
||||||
|
// key succeeds), so a retried cleanup after a partial failure cannot stick.
|
||||||
|
func TestS3ContextStoreDelete(t *testing.T) {
|
||||||
|
fake := &fakeS3{}
|
||||||
|
s := &S3ContextStore{client: fake, bucket: "felis-uploads", prefix: "builds"}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := s.Put(ctx, "sub-abc", strings.NewReader("\x1f\x8bbytes")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Delete(ctx, "sub-abc"); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if ok, err := s.Exists(ctx, "sub-abc"); err != nil || ok {
|
||||||
|
t.Fatalf("Exists after Delete = (%v, %v), want (false, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if err := s.Delete(ctx, "sub-abc"); err != nil {
|
||||||
|
t.Fatalf("second Delete = %v, want nil (idempotent)", err)
|
||||||
|
}
|
||||||
|
// A transport failure is surfaced, not swallowed.
|
||||||
|
fake.removeErr = errors.New("s3 unavailable")
|
||||||
|
if err := s.Delete(ctx, "sub-abc"); err == nil {
|
||||||
|
t.Fatal("Delete with a failing transport = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Open serves the stored object's bytes and maps a missing key to ErrBlobNotFound
|
// Open serves the stored object's bytes and maps a missing key to ErrBlobNotFound
|
||||||
// (the internal fetch route's 404), eagerly — before the caller reads a byte.
|
// (the internal fetch route's 404), eagerly — before the caller reads a byte.
|
||||||
func TestS3ContextStoreOpen(t *testing.T) {
|
func TestS3ContextStoreOpen(t *testing.T) {
|
||||||
|
|||||||
@@ -203,6 +203,17 @@ type Store interface {
|
|||||||
// (Approve surfaces that distinctly so remediation does not double-build; see
|
// (Approve surfaces that distinctly so remediation does not double-build; see
|
||||||
// the Approve ordering note and the package KNOWN-LIMITATION).
|
// the Approve ordering note and the package KNOWN-LIMITATION).
|
||||||
LinkBuild(ctx context.Context, id, buildID string) error
|
LinkBuild(ctx context.Context, id, buildID string) error
|
||||||
|
// DeleteSubmission removes a submission row outright — the admin delete path.
|
||||||
|
// Any status is deletable: the row is the business record and the caller has
|
||||||
|
// chosen to retire it. Reports whether a row was actually deleted; false
|
||||||
|
// means a concurrent delete won, which the Manager surfaces as ErrNotFound.
|
||||||
|
DeleteSubmission(ctx context.Context, id string) (bool, error)
|
||||||
|
// DeletePendingSubmission is the submitter's withdraw CAS: it deletes the row
|
||||||
|
// only while it is still owned by submittedBy AND still pending_review, so a
|
||||||
|
// concurrent approve/reject wins or loses cleanly and a reviewed submission
|
||||||
|
// can never be withdrawn out from under its build. Reports whether THIS call
|
||||||
|
// deleted the row.
|
||||||
|
DeletePendingSubmission(ctx context.Context, id, submittedBy string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds is the slice of the build subsystem the approval lane drives. An
|
// Builds is the slice of the build subsystem the approval lane drives. An
|
||||||
@@ -235,6 +246,11 @@ type Blobs interface {
|
|||||||
// store — never a recorded number that could drift from it (a re-upload
|
// store — never a recorded number that could drift from it (a re-upload
|
||||||
// supersedes the previous blob in place).
|
// supersedes the previous blob in place).
|
||||||
Size(ctx context.Context, id string) (int64, bool, error)
|
Size(ctx context.Context, id string) (int64, bool, error)
|
||||||
|
// Delete removes everything stored for id — the withdrawal/review-cleanup
|
||||||
|
// path. It is idempotent: deleting nothing is success, so a retried cleanup
|
||||||
|
// never fails on absence. Callers reap the blob only AFTER the row is gone
|
||||||
|
// (see Manager.deleteBlob), so a live row can never point at a reaped blob.
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
// Open returns the stored blob's bytes for the internal context-fetch route
|
// Open returns the stored blob's bytes for the internal context-fetch route
|
||||||
// the build Pod's initContainer dials (cmd/felis fetch-context). It returns an
|
// the build Pod's initContainer dials (cmd/felis fetch-context). It returns an
|
||||||
// error wrapping ErrBlobNotFound when no blob exists, so the route can answer
|
// error wrapping ErrBlobNotFound when no blob exists, so the route can answer
|
||||||
@@ -711,6 +727,86 @@ func (m *Manager) Reject(ctx context.Context, id, reviewedBy, reason string) (*S
|
|||||||
return sub, nil
|
return sub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Withdraw retracts the submitter's own pending submission: the row is deleted
|
||||||
|
// (CAS-guarded on owner + pending_review, so a concurrent review can never be
|
||||||
|
// undercut) and its uploaded context is reaped, freeing both the user's pending
|
||||||
|
// slot and their storage budget for a fresh submission. Once reviewed the
|
||||||
|
// context is frozen — an approved build may already be consuming it — so a
|
||||||
|
// non-pending row reports ErrAlreadyReviewed, exactly like UploadContext, and
|
||||||
|
// another user's id stays invisible (ErrNotFound), like every other owner-scoped
|
||||||
|
// operation on the lane.
|
||||||
|
func (m *Manager) Withdraw(ctx context.Context, id, submittedBy string) (*Submission, error) {
|
||||||
|
if strings.TrimSpace(submittedBy) == "" {
|
||||||
|
return nil, invalidf("submitter identity is required")
|
||||||
|
}
|
||||||
|
sub, err := m.Store.GetSubmission(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sub.SubmittedBy != submittedBy {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if sub.Status != StatusPendingReview {
|
||||||
|
return nil, ErrAlreadyReviewed
|
||||||
|
}
|
||||||
|
won, err := m.Store.DeletePendingSubmission(ctx, id, submittedBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !won {
|
||||||
|
// A concurrent approve/reject (or delete) moved the row between the load
|
||||||
|
// and the CAS. The context must NOT be reaped: it belongs to the review
|
||||||
|
// outcome now.
|
||||||
|
return nil, ErrAlreadyReviewed
|
||||||
|
}
|
||||||
|
if err := m.deleteBlob(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete retires any submission outright (the admin path): the row and its
|
||||||
|
// stored context are both removed, any status. The reviewer identity is recorded
|
||||||
|
// by the API's audit event, not on the row — the row no longer exists. Deleting
|
||||||
|
// an approved submission whose build is still running can fail that build (its
|
||||||
|
// context fetch answers 404); the admin has explicitly chosen to retire the
|
||||||
|
// artifact. A concurrent delete reports ErrNotFound, the same outcome the
|
||||||
|
// initial load would have given had it lost the race.
|
||||||
|
func (m *Manager) Delete(ctx context.Context, id string) (*Submission, error) {
|
||||||
|
sub, err := m.Store.GetSubmission(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deleted, err := m.Store.DeleteSubmission(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !deleted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err := m.deleteBlob(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteBlob reaps a submission's stored context after its row is gone. The row
|
||||||
|
// is deleted FIRST (for Withdraw, under the status CAS), so a cleanup failure
|
||||||
|
// here can never leave a live row pointing at a reaped blob — the failure mode
|
||||||
|
// is the other direction: the row is retired and the blob is orphaned on the
|
||||||
|
// uploads store, which the returned error names explicitly so the operator knows
|
||||||
|
// exactly what is left behind. A deployment with no upload transport (Blobs nil)
|
||||||
|
// has no blobs to reap.
|
||||||
|
func (m *Manager) deleteBlob(ctx context.Context, id string) error {
|
||||||
|
if m.Blobs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := m.Blobs.Delete(ctx, id); err != nil {
|
||||||
|
return fmt.Errorf("submit: submission removed, but its uploaded context could not be deleted (it may remain on the uploads store): %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// List returns every submission, newest first (the admin review queue).
|
// List returns every submission, newest first (the admin review queue).
|
||||||
func (m *Manager) List(ctx context.Context) ([]Submission, error) {
|
func (m *Manager) List(ctx context.Context) ([]Submission, error) {
|
||||||
return m.Store.ListSubmissions(ctx)
|
return m.Store.ListSubmissions(ctx)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type fakeBlobs struct {
|
|||||||
putErr error
|
putErr error
|
||||||
existsErr error
|
existsErr error
|
||||||
sizeErr error
|
sizeErr error
|
||||||
|
deleteErr error
|
||||||
forceExists *bool // overrides the stored-map lookup for the approve-gate tests
|
forceExists *bool // overrides the stored-map lookup for the approve-gate tests
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +66,15 @@ func (f *fakeBlobs) Size(_ context.Context, id string) (int64, bool, error) {
|
|||||||
return int64(len(b)), true, nil
|
return int64(len(b)), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete mirrors the real stores: idempotent, nothing stored is success.
|
||||||
|
func (f *fakeBlobs) Delete(_ context.Context, id string) error {
|
||||||
|
if f.deleteErr != nil {
|
||||||
|
return f.deleteErr
|
||||||
|
}
|
||||||
|
delete(f.stored, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeBlobs) Open(_ context.Context, id string) (io.ReadCloser, error) {
|
func (f *fakeBlobs) Open(_ context.Context, id string) (io.ReadCloser, error) {
|
||||||
b, ok := f.stored[id]
|
b, ok := f.stored[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -90,6 +100,7 @@ type fakeStore struct {
|
|||||||
approveErr error
|
approveErr error
|
||||||
rejectErr error
|
rejectErr error
|
||||||
linkErr error
|
linkErr error
|
||||||
|
deleteErr error
|
||||||
|
|
||||||
linked []string // "id=buildID" recorder
|
linked []string // "id=buildID" recorder
|
||||||
}
|
}
|
||||||
@@ -190,6 +201,29 @@ func (f *fakeStore) LinkBuild(_ context.Context, id, buildID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteSubmission(_ context.Context, id string) (bool, error) {
|
||||||
|
if f.deleteErr != nil {
|
||||||
|
return false, f.deleteErr
|
||||||
|
}
|
||||||
|
if _, ok := f.subs[id]; !ok {
|
||||||
|
return false, nil // concurrent delete won / nonexistent
|
||||||
|
}
|
||||||
|
delete(f.subs, id)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeletePendingSubmission(_ context.Context, id, by string) (bool, error) {
|
||||||
|
if f.deleteErr != nil {
|
||||||
|
return false, f.deleteErr
|
||||||
|
}
|
||||||
|
s, ok := f.subs[id]
|
||||||
|
if !ok || s.SubmittedBy != by || s.Status != StatusPendingReview {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
delete(f.subs, id)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fakeBuilds records Submit calls and can inject a failure.
|
// fakeBuilds records Submit calls and can inject a failure.
|
||||||
type fakeBuilds struct {
|
type fakeBuilds struct {
|
||||||
submitErr error
|
submitErr error
|
||||||
@@ -819,6 +853,155 @@ func TestCreatePendingCountFailureSurfaces(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Withdraw retracts the submitter's own pending submission: the row AND its
|
||||||
|
// uploaded context are gone — which is what frees the pending slot and the
|
||||||
|
// storage budget for a fresh submission.
|
||||||
|
func TestWithdrawDeletesRowAndBlob(t *testing.T) {
|
||||||
|
m, st, _ := newManager()
|
||||||
|
fb := newFakeBlobs()
|
||||||
|
m.Blobs = fb
|
||||||
|
ctx := context.Background()
|
||||||
|
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
|
||||||
|
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("bytes"))); err != nil {
|
||||||
|
t.Fatalf("UploadContext: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := m.Withdraw(ctx, seed.ID, "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Withdraw: %v", err)
|
||||||
|
}
|
||||||
|
if sub.ID != seed.ID || sub.Status != StatusPendingReview {
|
||||||
|
t.Fatalf("withdrawn row = %+v, want the pending row back", sub)
|
||||||
|
}
|
||||||
|
if _, ok := st.subs[seed.ID]; ok {
|
||||||
|
t.Fatal("withdraw must delete the row")
|
||||||
|
}
|
||||||
|
if _, ok := fb.stored[seed.ID]; ok {
|
||||||
|
t.Fatal("withdraw must reap the uploaded context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another user's id is invisible on the withdraw path (404, not 403), and
|
||||||
|
// nothing is touched.
|
||||||
|
func TestWithdrawNotOwnerIsNotFound(t *testing.T) {
|
||||||
|
m, st, _ := newManager()
|
||||||
|
fb := newFakeBlobs()
|
||||||
|
m.Blobs = fb
|
||||||
|
ctx := context.Background()
|
||||||
|
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
|
||||||
|
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("x"))); err != nil {
|
||||||
|
t.Fatalf("UploadContext: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := m.Withdraw(ctx, seed.ID, "user-2"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, ok := st.subs[seed.ID]; !ok {
|
||||||
|
t.Fatal("a non-owner withdraw must not delete the row")
|
||||||
|
}
|
||||||
|
if _, ok := fb.stored[seed.ID]; !ok {
|
||||||
|
t.Fatal("a non-owner withdraw must not reap the context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reviewed submission is frozen: the withdraw CAS refuses it (409), keeping
|
||||||
|
// the context a running/queued build may still consume.
|
||||||
|
func TestWithdrawReviewedIsAlreadyReviewed(t *testing.T) {
|
||||||
|
m, st, _ := newManager()
|
||||||
|
fb := newFakeBlobs()
|
||||||
|
m.Blobs = fb
|
||||||
|
ctx := context.Background()
|
||||||
|
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
|
||||||
|
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("x"))); err != nil {
|
||||||
|
t.Fatalf("UploadContext: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.Reject(ctx, seed.ID, "[email protected]", "no"); err != nil {
|
||||||
|
t.Fatalf("Reject: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := m.Withdraw(ctx, seed.ID, "user-1"); !errors.Is(err, ErrAlreadyReviewed) {
|
||||||
|
t.Fatalf("err = %v, want ErrAlreadyReviewed", err)
|
||||||
|
}
|
||||||
|
if _, ok := st.subs[seed.ID]; !ok {
|
||||||
|
t.Fatal("a reviewed row must survive a withdraw attempt")
|
||||||
|
}
|
||||||
|
if _, ok := fb.stored[seed.ID]; !ok {
|
||||||
|
t.Fatal("a reviewed row's context must survive a withdraw attempt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin delete retires ANY status, reaping the context with it.
|
||||||
|
func TestDeleteAnyStatusReapsContext(t *testing.T) {
|
||||||
|
m, st, _ := newManager()
|
||||||
|
fb := newFakeBlobs()
|
||||||
|
m.Blobs = fb
|
||||||
|
ctx := context.Background()
|
||||||
|
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
|
||||||
|
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("x"))); err != nil {
|
||||||
|
t.Fatalf("UploadContext: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.Reject(ctx, seed.ID, "[email protected]", "no"); err != nil {
|
||||||
|
t.Fatalf("Reject: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := m.Delete(ctx, seed.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if sub.Status != StatusRejected {
|
||||||
|
t.Fatalf("returned status = %q, want the row as it was before the delete", sub.Status)
|
||||||
|
}
|
||||||
|
if _, ok := st.subs[seed.ID]; ok {
|
||||||
|
t.Fatal("delete must remove the row")
|
||||||
|
}
|
||||||
|
if _, ok := fb.stored[seed.ID]; ok {
|
||||||
|
t.Fatal("delete must reap the context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting an unknown id — including the second delete of one already gone —
|
||||||
|
// is ErrNotFound (404), not a crash and not a silent success.
|
||||||
|
func TestDeleteUnknownIsNotFound(t *testing.T) {
|
||||||
|
m, _, _ := newManager()
|
||||||
|
m.Blobs = newFakeBlobs()
|
||||||
|
if _, err := m.Delete(context.Background(), "sub-nope"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deployment without an upload transport has no blobs to reap: delete still
|
||||||
|
// retires the row.
|
||||||
|
func TestDeleteWithoutTransport(t *testing.T) {
|
||||||
|
m, st, _ := newManager() // Blobs nil
|
||||||
|
ctx := context.Background()
|
||||||
|
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
|
||||||
|
if _, err := m.Delete(ctx, seed.ID); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := st.subs[seed.ID]; ok {
|
||||||
|
t.Fatal("delete must remove the row even with no transport")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blob-cleanup failure after the row is gone must surface loudly (naming what
|
||||||
|
// was left behind), never masquerade as success.
|
||||||
|
func TestDeleteBlobCleanupFailureSurfaces(t *testing.T) {
|
||||||
|
m, st, _ := newManager()
|
||||||
|
fb := newFakeBlobs()
|
||||||
|
fb.deleteErr = errors.New("pvc read-only")
|
||||||
|
m.Blobs = fb
|
||||||
|
ctx := context.Background()
|
||||||
|
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
|
||||||
|
|
||||||
|
_, err := m.Delete(ctx, seed.ID)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "could not be deleted") {
|
||||||
|
t.Fatalf("err = %v, want the cleanup failure named", err)
|
||||||
|
}
|
||||||
|
if _, ok := st.subs[seed.ID]; ok {
|
||||||
|
t.Fatal("the row is deleted before the blob reap; it must stay deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApproveRefusesMissingContext(t *testing.T) {
|
func TestApproveRefusesMissingContext(t *testing.T) {
|
||||||
// With a transport wired, approving a submission whose context was never
|
// With a transport wired, approving a submission whose context was never
|
||||||
// uploaded fails BEFORE the CAS: the row stays pending and no build starts.
|
// uploaded fails BEFORE the CAS: the row stays pending and no build starts.
|
||||||
|
|||||||
@@ -53,6 +53,9 @@
|
|||||||
"table_display_name": "Name",
|
"table_display_name": "Name",
|
||||||
"approve_btn": "Approve",
|
"approve_btn": "Approve",
|
||||||
"reject_btn": "Reject",
|
"reject_btn": "Reject",
|
||||||
|
"delete_submission_btn": "Delete submission",
|
||||||
|
"delete_submission_confirm": "Delete",
|
||||||
|
"delete_submission_cancel": "Cancel",
|
||||||
"reject_reason_label": "Rejection Reason",
|
"reject_reason_label": "Rejection Reason",
|
||||||
"reject_reason_placeholder": "Please provide a reason (max 1000 characters)",
|
"reject_reason_placeholder": "Please provide a reason (max 1000 characters)",
|
||||||
"reject_dialog_title": "Reject Submission",
|
"reject_dialog_title": "Reject Submission",
|
||||||
|
|||||||
@@ -36,5 +36,9 @@
|
|||||||
"field_context_ref": "Context Reference",
|
"field_context_ref": "Context Reference",
|
||||||
"field_image_ref": "Image Reference",
|
"field_image_ref": "Image Reference",
|
||||||
"clear_btn": "Clear",
|
"clear_btn": "Clear",
|
||||||
"file_hint": "Supports .tar.gz (max 1GB)"
|
"file_hint": "Supports .tar.gz (max 1GB)",
|
||||||
|
"withdraw_btn": "Withdraw",
|
||||||
|
"withdraw_hint": "Withdrawing deletes this submission and its uploaded context, freeing your pending slot and storage budget.",
|
||||||
|
"withdraw_confirm": "Withdraw",
|
||||||
|
"withdraw_cancel": "Cancel"
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,9 @@
|
|||||||
"table_display_name": "名称",
|
"table_display_name": "名称",
|
||||||
"approve_btn": "通过",
|
"approve_btn": "通过",
|
||||||
"reject_btn": "驳回",
|
"reject_btn": "驳回",
|
||||||
|
"delete_submission_btn": "删除提交",
|
||||||
|
"delete_submission_confirm": "确认删除",
|
||||||
|
"delete_submission_cancel": "取消",
|
||||||
"reject_reason_label": "驳回原因",
|
"reject_reason_label": "驳回原因",
|
||||||
"reject_reason_placeholder": "请填写驳回原因(限 1000 字符)",
|
"reject_reason_placeholder": "请填写驳回原因(限 1000 字符)",
|
||||||
"reject_dialog_title": "驳回申请",
|
"reject_dialog_title": "驳回申请",
|
||||||
|
|||||||
@@ -36,5 +36,9 @@
|
|||||||
"field_context_ref": "构建上下文引用",
|
"field_context_ref": "构建上下文引用",
|
||||||
"field_image_ref": "目标镜像引用",
|
"field_image_ref": "目标镜像引用",
|
||||||
"clear_btn": "清除",
|
"clear_btn": "清除",
|
||||||
"file_hint": "支持 .tar.gz 格式 (最大 1GB)"
|
"file_hint": "支持 .tar.gz 格式 (最大 1GB)",
|
||||||
|
"withdraw_btn": "撤回提交",
|
||||||
|
"withdraw_hint": "撤回会删除该提交及其已上传的构建上下文,并释放你的待审核名额与存储配额。",
|
||||||
|
"withdraw_confirm": "确认撤回",
|
||||||
|
"withdraw_cancel": "取消"
|
||||||
}
|
}
|
||||||
@@ -530,6 +530,28 @@ describe("image whitelist and builds wire shapes", () => {
|
|||||||
expect(humanizeError({ code: "submission_quota_exceeded" })).toMatch(/quota/i);
|
expect(humanizeError({ code: "submission_quota_exceeded" })).toMatch(/quota/i);
|
||||||
expect(humanizeError({ code: "submission_cooldown" })).toMatch(/try again/i);
|
expect(humanizeError({ code: "submission_cooldown" })).toMatch(/try again/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("withdrawSubmission DELETEs /me/submissions/{id}", async () => {
|
||||||
|
const sub = { id: "sub-4", status: "pending_review" };
|
||||||
|
const fetchSpy = fakeFetch(sub);
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.withdrawSubmission("sub-4");
|
||||||
|
expect(res).toEqual(sub);
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/me/submissions/sub-4");
|
||||||
|
expect((opts as RequestInit).method).toBe("DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteSubmission DELETEs /submissions/{id}", async () => {
|
||||||
|
const sub = { id: "sub-5", status: "rejected" };
|
||||||
|
const fetchSpy = fakeFetch(sub);
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.deleteSubmission("sub-5");
|
||||||
|
expect(res).toEqual(sub);
|
||||||
|
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/submissions/sub-5");
|
||||||
|
expect((opts as RequestInit).method).toBe("DELETE");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updates maintenance window", () => {
|
describe("updates maintenance window", () => {
|
||||||
|
|||||||
@@ -474,6 +474,10 @@ export const api = {
|
|||||||
rejectSubmission: (id: string, reason: string) =>
|
rejectSubmission: (id: string, reason: string) =>
|
||||||
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
|
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
|
||||||
|
|
||||||
|
// Retire a submission outright (row + uploaded context) — the review queue's
|
||||||
|
// lifecycle valve, the only way an upload is reclaimed from the PVC.
|
||||||
|
deleteSubmission: (id: string) => request<Submission>("DELETE", `/submissions/${id}`),
|
||||||
|
|
||||||
// The reviewer's read path to the uploaded build context: the executed
|
// The reviewer's read path to the uploaded build context: the executed
|
||||||
// Dockerfile lives inside the tarball, so approving without this would be
|
// Dockerfile lives inside the tarball, so approving without this would be
|
||||||
// blind. The body is the attacker-supplied archive — download it, never
|
// blind. The body is the attacker-supplied archive — download it, never
|
||||||
@@ -519,6 +523,10 @@ export const api = {
|
|||||||
"Content-Type": "application/x-gzip",
|
"Content-Type": "application/x-gzip",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Retract the caller's own pending submission (and its uploaded context), which
|
||||||
|
// frees their pending slot and storage budget. Reviewed submissions are frozen.
|
||||||
|
withdrawSubmission: (id: string) => request<Submission>("DELETE", `/me/submissions/${id}`),
|
||||||
|
|
||||||
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
|
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
|
||||||
|
|
||||||
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
|
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
CircleSlash,
|
CircleSlash,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { SearchInput } from "@/components/SearchInput";
|
import { SearchInput } from "@/components/SearchInput";
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MessageLine } from "@/components/MessageLine";
|
import { MessageLine } from "@/components/MessageLine";
|
||||||
|
import { InlineConfirm } from "@/components/InlineConfirm";
|
||||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||||
import { Pagination } from "@/components/Pagination";
|
import { Pagination } from "@/components/Pagination";
|
||||||
import { StatCard } from "@/components/StatCard";
|
import { StatCard } from "@/components/StatCard";
|
||||||
@@ -90,6 +92,12 @@ export function MySubmissionsPage() {
|
|||||||
const [submitStep, setSubmitStep] = useState<"create" | "upload" | null>(null);
|
const [submitStep, setSubmitStep] = useState<"create" | "upload" | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Row action state: withdraw arms a row (trash → confirm/cancel) before it
|
||||||
|
// fires, and a failure lands in actionError above the list.
|
||||||
|
const [confirmingWithdraw, setConfirmingWithdraw] = useState<string | null>(null);
|
||||||
|
const [withdrawing, setWithdrawing] = useState<string | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Search & Filtering State
|
// Search & Filtering State
|
||||||
@@ -225,6 +233,23 @@ export function MySubmissionsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Withdraw retracts a still-pending submission and its uploaded context,
|
||||||
|
// freeing the pending slot and the storage budget for a fresh submission.
|
||||||
|
async function handleWithdraw(id: string) {
|
||||||
|
if (withdrawing) return;
|
||||||
|
setWithdrawing(id);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await api.withdrawSubmission(id);
|
||||||
|
setConfirmingWithdraw(null);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(humanizeError(err));
|
||||||
|
} finally {
|
||||||
|
setWithdrawing(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -250,6 +275,9 @@ export function MySubmissionsPage() {
|
|||||||
className="mb-6"
|
className="mb-6"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Row-action error (withdraw) */}
|
||||||
|
{actionError && <MessageLine kind="error" message={actionError} compact />}
|
||||||
|
|
||||||
{/* Stats Cards Row */}
|
{/* Stats Cards Row */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
<StatCard icon={ClipboardCheck} label={t("filter_all")} value={stats.total} />
|
<StatCard icon={ClipboardCheck} label={t("filter_all")} value={stats.total} />
|
||||||
@@ -410,6 +438,35 @@ export function MySubmissionsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sub.status === "pending_review" && (
|
||||||
|
<div className="pt-2 border-t border-border/20 mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground/80">{t("withdraw_hint")}</span>
|
||||||
|
{confirmingWithdraw === sub.id ? (
|
||||||
|
<InlineConfirm
|
||||||
|
open={true}
|
||||||
|
confirming={withdrawing === sub.id}
|
||||||
|
onConfirm={() => handleWithdraw(sub.id)}
|
||||||
|
onCancel={() => setConfirmingWithdraw(null)}
|
||||||
|
confirmLabel={t("withdraw_confirm")}
|
||||||
|
cancelLabel={t("withdraw_cancel")}
|
||||||
|
className="flex shrink-0 items-center gap-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 shrink-0 border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => setConfirmingWithdraw(sub.id)}
|
||||||
|
disabled={withdrawing !== null}
|
||||||
|
title={t("withdraw_hint")}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{t("withdraw_btn")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{sub.status !== "pending_review" && (
|
{sub.status !== "pending_review" && (
|
||||||
<div className="pt-1 border-t border-border/20 mt-2 flex flex-col gap-1.5">
|
<div className="pt-1 border-t border-border/20 mt-2 flex flex-col gap-1.5">
|
||||||
{sub.reviewed_by && (
|
{sub.reviewed_by && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download } from "lucide-react";
|
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download, Trash2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { StatCard } from "@/components/StatCard";
|
import { StatCard } from "@/components/StatCard";
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MessageLine } from "@/components/MessageLine";
|
import { MessageLine } from "@/components/MessageLine";
|
||||||
|
import { InlineConfirm } from "@/components/InlineConfirm";
|
||||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||||
import { Pagination } from "@/components/Pagination";
|
import { Pagination } from "@/components/Pagination";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
@@ -43,8 +44,11 @@ export function SubmissionsPage() {
|
|||||||
|
|
||||||
// Pending actions (for button spinners)
|
// Pending actions (for button spinners)
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [busyType, setBusyType] = useState<"approve" | "reject" | null>(null);
|
const [busyType, setBusyType] = useState<"approve" | "reject" | "delete" | null>(null);
|
||||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
|
// Delete arms the row (trash → confirm/cancel) before it fires; a row gone on
|
||||||
|
// one stray click would take its uploaded context with it.
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
// Search & Filtering State
|
// Search & Filtering State
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -120,6 +124,26 @@ export function SubmissionsPage() {
|
|||||||
setRejectDialogOpen(true);
|
setRejectDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete retires the submission outright — any status — along with its
|
||||||
|
// uploaded context: the lane's only lifecycle valve, the path that reclaims
|
||||||
|
// a rejected or consumed upload from the uploads PVC.
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (busyId) return;
|
||||||
|
setBusyId(id);
|
||||||
|
setBusyType("delete");
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await api.deleteSubmission(id);
|
||||||
|
setConfirmingDelete(null);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(humanizeError(err));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
setBusyType(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRejectSubmit(e?: React.FormEvent) {
|
async function handleRejectSubmit(e?: React.FormEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (!rejectingId || !rejectReason.trim()) return;
|
if (!rejectingId || !rejectReason.trim()) return;
|
||||||
@@ -266,6 +290,7 @@ export function SubmissionsPage() {
|
|||||||
const isExpanded = expandedId === sub.id;
|
const isExpanded = expandedId === sub.id;
|
||||||
const isBusyApprove = busyId === sub.id && busyType === "approve";
|
const isBusyApprove = busyId === sub.id && busyType === "approve";
|
||||||
const isBusyReject = busyId === sub.id && busyType === "reject";
|
const isBusyReject = busyId === sub.id && busyType === "reject";
|
||||||
|
const isBusyDelete = busyId === sub.id && busyType === "delete";
|
||||||
return (
|
return (
|
||||||
<div key={sub.id} className="flex flex-col">
|
<div key={sub.id} className="flex flex-col">
|
||||||
<div
|
<div
|
||||||
@@ -306,10 +331,13 @@ export function SubmissionsPage() {
|
|||||||
{formatRelative(sub.created_at, now, locale)}
|
{formatRelative(sub.created_at, now, locale)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons (only in table row if NOT pending, else show triggers) */}
|
{/* Action buttons: approve/reject on pending rows, plus the
|
||||||
|
two-step delete on every row — it is the only path that
|
||||||
|
reclaims an upload from the uploads PVC. */}
|
||||||
<div className="col-span-1 text-right" onClick={(e) => e.stopPropagation()}>
|
<div className="col-span-1 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
{sub.status === "pending_review" ? (
|
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{sub.status === "pending_review" && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -338,12 +366,35 @@ export function SubmissionsPage() {
|
|||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
) : (
|
|
||||||
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
{confirmingDelete === sub.id ? (
|
||||||
|
<InlineConfirm
|
||||||
|
open={true}
|
||||||
|
confirming={isBusyDelete}
|
||||||
|
onConfirm={() => handleDelete(sub.id)}
|
||||||
|
onCancel={() => setConfirmingDelete(null)}
|
||||||
|
confirmLabel={t("delete_submission_confirm")}
|
||||||
|
cancelLabel={t("delete_submission_cancel")}
|
||||||
|
className="flex shrink-0 items-center gap-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7 text-destructive hover:text-destructive border-destructive/20 hover:bg-destructive/10 focus-visible:ring-destructive"
|
||||||
|
onClick={() => setConfirmingDelete(sub.id)}
|
||||||
|
disabled={!!busyId}
|
||||||
|
title={t("delete_submission_btn")}
|
||||||
|
>
|
||||||
|
{isBusyDelete ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user