Files
pad/internal/store/attachments_test.go
T
xarmian eae42b843e fix(store): scope attachment list JOINs by workspace (TASK-2399)
WorkspaceAttachments joined `items` (and, through it, `collections`)
on item_id alone, so an attachment whose item_id points at another
workspace's item borrowed that item's title, slug, and collection
into the storage listing.

Both queries — the count and the result — now join with
`ON i.id = a.item_id AND i.workspace_id = a.workspace_id`. The
predicate is deliberately in ON, not WHERE: in WHERE the LEFT JOIN
degenerates into an inner join and the malformed row would vanish
from the listing entirely, hiding a row that still consumes quota
and that the PLAN-2397 repair has to be able to see. In ON the row
survives with NULL item/collection metadata.

Keeping the two queries in step matters — they are separate SQL and
a restricted caller's count must not diverge from their rows.

Review turned up a second hop of the same leak, folded in here:
items.collection_id has no composite workspace foreign key, so a
LOCAL item can reference a FOREIGN collection and surface its slug
even through a scoped item join. The collections join now carries
its own workspace predicate, same ON-clause rule.

Two fixtures pin both hops, each verified by mutation to fail when
its predicate is moved to WHERE or removed.

PLAN-2391 DR-3.
2026-08-01 18:11:35 +00:00

714 lines
25 KiB
Go

