package httpserver_test import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/krow/krow-backend/go-api/internal/auth" "github.com/krow/krow-backend/go-api/internal/config" "github.com/krow/krow-backend/go-api/internal/db" "github.com/krow/krow-backend/go-api/internal/httpserver" "github.com/krow/krow-backend/go-api/internal/testutil" ) // The shared authentication fixture. // // Every endpoint in this package except /health and the two auth routes now // requires a session, so the harness signs in before it hands a test anything. // That is what keeps the thirty-odd pre-existing tests in api_test.go working // unchanged: they still call a.do("GET", "/api/v1/…"), and the cookie rides // along underneath. // // The alternative — inserting a session row directly — would test the // middleware against a session no login ever produced. Signing in through the // real handler means the fixture itself exercises the flow it depends on. // harnessPassword is the password every test account is given. It is a literal // in a test file for a database that is created and dropped by the same // process; it is not a credential for anything that outlives the run. const harnessPassword = "harness-password-not-a-real-secret" // harnessHash is argon2id at production cost — about a tenth of a second — so // it is computed once for the whole package rather than once per test. var harnessHash = sync.OnceValues(func() (string, error) { return auth.HashPassword(harnessPassword) }) // setPassword gives a user a known password. func setPassword(t *testing.T, pool *pgxpool.Pool, userID string) { t.Helper() hash, err := harnessHash() if err != nil { t.Fatalf("hash the harness password: %v", err) } if _, err := pool.Exec(context.Background(), `UPDATE users SET password_hash = $2::text WHERE id = $1::uuid`, userID, hash); err != nil { t.Fatalf("set the harness password: %v", err) } } // setStatus flips a user between 'active' and 'suspended'. func setStatus(t *testing.T, pool *pgxpool.Pool, userID, status string) { t.Helper() if _, err := pool.Exec(context.Background(), `UPDATE users SET status = $2::text WHERE id = $1::uuid`, userID, status); err != nil { t.Fatalf("set status %s: %v", status, err) } } // seededUser is the demo ADMINISTRATOR the fixture loads into the test // database. // // The role is now part of the question. The fixture used to hold one account, // so "the seeded user" and "the administrator" were the same row and ordering // by date was enough to find it. It holds two since the Employer console gained // somebody to sign in as, both created on the same seeded date, which left the // tiebreak to a deterministic UUID — and picked the employer. Tests that assert // an administrator's access were then asserting an employer's, and failed // exactly as they should have. // // So it asks for what it means. Ordering is kept beneath the filter for the // case of several administrators. func seededUser(t *testing.T, pool *pgxpool.Pool) (id, email string) { t.Helper() if err := pool.QueryRow(context.Background(), `SELECT id::text, email::text FROM users WHERE role = 'admin' ORDER BY created_date, id LIMIT 1`). Scan(&id, &email); err != nil { t.Fatalf("read the seeded administrator: %v", err) } return id, email } // newUser adds a user to an organization, with the harness password set. func newUser(t *testing.T, pool *pgxpool.Pool, orgID, email, role string) string { t.Helper() var id string if err := pool.QueryRow(context.Background(), `INSERT INTO users (org_id, email, full_name, role) VALUES ($1::uuid, $2::citext, $3, $4) RETURNING id::text`, orgID, email, "Test User", role).Scan(&id); err != nil { t.Fatalf("create user %s: %v", email, err) } setPassword(t, pool, id) return id } // loginResult is what signIn observed: the response, and the cookie if one was // set. Tests assert on both. type loginResult struct { code int body map[string]any cookie *http.Cookie raw *httptest.ResponseRecorder } // signIn posts credentials to the real login handler. func signIn(t *testing.T, handler http.Handler, email, password string, remember bool) loginResult { t.Helper() payload, err := json.Marshal(map[string]any{ "email": email, "password": password, "remember_me": remember, }) if err != nil { t.Fatalf("encode the login payload: %v", err) } req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(string(payload))) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) out := loginResult{code: rec.Code, raw: rec} if rec.Body.Len() > 0 { _ = json.Unmarshal(rec.Body.Bytes(), &out.body) } for _, c := range rec.Result().Cookies() { if c.Name == sessionCookie { out.cookie = c } } return out } // sessionCookie is the name the server uses. Duplicated here rather than // exported from the package: a test that asserts the cookie name should fail // when the name changes, not silently follow it. const sessionCookie = "krow_session" // newServer builds a server over a fresh migrated, seeded database. func newServer(t *testing.T, h *testutil.Harness, origins []string, opts ...httpserver.Option) *httpserver.Server { t.Helper() cfg := &config.Config{ AppEnv: "development", HTTP: config.HTTPConfig{ Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second, CORSOrigins: origins, }, DB: config.DBConfig{Schema: "public"}, } log := slog.New(slog.NewTextHandler(io.Discard, nil)) srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"}, log, opts...) if err != nil { t.Fatalf("build the server: %v", err) } return srv } // withSession attaches a cookie to every request passing through, so a test // about something else — CORS, say — is not also a test about signing in. func withSession(handler http.Handler, cookie *http.Cookie) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if cookie != nil { r.AddCookie(cookie) } handler.ServeHTTP(w, r) }) } // newServerWithEnv builds a server for a given APP_ENV, so the cookie's Secure // flag can be observed on both sides of the development boundary. func newServerWithEnv(t *testing.T, h *testutil.Harness, appEnv string) http.Handler { t.Helper() cfg := &config.Config{ AppEnv: appEnv, HTTP: config.HTTPConfig{Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second}, DB: config.DBConfig{Schema: "public"}, } log := slog.New(slog.NewTextHandler(io.Discard, nil)) srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"}, log) if err != nil { t.Fatalf("build the %s server: %v", appEnv, err) } return srv.Handler() } /* ── Role fixtures (Phase 3D) ───────────────────────────────────────────── */ // actor is one signed-in user of a known role. type actor struct { name string // for test output only id string email string role string cookie *http.Cookie } // signInAs creates a user with the given role and signs them in. func signInAs(t *testing.T, handler http.Handler, pool *pgxpool.Pool, orgID, name, email, role string) actor { t.Helper() id := newUserWithRole(t, pool, orgID, email, role) result := signIn(t, handler, email, harnessPassword, false) if result.code != http.StatusOK || result.cookie == nil { t.Fatalf("could not sign in %s (%s): status %d", name, role, result.code) } return actor{name: name, id: id, email: email, role: role, cookie: result.cookie} } // newUserWithRole inserts a user with an explicit role and the harness password. // // Written straight to the database rather than through the API on purpose: // users.role is server-owned and there is deliberately no endpoint that sets // it, which is the property Phase 3D depends on. func newUserWithRole(t *testing.T, pool *pgxpool.Pool, orgID, email, role string) string { t.Helper() var id string if err := pool.QueryRow(context.Background(), `INSERT INTO users (org_id, email, full_name, role, account_type) VALUES ($1::uuid, $2::citext, $3, $4::text, 'employer') RETURNING id::text`, orgID, email, "Test "+role, role).Scan(&id); err != nil { t.Fatalf("create %s user %s: %v", role, email, err) } setPassword(t, pool, id) return id }