Tests for shared lists & docs catch-up to post-MVP state

Integration tests for the invite/join/membership feature that shipped
without any coverage:

- internal/store/liststore_test.go: CreateList adds owner as member with
  invite code, GetLists returns owned+joined but not foreign lists,
  GetList access control (owner/member yes, stranger and soft-deleted no),
  JoinByInviteCode normalization/idempotency/role-keeping, lazy invite
  code generation. Runs against TEST_DATABASE_URL, skips otherwise.
- internal/httpapi/api_test.go: full E2E over the real router — register,
  create list (code in response), invite endpoint, join (lowercase),
  cross-member op push/pull sync, stranger gets 404 on every list
  endpoint, invalid code 400, idempotent re-join, and 401 gating of all
  protected routes.
- lists.go Invite handler: store errors now map through apiError, so
  non-members get 404 instead of 400 (consistent with Get/Push/Pull).

Docs updated to the actual post-MVP state: AGENTS.md (post-MVP features,
repo structure, roadmap with open points like join rate limiting),
API.md (join/invite endpoints, invite_code fields, membership rules),
SYNC.md (shared lists section), README (local-only default, sharing,
integration test recipe).
This commit is contained in:
Tronax 2026-08-22 09:40:13 +02:00
parent 85c790ed23
commit 67033e561c
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
7 changed files with 692 additions and 33 deletions

View file

@ -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)
}
}
}

View file

@ -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
}