Files
pad/internal/appstore/bug3653_storage_cap_test.go
xarmian 4f277f836a fix(apps): an app upload is charged against the plan's storage cap (BUG-3653 part B) (#2119)
* fix(apps): an app upload is charged against the plan's storage cap (BUG-3653)

App uploads were capped only per install (2,000 files, 1 GiB), so on
Pad Cloud an app could add bytes past the workspace owner's storage_bytes
limit that a human upload would be refused for.

- FenceSpec.StorageLimit makes BeginFenced take the owner's storage
  plan-limit lock before its install read, as the lock's ordering rule
  requires. FencedTx.CheckStorageLimit charges bytes under it and returns
  ErrAppStorageLimit, which carries no totals, like the item cap.
- Admission checks the cap at the declared size before any byte is read,
  so the common refusal writes no blob. The row insert charges it
  authoritatively, and an owner change mid-check is retried
  (ErrStorageOwnerMoved, now exported for this caller).
- The server answers 403 storage_limit_reached.
- appstore sets the flag from Options.PlanLimit, so self-hosted is unchanged.
- The boundary allow-list takes the read-only storage helpers and the
  advisory lock, each with what it writes.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* docs(apps): the fence's lock-order comments name the storage lock an app upload takes first (BUG-3653)

Codex round 1: two comments said the install FOR SHARE (fenced_tx.go) and
the seq lock (fenced_attachments.go) come first, which a StorageLimit
spec now precedes.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(apps): an app upload whose row never commits reclaims its blob (BUG-3652, BUG-3653)

