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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user