Add per-user item ownership and isolation

Items are now private to their owner. Add items.user_id (migrated in
place, existing items backfilled to the first admin) and scope every
item, results, and dashboard view to the signed-in user via owner-scoped
queries plus an ownedItem 404 guard. Admins get strict isolation too;
elevation stays limited to settings and user management.

Alert routing follows ownership: deal emails go only to the item owner
and the weekly digest is built per-recipient from their own items. The
scheduler still polls every active item; the Apify/eBay budget stays a
shared pool visible to all.

Add TestItemsArePrivatePerUser and seed owners in db tests.
This commit is contained in:
prosolis
2026-06-20 13:39:19 -07:00
parent 90a74967e0
commit feea126c5e
11 changed files with 327 additions and 72 deletions

View File

@@ -324,6 +324,74 @@ func TestForwardAuthEmailPrefsRoundTrip(t *testing.T) {
}
}
func TestItemsArePrivatePerUser(t *testing.T) {
app, h := newTestApp(t)
enableForwardAuth(t, app)
// Two distinct forward-auth identities behind the trusted proxy.
alice := newClient(h, "10.0.0.5:5555", map[string]string{
hdrEmail: "alice@example.com", hdrGroups: "users",
})
bob := newClient(h, "10.0.0.6:5555", map[string]string{
hdrEmail: "bob@example.com", hdrGroups: "users",
})
// Alice creates an item. Pull a CSRF token from the add-item form first.
rec := alice.do(http.MethodGet, "/items/new", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /items/new = %d, want 200", rec.Code)
}
token := extractCSRF(t, rec.Body.String())
form := url.Values{
"csrf_token": {token},
"name": {"Alice TwinBee"},
"search_query": {"twinbee"},
"marketplace": {"ebay.com"},
"poll_interval_minutes": {"720"},
"ntfy_priority": {"default"},
}
rec = alice.do(http.MethodPost, "/items", form)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /items = %d, want 303; body: %s", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location") // /items/{id}/results
if !strings.HasPrefix(loc, "/items/") {
t.Fatalf("Location = %q, want /items/{id}/results", loc)
}
itemPath := strings.TrimSuffix(loc, "/results") // /items/{id}
// Alice sees her item; Bob's list does not.
if rec := alice.do(http.MethodGet, "/items", nil); !strings.Contains(rec.Body.String(), "Alice TwinBee") {
t.Errorf("Alice's /items missing her own item")
}
if rec := bob.do(http.MethodGet, "/items", nil); strings.Contains(rec.Body.String(), "Alice TwinBee") {
t.Errorf("Bob's /items leaked Alice's item")
}
// Bob cannot reach Alice's item by direct id: results, edit, error, run,
// toggle, and delete all 404 rather than acting on someone else's item.
if rec := bob.do(http.MethodGet, loc, nil); rec.Code != http.StatusNotFound {
t.Errorf("Bob GET %s = %d, want 404", loc, rec.Code)
}
if rec := bob.do(http.MethodGet, itemPath+"/edit", nil); rec.Code != http.StatusNotFound {
t.Errorf("Bob GET %s/edit = %d, want 404", itemPath, rec.Code)
}
// A state-changing attempt needs Bob's own CSRF token (from any rendered
// form); it must still 404 on ownership, not act.
br := bob.do(http.MethodGet, "/settings", nil)
btoken := extractCSRF(t, br.Body.String())
del := url.Values{"csrf_token": {btoken}}
if rec := bob.do(http.MethodPost, itemPath+"/delete", del); rec.Code != http.StatusNotFound {
t.Errorf("Bob POST %s/delete = %d, want 404", itemPath, rec.Code)
}
// Alice's item still exists after Bob's delete attempt.
if rec := alice.do(http.MethodGet, loc, nil); rec.Code != http.StatusOK {
t.Errorf("Alice GET %s after Bob's delete attempt = %d, want 200", loc, rec.Code)
}
}
func containsEmail(us []models.User, email string) bool {
for _, u := range us {
if u.Email == email {