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