mitbringsl/backend/internal/httpapi/api_test.go
Tronax 67033e561c
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).
2026-08-22 09:40:57 +02:00

249 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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