go-loose/internal/behaviour/persistence_test.go

249 lines
9.6 KiB
Go
Raw Normal View History

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
package behaviour
import (
"net/http"
"os"
"strings"
"testing"
)
// persistencePhases replaces the running process and then reads everything back.
// This is the phase the whole suite exists for: it proves the application keeps
// its state in PostgreSQL rather than in memory, which is the one property that
// cannot be checked by looking at a single request.
func persistencePhases(t *testing.T, j *journey) {
t.Helper()
// Take a copy of the identifiers so the assertions below can be made against
// values captured before the restart rather than against whatever the new
// process happens to hand back.
want := struct {
tenant string
application string
contract string
apiKey string
clientID string
member string
customRole string
displayName string
avatarSet bool
memberGrants int
}{
tenant: j.scopedTenant, application: j.applicationID, contract: j.contractID,
apiKey: j.apiKeyID, clientID: j.clientID, member: j.memberEmail,
customRole: j.customRoleID, displayName: j.profileName,
}
// Put a picture back so the restart has binary state to preserve.
response := j.admin.json(http.MethodPut, j.adminHost(), "/api/v1/profile", map[string]any{
"avatar": pngBase64,
})
if response.StatusCode != http.StatusOK {
t.Fatalf("set a picture to carry across the restart: %d", response.StatusCode)
}
type withAvatar struct {
AvatarURL string `json:"avatar_url"`
}
avatarPath := decode[withAvatar](t, response, http.StatusOK).AvatarURL
want.avatarSet = avatarPath != ""
phase(t, "a client session is established before the restart", func(t *testing.T) {
// The earlier phase revoked its token on purpose, so a fresh one is needed to
// show that a session outlives the process.
j.accessToken = establishClientSession(t, j, "before-restart")
if j.accessToken == "" {
t.Fatal("no client session token")
}
response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil,
map[string]string{"Authorization": "Bearer " + j.accessToken})
statusOnly(t, response, http.StatusOK)
})
phase(t, "the installation survives the process that wrote it", func(t *testing.T) {
j.restart(os.Getenv("GO_LOOSE_BEHAVIOUR_DATABASE_URL"))
// A restarted instance must know it is installed without the environment
// saying so, which means it read the installation row and decrypted the
// stored OIDC secret.
response := j.browser().get(j.h.apex(), "/install", nil)
if response.StatusCode != http.StatusSeeOther || location(t, response) != "/login" {
t.Errorf("GET /install after restart = %d %q, want 303 to /login",
response.StatusCode, response.Header.Get("Location"))
}
})
phase(t, "the provider configuration still works after the restart", func(t *testing.T) {
browser := j.browser()
response := browser.get(j.h.apex(), "/auth/start", nil)
if response.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want a provider redirect: %s", response.StatusCode, bodyOf(t, response))
}
target := location(t, response)
if !strings.Contains(target, "accounts.google.com") {
t.Errorf("redirect = %q, want the installed provider", target)
}
if !strings.Contains(target, "client_id=") {
t.Errorf("the provider redirect carries no client id: %s", target)
}
})
phase(t, "an existing session is still valid after the restart", func(t *testing.T) {
// The session is a signed cookie, so a new process with the same secret
// keeps honouring it.
response := j.admin.get(j.adminHost(), "/api/v1/dashboard", nil)
statusOnly(t, response, http.StatusOK)
})
phase(t, "the tenant, application, contract, and revoked key all persist", func(t *testing.T) {
var tenants, applications, contracts, endpoints int
if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM tenants WHERE slug = $1`, want.tenant).Scan(&tenants); err != nil {
t.Fatalf("count tenants: %v", err)
}
if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM applications WHERE id = $1`, want.application).Scan(&applications); err != nil {
t.Fatalf("count applications: %v", err)
}
if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM openapi_contracts WHERE id = $1`, want.contract).Scan(&contracts); err != nil {
t.Fatalf("count contracts: %v", err)
}
if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM contract_endpoints WHERE contract_id = $1`, want.contract).Scan(&endpoints); err != nil {
t.Fatalf("count endpoints: %v", err)
}
if tenants != 1 || applications != 1 || contracts != 1 || endpoints != 3 {
t.Errorf("persisted: tenants=%d applications=%d contracts=%d endpoints=%d",
tenants, applications, contracts, endpoints)
}
// The revoked key must stay revoked.
var status string
if err := j.h.db.QueryRowContext(t.Context(), `SELECT status FROM api_keys WHERE id = $1`, want.apiKey).Scan(&status); err != nil {
t.Fatalf("read the key: %v", err)
}
if status != "revoked" {
t.Errorf("key status = %q, want revoked", status)
}
})
phase(t, "the dashboard reports the same state to the new process", func(t *testing.T) {
type listing struct {
Tenants []struct{ Slug string } `json:"tenants"`
Applications []struct {
Slug string `json:"slug"`
RedirectURIs []string `json:"redirect_uris"`
} `json:"applications"`
Users []struct {
Email string `json:"email"`
Role string `json:"role"`
} `json:"users"`
}
result := decode[listing](t, j.admin.get(j.adminHost(), "/api/v1/dashboard", nil), http.StatusOK)
slugs := map[string]bool{}
for _, tenant := range result.Tenants {
slugs[tenant.Slug] = true
}
if !slugs[want.tenant] {
t.Errorf("tenant %s is missing after the restart", want.tenant)
}
emails := map[string]string{}
for _, user := range result.Users {
emails[user.Email] = user.Role
}
if emails[want.member] == "" {
t.Errorf("%s is missing after the restart", want.member)
}
if emails[localAdminMail] != "owner" {
t.Errorf("the administrator's role is %q after the restart, want owner", emails[localAdminMail])
}
})
phase(t, "the customized display name and the picture both persist", func(t *testing.T) {
type profile struct {
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
NameCustomized bool `json:"name_customized"`
}
result := decode[profile](t, j.admin.get(j.adminHost(), "/api/v1/profile", nil), http.StatusOK)
if result.DisplayName != want.displayName {
t.Errorf("display name = %q, want %q", result.DisplayName, want.displayName)
}
if !result.NameCustomized {
t.Error("name_customized was lost")
}
if result.AvatarURL != avatarPath {
t.Errorf("avatar_url = %q, want %q", result.AvatarURL, avatarPath)
}
if !want.avatarSet {
t.Fatal("no picture was set before the restart")
}
})
phase(t, "the picture is still served, with the same bytes", func(t *testing.T) {
key := strings.TrimPrefix(avatarPath, "/api/v1/avatars/")
response := j.browser().get(j.h.apex(), "/api/v1/avatars/"+key, nil)
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want the picture to survive: %s", response.StatusCode, bodyOf(t, response))
}
if got := response.Header.Get("Content-Type"); got != "image/png" {
t.Errorf("content type = %q", got)
}
if len(bodyOf(t, response)) == 0 {
t.Error("empty picture after the restart")
}
})
phase(t, "the tenant role and the permission catalog persist", func(t *testing.T) {
type listing struct {
Roles []struct {
Slug string `json:"slug"`
} `json:"roles"`
}
result := decode[listing](t, j.admin.get(j.adminHost(),
"/api/v1/roles?tenant_id="+j.scopedTenantID, nil), http.StatusOK)
found := false
for _, role := range result.Roles {
if role.Slug == "reporter" {
found = true
}
}
if !found {
t.Error("the tenant-defined role is missing after the restart")
}
statusOnly(t, j.admin.get(j.adminHost(), "/api/v1/permissions", nil), http.StatusOK)
})
phase(t, "a client session token issued before the restart still resolves", func(t *testing.T) {
// The session is a row in PostgreSQL, not process state, so a deployment
// that restarts does not sign every user out of the tenant applications.
if j.accessToken == "" {
t.Fatal("no client session was established before the restart")
}
type identity struct {
Email string `json:"email"`
}
response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil,
map[string]string{"Authorization": "Bearer " + j.accessToken})
user := decode[identity](t, response, http.StatusOK)
if user.Email != j.memberEmail {
t.Errorf("email = %q, want %s", user.Email, j.memberEmail)
}
})
phase(t, "a new sign-in still works after the restart", func(t *testing.T) {
browser := j.browser()
host := j.adminHost()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
response := browser.form(host, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": localAdminMail, "password": localAdminPass,
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in after restart = %d: %s", response.StatusCode, bodyOf(t, response))
}
statusOnly(t, browser.get(host, "/api/v1/dashboard", nil), http.StatusOK)
})
phase(t, "an API key issued before the restart is still refused after it", func(t *testing.T) {
response := j.browser().request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
stringReader(`{"tenant":"behaviour","application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`),
map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"})
statusOnly(t, response, http.StatusForbidden)
})
}