Files
pad/internal/server/bug3653_storage_cap_doors_test.go
T
xarmian aae955c972 fix(storage): bundle import and cross-workspace copy are charged against the storage cap (BUG-3653, TASK-3625) (#2117)
* fix(storage): bundle import and cross-workspace copy are charged against the storage cap (BUG-3653, TASK-3625)

TASK-3619 enforced storage_bytes at upload only. Two doors still added
attachment bytes unchecked:

- Cross-workspace copy cloned attachment rows after checking only the
  item cap. It now takes the destination's storage lock before its
  workspace locks (the plan-limit lock's ordering rule), and once the
  attachments are planned charges every cloned row, variants included,
  refusing with the plan-limit 403. Cloud mode only, like the item cap.
  An ownership change mid-check is retried, as an upload's is.
- Bundle import inserted rehydrated rows with a plain CreateAttachment
  and swallowed any insert error. CreateAttachment now takes
  WithPlanLimit; import passes it in cloud mode, and a refusal stops the
  import with the 403 and rolls back the partial workspace instead of
  importing a workspace with files silently missing.

enforceStorageLimitTx is split into lockStorageLimitTx and
checkStorageLimitTx so copy can lock first and check later.

App uploads (the third door in BUG-3653) follow in a separate change:
their fenced transaction takes its own locks first.

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

* docs(storage): say why imported thumbnails are not charged against the storage cap (BUG-3653)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-13 12:27:31 -04:00

157 lines
5.6 KiB
Go

package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/attachments"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// BUG-3653: every door that adds attachment bytes to a workspace is charged
// against the storage cap TASK-3619 enforces at upload, and is refused when
// over it. Upload was the only door that checked; bundle import and
// cross-workspace copy inserted their rows unchecked.
// capPlan puts user on plan with a tiny storage_bytes override standing in
// for the real cap; it rides the same resolution chain.
func capPlan(t *testing.T, srv *Server, userID, plan string, limit int) {
t.Helper()
if _, err := srv.store.SetUserPlan(userID, store.PlanWrite{Plan: plan, Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatal(err)
}
if err := srv.store.SetUserPlanOverrides(userID, `{"storage_bytes":`+strconv.Itoa(limit)+`}`); err != nil {
t.Fatal(err)
}
}
func withFSAttachments(t *testing.T, srv *Server) {
t.Helper()
fs, err := attachments.NewFSStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
reg := attachments.NewRegistry()
reg.Register(attachments.FSPrefix, fs)
srv.SetAttachments(reg, 0)
}
// planLimitRefusal decodes a 403 and checks it is the storage refusal.
func planLimitRefusal(t *testing.T, body []byte, code int) map[string]any {
t.Helper()
if code != http.StatusForbidden {
t.Fatalf("status %d %s, want 403 plan_limit_exceeded", code, body)
}
var env struct {
Error struct {
Code string `json:"code"`
Details map[string]any `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatal(err)
}
if env.Error.Code != "plan_limit_exceeded" || env.Error.Details["feature"] != "storage_bytes" {
t.Fatalf("refusal = %s, want plan_limit_exceeded on storage_bytes", body)
}
return env.Error.Details
}
// Copy: a Pro account's storage is pooled, so copying an item whose file is
// 30 bytes into a sibling workspace takes the account from 30 to 60 bytes,
// past its 40. A direct upload of those bytes is refused; the copy was not.
// Self-hosted (cloud off) is not limited and still copies.
func TestBUG3653_CopyPastTheStorageLimitIs403(t *testing.T) {
for _, cloud := range []bool{true, false} {
srv := testServer(t)
srv.cloudMode = cloud
withFSAttachments(t, srv)
user, tok := loginTestUserAs(t, srv, "copy3653@example.test", "Copier", "pw-3653-abcdefgh")
capPlan(t, srv, user.ID, "pro", 40)
src := mustCreateOwnedWorkspace(t, srv, "Cap Source 3653", user)
dst := mustCreateOwnedWorkspace(t, srv, "Cap Dest 3653", user)
srcColl := mustCollection(t, srv, src.ID, "Tasks")
dstColl := mustCollection(t, srv, dst.ID, "Tasks")
rr := capUpload(srv, src.Slug, tok, []byte(strings.Repeat("a", 30)))
if rr.Code != http.StatusCreated {
t.Fatalf("cloud=%v upload: %d %s", cloud, rr.Code, rr.Body.String())
}
var up struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &up); err != nil || up.ID == "" {
t.Fatalf("decode upload: %v %s", err, rr.Body.String())
}
it, err := srv.store.CreateItem(src.ID, srcColl.ID, models.ItemCreate{
Title: "Has a file", Content: "[notes](pad-attachment:" + up.ID + ")",
})
if err != nil {
t.Fatal(err)
}
rr = doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+src.Slug+"/items/"+it.Slug+"/copy",
map[string]any{"target_workspace": dst.Slug, "target_collection": dstColl.Slug}, tok)
total, _ := attachmentRowsFor(t, srv, dst.ID)
if !cloud {
if rr.Code != http.StatusCreated || total != 1 {
t.Fatalf("self-hosted copy: %d %s, %d rows in the destination, want 201 and 1", rr.Code, rr.Body.String(), total)
}
continue
}
d := planLimitRefusal(t, rr.Body.Bytes(), rr.Code)
if d["current"] != float64(30) || d["requested"] != float64(30) || d["limit"] != float64(40) {
t.Errorf("refusal details = %v, want current 30, requested 30, limit 40", d)
}
if total != 0 {
t.Errorf("refused copy left %d attachment rows in the destination", total)
}
var items int
if err := srv.store.DB().QueryRow(srv.store.D().Rebind(`SELECT COUNT(*) FROM items WHERE workspace_id = ?`), dst.ID).Scan(&items); err != nil {
t.Fatal(err)
}
if items != 0 {
t.Errorf("refused copy left %d items in the destination", items)
}
}
}
// Import: a bundle whose file alone exceeds the importer's cap is refused
// with the plan-limit 403 and leaves no workspace behind. Self-hosted imports
// it.
func TestBUG3653_ImportPastTheStorageLimitIs403(t *testing.T) {
bundle := realBundleWithBlob(t)
for _, cloud := range []bool{true, false} {
srv := attachmentsServerOn(t, store.DriverSQLite)
srv.cloudMode = cloud
user, tok := memberImporter(t, srv)
capPlan(t, srv, user.ID, "free", 10)
rr := importAs(srv, "capped-import", bundle, tok)
if !cloud {
if rr.Code != http.StatusCreated && rr.Code != http.StatusOK {
t.Fatalf("self-hosted import: %d %s", rr.Code, rr.Body.String())
}
continue
}
planLimitRefusal(t, rr.Body.Bytes(), rr.Code)
var live, rows int
if err := srv.store.DB().QueryRow(srv.store.D().Rebind(
`SELECT COUNT(*) FROM workspaces WHERE owner_id = ? AND deleted_at IS NULL`), user.ID).Scan(&live); err != nil {
t.Fatal(err)
}
if err := srv.store.DB().QueryRow(srv.store.D().Rebind(
`SELECT COUNT(*) FROM attachments a JOIN workspaces w ON w.id = a.workspace_id WHERE w.owner_id = ? AND a.deleted_at IS NULL`), user.ID).Scan(&rows); err != nil {
t.Fatal(err)
}
if live != 0 || rows != 0 {
t.Errorf("refused import left %d live workspaces and %d live attachment rows", live, rows)
}
}
}