219 lines
7.6 KiB
Go
219 lines
7.6 KiB
Go
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 user the fixture loads into the test database.
|
|
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 ORDER BY created_date, id LIMIT 1`).
|
|
Scan(&id, &email); err != nil {
|
|
t.Fatalf("read the seeded user: %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
|
|
}
|