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.
159 lines
4.9 KiB
Go
159 lines
4.9 KiB
Go
package submit
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestLocalContextStorePutAndExists(t *testing.T) {
|
|
base := t.TempDir()
|
|
s := &LocalContextStore{Base: base}
|
|
ctx := context.Background()
|
|
|
|
if ok, err := s.Exists(ctx, "sub-abc"); err != nil || ok {
|
|
t.Fatalf("Exists before Put = (%v, %v), want (false, nil)", ok, err)
|
|
}
|
|
|
|
payload := "\x1f\x8b\x08\x00the modpack context"
|
|
n, err := s.Put(ctx, "sub-abc", strings.NewReader(payload))
|
|
if err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if n != int64(len(payload)) {
|
|
t.Fatalf("Put returned %d bytes, want %d", n, len(payload))
|
|
}
|
|
|
|
// The blob lands at exactly {base}/{id}/context.tar.gz — where deriveContextRef
|
|
// points Kaniko's --context.
|
|
dest := filepath.Join(base, "sub-abc", contextBlobName)
|
|
got, err := os.ReadFile(dest)
|
|
if err != nil {
|
|
t.Fatalf("read stored blob: %v", err)
|
|
}
|
|
if string(got) != payload {
|
|
t.Fatalf("stored %q, want %q", got, payload)
|
|
}
|
|
if ok, err := s.Exists(ctx, "sub-abc"); err != nil || !ok {
|
|
t.Fatalf("Exists after Put = (%v, %v), want (true, nil)", ok, err)
|
|
}
|
|
|
|
// The write is atomic: no leftover temp files beside the committed blob.
|
|
entries, err := os.ReadDir(filepath.Join(base, "sub-abc"))
|
|
if err != nil {
|
|
t.Fatalf("read dir: %v", err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Name() != contextBlobName {
|
|
var names []string
|
|
for _, e := range entries {
|
|
names = append(names, e.Name())
|
|
}
|
|
t.Fatalf("dir entries = %v, want only %q (no temp files)", names, contextBlobName)
|
|
}
|
|
}
|
|
|
|
// Open is the internal context-fetch route's read path: it serves exactly the
|
|
// stored bytes, and a missing blob is ErrBlobNotFound (404), never a bare os error.
|
|
func TestLocalContextStoreOpen(t *testing.T) {
|
|
base := t.TempDir()
|
|
s := &LocalContextStore{Base: base}
|
|
ctx := context.Background()
|
|
|
|
if _, err := s.Open(ctx, "sub-gone"); !errors.Is(err, ErrBlobNotFound) {
|
|
t.Fatalf("Open of a missing blob = %v, want ErrBlobNotFound", err)
|
|
}
|
|
|
|
payload := "\x1f\x8b\x08\x00the modpack context"
|
|
if _, err := s.Put(ctx, "sub-abc", strings.NewReader(payload)); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
rc, err := s.Open(ctx, "sub-abc")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer rc.Close()
|
|
got, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if string(got) != payload {
|
|
t.Fatalf("Open served %q, want %q", got, payload)
|
|
}
|
|
// The same path guard as Put: an id that could escape Base is refused.
|
|
if _, err := s.Open(ctx, "../etc/passwd"); err == nil {
|
|
t.Fatal("Open must reject an unsafe id")
|
|
}
|
|
}
|
|
|
|
func TestLocalContextStorePutOverwrites(t *testing.T) {
|
|
base := t.TempDir()
|
|
s := &LocalContextStore{Base: base}
|
|
ctx := context.Background()
|
|
|
|
if _, err := s.Put(ctx, "sub-1", strings.NewReader("\x1f\x8bfirst")); err != nil {
|
|
t.Fatalf("first Put: %v", err)
|
|
}
|
|
if _, err := s.Put(ctx, "sub-1", strings.NewReader("\x1f\x8bsecond upload")); err != nil {
|
|
t.Fatalf("second Put: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(base, "sub-1", contextBlobName))
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if string(got) != "\x1f\x8bsecond upload" {
|
|
t.Fatalf("stored %q, want the second upload (a re-upload supersedes)", got)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
base := t.TempDir()
|
|
s := &LocalContextStore{Base: base}
|
|
ctx := context.Background()
|
|
|
|
for _, id := range []string{"../evil", "sub/../../etc", "SUB-UPPER", "has space", "", "a/b"} {
|
|
if _, err := s.Put(ctx, id, strings.NewReader("\x1f\x8bx")); err == nil {
|
|
t.Errorf("Put(%q) succeeded, want rejection", id)
|
|
}
|
|
if _, err := s.Exists(ctx, id); err == nil {
|
|
t.Errorf("Exists(%q) succeeded, want rejection", id)
|
|
}
|
|
}
|
|
// Nothing escaped the base directory.
|
|
if _, err := os.Stat(filepath.Join(filepath.Dir(base), "evil")); !os.IsNotExist(err) {
|
|
t.Fatal("an unsafe id wrote outside Base")
|
|
}
|
|
}
|