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).
249 lines
8.5 KiB
Go
249 lines
8.5 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|