feat(attachments): Settings → Storage tab with attachment list (TASK-882) (#303)

* feat(attachments): Settings → Storage tab with attachment list (TASK-882)

Adds the Settings → Storage tab and the underlying list/delete API
endpoints so workspace owners can audit and reclaim attachment bytes.

Backend (TASK-882 needs this — there was no list/delete API yet):
- store.WorkspaceAttachments: paginated list with filter (category,
  attached/unattached, collection_id) + sort allowlist (size, filename,
  created_at — each with desc variant). LEFT JOIN to items + collections
  enriches each row with item_title/slug + collection_slug for the
  "in [[Item]]" link. Hides derived (thumbnail) rows by default — they
  count toward quota but are managed automatically and would clutter
  the user-facing list.
- store.SoftDeleteAttachment: tombstones the row + every variant. Blob
  on disk stays put; orphan GC reclaims past the grace period (TASK-886).
- GET /workspaces/{ws}/attachments — viewer+, returns
  {attachments, total, limit, offset}.
- DELETE /workspaces/{ws}/attachments/{id} — editor+. Refuses to delete
  derived rows directly (returns 400 with derived_attachment code) and
  invalidates the storage-usage cache.

Frontend:
- StorageTab.svelte component (lib/components/settings) with usage bar
  (color thresholds at 80%/100%, override badge), 5-select filter row
  (category, item, collection, sort, page size), attachment list with
  thumbnails (image variants via thumb-sm, emoji icon otherwise), item
  link, MIME, size, date, and per-row delete with confirm() dialog.
  Pagination footer with Prev/Next + "showing X–Y of Z".
- TS api.attachments.list() / delete() + types.
- Wired as a new "Storage" tab on the workspace settings page.

Tests:
- TestListAttachments_Pagination: 3 uploads, default + size-asc sort,
  limit/offset paging.
- TestListAttachments_HidesDerived: synthetic thumbnail row, asserts
  the list excludes parent_id != NULL rows.
- TestDeleteAttachment_HappyPath: upload → delete → list empty →
  storage usage drops to 0 (cache invalidation hook fires) → second
  delete returns 404.
- TestDeleteAttachment_DerivedRefused: thumbnail rows can't be deleted
  directly via the API.

Parent: PLAN-866.

* fix(attachments): collection visibility + category gaps + item ref shape per Codex (round 1)

Three findings from Codex on PR #303 round 1:

P1 — Collection visibility leak. The storage list returned all
workspace attachments without applying per-user collection access,
so a member with collection_access=specific would receive hidden
collections' attachment IDs/filenames/item titles and could then
pull the bytes via the existing download endpoint.

Fixed by threading visibleCollectionIDs(r, workspaceID) through to
the store filter. nil = admin/no restriction; empty slice = zero
visible collections (zero rows by design); explicit set = restrict
i.collection_id IN (...). Orphans (item_id IS NULL) are excluded
for restricted users since their filenames would still leak.

P2 — item_ref shape didn't match the route. The store synthesized
"<collection_slug>/<item_number>" and the UI inserted it verbatim
into the URL, producing /user/ws/tasks/tasks/5. Dropped item_ref
entirely; UI now builds URLs from item_slug + collection_slug
which is the actual route shape.

P2 — Category filter coverage. mimePrefixForCategory only handled
image/video/audio. Selecting Documents/Text/Archive/Other in the
UI silently passed through with no MIME predicate so the list
showed everything. Replaced with mimePredicateForCategory which
emits the right SQL fragment per bucket: prefix LIKE for the type/
buckets, explicit IN list for document/text/archive (mirroring the
allowlist in internal/attachments/mime.go), and a NOT-IN composite
for "other".

Tests:
- TestWorkspaceAttachments_VisibilityFilter: admin sees all 3 rows;
  restricted to one collection sees only that collection's row +
  orphan suppressed; empty visibility yields zero rows.
- TestWorkspaceAttachments_CategoryFilters: image/document/text/
  archive/other each return exactly the matching MIME types.

* fix(attachments): item-level visibility on list + delete per Codex (round 2)

Two more findings from Codex on PR #303 round 2:

1. The list filter used VisibleCollectionIDs alone — but that set
   includes collections containing any item-level grant for the user.
   A guest with one item granted in collection B would still receive
   attachment metadata for every item in collection B. Replaced with
   the (fullCollIDs, grantedItemIDs) tuple from guestResourceFilter so
   the SQL ORs collection-level full access against per-item grants,
   matching how handlers_search / handlers_activity narrow lists.

2. The delete endpoint validated workspace membership but never
   checked the attachment's parent item is visible to the caller.
   An editor with restricted collection access could delete
   attachments in hidden collections by guessing/obtaining the
   attachment ID. Added requireItemVisible after fetching the parent
   item, plus a fallback gate for orphan attachments (item_id IS
   NULL) so restricted users get 404 there as well.

Store-level filter renamed: VisibleCollectionIDs → Restricted +
FullCollectionIDs + GrantedItemIDs. Tests cover the collection-only,
item-grant-only, and zero-visibility paths.

* fix(attachments): allow deleting attachments when parent item is soft-deleted (round 3)

Codex P2 from PR #303 round 3: the storage list intentionally surfaces
attachments whose parent item has been soft-deleted (so the user sees
what's still consuming quota), but the delete handler used GetItem,
which filters soft-deleted out and returned 404 before
SoftDeleteAttachment could run — turning every Delete button on those
rows into a no-op.

Fixed by adding store.GetItemIncludeDeleted (mirroring the existing
GetItemBySlugIncludeDeleted) and switching the delete path to use it.
The visibility check still keys off the (still-set) collection_id, so
soft-deleting an item doesn't escalate access — restricted users still
hit requireItemVisible's 404 if they couldn't see the parent.

Regression test: create item → attach → soft-delete item → list still
returns the row → delete returns 204.

* fix(attachments): list surfaces attachments under soft-deleted parents (round 4)

Codex round-4 finding: WorkspaceAttachments still LEFT JOIN'd items
with AND i.deleted_at IS NULL, so attachments whose parent item was
soft-deleted disappeared from the list — even though the previous
round wired GetItemIncludeDeleted on the delete path. Net effect:
restricted editors with access to that collection couldn't discover
the row in the UI; only full-access users saw it as an orphan-looking
entry.

Fix: drop the deleted_at filter from the JOIN. The collection ACL
predicate (i.collection_id IN ...) now sees the (still-set)
collection_id from the soft-deleted item, so visibility behaves
consistently for live and tombstoned parents. Soft-deleted items
don't escalate access — the collection_id stays put.

UX: response now carries item_deleted=true when the parent is
soft-deleted; the StorageTab renders the title with strike-through
+ a small "deleted" badge instead of a clickable link (which would
404).

Tests:
- store-level: admin/full-access sees the row + ItemDeleted flag,
  restricted-to-correct-collection sees it, restricted-to-other-
  collection does not.
- (existing TestDeleteAttachment_AfterParentSoftDeleted continues
  to pass on the handler side.)
This commit is contained in:
xarmian
2026-04-29 17:44:12 -04:00
committed by GitHub
parent 335762c2bf
commit 504d348917
10 changed files with 2127 additions and 1 deletions
+231
View File
@@ -1,10 +1,16 @@
package server
import (
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/store"
)
@@ -129,3 +135,228 @@ func (s *Server) handleGetWorkspaceStorageUsage(w http.ResponseWriter, r *http.R
s.storageInfoCache.set(workspaceID, info)
writeJSON(w, http.StatusOK, info)
}
// handleListWorkspaceAttachments returns a paginated list of original
// (non-derived) attachments in the workspace. Supports filter +
// sort + pagination via query string:
//
// GET /api/v1/workspaces/{ws}/attachments
// ?category=image|video|audio|document|text|archive|other
// &item=attached|unattached
// &collection=<collection_id>
// &sort=size|size_desc|filename|filename_desc|created_at|created_at_desc
// &limit=<1..200>
// &offset=<n>
//
// Unknown values are silently ignored — the server defaults
// (`created_at_desc`, limit 50, offset 0, no filters) take over.
//
// Auth: viewer+. Same gate as storage/usage — workspace-wide
// attachment metadata leaks the same surface area.
//
// Response: {attachments: [...], total: N, limit, offset}.
func (s *Server) handleListWorkspaceAttachments(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "viewer") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
q := r.URL.Query()
filters := store.AttachmentListFilters{
MimeCategory: strings.ToLower(strings.TrimSpace(q.Get("category"))),
CollectionID: strings.TrimSpace(q.Get("collection")),
Sort: strings.ToLower(strings.TrimSpace(q.Get("sort"))),
}
switch strings.ToLower(strings.TrimSpace(q.Get("item"))) {
case "attached":
filters.Attached = true
case "unattached":
filters.Unattached = true
}
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filters.Limit = n
}
}
if v := q.Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filters.Offset = n
}
}
// Enforce per-user collection + item-level access control. Mirrors
// the (fullCollIDs, grantedItemIDs) tuple used by handlers_search /
// handlers_activity for cross-collection lists. nil/nil from
// guestResourceFilter means admin or full-access member — no
// restriction. Otherwise a restricted user's view is the union of
// their member-access collections + any item-level grants.
fullCollIDs, grantedItemIDs, err := s.guestResourceFilter(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
restricted := fullCollIDs != nil || grantedItemIDs != nil
if restricted {
filters.Restricted = true
filters.FullCollectionIDs = fullCollIDs
filters.GrantedItemIDs = grantedItemIDs
// Restricted users never see orphans (item_id IS NULL) — the
// store filter already excludes them, so the Unattached
// filter would always yield zero rows. Short-circuit so the
// UI sees an immediate empty page rather than firing the SQL.
if filters.Unattached {
writeJSON(w, http.StatusOK, map[string]any{
"attachments": []store.AttachmentListItem{},
"total": 0,
"limit": effectiveLimit(filters.Limit),
"offset": effectiveOffset(filters.Offset),
})
return
}
}
rows, total, err := s.store.WorkspaceAttachments(workspaceID, filters)
if err != nil {
writeInternalError(w, err)
return
}
if rows == nil {
// Marshal `[]` rather than `null` so the UI can iterate
// without a falsy-check guard on every render.
rows = []store.AttachmentListItem{}
}
writeJSON(w, http.StatusOK, map[string]any{
"attachments": rows,
"total": total,
"limit": effectiveLimit(filters.Limit),
"offset": effectiveOffset(filters.Offset),
})
}
// effectiveLimit / effectiveOffset mirror the store-side defaults so
// the response carries the canonical values the handler used. Useful
// to the UI when the request omitted them.
func effectiveLimit(n int) int {
if n <= 0 {
return 50
}
if n > 200 {
return 200
}
return n
}
func effectiveOffset(n int) int {
if n < 0 {
return 0
}
return n
}
// handleDeleteWorkspaceAttachment soft-deletes an attachment by ID.
// Tombstones the row + every thumbnail variant; the orphan GC reclaims
// the on-disk blob after the grace period (TASK-886).
//
// DELETE /api/v1/workspaces/{ws}/attachments/{attachmentID}
//
// Auth: editor+. Delete is destructive (the bytes go away after GC) —
// view-only members shouldn't be able to remove attachments other
// users uploaded.
//
// Cross-workspace requests get 404 (not 403) to avoid leaking which
// IDs exist in other workspaces. Same pattern as the download
// handler.
//
// Returns 204 on success. The storage-usage cache is invalidated
// eagerly so the bar drops within a refresh cycle.
func (s *Server) handleDeleteWorkspaceAttachment(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
id := chi.URLParam(r, "attachmentID")
if id == "" {
writeError(w, http.StatusBadRequest, "missing_id", "Attachment ID required")
return
}
att, err := s.store.GetAttachment(id)
if err != nil {
writeInternalError(w, err)
return
}
if att == nil || att.WorkspaceID != workspaceID || att.DeletedAt != nil {
writeError(w, http.StatusNotFound, "not_found", "Attachment not found")
return
}
// Refuse to delete derived (thumbnail) rows directly — they're
// auto-managed and deleting the original cascades. A direct
// delete here would leave the original without thumbnails until
// a future "regenerate" job runs.
if att.ParentID != nil {
writeError(w, http.StatusBadRequest, "derived_attachment",
"Cannot delete a thumbnail directly — delete the original.")
return
}
// Item-level visibility check. An editor with restricted collection
// access shouldn't be able to delete attachments in collections
// they can't see — even if they obtain the attachment ID some
// other way. Mirrors requireItemVisible's logic but operates on
// the attachment's parent item id rather than a fully-loaded item.
//
// GetItemIncludeDeleted is used (not GetItem) because the storage
// list intentionally surfaces attachments whose parent item is
// soft-deleted — they're still consuming quota and the user
// needs a path to delete the blob. The collection_id stays set
// after a soft delete, so the visibility predicate still works.
if att.ItemID != nil {
item, err := s.store.GetItemIncludeDeleted(*att.ItemID)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil || !s.requireItemVisible(w, r, workspaceID, item) {
// requireItemVisible already wrote a 404 on its denial path.
// Two cases land us here: the item was hard-deleted out from
// under us (item == nil) or the user can't see it.
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Attachment not found")
}
return
}
} else {
// Orphan attachments (item_id IS NULL) are not associated with
// any collection, so collection-level visibility doesn't apply.
// Restricted members shouldn't reach here because the LIST
// endpoint hides orphans from them, but a direct DELETE with a
// guessed UUID could — gate orphans on full-access editors.
if fullCollIDs, grantedItemIDs, gErr := s.guestResourceFilter(r, workspaceID); gErr != nil {
writeInternalError(w, gErr)
return
} else if fullCollIDs != nil || grantedItemIDs != nil {
writeError(w, http.StatusNotFound, "not_found", "Attachment not found")
return
}
}
if err := s.store.SoftDeleteAttachment(id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "Attachment not found")
return
}
writeInternalError(w, err)
return
}
s.storageInfoCache.invalidate(workspaceID)
w.WriteHeader(http.StatusNoContent)
}
+320
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
@@ -114,6 +115,325 @@ func TestStorageUsage_RejectsGuests(t *testing.T) {
}
}
// TestListAttachments_Pagination covers list + paginate + sort ordering.
// Uploads three blobs of distinct sizes and verifies:
// - default sort is created_at DESC (newest first)
// - sort=size ascends from smallest
// - limit + offset paginate correctly with the right total count
func TestListAttachments_Pagination(t *testing.T) {
srv, slug := testServerWithAttachments(t)
// Three uploads. realPNG is the only stdlib-decodable shape we
// have; pad each one with a different number of trailing bytes
// after the IEND chunk so the size_bytes column varies. The
// upload handler doesn't re-validate after the IEND — extra
// trailing bytes are stored verbatim, which suits the test fine.
base := realPNG()
mkBody := func(extra int) []byte {
out := make([]byte, len(base)+extra)
copy(out, base)
return out
}
doMultipartUpload(srv, slug, "small.png", mkBody(0))
doMultipartUpload(srv, slug, "medium.png", mkBody(100))
doMultipartUpload(srv, slug, "large.png", mkBody(1000))
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/attachments", nil)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var resp struct {
Attachments []struct {
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
} `json:"attachments"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
}
if resp.Total != 3 {
t.Errorf("total = %d, want 3", resp.Total)
}
if len(resp.Attachments) != 3 {
t.Fatalf("got %d rows, want 3", len(resp.Attachments))
}
// Default created_at DESC. Three uploads in the same millisecond
// can land with the same timestamp, so we don't pin the order —
// just assert all three filenames are present in the page. The
// sort=size assertion below covers the ordered case.
gotFilenames := map[string]bool{}
for _, a := range resp.Attachments {
gotFilenames[a.Filename] = true
}
for _, want := range []string{"small.png", "medium.png", "large.png"} {
if !gotFilenames[want] {
t.Errorf("default sort: missing filename %q in result", want)
}
}
// Ascending size sort.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/attachments?sort=size", nil)
if rr.Code != http.StatusOK {
t.Fatalf("sort=size status = %d", rr.Code)
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Attachments[0].Filename != "small.png" || resp.Attachments[2].Filename != "large.png" {
t.Errorf("sort=size order: %q,%q,%q want small,medium,large",
resp.Attachments[0].Filename, resp.Attachments[1].Filename, resp.Attachments[2].Filename)
}
// Pagination: limit=2, offset=2 → only 1 row.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/attachments?limit=2&offset=2", nil)
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Total != 3 {
t.Errorf("total stays 3 across pages, got %d", resp.Total)
}
if len(resp.Attachments) != 1 {
t.Errorf("limit=2 offset=2: got %d rows, want 1", len(resp.Attachments))
}
if resp.Limit != 2 || resp.Offset != 2 {
t.Errorf("echoed limit/offset = %d/%d, want 2/2", resp.Limit, resp.Offset)
}
}
// TestListAttachments_HidesDerived asserts that thumbnail rows
// (parent_id != NULL) don't show up in the list. They count toward
// quota but are managed automatically — surfacing them clutters the
// settings page with rows the user didn't upload.
//
// We synthesize a thumbnail row directly via the store rather than
// running the real thumbnail pipeline, which would require a decodable
// image the pure-Go processor accepts.
func TestListAttachments_HidesDerived(t *testing.T) {
srv, slug := testServerWithAttachments(t)
wsID := workspaceIDForSlug(t, srv, slug)
if rr := doMultipartUpload(srv, slug, "original.png", realPNG()); rr.Code != http.StatusCreated {
t.Fatalf("upload: %d", rr.Code)
}
// Insert a synthetic thumbnail row pointing at the original.
// The handler should skip it because parent_id IS NOT NULL.
originalID := getOnlyAttachmentID(t, srv, wsID)
thumbVariant := "thumb-sm"
if err := srv.store.CreateAttachment(&models.Attachment{
WorkspaceID: wsID,
UploadedBy: "system",
StorageKey: "fs:fakehash",
ContentHash: "fakehash",
MimeType: "image/png",
SizeBytes: 123,
Filename: "original-thumb-sm.png",
ParentID: &originalID,
Variant: &thumbVariant,
}); err != nil {
t.Fatalf("CreateAttachment(thumb): %v", err)
}
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/attachments", nil)
var resp struct {
Attachments []struct{ ID string } `json:"attachments"`
Total int `json:"total"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Total != 1 {
t.Errorf("total = %d, want 1 (derived thumbnail must be hidden)", resp.Total)
}
}
// TestDeleteAttachment_HappyPath covers the soft-delete flow: a
// successful upload, a 204 from the delete handler, and a follow-up
// list call that no longer sees the row. Storage usage drops to 0
// confirming the cache invalidation hook fires.
func TestDeleteAttachment_HappyPath(t *testing.T) {
srv, slug := testServerWithAttachments(t)
body := realPNG()
rr := doMultipartUpload(srv, slug, "victim.png", body)
if rr.Code != http.StatusCreated {
t.Fatalf("upload: %d %s", rr.Code, rr.Body.String())
}
var upload struct{ ID string }
if err := json.Unmarshal(rr.Body.Bytes(), &upload); err != nil {
t.Fatalf("decode upload: %v", err)
}
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/attachments/"+upload.ID, nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("delete: status=%d body=%s", rr.Code, rr.Body.String())
}
// List should be empty now.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/attachments", nil)
var listResp struct {
Total int `json:"total"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &listResp); err != nil {
t.Fatalf("decode list: %v", err)
}
if listResp.Total != 0 {
t.Errorf("after delete: total = %d, want 0", listResp.Total)
}
// Storage usage drops to 0 (cache was invalidated).
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/storage/usage", nil)
var usage storageUsageResponse
if err := json.Unmarshal(rr.Body.Bytes(), &usage); err != nil {
t.Fatalf("decode usage: %v", err)
}
if usage.UsedBytes != 0 {
t.Errorf("after delete: used_bytes = %d, want 0", usage.UsedBytes)
}
// Second delete → 404 (already tombstoned).
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/attachments/"+upload.ID, nil)
if rr.Code != http.StatusNotFound {
t.Errorf("second delete: status=%d, want 404", rr.Code)
}
}
// TestDeleteAttachment_AfterParentSoftDeleted pins Codex P2 from
// PR #303 round 3: when the parent item is soft-deleted, the
// attachment row remains in the storage list (so the user can see
// it's still consuming quota), and the Delete button must work.
//
// The earlier draft used GetItem which filters out soft-deleted
// items, so the handler returned 404 before ever calling
// SoftDeleteAttachment. This test creates an item, attaches a row,
// soft-deletes the item, and then exercises the delete endpoint.
func TestDeleteAttachment_AfterParentSoftDeleted(t *testing.T) {
srv, slug := testServerWithAttachments(t)
wsID := workspaceIDForSlug(t, srv, slug)
// Create a real item so the attachment has a parent. Use the
// docs collection (preseeded by the workspace template).
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/docs/items",
map[string]any{"title": "Doomed", "content": "x"})
if rr.Code != http.StatusCreated {
t.Fatalf("create item: %d %s", rr.Code, rr.Body.String())
}
var item struct{ ID string }
if err := json.Unmarshal(rr.Body.Bytes(), &item); err != nil {
t.Fatalf("decode item: %v", err)
}
// Attach a row to the item directly via the store — easier than
// orchestrating an upload + association sequence.
att := &models.Attachment{
WorkspaceID: wsID,
ItemID: &item.ID,
UploadedBy: "system",
StorageKey: "fs:" + "x",
ContentHash: "fakehash3",
MimeType: "image/png",
SizeBytes: 123,
Filename: "doomed.png",
}
if err := srv.store.CreateAttachment(att); err != nil {
t.Fatalf("CreateAttachment: %v", err)
}
// Soft-delete the parent item.
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/items/"+item.ID, nil)
if rr.Code != http.StatusNoContent && rr.Code != http.StatusOK {
t.Fatalf("soft-delete item: %d %s", rr.Code, rr.Body.String())
}
// Attachment should still be visible in the storage list.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/attachments", nil)
var resp struct {
Total int `json:"total"`
Attachments []store.AttachmentListItem `json:"attachments"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode list: %v", err)
}
if resp.Total != 1 {
t.Errorf("after soft-delete: total=%d, want 1 (attachment must remain visible)", resp.Total)
}
// Delete must succeed despite the parent item being soft-deleted.
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/attachments/"+att.ID, nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("delete after parent soft-delete: status=%d body=%s", rr.Code, rr.Body.String())
}
}
// TestDeleteAttachment_DerivedRefused pins the carve-out for thumbnail
// rows: a direct delete of a derived attachment should return 400
// rather than silently succeed (which would leave the original
// without thumbnails until a regenerate job runs).
func TestDeleteAttachment_DerivedRefused(t *testing.T) {
srv, slug := testServerWithAttachments(t)
wsID := workspaceIDForSlug(t, srv, slug)
if rr := doMultipartUpload(srv, slug, "x.png", realPNG()); rr.Code != http.StatusCreated {
t.Fatalf("upload: %d", rr.Code)
}
originalID := getOnlyAttachmentID(t, srv, wsID)
thumbVariant := "thumb-sm"
thumb := &models.Attachment{
WorkspaceID: wsID,
UploadedBy: "system",
StorageKey: "fs:fakehash2",
ContentHash: "fakehash2",
MimeType: "image/png",
SizeBytes: 1,
Filename: "x-thumb-sm.png",
ParentID: &originalID,
Variant: &thumbVariant,
}
if err := srv.store.CreateAttachment(thumb); err != nil {
t.Fatalf("CreateAttachment(thumb): %v", err)
}
rr := doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/attachments/"+thumb.ID, nil)
if rr.Code != http.StatusBadRequest {
t.Errorf("delete derived: status=%d, want 400", rr.Code)
}
}
// getOnlyAttachmentID returns the ID of the single live attachment
// row in a workspace — the test infrastructure uploads at most one
// blob per call, so this lookup is deterministic. Fails the test if
// zero or multiple rows are live.
func getOnlyAttachmentID(t *testing.T, srv *Server, workspaceID string) string {
t.Helper()
rows, _, err := srv.store.WorkspaceAttachments(workspaceID, store.AttachmentListFilters{})
if err != nil {
t.Fatalf("WorkspaceAttachments: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected exactly 1 attachment, got %d", len(rows))
}
return rows[0].ID
}
// workspaceIDForSlug looks up the internal UUID for a slug — used
// by tests that synthesize attachment rows directly via the store
// (bypassing the upload handler).
func workspaceIDForSlug(t *testing.T, srv *Server, slug string) string {
t.Helper()
ws, err := srv.store.GetWorkspaceBySlug(slug)
if err != nil {
t.Fatalf("GetWorkspaceBySlug(%q): %v", slug, err)
}
if ws == nil {
t.Fatalf("workspace %q not found", slug)
}
return ws.ID
}
// TestStorageInfoCache covers the in-memory cache directly so the TTL +
// invalidate paths have a focused test. The handler-level integration
// is exercised by TestStorageUsage_TracksUploads (which depends on
+2
View File
@@ -731,9 +731,11 @@ func (s *Server) setupRouter() {
// path on HEAD; http.ServeContent already strips the body
// on the seekable path.
r.Post("/attachments", s.handleUploadAttachment)
r.Get("/attachments", s.handleListWorkspaceAttachments)
r.Get("/attachments/{attachmentID}", s.handleGetAttachment)
r.Head("/attachments/{attachmentID}", s.handleGetAttachment)
r.Post("/attachments/{attachmentID}/transform", s.handleTransformAttachment)
r.Delete("/attachments/{attachmentID}", s.handleDeleteWorkspaceAttachment)
// Storage usage summary for Settings → Storage and other
// quota-aware UI surfaces (TASK-881). Cached behind a