With #2118 merged, the app door gets the same reclaim as the human
doors. PendingUpload.Close, when the row never committed (its insert was
refused, as by the storage cap after an admission another upload raced,
or the caller's re-check refused it before Insert), hands the canonical
blob to Options.Reclaimer while the hash guard is still held. The server
implements it with reclaimRefusedBlob on the FS backend, which keeps the
blob when another row or writer shares the hash.

Options.Reclaimer is an interface, not a func field: the appstore
boundary guard refuses function values in app code outright (rule 9),
and refuses interface calls only where an internal/store type could
answer them (rule 4). The server's implementation reads row existence
for the hash and deletes a blob file; it writes no row.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(apps): a pending upload attempts its blob reclaim once, cleared before the call (BUG-3652)

Codex round: the storage key was cleared after the reclaimer returned.
It is now captured and cleared first, so a second Close can never retry
the reclaim after the hash guard was released, whatever the first
attempt did.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-13 15:44:44 -04:00

171 lines
6.1 KiB
Go

package appstore
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/attachments"
"github.com/PerpetualSoftware/pad/internal/store"
)
// BUG-3653 part B: an app upload is charged against the workspace owner's
// storage_bytes limit, as a human upload is (TASK-3619), on top of the
// install's own caps. Cloud only (Options.PlanLimit), like the item cap.
func newCappedUploadFixture(t *testing.T, planLimit bool, limit string) uploadFixture {
t.Helper()
blobDir := t.TempDir()
blobs, err := attachments.NewFSStore(blobDir)
if err != nil {
t.Fatal(err)
}
inflight := &attachments.InFlight{}
f := newAppFixture(t, Options{Blobs: blobs, InFlight: inflight, PlanLimit: planLimit})
if _, err := f.s.SetUserPlan(f.owner.ID, store.PlanWrite{Plan: "pro", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatal(err)
}
if err := f.s.SetUserPlanOverrides(f.owner.ID, `{"storage_bytes":`+limit+`}`); err != nil {
t.Fatal(err)
}
mine, err := f.a.CreateItem(context.Background(), f.spec, f.companion.ID, AppItemCreate{Title: "Ticket with files"}, f.actor)
if err != nil {
t.Fatal(err)
}
return uploadFixture{appFixture: f, blobDir: blobDir, blobs: blobs, inflight: inflight, mine: mine}
}
// blobFiles counts the canonical blobs on disk, ignoring the staging area.
func blobFiles(t *testing.T, dir string) int {
t.Helper()
n := 0
err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && !strings.Contains(p, "staging") && !strings.HasPrefix(d.Name(), ".") {
n++
}
return nil
})
if err != nil {
t.Fatal(err)
}
return n
}
func TestBUG3653_AppUploadPastTheStorageCapIsRefused(t *testing.T) {
ctx := context.Background()
f := newCappedUploadFixture(t, true, "20")
if _, err := f.upload(ctx, f.spec, f.mine.ID, []byte(strings.Repeat("a", 15)), "a.txt"); err != nil {
t.Fatalf("under the cap: %v", err)
}
before := blobFiles(t, f.blobDir)
_, err := f.upload(ctx, f.spec, f.mine.ID, []byte(strings.Repeat("b", 15)), "b.txt")
if !errors.Is(err, store.ErrAppStorageLimit) {
t.Fatalf("an app upload past the storage cap: err = %v, want ErrAppStorageLimit", err)
}
if strings.ContainsAny(err.Error(), "0123456789") {
t.Errorf("the refusal carries a number: %q", err)
}
if after := blobFiles(t, f.blobDir); after != before {
t.Errorf("blobs on disk: %d before the refused upload, %d after", before, after)
}
// Self-hosted (PlanLimit off): the same upload lands.
g := newCappedUploadFixture(t, false, "20")
if _, err := g.upload(ctx, g.spec, g.mine.ID, []byte(strings.Repeat("a", 15)), "a.txt"); err != nil {
t.Fatal(err)
}
if _, err := g.upload(ctx, g.spec, g.mine.ID, []byte(strings.Repeat("b", 15)), "b.txt"); err != nil {
t.Fatalf("self-hosted upload past the cap: %v", err)
}
}
// Admission is advisory; the row insert charges the cap. An upload admitted
// while there was room is refused at its insert when another upload filled
// the cap in between.
func TestBUG3653_AppUploadInsertChargesTheCapAuthoritatively(t *testing.T) {
ctx := context.Background()
f := newCappedUploadFixture(t, true, "20")
body := []byte(strings.Repeat("p", 15))
pending, err := f.a.StageUpload(ctx, f.spec, f.mine.ID, AppUpload{Body: bytes.NewReader(body), DeclaredSize: int64(len(body)), Filename: "p.txt"}, f.actor)
if err != nil {
t.Fatalf("admission with room: %v", err)
}
defer pending.Close()
if _, err := f.upload(ctx, f.spec, f.mine.ID, []byte(strings.Repeat("q", 10)), "q.txt"); err != nil {
t.Fatalf("the upload that fills the cap: %v", err)
}
if _, err := pending.Insert(ctx); !errors.Is(err, store.ErrAppStorageLimit) {
t.Fatalf("insert past the cap: err = %v, want ErrAppStorageLimit", err)
}
}
// BUG-3652 on the app door: an upload whose row never commits hands its
// canonical blob to the reclaim hook, once, while its hash guard is held; a
// committed upload never does.
func TestBUG3652_AppUploadReclaimsTheBlobOfARowThatNeverCommitted(t *testing.T) {
ctx := context.Background()
type call struct{ hash, key string }
var calls []call
var guardHeld []bool
newF := func() uploadFixture {
f := newCappedUploadFixture(t, true, "20")
f.a.opts.Reclaimer = reclaimRecorder(func(hash, key string) {
calls = append(calls, call{hash, key})
f.inflight.Lock()
guardHeld = append(guardHeld, f.inflight.CountLocked(hash) > 0)
f.inflight.Unlock()
})
return f
}
// Committed: no reclaim.
f := newF()
if _, err := f.upload(ctx, f.spec, f.mine.ID, []byte(strings.Repeat("a", 5)), "a.txt"); err != nil {
t.Fatal(err)
}
if len(calls) != 0 {
t.Fatalf("a committed upload was reclaimed: %v", calls)
}
// Refused at its insert after admission: reclaimed once, guard held.
body := []byte(strings.Repeat("p", 15))
pending, err := f.a.StageUpload(ctx, f.spec, f.mine.ID, AppUpload{Body: bytes.NewReader(body), DeclaredSize: int64(len(body)), Filename: "p.txt"}, f.actor)
if err != nil {
t.Fatal(err)
}
if _, err := f.upload(ctx, f.spec, f.mine.ID, []byte(strings.Repeat("q", 10)), "q.txt"); err != nil {
t.Fatal(err)
}
if _, err := pending.Insert(ctx); !errors.Is(err, store.ErrAppStorageLimit) {
t.Fatalf("insert: %v", err)
}
want := call{pending.row.ContentHash, pending.row.StorageKey}
pending.Close()
pending.Close() // idempotent: still one call
if len(calls) != 1 || calls[0] != want || !guardHeld[0] {
t.Fatalf("calls = %v (guard held %v), want one call %v with the guard held", calls, guardHeld, want)
}
// Closed without Insert (the caller's re-check refused it): reclaimed.
other := []byte(strings.Repeat("r", 3))
p2, err := f.a.StageUpload(ctx, f.spec, f.mine.ID, AppUpload{Body: bytes.NewReader(other), DeclaredSize: int64(len(other)), Filename: "r.txt"}, f.actor)
if err != nil {
t.Fatal(err)
}
p2.Close()
if len(calls) != 2 || calls[1].hash != p2.row.ContentHash {
t.Fatalf("calls = %v, want a second call for the upload closed without Insert", calls)
}
}
type reclaimRecorder func(hash, key string)
func (r reclaimRecorder) ReclaimRefusedBlob(_ context.Context, hash, key string) { r(hash, key) }