go-loose/internal/behaviour/profile_test.go

288 lines
11 KiB
Go
Raw Permalink 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 (
"encoding/base64"
"net/http"
"strings"
"testing"
)
// profilePhases edits the caller's own display name and picture, and checks the
// public URL that client applications such as go-tell embed.
func profilePhases(t *testing.T, j *journey) {
t.Helper()
host := j.adminHost()
phase(t, "the profile reports the caller and their memberships", func(t *testing.T) {
type membership struct {
TenantSlug string `json:"tenant_slug"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Permissions []string `json:"permissions"`
}
type profile struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Memberships []membership `json:"memberships"`
NameCustomized bool `json:"name_customized"`
}
result := decode[profile](t, j.admin.get(host, "/api/v1/profile", nil), http.StatusOK)
if result.Email != localAdminMail {
t.Errorf("email = %q", result.Email)
}
if result.DisplayName != "Local Administrator" {
t.Errorf("display name = %q", result.DisplayName)
}
if result.AvatarURL != "" {
t.Errorf("avatar_url = %q, want empty before a picture is set", result.AvatarURL)
}
if result.NameCustomized {
t.Error("name_customized is true before the name was edited")
}
if len(result.Memberships) == 0 {
t.Fatal("no memberships reported")
}
for _, membership := range result.Memberships {
if len(membership.Permissions) == 0 {
t.Errorf("membership %s reports no permissions", membership.TenantSlug)
}
}
})
phase(t, "an empty update is refused", func(t *testing.T) {
statusOnly(t, j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{}), http.StatusBadRequest)
})
phase(t, "an empty display name is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{"display_name": " "})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "the display name is changed and marked as customized", func(t *testing.T) {
j.profileName = "Ada Lovelace"
type updated struct {
DisplayName string `json:"display_name"`
NameCustomized bool `json:"name_customized"`
}
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"display_name": j.profileName,
})
result := decode[updated](t, response, http.StatusOK)
if result.DisplayName != j.profileName {
t.Errorf("display name = %q", result.DisplayName)
}
if !result.NameCustomized {
t.Error("name_customized was not set")
}
})
phase(t, "the customized name survives signing in again", func(t *testing.T) {
// Signing in upserts the user from the identity provider, which would
// otherwise overwrite the name the person chose.
browser := j.browser()
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 = %d: %s", response.StatusCode, bodyOf(t, response))
}
type profile struct {
DisplayName string `json:"display_name"`
}
result := decode[profile](t, browser.get(host, "/api/v1/profile", nil), http.StatusOK)
if result.DisplayName != j.profileName {
t.Errorf("display name = %q after signing in, want %q", result.DisplayName, j.profileName)
}
})
phase(t, "a picture is rejected when it is not an image", func(t *testing.T) {
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"avatar": base64.StdEncoding.EncodeToString([]byte("this is not an image")),
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "an unsupported image type is rejected", func(t *testing.T) {
// A real PNG announced as a PDF: the declared type must be checked.
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"avatar": "data:application/pdf;base64," + base64.StdEncoding.EncodeToString(decodeBase64PNG(t)),
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a picture is accepted and published under a key", func(t *testing.T) {
type updated struct {
AvatarURL string `json:"avatar_url"`
}
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"avatar": base64.StdEncoding.EncodeToString(decodeBase64PNG(t)),
})
result := decode[updated](t, response, http.StatusOK)
if !strings.HasPrefix(result.AvatarURL, "/api/v1/avatars/") {
t.Fatalf("avatar_url = %q, want a path under /api/v1/avatars", result.AvatarURL)
}
j.avatarKey = strings.TrimPrefix(result.AvatarURL, "/api/v1/avatars/")
if j.avatarKey == "" {
t.Fatal("no avatar key")
}
})
phase(t, "the picture is served to anyone, without a session", func(t *testing.T) {
stranger := j.browser()
response := stranger.get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, nil)
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", response.StatusCode)
}
if got := response.Header.Get("Content-Type"); got != "image/png" {
t.Errorf("content type = %q, want image/png", got)
}
if got := response.Header.Get("Cache-Control"); !strings.Contains(got, "max-age") {
t.Errorf("cache control = %q", got)
}
if response.Header.Get("ETag") == "" {
t.Error("no ETag on a picture")
}
if got := response.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("nosniff = %q", got)
}
body := bodyOf(t, response)
if len(body) == 0 {
t.Error("empty picture body")
}
})
phase(t, "an unchanged picture answers 304", func(t *testing.T) {
first := j.browser().get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, nil)
etag := first.Header.Get("ETag")
bodyOf(t, first)
second := j.browser().get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, map[string]string{
"If-None-Match": etag,
})
if second.StatusCode != http.StatusNotModified {
t.Fatalf("status = %d, want 304", second.StatusCode)
}
second.Body.Close()
})
phase(t, "an unknown or malformed picture key never serves anything", func(t *testing.T) {
for _, key := range []string{"does-not-exist", strings.Repeat("a", 43), "..%2f..%2fetc%2fpasswd"} {
response := j.browser().get(j.h.apex(), "/api/v1/avatars/"+key, nil)
// A traversal may be normalised into a redirect by the router; what matters
// is that no bytes are ever served for it.
if response.StatusCode == http.StatusOK {
t.Errorf("key %q returned a picture", key)
}
bodyOf(t, response)
}
})
phase(t, "replacing the picture retires the old key", func(t *testing.T) {
original := j.avatarKey
type updated struct {
AvatarURL string `json:"avatar_url"`
}
// A different image: the 1x1 PNG's colour channel is inverted.
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"avatar": base64.StdEncoding.EncodeToString(decodeBase64PNG(t)),
})
result := decode[updated](t, response, http.StatusOK)
j.avatarKey = strings.TrimPrefix(result.AvatarURL, "/api/v1/avatars/")
if j.avatarKey == "" {
t.Fatal("no avatar key after replacing the picture")
}
response = j.browser().get(j.h.apex(), "/api/v1/avatars/"+original, nil)
if response.StatusCode != http.StatusNotFound {
t.Errorf("the previous key still resolves: %d", response.StatusCode)
}
response.Body.Close()
response = j.browser().get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, nil)
if response.StatusCode != http.StatusOK {
t.Errorf("the new key does not resolve: %d", response.StatusCode)
}
response.Body.Close()
})
phase(t, "the profile of another member still shows no picture", func(t *testing.T) {
type profile struct {
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
result := decode[profile](t, j.admin.get(host, "/api/v1/profile", nil), http.StatusOK)
if result.AvatarURL == "" {
t.Error("the administrator's picture disappeared")
}
})
phase(t, "removing the picture clears it everywhere", func(t *testing.T) {
key := j.avatarKey
type updated struct {
AvatarURL string `json:"avatar_url"`
}
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{"remove_avatar": true})
result := decode[updated](t, response, http.StatusOK)
if result.AvatarURL != "" {
t.Errorf("avatar_url = %q, want empty", result.AvatarURL)
}
response = j.browser().get(j.h.apex(), "/api/v1/avatars/"+key, nil)
if response.StatusCode != http.StatusNotFound {
t.Errorf("the removed picture still resolves: %d", response.StatusCode)
}
response.Body.Close()
})
phase(t, "the member is moved to the self-service role", func(t *testing.T) {
// The role phase left this person holding a role that grants console.read.
// Moving them to the built-in self-service role is the flow that proves the
// console and the API agree about who may open it.
response := j.admin.json(http.MethodPut, host, "/api/v1/users/"+j.ungrantedMemberID+"/access", map[string]any{
"tenant_id": j.scopedTenantID, "role_id": j.userRoleID, "application_ids": []string{},
})
if response.StatusCode != http.StatusNoContent {
t.Fatalf("change role = %d: %s", response.StatusCode, bodyOf(t, response))
}
})
phase(t, "a member without console access is sent to their profile page", func(t *testing.T) {
// The ungranted member holds the self-service role, which grants
// profile.edit but not console.read.
browser := j.browser()
tenantHost := j.h.tenantHost(j.scopedTenant)
token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, "/login", nil)))
response := browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": j.ungrantedEmail, "password": j.ungrantedPassword,
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
response = browser.get(tenantHost, "/", nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("console = %d, want a redirect: %s", response.StatusCode, bodyOf(t, response))
}
if got := location(t, response); got != "/profile" {
t.Errorf("redirect = %q, want /profile", got)
}
page := bodyOf(t, browser.get(tenantHost, "/profile", nil))
if !strings.Contains(page, "<html") {
t.Error("the profile page did not render")
}
})
phase(t, "a member without console access cannot read the dashboard", func(t *testing.T) {
browser := j.browser()
tenantHost := j.h.tenantHost(j.scopedTenant)
token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, "/login", nil)))
response := browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": j.ungrantedEmail, "password": j.ungrantedPassword,
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
statusOnly(t, browser.get(tenantHost, "/api/v1/dashboard", nil), http.StatusForbidden)
})
}