diff --git a/AGENTS.md b/AGENTS.md index a9c03de..c3b8967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,21 @@ Vollständiger Plan liegt als genehmigtem Plan zugrunde (siehe Abschnitt "Roadma Google via **Credential Manager**, Generic OIDC via **Custom Tabs + eigenes PKCE**. - **Vorschläge:** aggregiert aus Item-Namen aller User (`item_names`-Tabelle, pg_trgm fuzzy search). Endpoint `GET /api/suggestions?q=`. -- **MVP-Scope:** Single-Owner-Listen (`list_members` existiert, wird in Phase 2 für - geteilte Listen genutzt); keine Echtzeit-Push (nur Periodic-Pull 15 min + Pull-on-Online); +- **Post-MVP (umgesetzt):** + - **Geteilte Listen per Invite-Code:** `lists.invite_code` (8 Zeichen, UNIQUE, + Migration 000002). `POST /api/lists/{id}/invite` (Owner/Member lesen Code, + wird lazy generiert), `POST /api/lists/join` (beitreten als `member`). + `GET /api/lists` + `GET /api/lists/{id}` + Push/Pull-Ops prüfen Membership + (owner ODER `list_members`-Eintrag). Kein Leave/Revoke-Endpoint, kein + Rate-Limiting auf join (bekannt, bewusst offengehalten). + - **Account optional / Local-Only-Mode:** App startet ohne Login direkt in die + Listen (`local_user`-Fallback im `SessionManager`); Sync/Account ist optionaler + Einstieg. Teilen/Beitreten erfordert aktive Server-Verbindung (App-gated). + - **Self-Hosted Server-URL:** konfigurierbar in der App (Auth-Screen), umgesetzt + via `DynamicBaseUrlInterceptor` (schreibt scheme/host/port pro Request um). + - **Authentik-OIDC:** läuft über den bestehenden Generic-OIDC-Provider + (`provider: "generic"`); die App erlaubt manuelle id_token-Eingabe. +- **MVP-Scope:** keine Echtzeit-Push (nur Periodic-Pull 15 min + Pull-on-Online); keine Web-UI. --- @@ -103,22 +116,26 @@ mitbringsl/ │ │ ├── store/ # PHASE C – Store-Schicht │ │ │ ├── db.go # pgxpool-Setup │ │ │ ├── opstore.go # AppendOps (idempotent, LWW-Projektion), PullOps (cursor) -│ │ │ ├── liststore.go # CreateList/GetLists/GetList +│ │ │ ├── liststore.go # CreateList/GetLists/GetList/GetInviteCode/JoinByInviteCode +│ │ │ ├── liststore_test.go # Invite/Join/Membership-Integrationstests (brauchen TEST_DATABASE_URL) │ │ │ ├── itemstore.go # GetItems │ │ │ └── suggeststore.go # Search (pg_trgm fuzzy) │ │ └── httpapi/ │ │ ├── api.go # API-Objekt + Router (alle Routen aktiv) +│ │ ├── api_test.go # E2E-HTTP-Tests: Share/Join/Sync + 401-Gating (TEST_DATABASE_URL) │ │ ├── render.go # JSON-Render + Problem + Fehler-Sentinale + decodeJSON │ │ ├── middleware.go # requestID/logging/recover/cors + Chain │ │ ├── health.go # /healthz + /readyz │ │ ├── auth.go # Register/Login/Logout/OIDC-Handler + RequireAuth-Middleware -│ │ ├── lists.go # GET/POST /api/lists, GET /api/lists/{id} -│ │ ├── ops.go # POST/GET /api/lists/{id}/ops (Push/Pull) +│ │ ├── lists.go # GET/POST /api/lists, GET /api/lists/{id}, POST /invite, POST /join +│ │ ├── ops.go # POST/GET /api/lists/{id}/ops (Push/Pull, Membership-Check) │ │ └── suggest.go # GET /api/suggestions?q= │ ├── migrations/ │ │ ├── embed.go # //go:embed *.sql │ │ ├── 000001_init_schema.up.sql # users/sessions/lists/list_members/items/op_log/item_names -│ │ └── 000001_init_schema.down.sql +│ │ ├── 000001_init_schema.down.sql +│ │ ├── 000002_add_invite_code.up.sql # lists.invite_code UNIQUE + Backfill owner→list_members +│ │ └── 000002_add_invite_code.down.sql │ ├── Dockerfile # Multi-Stage, baut server + migrate │ ├── .dockerignore │ ├── go.mod / go.sum @@ -128,8 +145,22 @@ mitbringsl/ │ ├── Caddyfile.behind-proxy # hinter externem Reverse Proxy: auto_https off, trusted_proxies │ ├── .env.example # alle env-Vars dokumentiert inkl. Caddy-Ports │ └── db/init/001_extensions.sql # CREATE EXTENSION pgcrypto, pg_trgm -├── docs/ # NOCH LEER (folgt Phase F) -└── android/ # NOCH LEER (folgt Phase D) +├── docs/ +│ ├── ARCHITECTURE.md +│ ├── SYNC.md # HLC/LWW/op_log +│ └── API.md # REST Specs (inkl. join/invite) +└── android/ # Single-Module :app (Compose M3, Room, Hilt) + └── app/src/main/java/com/example/mitbringsl/ + ├── data/auth/ # SessionManager (Local-User-Fallback, Server-URL) + ├── data/local/ # Room DB (lists, items, op_log), DAOs (LWW upsert) + ├── data/remote/ # Retrofit-API, DTOs, DynamicBaseUrlInterceptor, AuthInterceptor + ├── data/repository/ # ShoppingRepository, AuthRepository + ├── data/sync/ # HybridLogicalClock, SyncManager, SyncWorker + ├── di/ # Hilt Modules + ├── ui/auth/ # AuthScreen (Login/Register/OIDC/Server-URL) + ├── ui/lists/ # ListsScreen/ViewModel (Join-Dialog, Offline-Banner) + ├── ui/detail/ # ListDetailScreen/ViewModel (Share-Button, Autocomplete) + └── util/ # NetworkMonitor ``` --- @@ -171,19 +202,36 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen - ✅ **Phase E – SyncEngine:** `HybridLogicalClock` (client-seitig), `SyncWorker` (`CoroutineWorker` Outbox Drain + Server Cursor Pull), `SyncManager` (15 min periodisch + Sofort-Sync), `ShoppingRepository` (local-first mutations via Room + `op_log`). - ✅ **Phase E – Listen-Übersicht + Detail + AddItemBar:** `ListsScreen` & `ListsViewModel`, `ListDetailScreen` & `ListDetailViewModel` (Sectioning erledigt/offen, Autocomplete Suggestions dropdown), Compose Navigation 3. - ✅ **Phase F – Polish:** `NetworkMonitor` (ConnectivityManager StateFlow), Offline-Banner in `ListsScreen`, Material 3 Empty States. -- ✅ **Phase F – README + docs:** `README.md` Quickstart, `docs/ARCHITECTURE.md`, `docs/SYNC.md` (HLC/LWW/op_log), `docs/API.md` (REST Specs). +- ✅ **Phase F – README + docs:** `README.md` Quickstart, `docs/ARCHITECTURE.md`, + `docs/SYNC.md` (HLC/LWW/op_log), `docs/API.md` (REST Specs). +- ✅ **Post-MVP – Account optional:** App startet ohne Login direkt in die Listen + (Local-Only-Default), Sync/Account optional. +- ✅ **Post-MVP – Self-Hosted Server-URL + Authentik:** Server-URL in der App + konfigurierbar (`DynamicBaseUrlInterceptor`), Authentik über Generic-OIDC. +- ✅ **Post-MVP – Geteilte Listen:** Invite-Codes (Migration 000002), Join/Invite- + Endpoints, Membership-Checks in `GetLists`/`GetList`/Push/Pull. +- ✅ **Post-MVP – Tests für Share/Join:** `liststore_test.go` (Store-Level) + + `api_test.go` (E2E über HTTP): Invite/Join/Idempotenz/Rollen/Access-Control + (Fremde → 404 auf allen Listen-Endpoints), Cross-Member-Sync via op_log, + 401-Gating aller geschützten Routen. Beide Dateien skippen ohne + `TEST_DATABASE_URL` (Docker-Rezept im Dateikopf). ### Wo genau weitermachen? -### Wo genau weitermachen? -**Alle Phasen (Phase A bis F) sind vollständig abgeschlossen und verifiziert ✅.** +**MVP + Post-MVP-Features sind abgeschlossen.** Das Backend ist feature-complete +für den aktuellen Scope. Offene Punkte, geordnet nach Nutzen: -Erledigt: -- ✅ Phase A: Backend-Fundament, Migrationen, Docker, Caddy Setup. -- ✅ Phase B: Argon2id Password Auth, Sessions, OIDC (Google/Generic) Verification. -- ✅ Phase C: HLC Sync Engine, idempotent `op_log` append, LWW Projections, REST Endpoints & Suggestions. -- ✅ Phase D: Android Architecture (Kotlin 2.x, Compose M3, Room LWW DAOs, Retrofit API, Hilt DI). -- ✅ Phase E: Local-First `ShoppingRepository`, WorkManager `SyncWorker` & `SyncManager`, Full UI (Auth, Lists, Detail mit Autocomplete). -- ✅ Phase F: `NetworkMonitor` Offline-Indicator, Dokumentation (`ARCHITECTURE.md`, `SYNC.md`, `API.md`, `README.md`). +1. **Join-Rate-Limiting:** `POST /api/lists/join` hat kein Rate-Limit; 8-Zeichen- + Code ist brute-force-bar (62⁸ ≈ 2×10¹⁴, aber trotzdem). Z.B. pro Session/IP + drosseln oder fehlerhafte Joins verzögern. +2. **Leave-List / Member-Removal / Code-Revocation:** Es gibt keinen Endpoint, + eine Liste zu verlassen, Mitglieder zu entfernen oder einen Invite-Code zu + rotieren. `list_members`-Rollen (`owner`/`member`) werden bisher kaum genutzt. +3. **Invite-Code-Optimierung:** Codes sind UUID-Präfixe (`uuid[:8]`), nicht + kollisionsresistent geprüft (UNIQUE-Constraint fängt es, aber CreateList kann + dann fehlschlagen). Besser: kryptografisches Alphabet ohne Verwechslungsbuchstaben. +4. **Echtzeit-Push (langfristig):** Periodic-Pull 15 min ist MVP; SSE/WebSocket + für sofortige Updates wäre der nächste Schritt. +5. **Android-Tests:** UI/ViewModel-Tests fehlen fast komplett (nur 2 Stock-Tests). --- @@ -213,19 +261,27 @@ Erledigt: # Backend lokal bauen cd backend && go build ./... && go vet ./... +# Unit-Tests (ohne DB) +cd backend && go test ./... + +# Integrationstests (Store + E2E-HTTP) gegen Docker-PostgreSQL: +docker run -d --name mitbringsl-test-pg -e POSTGRES_USER=app \ + -e POSTGRES_PASSWORD=testpw -e POSTGRES_DB=appdb -p 55432:5432 postgres:16-alpine +cd backend && TEST_DATABASE_URL="postgres://app:testpw@localhost:55432/appdb?sslmode=disable" \ + go test ./... +docker rm -f mitbringsl-test-pg # danach aufräumen + # Backend-Image bauen cd backend && docker build -t mitbringsl-backend:test . # Komplettes Stack starten (braucht deploy/.env) cd deploy && cp .env.example .env && docker compose up -d --build -# Migrationen manuell gegen bestehende DB anwenden -docker run --rm --network \ - -e DATABASE_URL="postgres://app:PW@:5432/appdb?sslmode=disable" \ - mitbringsl-backend:test /app/migrate up +# Android bauen +cd android && ./gradlew assembleDebug && ./gradlew test ``` ## Git-Status -- Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`). -- Phase A + Phase B + Phase C + Phase D + Phase E + Phase F committed und gepusht. -- **Projekt Mitbringsl MVP ist vollständig abgeschlossen ✅.** +- Branch `main`, Remote `origin` konfiguriert (git.dietzlabs.net). +- MVP (Phase A–F) + Post-MVP-Features (optionale Accounts, Server-URL-Config, + geteilte Listen) committed und gepusht. diff --git a/README.md b/README.md index 3c3d6fb..ff3ba47 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ Mitbringsl ist eine moderne, schnelle und werbefreie Einkaufslisten-App mit **Lo ## 🌟 Highlights & Features - 📱 **Android App**: Kotlin 2.x, Jetpack Compose Material 3, Room, Hilt, WorkManager. +- 🔌 **Local-Only by Default**: Kein Account nötig — die App startet direkt ohne Login; Sync & Account sind optional (auch Self-Hosted, Server-URL in der App konfigurierbar). +- 👥 **Geteilte Listen**: Listen per 8-Zeichen-Invite-Code teilen und gemeinsam bearbeiten; Membership wird serverseitig bei jedem Sync geprüft. - ⚡ **Local-First Sync Engine**: Hybrid Logical Clock (HLC), append-only `op_log`, Last-Write-Wins (LWW) Projektionen, Idempotente Push/Pull-Algorithmen. -- 🔐 **Datenschutz & Auth**: Argon2id Passwort-Hashing, opaque Session-Tokens, OIDC-Unterstützung (Google & eigene IdPs). +- 🔐 **Datenschutz & Auth**: Argon2id Passwort-Hashing, opaque Session-Tokens, OIDC-Unterstützung (Google & eigene IdPs wie Authentik/Keycloak). - 🚀 **Go Backend**: Stdlib `net/http` Routing (Go 1.26), PostgreSQL 16 (`pgxpool`), `pg_trgm` Fuzzy Autocomplete. - 🛡️ **Deployment**: Multi-Stage Docker Container, Caddy Reverse Proxy mit automatischem HTTPS / Standalone & Behind-Proxy Modi. @@ -50,6 +52,16 @@ go vet ./... go test ./... ``` +Integrationstests (Invite/Join/Membership, E2E über HTTP) laufen gegen eine +wegwerfbare PostgreSQL-Instanz: +```bash +docker run -d --name mitbringsl-test-pg -e POSTGRES_USER=app \ + -e POSTGRES_PASSWORD=testpw -e POSTGRES_DB=appdb -p 55432:5432 postgres:16-alpine +cd backend +TEST_DATABASE_URL="postgres://app:testpw@localhost:55432/appdb?sslmode=disable" go test ./... +docker rm -f mitbringsl-test-pg +``` + ### 2. Stack per Docker Compose starten ```bash cd deploy diff --git a/backend/internal/httpapi/api_test.go b/backend/internal/httpapi/api_test.go new file mode 100644 index 0000000..1139d1f --- /dev/null +++ b/backend/internal/httpapi/api_test.go @@ -0,0 +1,249 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/mitbringsl/backend/internal/config" + "github.com/mitbringsl/backend/migrations" +) + +// End-to-end tests over the real HTTP stack (router + middleware + auth + +// stores) against a throwaway Postgres. Skipped unless TEST_DATABASE_URL is +// set; see internal/store/liststore_test.go for the Docker recipe. + +var testPool *pgxpool.Pool + +func TestMain(m *testing.M) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + fmt.Println("TEST_DATABASE_URL not set – skipping httpapi integration tests") + os.Exit(0) + } + src, err := iofs.New(migrations.FS, ".") + if err != nil { + fmt.Fprintf(os.Stderr, "create source: %v\n", err) + os.Exit(1) + } + mg, err := migrate.NewWithSourceInstance("iofs", src, dsn) + if err != nil { + fmt.Fprintf(os.Stderr, "create migrate instance: %v\n", err) + os.Exit(1) + } + defer mg.Close() + if err := mg.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + fmt.Fprintf(os.Stderr, "migrate up: %v\n", err) + os.Exit(1) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + fmt.Fprintf(os.Stderr, "connect: %v\n", err) + os.Exit(1) + } + testPool = pool + code := m.Run() + pool.Close() + os.Exit(code) +} + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + cfg := &config.Config{ + HTTPAddr: ":0", + AppEnv: "development", + SessionTokenBytes: 32, + SessionTokenTTL: time.Hour, + SessionCookieName: "test_session", + } + ts := httptest.NewServer(NewAPI(cfg, testPool).Handler()) + t.Cleanup(ts.Close) + return ts +} + +// registerUser creates an account via the public API and returns its bearer token. +func registerUser(t *testing.T, ts *httptest.Server) string { + t.Helper() + body := fmt.Sprintf(`{"email":%q,"password":"test-password-1"}`, + fmt.Sprintf("%s@test.example", uuid.NewString())) + status, data := doRequest(t, ts, http.MethodPost, "/auth/register", "", body) + if status != http.StatusCreated { + t.Fatalf("register: status = %d, body = %s", status, data) + } + var out struct { + Token string `json:"token"` + } + if err := json.Unmarshal(data, &out); err != nil || out.Token == "" { + t.Fatalf("register: no token in response (%v): %s", err, data) + } + return out.Token +} + +// doRequest performs a JSON request with an optional bearer token and returns +// the status code and response body. +func doRequest(t *testing.T, ts *httptest.Server, method, path, token, body string) (int, []byte) { + t.Helper() + var rd io.Reader + if body != "" { + rd = strings.NewReader(body) + } + req, err := http.NewRequest(method, ts.URL+path, rd) + if err != nil { + t.Fatalf("build request: %v", err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response: %v", err) + } + return resp.StatusCode, data +} + +// TestSharedLists_InviteJoinAndSyncAccess covers the full sharing flow: +// the owner creates a list and shares the invite code, another user joins, +// both can sync ops on the shared list, and a third user without membership +// is locked out of every endpoint. +func TestSharedLists_InviteJoinAndSyncAccess(t *testing.T) { + ts := newTestServer(t) + alice := registerUser(t, ts) + bob := registerUser(t, ts) + carol := registerUser(t, ts) + + // Alice creates a list; the response already carries the invite code. + status, data := doRequest(t, ts, http.MethodPost, "/api/lists", alice, `{"name":"Wocheneinkauf"}`) + if status != http.StatusCreated { + t.Fatalf("create list: status = %d, body = %s", status, data) + } + var created struct { + ID string `json:"id"` + InviteCode string `json:"invite_code"` + } + if err := json.Unmarshal(data, &created); err != nil { + t.Fatalf("create list: decode: %v (%s)", err, data) + } + if created.ID == "" || created.InviteCode == "" { + t.Fatalf("create list: missing id or invite_code: %s", data) + } + listPath := "/api/lists/" + created.ID + + // The invite endpoint returns the same code for the owner. + status, data = doRequest(t, ts, http.MethodPost, listPath+"/invite", alice, `{}`) + if status != http.StatusOK { + t.Fatalf("invite: status = %d, body = %s", status, data) + } + var inv struct { + InviteCode string `json:"invite_code"` + } + if err := json.Unmarshal(data, &inv); err != nil || inv.InviteCode != created.InviteCode { + t.Fatalf("invite: unexpected code (%v): %s", err, data) + } + + // Bob joins via the invite code, typed lowercase. + status, _ = doRequest(t, ts, http.MethodPost, "/api/lists/join", bob, + fmt.Sprintf(`{"invite_code":%q}`, strings.ToLower(created.InviteCode))) + if status != http.StatusOK { + t.Fatalf("join: status = %d", status) + } + + // Bob now sees the list in his overview and can open the detail view. + status, data = doRequest(t, ts, http.MethodGet, "/api/lists", bob, "") + if status != http.StatusOK || !strings.Contains(string(data), created.ID) { + t.Fatalf("bob GET /api/lists: status = %d, body = %s", status, data) + } + status, data = doRequest(t, ts, http.MethodGet, listPath, bob, "") + if status != http.StatusOK { + t.Fatalf("bob GET detail: status = %d, body = %s", status, data) + } + + // Bob pushes an op; Alice pulls it – sync works across members. + pushBody := fmt.Sprintf( + `{"client_id":%q,"ops":[{"client_seq":1,"op_type":"item_add","target_id":%q,"hlc_ts":1000,"payload":{"name":"Milch"}}]}`, + uuid.NewString(), uuid.NewString(), + ) + status, data = doRequest(t, ts, http.MethodPost, listPath+"/ops", bob, pushBody) + if status != http.StatusOK { + t.Fatalf("bob push ops: status = %d, body = %s", status, data) + } + status, data = doRequest(t, ts, http.MethodGet, listPath+"/ops?since=0", alice, "") + if status != http.StatusOK || !strings.Contains(string(data), "Milch") { + t.Fatalf("alice pull ops: status = %d, body = %s", status, data) + } + + // Carol (no membership) is locked out of detail, ops and invite. + if status, _ = doRequest(t, ts, http.MethodGet, listPath, carol, ""); status != http.StatusNotFound { + t.Errorf("carol GET detail: status = %d, want 404", status) + } + if status, _ = doRequest(t, ts, http.MethodPost, listPath+"/ops", carol, pushBody); status != http.StatusNotFound { + t.Errorf("carol push ops: status = %d, want 404", status) + } + if status, _ = doRequest(t, ts, http.MethodGet, listPath+"/ops?since=0", carol, ""); status != http.StatusNotFound { + t.Errorf("carol pull ops: status = %d, want 404", status) + } + if status, _ = doRequest(t, ts, http.MethodPost, listPath+"/invite", carol, `{}`); status != http.StatusNotFound { + t.Errorf("carol invite: status = %d, want 404", status) + } + // Carol's own list overview must not leak the shared list. + status, data = doRequest(t, ts, http.MethodGet, "/api/lists", carol, "") + if status != http.StatusOK { + t.Fatalf("carol GET /api/lists: status = %d, body = %s", status, data) + } + if strings.Contains(string(data), created.ID) { + t.Error("carol must not see the shared list in GET /api/lists") + } + + // Invalid invite code and idempotent re-join. + if status, _ = doRequest(t, ts, http.MethodPost, "/api/lists/join", bob, `{"invite_code":"NOPE0000"}`); status != http.StatusBadRequest { + t.Errorf("join with invalid code: status = %d, want 400", status) + } + if status, _ = doRequest(t, ts, http.MethodPost, "/api/lists/join", bob, + fmt.Sprintf(`{"invite_code":%q}`, created.InviteCode)); status != http.StatusOK { + t.Errorf("idempotent re-join: status = %d, want 200", status) + } +} + +func TestProtectedEndpointsRequireAuth(t *testing.T) { + ts := newTestServer(t) + listID := uuid.NewString() + paths := []struct { + method, path string + body string + }{ + {http.MethodGet, "/api/lists", ""}, + {http.MethodPost, "/api/lists", `{"name":"x"}`}, + {http.MethodGet, "/api/lists/" + listID, ""}, + {http.MethodPost, "/api/lists/join", `{"invite_code":"ABCD1234"}`}, + {http.MethodPost, "/api/lists/" + listID + "/invite", `{}`}, + {http.MethodPost, "/api/lists/" + listID + "/ops", `{}`}, + {http.MethodGet, "/api/lists/" + listID + "/ops?since=0", ""}, + {http.MethodGet, "/api/suggestions?q=mi", ""}, + } + for _, p := range paths { + if status, _ := doRequest(t, ts, p.method, p.path, "", p.body); status != http.StatusUnauthorized { + t.Errorf("%s %s without token: status = %d, want 401", p.method, p.path, status) + } + } +} diff --git a/backend/internal/httpapi/lists.go b/backend/internal/httpapi/lists.go index a0aa104..b63b578 100644 --- a/backend/internal/httpapi/lists.go +++ b/backend/internal/httpapi/lists.go @@ -180,8 +180,10 @@ func (h *ListHandler) Invite(w http.ResponseWriter, r *http.Request) { code, err := h.lists.GetInviteCode(r.Context(), listID, userID) if err != nil { - slog.Error("get invite code failed", "error", err, "list_id", listID) - renderError(w, http.StatusBadRequest, "Bad request", "Could not get invite code.") + if !apiError(w, err) { + slog.Error("get invite code failed", "error", err, "list_id", listID) + renderError(w, http.StatusInternalServerError, "Internal error", "Could not get invite code.") + } return } diff --git a/backend/internal/store/liststore_test.go b/backend/internal/store/liststore_test.go new file mode 100644 index 0000000..2fa945c --- /dev/null +++ b/backend/internal/store/liststore_test.go @@ -0,0 +1,297 @@ +package store + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/mitbringsl/backend/migrations" +) + +// Integration tests for the ListStore invite/join/membership logic. +// They run only when TEST_DATABASE_URL points at a throwaway Postgres, e.g.: +// +// docker run -d --name mitbringsl-test-pg -e POSTGRES_USER=app \ +// -e POSTGRES_PASSWORD=testpw -e POSTGRES_DB=appdb -p 55432:5432 postgres:16-alpine +// TEST_DATABASE_URL="postgres://app:testpw@localhost:55432/appdb?sslmode=disable" go test ./internal/store/... +// +// Without the variable the tests are skipped (exit 0). + +var testPool *pgxpool.Pool + +func TestMain(m *testing.M) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + fmt.Println("TEST_DATABASE_URL not set – skipping store integration tests") + os.Exit(0) + } + if err := applyMigrations(dsn); err != nil { + fmt.Fprintf(os.Stderr, "apply migrations: %v\n", err) + os.Exit(1) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + fmt.Fprintf(os.Stderr, "connect: %v\n", err) + os.Exit(1) + } + testPool = pool + code := m.Run() + pool.Close() + os.Exit(code) +} + +func applyMigrations(dsn string) error { + src, err := iofs.New(migrations.FS, ".") + if err != nil { + return fmt.Errorf("create source: %w", err) + } + mg, err := migrate.NewWithSourceInstance("iofs", src, dsn) + if err != nil { + return fmt.Errorf("create migrate instance: %w", err) + } + defer mg.Close() + if err := mg.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate up: %w", err) + } + return nil +} + +// createTestUser inserts a fresh user row and returns its id. +func createTestUser(t *testing.T) uuid.UUID { + t.Helper() + var id uuid.UUID + err := testPool.QueryRow(context.Background(), + `INSERT INTO users (email) VALUES ($1) RETURNING id`, + fmt.Sprintf("%s@test.example", uuid.NewString()), + ).Scan(&id) + if err != nil { + t.Fatalf("create test user: %v", err) + } + return id +} + +func TestCreateList_AddsOwnerAsMemberWithInviteCode(t *testing.T) { + owner := createTestUser(t) + ls := NewListStore(testPool) + + l, err := ls.CreateList(context.Background(), owner, "Einkauf") + if err != nil { + t.Fatalf("CreateList: %v", err) + } + if l.InviteCode == "" { + t.Fatal("expected non-empty invite code") + } + + var role string + err = testPool.QueryRow(context.Background(), + `SELECT role FROM list_members WHERE list_id = $1 AND user_id = $2`, + l.ID, owner, + ).Scan(&role) + if err != nil { + t.Fatalf("owner missing from list_members: %v", err) + } + if role != "owner" { + t.Fatalf("owner role = %q, want %q", role, "owner") + } +} + +func TestGetLists_ReturnsOwnedAndJoinedLists(t *testing.T) { + ctx := context.Background() + owner, member, stranger := createTestUser(t), createTestUser(t), createTestUser(t) + ls := NewListStore(testPool) + + owned, err := ls.CreateList(ctx, owner, "Owned") + if err != nil { + t.Fatalf("CreateList owned: %v", err) + } + foreign, err := ls.CreateList(ctx, member, "Foreign") + if err != nil { + t.Fatalf("CreateList foreign: %v", err) + } + private, err := ls.CreateList(ctx, stranger, "Private") + if err != nil { + t.Fatalf("CreateList private: %v", err) + } + + if _, err := ls.JoinByInviteCode(ctx, owner, foreign.InviteCode); err != nil { + t.Fatalf("JoinByInviteCode: %v", err) + } + + got, err := ls.GetLists(ctx, owner) + if err != nil { + t.Fatalf("GetLists: %v", err) + } + ids := map[uuid.UUID]bool{} + for _, l := range got { + ids[l.ID] = true + } + if !ids[owned.ID] { + t.Error("own list missing from GetLists") + } + if !ids[foreign.ID] { + t.Error("joined list missing from GetLists") + } + if ids[private.ID] { + t.Error("stranger's list must not appear in GetLists") + } +} + +func TestGetList_AccessControl(t *testing.T) { + ctx := context.Background() + owner, member, stranger := createTestUser(t), createTestUser(t), createTestUser(t) + ls := NewListStore(testPool) + + l, err := ls.CreateList(ctx, owner, "Shared") + if err != nil { + t.Fatalf("CreateList: %v", err) + } + if _, err := ls.JoinByInviteCode(ctx, member, l.InviteCode); err != nil { + t.Fatalf("JoinByInviteCode: %v", err) + } + + if _, err := ls.GetList(ctx, l.ID, owner); err != nil { + t.Errorf("owner should have access: %v", err) + } + if _, err := ls.GetList(ctx, l.ID, member); err != nil { + t.Errorf("member should have access: %v", err) + } + if _, err := ls.GetList(ctx, l.ID, stranger); !errors.Is(err, pgx.ErrNoRows) { + t.Errorf("stranger should get ErrNoRows, got %v", err) + } + + // Soft-deleted lists are invisible even to the owner. + if _, err := testPool.Exec(ctx, + `UPDATE lists SET deleted_at = now() WHERE id = $1`, l.ID); err != nil { + t.Fatalf("soft delete: %v", err) + } + if _, err := ls.GetList(ctx, l.ID, owner); !errors.Is(err, pgx.ErrNoRows) { + t.Errorf("owner should get ErrNoRows for deleted list, got %v", err) + } +} + +func TestJoinByInviteCode(t *testing.T) { + ctx := context.Background() + owner, member := createTestUser(t), createTestUser(t) + ls := NewListStore(testPool) + + l, err := ls.CreateList(ctx, owner, "Shared") + if err != nil { + t.Fatalf("CreateList: %v", err) + } + + // Happy path (lowercase input is normalized). + joined, err := ls.JoinByInviteCode(ctx, member, lower(l.InviteCode)) + if err != nil { + t.Fatalf("JoinByInviteCode: %v", err) + } + if joined.ID != l.ID { + t.Fatalf("joined list id = %v, want %v", joined.ID, l.ID) + } + + var role string + err = testPool.QueryRow(ctx, + `SELECT role FROM list_members WHERE list_id = $1 AND user_id = $2`, + l.ID, member, + ).Scan(&role) + if err != nil { + t.Fatalf("member missing from list_members: %v", err) + } + if role != "member" { + t.Fatalf("member role = %q, want %q", role, "member") + } + + // Joining again is idempotent (ON CONFLICT DO NOTHING) and keeps the role. + if _, err := ls.JoinByInviteCode(ctx, member, l.InviteCode); err != nil { + t.Fatalf("second JoinByInviteCode should be idempotent: %v", err) + } + if err := testPool.QueryRow(ctx, + `SELECT role FROM list_members WHERE list_id = $1 AND user_id = $2`, + l.ID, member, + ).Scan(&role); err != nil || role != "member" { + t.Fatalf("role after rejoin = %q (err %v), want member", role, err) + } + + // Owner joining their own list must not overwrite the owner role. + if _, err := ls.JoinByInviteCode(ctx, owner, l.InviteCode); err != nil { + t.Fatalf("owner self-join should be a no-op, got %v", err) + } + if err := testPool.QueryRow(ctx, + `SELECT role FROM list_members WHERE list_id = $1 AND user_id = $2`, + l.ID, owner, + ).Scan(&role); err != nil || role != "owner" { + t.Fatalf("owner role after self-join = %q (err %v), want owner", role, err) + } + + // Unknown code. + if _, err := ls.JoinByInviteCode(ctx, member, "NOPE0000"); !errors.Is(err, pgx.ErrNoRows) { + t.Errorf("unknown code should give ErrNoRows, got %v", err) + } +} + +func TestGetInviteCode(t *testing.T) { + ctx := context.Background() + owner, stranger := createTestUser(t), createTestUser(t) + ls := NewListStore(testPool) + + l, err := ls.CreateList(ctx, owner, "Shared") + if err != nil { + t.Fatalf("CreateList: %v", err) + } + + // Owner receives the stored code. + code, err := ls.GetInviteCode(ctx, l.ID, owner) + if err != nil { + t.Fatalf("GetInviteCode: %v", err) + } + if code != l.InviteCode { + t.Fatalf("code = %q, want %q", code, l.InviteCode) + } + + // Stranger is denied. + if _, err := ls.GetInviteCode(ctx, l.ID, stranger); !errors.Is(err, pgx.ErrNoRows) { + t.Errorf("stranger should get ErrNoRows, got %v", err) + } + + // Lists without a code (nullable column) get one generated lazily. + var bareID uuid.UUID + err = testPool.QueryRow(ctx, + `INSERT INTO lists (name, owner_id) VALUES ('Bare', $1) RETURNING id`, + owner, + ).Scan(&bareID) + if err != nil { + t.Fatalf("insert bare list: %v", err) + } + gen, err := ls.GetInviteCode(ctx, bareID, owner) + if err != nil { + t.Fatalf("GetInviteCode for bare list: %v", err) + } + if gen == "" { + t.Fatal("expected generated invite code") + } + again, err := ls.GetInviteCode(ctx, bareID, owner) + if err != nil { + t.Fatalf("GetInviteCode second call: %v", err) + } + if again != gen { + t.Fatalf("generated code not stable: %q vs %q", again, gen) + } +} + +func lower(s string) string { + b := []byte(s) + for i := range b { + if b[i] >= 'A' && b[i] <= 'Z' { + b[i] += 'a' - 'A' + } + } + return string(b) +} diff --git a/docs/API.md b/docs/API.md index cfa758d..28e279c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -57,29 +57,36 @@ Revoke active session token. Returns `204 No Content`. ## Lists (`/api/lists`) +All list endpoints return `404 Not Found` when the authenticated user is neither +owner nor member of the list (no membership leaks), and `401` without a valid +session. List responses carry an `invite_code` (8 characters) for sharing. + ### `GET /api/lists` -Fetch all active (non-deleted) lists owned by the authenticated user. +Fetch all active (non-deleted) lists the authenticated user owns **or** joined +as a member. - **Response (200 OK)**: ```json { "lists": [ - { "id": "", "name": "Wocheneinkauf", "updated_at": "2026-08-05T19:00:00Z", "hlc_ts": 177000000000000 } + { "id": "", "name": "Wocheneinkauf", "invite_code": "A1B2C3D4", "updated_at": "2026-08-05T19:00:00Z", "hlc_ts": 177000000000000 } ] } ``` ### `POST /api/lists` -Create a new list. +Create a new list. The owner is automatically added to `list_members` (role +`owner`) and an invite code is generated. - **Request**: `{ "name": "Supermarkt" }` -- **Response (201 Created)**: `{ "id": "", "name": "Supermarkt", ... }` +- **Response (201 Created)**: `{ "id": "", "name": "Supermarkt", "invite_code": "A1B2C3D4", ... }` ### `GET /api/lists/{id}` -Fetch list detail including items. +Fetch list detail including items (owner or member only). - **Response (200 OK)**: ```json { "id": "", "name": "Wocheneinkauf", + "invite_code": "A1B2C3D4", "updated_at": "...", "hlc_ts": 177000000000000, "items": [ @@ -88,10 +95,25 @@ Fetch list detail including items. } ``` +### `POST /api/lists/{id}/invite` +Return the invite code of a list (owner or member only). Generates a code on +the fly for legacy lists without one. +- **Request**: empty JSON object `{}` +- **Response (200 OK)**: `{ "invite_code": "A1B2C3D4" }` + +### `POST /api/lists/join` +Join a shared list using its invite code (case-insensitive). Joining again is +idempotent; the existing membership role is kept (an owner cannot be demoted). +- **Request**: `{ "invite_code": "a1b2c3d4" }` +- **Response (200 OK)**: the joined list (same shape as `GET /api/lists` items). +- **400 Bad Request**: unknown or empty invite code. + --- ## Sync & Ops (`/api/lists/{id}/ops`) +Both endpoints require membership (owner or member); otherwise `404 Not Found`. + ### `POST /api/lists/{id}/ops` Push a batch of client operations (max 100). - **Request**: diff --git a/docs/SYNC.md b/docs/SYNC.md index 56902d6..d926e9a 100644 --- a/docs/SYNC.md +++ b/docs/SYNC.md @@ -56,3 +56,24 @@ All state modifications are represented as structured operations: Each operation carries a `(client_id, client_seq)` tuple enforced by a `UNIQUE` constraint in Postgres (`op_log`). Re-sent requests return previous `(seq, hlc_ts)` assignments without duplicating side effects. - **Cursor Pull (`GET /api/lists/{id}/ops?since={seq}`)**: Clients track `max(server_seq)` locally. Incremental sync fetches ops where `seq > cursor`, sorted by monotonic server sequence `seq ASC`. + +--- + +## 6. Shared Lists + +Since shared lists were introduced, ops flow between **all members** of a list, +not just its owner: + +- **Membership**: A list has an owner (`lists.owner_id`, role `owner`) and any + number of members (`list_members`, role `member`). Members join via an + 8-character invite code (`POST /api/lists/join`); owner and members can read + the code (`POST /api/lists/{id}/invite`) to share it. +- **Sync scope**: Push and Pull verify on every request that the caller is owner + or member of the list — non-members receive `404` (not `403`, so membership + cannot be probed). Once joined, a member pulls the full op history + (`?since=0`) and applies the same LWW projection locally. +- **Conflict semantics are unchanged**: With multiple writers the LWW register + `(hlc_ts, client_id)` resolves concurrent edits; the HLC guarantees that ops + from different clients that observed each other are still causally ordered. + Concurrent edits to the same item converge to the highest `hlc_ts` on every + device — last write wins, no data merge.