461 lines
14 KiB
Go
461 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) }
|