go-loose/internal/behaviour/harness_test.go
Daniel Noulet 8d8970d5a7 test: drive the whole application over HTTP, and serialise migrations
The behaviour suite in internal/behaviour installs the application into an
empty database through the web installer, signs in, walks every flow, and then
replaces the process to confirm the state was persisted rather than held in
memory. It found four defects, each fixed here or in the preceding commit:

- Migrate was not safe to run from two instances at once. CREATE TABLE IF NOT
  EXISTS is not race free in PostgreSQL and collided on the pg_type unique
  index, and two replicas could apply the same file. The run now holds a
  session-level advisory lock, so a rolling deployment waits instead of failing.

- Bootstrap refused ownership whenever a tenant held any member, so installing
  with the demonstration seed and then setting a local administrator password
  produced an account that could never reach the console. It now grants
  ownership only while the tenant has no owner, which still never takes a tenant
  over from one that does.

Three of the fixes are covered by store-level regression tests in the same
commit: the ownership rule, the duplicate tenant slug conflict, and concurrent
migration runs.

The suite is skipped by go test ./... because it needs a scratch database and
the Google credentials for the installer. Run it with make behaviour-test, which
also resets the database afterwards. make test no longer inherits the database
variables, so unit runs need nothing.

Completes the earlier correction to the docs: the role matrix lives in the
database rather than in internal/role, and a local administrator claims the
system administrator role when no Google account has.
2026-10-05 19:37:09 +02:00

460 lines
14 KiB
Go

