go-loose/internal/behaviour/membership_test.go

172 lines
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"
"testing"
)
// membershipPhases creates the person the hosted-login flow signs in as, and gives
// them exactly the two things a tenant application needs: membership of the tenant
// and access to its application.
func membershipPhases(t *testing.T, j *journey) {
t.Helper()
host := j.adminHost()
j.memberEmail = "member@behaviour.test"
j.memberPassword = "member-password-2026"
phase(t, "a role is chosen from the tenant's catalogue", func(t *testing.T) {
type role struct {
ID string `json:"id"`
Slug string `json:"slug"`
System bool `json:"system"`
Permissions []string `json:"permissions"`
Assignable bool `json:"assignable"`
}
type listing struct {
Roles []role `json:"roles"`
}
response := j.admin.get(host, "/api/v1/roles?tenant_id="+j.scopedTenantID, nil)
roles := decode[listing](t, response, http.StatusOK)
if len(roles.Roles) < 6 {
t.Fatalf("roles = %d, want at least the six built-ins", len(roles.Roles))
}
seen := map[string]bool{}
for _, item := range roles.Roles {
seen[item.Slug] = true
if item.Slug == "user" {
j.userRoleID = item.ID
}
if item.Slug == "viewer" {
j.viewerRoleID = item.ID
}
}
for _, want := range []string{"owner", "admin", "developer", "operator", "viewer", "user"} {
if !seen[want] {
t.Errorf("built-in role %q is missing", want)
}
}
if j.userRoleID == "" || j.viewerRoleID == "" {
t.Fatal("could not resolve the built-in roles")
}
})
phase(t, "an email address that is not a plain address is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
"tenant_id": j.scopedTenantID, "email": "Real Name <member@behaviour.test>",
"role_id": j.viewerRoleID, "password": j.memberPassword,
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a short password is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
"tenant_id": j.scopedTenantID, "email": "weak@behaviour.test",
"role_id": j.viewerRoleID, "password": "short",
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a member is invited and given access to the application", func(t *testing.T) {
type invited struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Status string `json:"status"`
Permissions []string `json:"permissions"`
}
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
"tenant_id": j.scopedTenantID, "email": j.memberEmail,
"display_name": "Behaviour Member", "role_id": j.viewerRoleID,
"password": j.memberPassword,
})
member := decode[invited](t, response, http.StatusCreated)
if member.Email != j.memberEmail {
t.Errorf("email = %q", member.Email)
}
if member.Status != "invited" {
// An invitation is not a login: the account stays "invited" until the
// person signs in for the first time.
t.Errorf("status = %q, want invited", member.Status)
}
if member.Role != "viewer" {
t.Errorf("role = %q, want viewer", member.Role)
}
j.memberID = member.ID
// Membership alone is not enough: the client login flow also needs an
// application grant.
access := j.admin.json(http.MethodPut, host, "/api/v1/users/"+member.ID+"/access", map[string]any{
"tenant_id": j.scopedTenantID, "role_id": j.viewerRoleID,
"application_ids": []string{j.applicationID},
})
if access.StatusCode != http.StatusNoContent {
t.Fatalf("grant access = %d: %s", access.StatusCode, bodyOf(t, access))
}
var granted int
if err := j.h.db.QueryRowContext(t.Context(), `
SELECT count(*) FROM user_application_access
WHERE user_id = $1 AND application_id = $2`, member.ID, j.applicationID).Scan(&granted); err != nil {
t.Fatalf("count grants: %v", err)
}
if granted != 1 {
t.Errorf("application grants = %d, want 1", granted)
}
})
phase(t, "a second member is invited without any application grant", func(t *testing.T) {
// Membership and application access are separate decisions, and the client
// login flow depends on the difference.
j.ungrantedEmail = "ungranted@behaviour.test"
j.ungrantedPassword = "ungranted-password-2026"
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
"tenant_id": j.scopedTenantID, "email": j.ungrantedEmail,
"display_name": "Ungranted Member", "role_id": j.userRoleID,
"password": j.ungrantedPassword,
})
if response.StatusCode != http.StatusCreated {
t.Fatalf("invite = %d: %s", response.StatusCode, bodyOf(t, response))
}
var grants int
if err := j.h.db.QueryRowContext(t.Context(), `
SELECT count(*) FROM user_application_access WHERE user_id = (
SELECT id FROM users WHERE email = $1)`, j.ungrantedEmail).Scan(&grants); err != nil {
t.Fatalf("count grants: %v", err)
}
if grants != 0 {
t.Errorf("grants = %d, want none", grants)
}
if err := j.h.db.QueryRowContext(t.Context(),
`SELECT id FROM users WHERE email = $1`, j.ungrantedEmail).Scan(&j.ungrantedMemberID); err != nil {
t.Fatalf("look up the ungranted member: %v", err)
}
})
phase(t, "the member appears in the tenant's user list", func(t *testing.T) {
type listing struct {
Users []struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
ApplicationIDs []string `json:"application_ids"`
} `json:"users"`
}
result := decode[listing](t, j.admin.get(host, "/api/v1/dashboard", nil), http.StatusOK)
found := false
for _, user := range result.Users {
if user.Email == j.memberEmail {
found = true
if len(user.ApplicationIDs) != 1 {
t.Errorf("application grants reported = %v", user.ApplicationIDs)
}
}
}
if !found {
t.Errorf("%s is missing from the dashboard", j.memberEmail)
}
})
}