Files
krow_backend/go-api/internal/httpserver/authfixture_test.go
Aravind cf99866e12
Some checks failed
CI / test (push) Failing after 4m38s
CI / fixture (push) Failing after 9s
create employee table
2026-09-05 10:44:47 +05:30

232 lines
8.3 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 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
}