// Package behaviour drives the whole application over real HTTP against a real
// PostgreSQL database, the way a person would: install it, sign in, click
// through every screen, and check that what was written can be read back after
// the process is replaced.
//
// The suite is opt-in. `go test ./...` skips it unless GO_LOOSE_BEHAVIOUR=1 is
// set, because it needs a scratch database and, for the installer, real Google
// credentials. Run it with `make behaviour-test`.
package behaviour
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/database"
"github.com/danyel/go-loose/internal/server"
)
const (
apexHost = "auth.test"
localAdminMail = "admin@goloose.local"
localAdminPass = "local-admin-password-2026"
sessionSecret = "behaviour-suite-session-secret-value-32"
)
// harness is one running instance of the application plus a browser that speaks
// to it over HTTP.
type harness struct {
t *testing.T
server *http.Server
listener net.Listener
handler http.Handler
db *sql.DB
addr string
baseURL string
origin string
}
// browser is a very small cookie jar with the domain rules that matter here.
// net/http/cookiejar cannot be used because the suite addresses the server by IP
// while sending tenant hostnames in the Host header, and the jar keys cookies by
// the URL host rather than the header. Getting that wrong would silently drop the
// shared session cookie that the client login flow depends on.
type browser struct {
t *testing.T
addr string
client *http.Client
cookies map[string]map[string]*http.Cookie // domain -> name -> cookie
}
// newBrowser returns a cookie jar bound to one instance. Cookies are per browser,
// so a second browser is a second, independent signed-out visitor.
func newBrowser(t *testing.T, addr string) *browser {
return &browser{
t: t, addr: addr,
client: &http.Client{
Timeout: 30 * time.Second,
// Redirects are inspected rather than followed: several flows assert on
// the Location header, and an automatic follow would hide it.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
cookies: map[string]map[string]*http.Cookie{},
}
}
// host extracts the hostname from a Host header that may carry a port.
func hostname(host string) string {
if parsed, _, err := net.SplitHostPort(host); err == nil {
return parsed
}
return host
}
// domainMatches implements the cookie domain rule: an exact host match, or a
// suffix match for a domain cookie.
func domainMatches(domain, host string) bool {
if domain == host {
return true
}
return strings.HasSuffix(host, "."+domain)
}
func (b *browser) absorb(host string, cookies []*http.Cookie) {
for _, cookie := range cookies {
domain := cookie.Domain
if domain == "" {
domain = hostname(host)
}
domain = strings.TrimPrefix(domain, ".")
if cookie.MaxAge < 0 {
if jar, ok := b.cookies[domain]; ok {
delete(jar, cookie.Name)
}
continue
}
if _, ok := b.cookies[domain]; !ok {
b.cookies[domain] = map[string]*http.Cookie{}
}
copied := *cookie
b.cookies[domain][cookie.Name] = &copied
}
}
// request performs one request without following redirects and records cookies.
func (b *browser) request(method, host, path string, body io.Reader, headers map[string]string) *http.Response {
b.t.Helper()
target := "http://" + b.addr + path
request, err := http.NewRequest(method, target, body)
if err != nil {
b.t.Fatalf("build %s %s: %v", method, path, err)
}
request.Host = host
for domain, jar := range b.cookies {
if !domainMatches(domain, hostname(host)) {
continue
}
for name, cookie := range jar {
request.AddCookie(&http.Cookie{Name: name, Value: cookie.Value})
}
}
for name, value := range headers {
if value == "" {
request.Header.Del(name)
continue
}
request.Header.Set(name, value)
}
response, err := b.client.Do(request)
if err != nil {
b.t.Fatalf("%s %s (host %s): %v", method, path, host, err)
}
b.absorb(host, response.Cookies())
return response
}
// get performs a GET and returns the response. The caller closes the body.
func (b *browser) get(host, path string, headers map[string]string) *http.Response {
return b.request(http.MethodGet, host, path, nil, headers)
}
// form performs a form POST, the way a browser submits an HTML form.
func (b *browser) form(host, path string, values url.Values, headers map[string]string) *http.Response {
encoded := values.Encode()
merged := map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "text/html,application/json",
"Content-Length": fmt.Sprint(len(encoded)),
}
for name, value := range headers {
merged[name] = value
}
return b.request(http.MethodPost, host, path, strings.NewReader(encoded), merged)
}
// json performs a JSON write, the way the console's own scripts do. The Origin
// header is what requireSameOrigin checks, so it defaults to the host being used.
func (b *browser) json(method, host, path string, payload any) *http.Response {
encoded, err := json.Marshal(payload)
if err != nil {
b.t.Fatalf("encode payload: %v", err)
}
return b.request(method, host, path, strings.NewReader(string(encoded)), map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"Origin": "http://" + host,
})
}
func bodyOf(t *testing.T, response *http.Response) string {
t.Helper()
defer response.Body.Close()
content, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(content)
}
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
func csrfFrom(t *testing.T, page string) string {
t.Helper()
match := csrfPattern.FindStringSubmatch(page)
if match == nil {
t.Fatalf("no CSRF token in page: %.400s", page)
}
return match[1]
}
// start boots the application on a fresh port. It is called once for the
// install phase and again by restart, which is how the suite proves that state
// lives in PostgreSQL rather than in the process.
// start boots the application. localAdmin is deliberately false for the install
// phase: configuring GO_LOOSE_LOCAL_ADMIN_PASSWORD marks an instance installed on
// its own, which would hide the installer this suite exists to exercise.
func start(t *testing.T, databaseURL string, localAdmin bool) *harness {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
baseURL := "http://" + apexHost + ":" + portOf(addr)
setEnv(t, baseEnv(addr, baseURL, databaseURL, localAdmin))
cfg, err := config.Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
ctx := context.Background()
db, err := database.Open(ctx, cfg.DatabaseURL)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := database.Migrate(ctx, db); err != nil {
t.Fatalf("migrate: %v", err)
}
handler, err := server.New(ctx, cfg, db, quietLogger())
if err != nil {
t.Fatalf("build server: %v", err)
}
harness := &harness{
t: t, listener: listener, handler: handler.Handler(), db: db,
addr: addr, baseURL: baseURL, origin: baseURL,
}
harness.serve()
return harness
}
func (h *harness) serve() {
h.server = &http.Server{Handler: h.handler, ReadHeaderTimeout: 5 * time.Second}
go func() {
// The listener is already open, so a failure here is a real defect rather
// than a bind race; report it through the test log.
_ = h.server.Serve(h.listener)
}()
h.t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = h.server.Shutdown(ctx)
})
}
// stop closes the running instance. The address is kept so restart can rebind it.
func (h *harness) stop() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = h.server.Shutdown(ctx)
// Shutdown stops accepting but the listener can outlive it briefly, so close it
// explicitly rather than hoping the port is free by the time we rebind.
_ = h.listener.Close()
}
// restart replaces the process with a new one over the same database.
//
// The new instance listens on a fresh port, the way a real deployment would come
// back up behind its proxy. Nothing observable depends on the port: the session
// cookie is scoped to the host without a port, and the application's redirect
// URIs are external.
func (h *harness) restart(databaseURL string) *harness {
h.t.Helper()
h.stop()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
h.t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
baseURL := "http://" + apexHost + ":" + portOf(addr)
setEnv(h.t, baseEnv(addr, baseURL, databaseURL, true))
cfg, err := config.Load()
if err != nil {
h.t.Fatalf("load config: %v", err)
}
ctx := context.Background()
handler, err := server.New(ctx, cfg, h.db, quietLogger())
if err != nil {
h.t.Fatalf("rebuild server: %v", err)
}
next := &harness{
t: h.t, listener: listener, handler: handler.Handler(), db: h.db,
addr: addr, baseURL: baseURL, origin: baseURL,
}
next.serve()
return next
}
// baseEnv is the environment the suite runs the application under.
func baseEnv(addr, baseURL, databaseURL string, localAdmin bool) map[string]string {
env := map[string]string{
"GO_LOOSE_ADDR": addr,
"GO_LOOSE_BASE_URL": baseURL,
"GO_LOOSE_AUTH_DOMAIN": apexHost,
"GO_LOOSE_DATABASE_URL": databaseURL,
"GO_LOOSE_SESSION_SECRET": sessionSecret,
"GO_LOOSE_DEV_LOGIN": "false",
"GO_LOOSE_LOCAL_LOGIN": "true",
"GO_LOOSE_OIDC_REDIRECT_URL": baseURL + "/auth/callback",
"GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS": "true",
"GO_LOOSE_BOOTSTRAP_TENANT": "nmbs",
"GO_LOOSE_BOOTSTRAP_APP": "guess",
// The provider credentials are never injected through the environment: the
// installer must be the only thing that configures OIDC here.
"GO_LOOSE_OIDC_ISSUER": "",
"GO_LOOSE_OIDC_CLIENT_ID": "",
"GO_LOOSE_OIDC_CLIENT_SECRET": "",
}
if localAdmin {
env["GO_LOOSE_LOCAL_ADMIN_EMAIL"] = localAdminMail
env["GO_LOOSE_LOCAL_ADMIN_PASSWORD"] = localAdminPass
}
return env
}
func portOf(addr string) string {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return "8080"
}
return port
}
// tenantHost is the hostname a tenant's members sign in on.
func (h *harness) tenantHost(slug string) string {
return slug + "." + apexHost + ":" + portOf(h.addr)
}
func (h *harness) apex() string {
return apexHost + ":" + portOf(h.addr)
}
func setEnv(t *testing.T, values map[string]string) {
t.Helper()
for name, value := range values {
t.Setenv(name, value)
}
}
// resetDatabase drops every table including schema_migrations, so the next start
// replays the migration history from the beginning.
func resetDatabase(t *testing.T, databaseURL string) {
t.Helper()
db, err := database.Open(context.Background(), databaseURL)
if err != nil {
t.Fatalf("open database to reset: %v", err)
}
defer db.Close()
rows, err := db.QueryContext(context.Background(),
`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
if err != nil {
t.Fatalf("list tables: %v", err)
}
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
t.Fatalf("scan table name: %v", err)
}
tables = append(tables, `"`+name+`"`)
}
rows.Close()
for _, table := range tables {
if _, err := db.ExecContext(context.Background(), "DROP TABLE IF EXISTS "+table+" CASCADE"); err != nil {
t.Fatalf("drop %s: %v", table, err)
}
}
}
// googleCredentials reads the real client credentials the installer is meant to
// receive. The values never appear in test output.
type googleCredentials struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
func loadGoogleCredentials(t *testing.T) googleCredentials {
t.Helper()
if override := os.Getenv("GO_LOOSE_BEHAVIOUR_CLIENT_ID"); override != "" {
return googleCredentials{ClientID: override, ClientSecret: os.Getenv("GO_LOOSE_BEHAVIOUR_CLIENT_SECRET")}
}
path := filepath.Join("..", "..", "google_secrets.json")
content, err := os.ReadFile(path)
if err != nil {
t.Skipf("no Google credentials at %s: %v", path, err)
}
var file struct {
Web struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
} `json:"web"`
}
if err := json.Unmarshal(content, &file); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
if file.Web.ClientID == "" || file.Web.ClientSecret == "" {
t.Skipf("%s has no client_id or client_secret", path)
}
return googleCredentials{ClientID: file.Web.ClientID, ClientSecret: file.Web.ClientSecret}
}
// decode reads a JSON response body into target and fails on a non-2xx status,
// reporting the server's own error message.
func decode[T any](t *testing.T, response *http.Response, wantStatus int) T {
t.Helper()
var target T
body := bodyOf(t, response)
if response.StatusCode != wantStatus {
t.Fatalf("status = %d, want %d: %s", response.StatusCode, wantStatus, body)
}
if err := json.Unmarshal([]byte(body), &target); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
return target
}
// statusOnly reads a body without decoding, for flows whose payload is empty.
func statusOnly(t *testing.T, response *http.Response, wantStatus int) string {
t.Helper()
body := bodyOf(t, response)
if response.StatusCode != wantStatus {
t.Fatalf("status = %d, want %d: %s", response.StatusCode, wantStatus, body)
}
return body
}
// location returns the redirect target of a response.
func location(t *testing.T, response *http.Response) string {
t.Helper()
target := response.Header.Get("Location")
if target == "" {
t.Fatalf("no Location header on %d", response.StatusCode)
}
return target
}
// quietLogger keeps the suite's output readable. The application logs every
// request, which would bury the test output; the suite reports its own failures.
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// stringReader is a small helper for the raw request bodies the authorization
// endpoint expects, where the exact bytes matter more than a struct.
func stringReader(body string) io.Reader { return strings.NewReader(body) }