package store
import (
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TestWorkspaceStorageInfo_NoOwner covers the "fresh install" /
// legacy-workspace path: workspace exists but has no owner_id.
// Expected: limit unlimited, plan empty, no override (matches the
// upload-time behavior in WorkspaceStorageLimit, which returns -1
// rather than rejecting the upload outright).
func TestWorkspaceStorageInfo_NoOwner(t *testing.T) {
s := testStore(t)
wsID := newID()
ts := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(s.q(`INSERT INTO workspaces (id, slug, name, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`),
wsID, "no-owner", "No Owner", "{}", ts, ts); err != nil {
t.Fatalf("insert workspace: %v", err)
}
info, err := s.WorkspaceStorageInfo(wsID)
if err != nil {
t.Fatalf("WorkspaceStorageInfo: %v", err)
}
if info.UsedBytes != 0 {
t.Errorf("used_bytes = %d, want 0", info.UsedBytes)
}
if info.LimitBytes != -1 {
t.Errorf("limit_bytes = %d, want -1", info.LimitBytes)
}
if info.Plan != "" {
t.Errorf("plan = %q, want empty", info.Plan)
}
if info.OverrideActive {
t.Errorf("override_active = true, want false")
}
}
// TestWorkspaceStorageInfo_FreePlanResolution exercises the full
// owner → plan → override resolution chain:
//
// 1. Free plan with no override → limit_bytes = DefaultFreeLimits, override_active=false
// 2. Free plan + storage_bytes override → limit_bytes = override value, override_active=true
// 3. Pro plan → limit_bytes = -1 unconditionally (Phase 1 quirk:
// pro/self-hosted bypass override resolution; the flag still
// surfaces the configured override for admin visibility)
func TestWorkspaceStorageInfo_FreePlanResolution(t *testing.T) {
s := testStore(t)
owner, err := s.CreateUser(models.UserCreate{
Email: "owner@example.com",
Name: "Owner",
Password: "correct-horse-battery-staple",
Role: "member",
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
t.Fatalf("SetUserPlan(free): %v", err)
}
wsID := newID()
ts := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(s.q(`INSERT INTO workspaces (id, slug, name, settings, owner_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
wsID, "owned", "Owned", "{}", owner.ID, ts, ts); err != nil {
t.Fatalf("insert workspace: %v", err)
}
// 1. Free plan, no override.
info, err := s.WorkspaceStorageInfo(wsID)
if err != nil {
t.Fatalf("WorkspaceStorageInfo: %v", err)
}
if info.Plan != "free" {
t.Errorf("plan = %q, want free", info.Plan)
}
if info.LimitBytes != int64(DefaultFreeLimits.StorageBytes) {
t.Errorf("limit_bytes = %d, want %d", info.LimitBytes, DefaultFreeLimits.StorageBytes)
}
if info.OverrideActive {
t.Errorf("override_active = true, want false")
}
// 2. Free plan + override.
if err := s.SetUserPlanOverrides(owner.ID, `{"storage_bytes":1073741824}`); err != nil {
t.Fatalf("SetUserPlanOverrides: %v", err)
}
info, err = s.WorkspaceStorageInfo(wsID)
if err != nil {
t.Fatalf("WorkspaceStorageInfo (override): %v", err)
}
if info.LimitBytes != 1073741824 {
t.Errorf("limit_bytes with override = %d, want 1073741824", info.LimitBytes)
}
if !info.OverrideActive {
t.Errorf("override_active = false, want true after setting override")
}
// 3. Pro plan: the limit is unlimited regardless of override (Phase 1).
// The override_active flag still surfaces the configured override
// so the admin UI can show it; it just doesn't affect the limit.
if err := s.SetUserPlan(owner.ID, "pro", ""); err != nil {
t.Fatalf("SetUserPlan(pro): %v", err)
}
info, err = s.WorkspaceStorageInfo(wsID)
if err != nil {
t.Fatalf("WorkspaceStorageInfo (pro): %v", err)
}
if info.LimitBytes != -1 {
t.Errorf("pro plan limit_bytes = %d, want -1 (unlimited)", info.LimitBytes)
}
if !info.OverrideActive {
t.Errorf("override_active should still be true for pro plan with configured override")
}
}
// TestWorkspaceAttachments_VisibilityFilter verifies that
// VisibleCollectionIDs gates the result set per Codex P1 from
// PR #303 round 1: a restricted member must not see attachments
// in collections they can't access, and orphans (item_id IS NULL)
// must be hidden as well so filenames don't leak.
func TestWorkspaceAttachments_VisibilityFilter(t *testing.T) {
s := testStore(t)
wsID := newID()
collA := newID()
collB := newID()
itemA := newID()
itemB := newID()
ts := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(s.q(`INSERT INTO workspaces (id, slug, name, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`),
wsID, "ws", "WS", "{}", ts, ts); err != nil {
t.Fatalf("insert workspace: %v", err)
}
for _, c := range []struct{ id, slug, name string }{{collA, "tasks", "Tasks"}, {collB, "secrets", "Secrets"}} {
if _, err := s.db.Exec(s.q(`INSERT INTO collections (id, workspace_id, name, slug, schema, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
c.id, wsID, c.name, c.slug, `{"fields":[]}`, ts, ts); err != nil {
t.Fatalf("insert collection %s: %v", c.slug, err)
}
}
mkItem := func(id, collID, slug, title string) {
t.Helper()
if _, err := s.db.Exec(s.q(`INSERT INTO items (id, workspace_id, collection_id, title, slug, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
id, wsID, collID, title, slug, ts, ts); err != nil {
t.Fatalf("insert item: %v", err)
}
}
mkItem(itemA, collA, "task-1", "Task 1")
mkItem(itemB, collB, "secret-1", "Secret")
mkAttach := func(itemID *string, filename string) {
t.Helper()
a := &models.Attachment{
WorkspaceID: wsID,
ItemID: itemID,
UploadedBy: "system",
StorageKey: "fs:" + newID(),
ContentHash: newID(),
MimeType: "image/png",
SizeBytes: 100,
Filename: filename,
}
if err := s.CreateAttachment(a); err != nil {
t.Fatalf("CreateAttachment: %v", err)
}
}
mkAttach(&itemA, "task-screenshot.png")
mkAttach(&itemB, "secret-screenshot.png")
mkAttach(nil, "orphan.png")
// Admin / unrestricted (nil) sees everything.
rows, total, err := s.WorkspaceAttachments(wsID, AttachmentListFilters{})
if err != nil {
t.Fatalf("admin list: %v", err)
}
if total != 3 || len(rows) != 3 {
t.Errorf("admin: total=%d rows=%d, want 3/3", total, len(rows))
}
// Restricted to tasks only: see task-screenshot, hide secret + orphan.
rows, total, err = s.WorkspaceAttachments(wsID, AttachmentListFilters{
Restricted: true,
FullCollectionIDs: []string{collA},
})
if err != nil {
t.Fatalf("restricted list: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("restricted: total=%d rows=%d, want 1/1", total, len(rows))
}
if rows[0].Filename != "task-screenshot.png" {
t.Errorf("restricted: filename=%q, want task-screenshot.png", rows[0].Filename)
}
// Item-level grant only: a restricted user with a single granted
// item in collB should see only that item's attachment, not the
// rest of collB's contents. Mirrors handlers_search's
// (fullCollIDs, grantedItemIDs) tuple.
rows, total, err = s.WorkspaceAttachments(wsID, AttachmentListFilters{
Restricted: true,
GrantedItemIDs: []string{itemB},
})
if err != nil {
t.Fatalf("item-grant list: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("item-grant: total=%d rows=%d, want 1/1", total, len(rows))
}
if rows[0].Filename != "secret-screenshot.png" {
t.Errorf("item-grant: filename=%q, want secret-screenshot.png", rows[0].Filename)
}
// Empty visibility (restricted with no collections + no item
// grants) — zero rows.
rows, total, err = s.WorkspaceAttachments(wsID, AttachmentListFilters{
Restricted: true,
})
if err != nil {
t.Fatalf("zero-visibility list: %v", err)
}
if total != 0 || len(rows) != 0 {
t.Errorf("zero-visibility: total=%d rows=%d, want 0/0", total, len(rows))
}
}
// TestWorkspaceAttachments_SurfacesSoftDeletedParents pins Codex P2
// from PR #303 round 4: an attachment whose parent item is soft-
// deleted must remain in the list so the user can reclaim the
// bytes, AND the collection-level visibility filter must still see
// the (still-set) collection_id so restricted users with access to
// that collection can find the attachment.
//
// Two assertions:
// - Full-access caller (Restricted=false) sees the row.
// - Restricted caller scoped to the right collection sees the row;
// restricted to a different collection does not.
// - The row carries item_deleted=true so the UI can render the
// "(deleted)" badge.
func TestWorkspaceAttachments_SurfacesSoftDeletedParents(t *testing.T) {
s := testStore(t)
wsID := newID()
collA := newID()
collB := newID()
itemA := newID()
ts := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(s.q(`INSERT INTO workspaces (id, slug, name, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`),
wsID, "ws", "WS", "{}", ts, ts); err != nil {
t.Fatalf("insert workspace: %v", err)
}
for _, c := range []struct{ id, slug, name string }{{collA, "tasks", "Tasks"}, {collB, "ideas", "Ideas"}} {
if _, err := s.db.Exec(s.q(`INSERT INTO collections (id, workspace_id, name, slug, schema, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
c.id, wsID, c.name, c.slug, `{"fields":[]}`, ts, ts); err != nil {
t.Fatalf("insert collection: %v", err)
}
}
// Soft-deleted item — deleted_at is set.
if _, err := s.db.Exec(s.q(`INSERT INTO items (id, workspace_id, collection_id, title, slug, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
itemA, wsID, collA, "Doomed", "doomed", ts, ts, ts); err != nil {
t.Fatalf("insert deleted item: %v", err)
}
if err := s.CreateAttachment(&models.Attachment{
WorkspaceID: wsID,
ItemID: &itemA,
UploadedBy: "system",
StorageKey: "fs:" + newID(),
ContentHash: newID(),
MimeType: "image/png",
SizeBytes: 100,
Filename: "doomed.png",
}); err != nil {
t.Fatalf("CreateAttachment: %v", err)
}
// 1. Admin / full-access sees the row + item_deleted flag.
rows, total, err := s.WorkspaceAttachments(wsID, AttachmentListFilters{})
if err != nil {
t.Fatalf("admin list: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("admin: total=%d rows=%d, want 1/1", total, len(rows))
}
if !rows[0].ItemDeleted {
t.Errorf("admin: ItemDeleted=false, want true (parent is soft-deleted)")
}
if rows[0].ItemTitle == nil || *rows[0].ItemTitle != "Doomed" {
t.Errorf("admin: item_title=%v, want Doomed (soft-deleted parent's title still surfaces)", rows[0].ItemTitle)
}
// 2. Restricted to collA sees the row.
rows, total, err = s.WorkspaceAttachments(wsID, AttachmentListFilters{
Restricted: true,
FullCollectionIDs: []string{collA},
})
if err != nil {
t.Fatalf("restricted-collA list: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("restricted-collA: total=%d rows=%d, want 1/1", total, len(rows))
}
// 3. Restricted to collB does NOT see the row.
rows, total, err = s.WorkspaceAttachments(wsID, AttachmentListFilters{
Restricted: true,
FullCollectionIDs: []string{collB},
})
if err != nil {
t.Fatalf("restricted-collB list: %v", err)
}
if total != 0 || len(rows) != 0 {
t.Errorf("restricted-collB: total=%d rows=%d, want 0/0", total, len(rows))
}
}
// TestWorkspaceAttachments_ForeignParentYieldsNullMetadata pins
// TASK-2399 (PLAN-2391 DR-3): an attachment whose item_id points at
// an item in ANOTHER workspace must not borrow that item's title,
// slug, or collection through the LEFT JOINs.
//
// The workspace predicate lives in the JOIN's ON clause, not in
// WHERE. That distinction is the whole point of this test: in WHERE
// the LEFT JOIN degenerates into an inner join and the malformed row
// would vanish from the listing entirely — hiding a row that still
// consumes quota and that the PLAN-2397 repair needs to be able to
// see. In ON, the row survives with NULL metadata.
//
// Count and result queries must agree — a restricted caller's total
// must match the rows they actually get back.
func TestWorkspaceAttachments_ForeignParentYieldsNullMetadata(t *testing.T) {
s := testStore(t)
wsA, wsB := newID(), newID()
collA, collB := newID(), newID()
itemA, itemB := newID(), newID()
ts := time.Now().UTC().Format(time.RFC3339)
for _, w := range []struct{ id, slug string }{{wsA, "ws-a"}, {wsB, "ws-b"}} {
if _, err := s.db.Exec(s.q(`INSERT INTO workspaces (id, slug, name, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`),
w.id, w.slug, w.slug, "{}", ts, ts); err != nil {
t.Fatalf("insert workspace %s: %v", w.slug, err)
}
}
for _, c := range []struct{ id, ws, slug, name string }{
{collA, wsA, "tasks", "Tasks"},
{collB, wsB, "secrets", "Secrets"},
} {
if _, err := s.db.Exec(s.q(`INSERT INTO collections (id, workspace_id, name, slug, schema, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
c.id, c.ws, c.name, c.slug, `{"fields":[]}`, ts, ts); err != nil {
t.Fatalf("insert collection %s: %v", c.slug, err)
}
}
for _, it := range []struct{ id, ws, coll, title, slug string }{
{itemA, wsA, collA, "Local Task", "local-task"},
{itemB, wsB, collB, "Foreign Secret", "foreign-secret"},
} {
if _, err := s.db.Exec(s.q(`INSERT INTO items (id, workspace_id, collection_id, title, slug, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
it.id, it.ws, it.coll, it.title, it.slug, ts, ts); err != nil {
t.Fatalf("insert item %s: %v", it.slug, err)
}
}
// Two attachments in workspace A: one well-formed, one whose
// item_id points at workspace B's item (the malformed row the
// upload invariant in TASK-2400 stops from being created, and
// that the PLAN-2397 repair has to be able to find).
mkAttach := func(itemID *string, filename string) string {
t.Helper()
a := &models.Attachment{
WorkspaceID: wsA,
ItemID: itemID,
UploadedBy: "system",
StorageKey: "fs:" + newID(),
ContentHash: newID(),
MimeType: "image/png",
SizeBytes: 100,
Filename: filename,
}
if err := s.CreateAttachment(a); err != nil {
t.Fatalf("CreateAttachment(%s): %v", filename, err)
}
return a.ID
}
mkAttach(&itemA, "local.png")
mkAttach(&itemB, "malformed.png")
byName := func(rows []AttachmentListItem) map[string]AttachmentListItem {
m := make(map[string]AttachmentListItem, len(rows))
for _, r := range rows {
m[r.Filename] = r
}
return m
}
// 1. Full-access viewer sees BOTH rows — the malformed one must
// not be filtered out by the workspace-scoped join.
rows, total, err := s.WorkspaceAttachments(wsA, AttachmentListFilters{})
if err != nil {
t.Fatalf("admin list: %v", err)
}
if total != 2 || len(rows) != 2 {
t.Fatalf("admin: total=%d rows=%d, want 2/2 (foreign-parent row must still list)", total, len(rows))
}
got := byName(rows)
local, ok := got["local.png"]
if !ok {
t.Fatalf("admin: local.png missing from %v", got)
}
if local.ItemTitle == nil || *local.ItemTitle != "Local Task" {
t.Errorf("local row: item_title=%v, want Local Task", local.ItemTitle)
}
if local.CollectionSlug == nil || *local.CollectionSlug != "tasks" {
t.Errorf("local row: collection_slug=%v, want tasks", local.CollectionSlug)
}
bad, ok := got["malformed.png"]
if !ok {
t.Fatalf("admin: malformed.png missing — the workspace predicate must be in ON, not WHERE")
}
if bad.ItemTitle != nil {
t.Errorf("foreign-parent row: item_title=%q, want nil (leaked another workspace's title)", *bad.ItemTitle)
}
if bad.ItemSlug != nil {
t.Errorf("foreign-parent row: item_slug=%q, want nil", *bad.ItemSlug)
}
if bad.CollectionSlug != nil {
t.Errorf("foreign-parent row: collection_slug=%q, want nil", *bad.CollectionSlug)
}
if bad.ItemDeleted {
t.Errorf("foreign-parent row: item_deleted=true, want false")
}
// The raw (malformed) association is still reported so the
// repair in PLAN-2397 can act on it.
if bad.ItemID == nil || *bad.ItemID != itemB {
t.Errorf("foreign-parent row: item_id=%v, want %s (raw value must survive for repair)", bad.ItemID, itemB)
}
// 2. A restricted caller scoped to workspace A's collection sees
// only the well-formed row — the foreign parent contributes no
// collection_id, so it fails the visibility predicate. Count
// and rows must agree (they're separate queries).
rows, total, err = s.WorkspaceAttachments(wsA, AttachmentListFilters{
Restricted: true,
FullCollectionIDs: []string{collA},
})
if err != nil {
t.Fatalf("restricted-collA list: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("restricted-collA: total=%d rows=%d, want 1/1", total, len(rows))
}
if rows[0].Filename != "local.png" {
t.Errorf("restricted-collA: filename=%q, want local.png", rows[0].Filename)
}
// 3. Naming the FOREIGN collection must not surface the malformed
// row either — the collection columns are reached through the
// scoped item join, so there is nothing to match.
rows, total, err = s.WorkspaceAttachments(wsA, AttachmentListFilters{
Restricted: true,
FullCollectionIDs: []string{collB},
})
if err != nil {
t.Fatalf("restricted-collB list: %v", err)
}
if total != 0 || len(rows) != 0 {
t.Errorf("restricted-collB: total=%d rows=%d, want 0/0", total, len(rows))
}
// 4. The CollectionID filter goes through the same scoped join.
rows, total, err = s.WorkspaceAttachments(wsA, AttachmentListFilters{CollectionID: collB})
if err != nil {
t.Fatalf("collection-filter list: %v", err)
}
if total != 0 || len(rows) != 0 {
t.Errorf("collection-filter (foreign collection): total=%d rows=%d, want 0/0", total, len(rows))
}
// 5. Count/result consistency for an item-grant caller. The grant
// predicate matches on a.item_id directly, so a grant naming
// the foreign item still selects the malformed row — but the
// two queries must at least agree with each other. Scoping the
// grant LOOKUP by workspace is DR-5 (a separate task); this
// only pins that the count doesn't diverge from the rows.
rows, total, err = s.WorkspaceAttachments(wsA, AttachmentListFilters{
Restricted: true,
GrantedItemIDs: []string{itemB},
})
if err != nil {
t.Fatalf("item-grant list: %v", err)
}
if total != len(rows) {
t.Errorf("item-grant: total=%d but got %d rows — count and result queries diverged", total, len(rows))
}
for _, r := range rows {
if r.ItemTitle != nil {
t.Errorf("item-grant: foreign-parent row leaked item_title=%q", *r.ItemTitle)
}
}
}
// TestWorkspaceAttachments_ForeignCollectionYieldsNullCollectionMetadata
// covers the second hop of the same leak (TASK-2399, found in review):
// items.collection_id has no composite workspace foreign key
// (migrations/005_collections.sql), so a local item can reference a
// collection in another workspace. Reaching collections through the
// now workspace-scoped item join is not enough on its own — the
// collections join needs its own workspace predicate, or the listing
// renders a foreign collection's slug.
//
// Again the predicate belongs in ON: the row must still list, with
// only the collection columns nulled.
func TestWorkspaceAttachments_ForeignCollectionYieldsNullCollectionMetadata(t *testing.T) {
s := testStore(t)
wsA, wsB := newID(), newID()
collA, collB := newID(), newID()
itemA := newID()
ts := time.Now().UTC().Format(time.RFC3339)
for _, w := range []struct{ id, slug string }{{wsA, "ws-a"}, {wsB, "ws-b"}} {
if _, err := s.db.Exec(s.q(`INSERT INTO workspaces (id, slug, name, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`),
w.id, w.slug, w.slug, "{}", ts, ts); err != nil {
t.Fatalf("insert workspace %s: %v", w.slug, err)
}
}
for _, c := range []struct{ id, ws, slug, name string }{
{collA, wsA, "tasks", "Tasks"},
{collB, wsB, "secrets", "Secrets"},
} {
if _, err := s.db.Exec(s.q(`INSERT INTO collections (id, workspace_id, name, slug, schema, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
c.id, c.ws, c.name, c.slug, `{"fields":[]}`, ts, ts); err != nil {
t.Fatalf("insert collection %s: %v", c.slug, err)
}
}
// A workspace-A item pointing at workspace B's collection.
if _, err := s.db.Exec(s.q(`INSERT INTO items (id, workspace_id, collection_id, title, slug, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
itemA, wsA, collB, "Local Task", "local-task", ts, ts); err != nil {
t.Fatalf("insert item: %v", err)
}
if err := s.CreateAttachment(&models.Attachment{
WorkspaceID: wsA,
ItemID: &itemA,
UploadedBy: "system",
StorageKey: "fs:" + newID(),
ContentHash: newID(),
MimeType: "image/png",
SizeBytes: 100,
Filename: "cross-coll.png",
}); err != nil {
t.Fatalf("CreateAttachment: %v", err)
}
rows, total, err := s.WorkspaceAttachments(wsA, AttachmentListFilters{})
if err != nil {
t.Fatalf("admin list: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("admin: total=%d rows=%d, want 1/1 (the row must still list)", total, len(rows))
}
// The item itself is local, so its metadata is legitimate.
if rows[0].ItemTitle == nil || *rows[0].ItemTitle != "Local Task" {
t.Errorf("item_title=%v, want Local Task (the item is in this workspace)", rows[0].ItemTitle)
}
// The collection is not.
if rows[0].CollectionSlug != nil {
t.Errorf("collection_slug=%q, want nil (leaked another workspace's collection)", *rows[0].CollectionSlug)
}
}
// TestWorkspaceAttachments_CategoryFilters covers the document/text/
// archive/other filter buckets per Codex P2 from PR #303 round 1:
// the earlier prefix-only mapping silently passed those filters
// through with no MIME predicate, returning the full list.
func TestWorkspaceAttachments_CategoryFilters(t *testing.T) {
s := testStore(t)
wsID := newID()
ts := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(s.q(`INSERT INTO workspaces (id, slug, name, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`),
wsID, "ws", "WS", "{}", ts, ts); err != nil {
t.Fatalf("insert workspace: %v", err)
}
mk := func(mime, filename string) {
t.Helper()
a := &models.Attachment{
WorkspaceID: wsID,
UploadedBy: "system",
StorageKey: "fs:" + newID(),
ContentHash: newID(),
MimeType: mime,
SizeBytes: 1,
Filename: filename,
}
if err := s.CreateAttachment(a); err != nil {
t.Fatalf("CreateAttachment: %v", err)
}
}
mk("image/png", "a.png")
mk("application/pdf", "b.pdf")
mk("text/markdown", "c.md")
mk("application/zip", "d.zip")
mk("application/octet-stream", "e.bin") // not in any named bucket → "other"
cases := []struct {
category string
want []string
}{
{"image", []string{"a.png"}},
{"document", []string{"b.pdf"}},
{"text", []string{"c.md"}},
{"archive", []string{"d.zip"}},
{"other", []string{"e.bin"}},
}
for _, tc := range cases {
t.Run(tc.category, func(t *testing.T) {
rows, total, err := s.WorkspaceAttachments(wsID, AttachmentListFilters{
MimeCategory: tc.category,
})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != len(tc.want) {
t.Fatalf("total=%d, want %d", total, len(tc.want))
}
got := make([]string, len(rows))
for i, r := range rows {
got[i] = r.Filename
}
for _, want := range tc.want {
found := false
for _, g := range got {
if g == want {
found = true
}
}
if !found {
t.Errorf("missing %q in result %v", want, got)
}
}
})
}
}
// TestWorkspaceStorageInfo_TracksLiveAttachments inserts a few
// attachment rows directly and asserts SUM(size_bytes) shows up in
// used_bytes — and that soft-deleted rows are excluded so the user
// sees the post-delete value (Settings → Storage UX expectation).
func TestWorkspaceStorageInfo_TracksLiveAttachments(t *testing.T) {
s := testStore(t)
wsID := newID()
ts := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(s.q(`INSERT INTO workspaces (id, slug, name, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`),
wsID, "ws", "WS", "{}", ts, ts); err != nil {
t.Fatalf("insert workspace: %v", err)
}
mkAttach := func(size int64, deleted bool) {
t.Helper()
a := &models.Attachment{
WorkspaceID: wsID,
UploadedBy: "system",
StorageKey: "fs:" + newID(),
ContentHash: newID(),
MimeType: "image/png",
SizeBytes: size,
Filename: "x.png",
}
if err := s.CreateAttachment(a); err != nil {
t.Fatalf("CreateAttachment: %v", err)
}
if deleted {
if _, err := s.db.Exec(s.q(`UPDATE attachments SET deleted_at = ? WHERE id = ?`), ts, a.ID); err != nil {
t.Fatalf("soft-delete: %v", err)
}
}
}
mkAttach(1024, false)
mkAttach(2048, false)
mkAttach(99999, true) // deleted — must not count
info, err := s.WorkspaceStorageInfo(wsID)
if err != nil {
t.Fatalf("WorkspaceStorageInfo: %v", err)
}
if info.UsedBytes != 3072 {
t.Errorf("used_bytes = %d, want 3072 (1024 + 2048; soft-deleted excluded)", info.UsedBytes)
}
}