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.
403 lines
13 KiB
Go
403 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"veola/internal/apify"
|
|
"veola/internal/auth"
|
|
"veola/internal/config"
|
|
"veola/internal/crypto"
|
|
"veola/internal/db"
|
|
"veola/internal/models"
|
|
"veola/internal/ntfy"
|
|
"veola/internal/scheduler"
|
|
)
|
|
|
|
// newTestApp builds an App backed by a fresh sqlite db in t.TempDir(). The
|
|
// scheduler, apify, and ntfy clients are wired but unused by the routes we
|
|
// hit here. The returned http.Handler is App.Routes().
|
|
func newTestApp(t *testing.T) (*App, http.Handler) {
|
|
t.Helper()
|
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
sqlDB, err := db.Open(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { sqlDB.Close() })
|
|
|
|
key, err := crypto.DeriveKey([]byte("test-encryption-key-32-bytes-min-aaaaaa"))
|
|
if err != nil {
|
|
t.Fatalf("DeriveKey: %v", err)
|
|
}
|
|
store := db.NewStore(sqlDB, key)
|
|
|
|
am, err := auth.NewManager(sqlDB, store, strings.Repeat("a", 32), false)
|
|
if err != nil {
|
|
t.Fatalf("auth.NewManager: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{}
|
|
ap := apify.New("")
|
|
nt := ntfy.New("")
|
|
sc := scheduler.New(cfg, store, ap, nt)
|
|
|
|
app := New(cfg, store, am, ap, nt, sc)
|
|
return app, app.Routes()
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
_, h := newTestApp(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
if got := rec.Body.String(); got != "ok" {
|
|
t.Fatalf("body = %q, want %q", got, "ok")
|
|
}
|
|
}
|
|
|
|
func TestSetupGateRedirectsWhenNoUsers(t *testing.T) {
|
|
_, h := newTestApp(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303", rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "/setup" {
|
|
t.Fatalf("Location = %q, want /setup", loc)
|
|
}
|
|
}
|
|
|
|
func TestRequireAuthRedirectsToLogin(t *testing.T) {
|
|
app, h := newTestApp(t)
|
|
hash, err := auth.HashPassword("a-long-enough-password")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303", rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "/login" {
|
|
t.Fatalf("Location = %q, want /login", loc)
|
|
}
|
|
}
|
|
|
|
func TestLoginPageRenders(t *testing.T) {
|
|
app, h := newTestApp(t)
|
|
hash, _ := auth.HashPassword("a-long-enough-password")
|
|
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "<form") {
|
|
t.Fatalf("body missing <form>")
|
|
}
|
|
}
|
|
|
|
// ============ forward-auth (Authentik) integration ============
|
|
|
|
// Authentik header names, matching internal/config defaults.
|
|
const (
|
|
hdrEmail = "X-Authentik-Email"
|
|
hdrUsername = "X-Authentik-Username"
|
|
hdrName = "X-Authentik-Name"
|
|
hdrGroups = "X-Authentik-Groups"
|
|
)
|
|
|
|
// enableForwardAuth turns on forward-auth trust for the 10.0.0.0/8 range with
|
|
// admin group "veola-admins", mirroring a Traefik-in-front deployment.
|
|
func enableForwardAuth(t *testing.T, app *App) {
|
|
t.Helper()
|
|
fa, err := auth.NewForwardAuthConfig(hdrUsername, hdrEmail, hdrName, hdrGroups, "veola-admins",
|
|
[]string{"10.0.0.0/8"})
|
|
if err != nil {
|
|
t.Fatalf("NewForwardAuthConfig: %v", err)
|
|
}
|
|
app.Auth.SetForwardAuth(fa)
|
|
}
|
|
|
|
// testClient threads cookies across requests so an scs session survives a
|
|
// GET-then-POST flow, and applies a fixed peer address + identity headers.
|
|
type testClient struct {
|
|
h http.Handler
|
|
remoteAddr string
|
|
headers map[string]string
|
|
cookies map[string]*http.Cookie
|
|
}
|
|
|
|
func newClient(h http.Handler, remoteAddr string, headers map[string]string) *testClient {
|
|
return &testClient{h: h, remoteAddr: remoteAddr, headers: headers, cookies: map[string]*http.Cookie{}}
|
|
}
|
|
|
|
func (c *testClient) do(method, target string, form url.Values) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if form != nil {
|
|
req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
} else {
|
|
req = httptest.NewRequest(method, target, nil)
|
|
}
|
|
if c.remoteAddr != "" {
|
|
req.RemoteAddr = c.remoteAddr
|
|
}
|
|
for k, v := range c.headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
for _, ck := range c.cookies {
|
|
req.AddCookie(ck)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
c.h.ServeHTTP(rec, req)
|
|
for _, ck := range rec.Result().Cookies() {
|
|
c.cookies[ck.Name] = ck
|
|
}
|
|
return rec
|
|
}
|
|
|
|
var csrfRe = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
|
|
|
|
func extractCSRF(t *testing.T, body string) string {
|
|
t.Helper()
|
|
m := csrfRe.FindStringSubmatch(body)
|
|
if m == nil {
|
|
t.Fatal("no csrf_token field in body")
|
|
}
|
|
return m[1]
|
|
}
|
|
|
|
func TestForwardAuthProvisionsAdminFromTrustedProxy(t *testing.T) {
|
|
app, h := newTestApp(t)
|
|
enableForwardAuth(t, app)
|
|
|
|
c := newClient(h, "10.0.0.5:5555", map[string]string{
|
|
hdrEmail: "ada@example.com",
|
|
hdrUsername: "ada",
|
|
hdrGroups: "users|veola-admins",
|
|
})
|
|
rec := c.do(http.MethodGet, "/", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (provisioned dashboard); body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
u, err := app.Store.GetUserByEmail(context.Background(), "ada@example.com")
|
|
if err != nil || u == nil {
|
|
t.Fatalf("GetUserByEmail: u=%v err=%v", u, err)
|
|
}
|
|
if u.Role != models.RoleAdmin {
|
|
t.Errorf("role = %q, want admin (from veola-admins group)", u.Role)
|
|
}
|
|
if u.AuthSource != "forward" {
|
|
t.Errorf("auth_source = %q, want forward", u.AuthSource)
|
|
}
|
|
}
|
|
|
|
func TestForwardAuthUntrustedPeerIgnoresSpoofedHeaders(t *testing.T) {
|
|
app, h := newTestApp(t)
|
|
enableForwardAuth(t, app)
|
|
|
|
// Same headers, but the direct peer is NOT in the trusted range: the
|
|
// headers must be ignored and no user provisioned.
|
|
c := newClient(h, "203.0.113.9:5555", map[string]string{
|
|
hdrEmail: "mallory@example.com",
|
|
hdrUsername: "mallory",
|
|
hdrGroups: "veola-admins",
|
|
})
|
|
rec := c.do(http.MethodGet, "/", nil)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303 (no trust, setup gate)", rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "/setup" {
|
|
t.Fatalf("Location = %q, want /setup", loc)
|
|
}
|
|
if u, _ := app.Store.GetUserByEmail(context.Background(), "mallory@example.com"); u != nil {
|
|
t.Fatalf("spoofed header provisioned a user: %+v", u)
|
|
}
|
|
}
|
|
|
|
func TestForwardAuthReSyncsRoleOnEveryRequest(t *testing.T) {
|
|
app, h := newTestApp(t)
|
|
enableForwardAuth(t, app)
|
|
|
|
admin := newClient(h, "10.0.0.5:5555", map[string]string{
|
|
hdrEmail: "bea@example.com",
|
|
hdrGroups: "veola-admins",
|
|
})
|
|
if rec := admin.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("first request status = %d, want 200", rec.Code)
|
|
}
|
|
u, _ := app.Store.GetUserByEmail(context.Background(), "bea@example.com")
|
|
if u == nil || u.Role != models.RoleAdmin {
|
|
t.Fatalf("after admin login, role = %v, want admin", u)
|
|
}
|
|
|
|
// IdP later drops the admin group: the next request must downgrade the role.
|
|
demoted := newClient(h, "10.0.0.5:5555", map[string]string{
|
|
hdrEmail: "bea@example.com",
|
|
hdrGroups: "users",
|
|
})
|
|
if rec := demoted.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("second request status = %d, want 200", rec.Code)
|
|
}
|
|
u, _ = app.Store.GetUserByEmail(context.Background(), "bea@example.com")
|
|
if u == nil || u.Role != models.RoleUser {
|
|
t.Fatalf("after group drop, role = %v, want user", u)
|
|
}
|
|
}
|
|
|
|
func TestForwardAuthEmailPrefsRoundTrip(t *testing.T) {
|
|
app, h := newTestApp(t)
|
|
enableForwardAuth(t, app)
|
|
|
|
// A forward-auth session authenticates without local login; carry its
|
|
// cookie + CSRF token into the POST.
|
|
c := newClient(h, "10.0.0.5:5555", map[string]string{
|
|
hdrEmail: "cleo@example.com",
|
|
hdrGroups: "users",
|
|
})
|
|
rec := c.do(http.MethodGet, "/settings", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /settings = %d, want 200", rec.Code)
|
|
}
|
|
token := extractCSRF(t, rec.Body.String())
|
|
|
|
provisioned, _ := app.Store.GetUserByEmail(context.Background(), "cleo@example.com")
|
|
if provisioned == nil {
|
|
t.Fatal("forward user not provisioned by GET /settings")
|
|
}
|
|
|
|
// A forward user can toggle opt-ins, but a form that tries to change the
|
|
// email must be ignored: the address is the Authentik match key, so
|
|
// changing it here would orphan the row on the next sign-in.
|
|
form := url.Values{
|
|
"csrf_token": {token},
|
|
"email": {"cleo+spoofed@example.com"},
|
|
"email_deal_alerts": {"1"},
|
|
"email_weekly_digest": {"1"},
|
|
}
|
|
rec = c.do(http.MethodPost, "/settings/email", form)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("POST /settings/email = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Email is unchanged (still the IdP address), so the row still re-matches.
|
|
u, err := app.Store.GetUserByEmail(context.Background(), "cleo@example.com")
|
|
if err != nil || u == nil {
|
|
t.Fatalf("forward user orphaned: GetUserByEmail(IdP addr) u=%v err=%v", u, err)
|
|
}
|
|
if u.ID != provisioned.ID {
|
|
t.Errorf("re-matched a different row: id %d != %d", u.ID, provisioned.ID)
|
|
}
|
|
if u.Email != "cleo@example.com" {
|
|
t.Errorf("email = %q, want the unchanged IdP address cleo@example.com", u.Email)
|
|
}
|
|
if !u.EmailDealAlerts || !u.EmailWeeklyDigest {
|
|
t.Errorf("opt-ins = (deal:%v digest:%v), want both true", u.EmailDealAlerts, u.EmailWeeklyDigest)
|
|
}
|
|
// The user appears in the deal-alert recipient list under their IdP address.
|
|
recips, err := app.Store.UsersWithDealAlerts(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("UsersWithDealAlerts: %v", err)
|
|
}
|
|
if !containsEmail(recips, "cleo@example.com") {
|
|
t.Errorf("deal-alert recipients %v missing cleo", recips)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|