first commit
This commit is contained in:
218
go-api/internal/httpserver/authfixture_test.go
Normal file
218
go-api/internal/httpserver/authfixture_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user