mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
eae42b843e
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.
714 lines
25 KiB
Go
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)
|
|
}
|
|
}
|