From 8d8970d5a7f3d37cf6825ccdfbf2429ebbc14f0b Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Mon, 5 Oct 2026 19:37:09 +0200 Subject: [PATCH] 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. --- Makefile | 21 +- README.md | 10 +- docs/client-login.md | 7 + docs/installation.md | 15 +- docs/operations.md | 2 + internal/behaviour/application_test.go | 191 ++++++++++ internal/behaviour/clientlogin_test.go | 368 +++++++++++++++++++ internal/behaviour/contract_test.go | 127 +++++++ internal/behaviour/harness_test.go | 460 ++++++++++++++++++++++++ internal/behaviour/journey_test.go | 466 +++++++++++++++++++++++++ internal/behaviour/membership_test.go | 171 +++++++++ internal/behaviour/persistence_test.go | 248 +++++++++++++ internal/behaviour/profile_test.go | 287 +++++++++++++++ internal/behaviour/role_test.go | 279 +++++++++++++++ internal/behaviour/security_test.go | 162 +++++++++ internal/behaviour/tenant_test.go | 77 ++++ internal/database/database.go | 38 ++ internal/store/integration_test.go | 125 +++++++ 18 files changed, 3050 insertions(+), 4 deletions(-) create mode 100644 internal/behaviour/application_test.go create mode 100644 internal/behaviour/clientlogin_test.go create mode 100644 internal/behaviour/contract_test.go create mode 100644 internal/behaviour/harness_test.go create mode 100644 internal/behaviour/journey_test.go create mode 100644 internal/behaviour/membership_test.go create mode 100644 internal/behaviour/persistence_test.go create mode 100644 internal/behaviour/profile_test.go create mode 100644 internal/behaviour/role_test.go create mode 100644 internal/behaviour/security_test.go create mode 100644 internal/behaviour/tenant_test.go diff --git a/Makefile b/Makefile index 1abb030..9e70892 100644 --- a/Makefile +++ b/Makefile @@ -14,13 +14,16 @@ GO_LOOSE_DEV_PORT ?= 8080 export -.PHONY: build test integration-db integration-test run compose-up compose-down compose-dev-up compose-dev-down compose-dev-logs fmt vet +.PHONY: build test integration-db integration-test behaviour-test run compose-up compose-down compose-dev-up compose-dev-down compose-dev-logs fmt vet build: go build ./... +# The database-backed suites drop every table they touch, so `test` runs without +# them. Each suite skips itself when its variable is empty, which keeps a +# developer without PostgreSQL from being blocked; `integration-test` sets them. test: - go test -race ./... + GO_LOOSE_TEST_DATABASE_URL= GO_LOOSE_TEST_SERVER_DATABASE_URL= go test -race ./... # Integration tests drop every table they touch, so they only run against scratch # databases. Both test helpers refuse any database whose name lacks "test", and @@ -33,15 +36,29 @@ test: GO_LOOSE_TEST_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose_test?sslmode=disable GO_LOOSE_TEST_SERVER_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose_server_test?sslmode=disable +# The behaviour suite drives the whole application over HTTP and needs the Google +# client credentials for the installer. It reads ../google_secrets.json, or the +# variables below, and skips when neither is present. +GO_LOOSE_BEHAVIOUR_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose_behaviour_test?sslmode=disable + # createdb is not idempotent, so ignore a database that is already there. integration-db: -docker compose exec -T postgres createdb -U goloose goloose_test -docker compose exec -T postgres createdb -U goloose goloose_server_test + -docker compose exec -T postgres createdb -U goloose goloose_behaviour_test integration-test: @test -n "$(GO_LOOSE_TEST_DATABASE_URL)" || { echo "set GO_LOOSE_TEST_DATABASE_URL to a scratch database"; exit 1; } go test -race ./internal/store/ ./internal/server/ -count=1 +# Installs the application into an empty database, walks every flow, restarts the +# process, and checks that everything is still there. Leaves the database empty +# again so the next run starts from nothing. +behaviour-test: + GO_LOOSE_BEHAVIOUR=1 go test ./internal/behaviour/ -count=1 -v + -docker compose exec -T postgres psql -q -U goloose -d goloose_behaviour_test \ + -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null + run: go run ./cmd/go-loose diff --git a/README.md b/README.md index cfe85ad..56591c4 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,15 @@ Development requires Go 1.27.1 or newer. Migrations run transactionally at startup and are tracked in `schema_migrations`. See the operations guide before creating one. -`make integration-db` creates the two scratch databases the database-backed tests use, and `make integration-test` runs them. Those tests drop every table they touch, so they refuse to run against a database whose name does not contain `test`. That guard exists because pointing them at a development database destroys it. +`make test` runs the unit tests and needs nothing. `make integration-db` creates the scratch databases the database-backed tests use, and `make integration-test` runs them. Those tests drop every table they touch, so they refuse to run against a database whose name does not contain `test`. That guard exists because pointing them at a development database destroys it. + +### Behaviour suite + +`make behaviour-test` drives the whole application over real HTTP against a real PostgreSQL database. It resets the database, installs the application through the web installer using the Google client credentials in `google_secrets.json`, signs in, walks every flow, then replaces the process and checks that the installation, tenants, applications, contracts, roles, sessions, profile, and picture are all still there. It resets the database again afterwards. + +The suite is skipped by `go test ./...` because it needs that credentials file and a scratch database. Set `GO_LOOSE_BEHAVIOUR_CLIENT_ID` and `GO_LOOSE_BEHAVIOUR_CLIENT_SECRET` instead of the file if you prefer. The one step it cannot automate is Google's interactive consent screen: it verifies the redirect to Google carries the installed client id and redirect URI, but completing the round trip needs a real Google account. + +Phases live in `internal/behaviour`, one file per area, and share a single ordered journey so each step can use what the previous one created. ### Hot reload diff --git a/docs/client-login.md b/docs/client-login.md index 55d6826..8c32522 100644 --- a/docs/client-login.md +++ b/docs/client-login.md @@ -170,6 +170,13 @@ A user with no membership in the application's tenant still authenticates, but f the client applications stop accepting their tokens on the next request. See [Signing out across the family](#signing-out-across-the-family). - API-key authentication and browser-user authentication are independent. Use `client.Middleware` for service/API keys and `BrowserAuth.Middleware` for logged-in users. +- **A `user_application_access` grant is the only thing that admits a person to your + application.** The system administrator is not an exception: they own every tenant, + which is what lets them administer it, but ownership does not sign them in here. + Nobody, administrator included, reaches your application without a grant recorded + against your `client_id`. If support needs to see the application as a user, grant + it deliberately from **Users & access** so it is attributable and revocable, rather + than assuming the administrator is already inside. ## Signing out across the family diff --git a/docs/installation.md b/docs/installation.md index 04d557a..c006ed3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,7 +33,9 @@ Later requests to `/install` redirect to login, and duplicate submissions are re ## First administrator and waiting room -The first user who completes Google SSO becomes the system administrator. A PostgreSQL advisory transaction lock makes this first-user claim atomic when multiple users sign in concurrently. The system administrator receives owner access to every tenant and application that exists at that moment. +The first user who completes Google SSO becomes the system administrator. A PostgreSQL advisory transaction lock makes this first-user claim atomic when multiple users sign in concurrently. The system administrator receives owner access to every tenant that exists at that moment. + +Owning a tenant is what lets the administrator administer it; it does not let them sign in to the applications running inside it. Access to a client application is always an explicit grant, so an administrator who wants to use one attaches themselves from **Users & access** like anybody else. The grant is visible, attributable and revocable, and revoking it signs them out of that application without affecting their console access. Later first-time SSO users are created without tenant membership. Their management page shows only the waiting room. A system administrator approves a waiting user from **Users & access**, selecting: @@ -78,3 +80,14 @@ An application must first have exact redirect URIs and a generated client secret Existing deployments may continue to supply all three `GO_LOOSE_OIDC_ISSUER`, `GO_LOOSE_OIDC_CLIENT_ID`, and `GO_LOOSE_OIDC_CLIENT_SECRET` variables. Go Loose treats that as an already configured deployment and bypasses the installer. Supplying `GO_LOOSE_LOCAL_LOGIN=true` together with a non-empty `GO_LOOSE_LOCAL_ADMIN_PASSWORD` does the same for a database-only development deployment. For new installations, prefer the web installer. `GO_LOOSE_LOCAL_LOGIN=true` without a bootstrap password enables seeded and invited database users but does not bypass installation. + +Two rules make that second mode usable rather than merely possible: + +- The local administrator claims the system administrator role when the installation has none. Creating tenants and approving waiting-room users both require that role, and only a first Google sign-in otherwise grants it, so a database-only deployment would otherwise have nobody able to do either. The claim is a no-op once any system administrator exists, so an installation already owned by a Google account is untouched. +- Bootstrapping grants ownership of a tenant only while that tenant has no owner. It deliberately does not care whether the tenant has other members, because the demonstration seed creates tenants holding administrators but no owner; refusing ownership there would leave a local administrator bootstrapped afterwards with an account that can never reach the console. A tenant that already has an owner is never taken over. + +Supplying `GO_LOOSE_LOCAL_ADMIN_PASSWORD` after a Google account has already claimed the role changes nothing. + +## Verifying an installation + +`make behaviour-test` installs the application into an empty database, signs in, walks every flow, and then restarts the process to confirm that everything was persisted rather than held in memory. See the [README](../README.md#behaviour-suite) for what it needs. diff --git a/docs/operations.md b/docs/operations.md index 9caf281..4b8192f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -37,6 +37,8 @@ The provider must return `sub`, `email`, and preferably `name` claims. Migration files live in `internal/database/migrations` and are embedded into the binary. Files run lexicographically once, in individual transactions. +A migration run holds a session-level advisory lock, so several replicas may start at the same time against one database. Without it the run races with itself: PostgreSQL's `CREATE TABLE IF NOT EXISTS` is not race free and can fail with a duplicate `pg_type` row, and two replicas can try to apply the same file. The second replica waits and then finds the work already recorded. + Create upgrades with an immutable numbered file: ```text diff --git a/internal/behaviour/application_test.go b/internal/behaviour/application_test.go new file mode 100644 index 0000000..4cf1a95 --- /dev/null +++ b/internal/behaviour/application_test.go @@ -0,0 +1,191 @@ +package behaviour + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// unknownButWellFormed has the shape of a real key so that it reaches the store +// and is rejected as unknown, rather than being turned away by format validation. +var unknownButWellFormed = "gl_unknown_" + strings.Repeat("A", 40) + +// applicationPhases registers an application, issues API keys, and exercises the +// authorization endpoint that every Go middleware integration depends on. +func applicationPhases(t *testing.T, j *journey) { + t.Helper() + host := j.adminHost() + + phase(t, "an application is registered with allowed hosts", func(t *testing.T) { + type application struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + AllowedHosts []string `json:"allowed_hosts"` + } + response := j.admin.json(http.MethodPost, host, "/api/v1/applications", map[string]any{ + "tenant_id": j.scopedTenantID, "slug": "behaviour-app", "name": "Behaviour App", + "description": "created by the behaviour suite", "allowed_hosts": []string{"app.behaviour.test"}, + }) + created := decode[application](t, response, http.StatusCreated) + if created.Slug != "behaviour-app" { + t.Errorf("slug = %q", created.Slug) + } + j.applicationID = created.ID + }) + + phase(t, "an allowed host carrying a scheme or port is refused", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, "/api/v1/applications", map[string]any{ + "tenant_id": j.scopedTenantID, "slug": "bad-hosts", "name": "Bad Hosts", + "allowed_hosts": []string{"https://app.behaviour.test"}, + }) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "an application without a tenant or a valid slug is refused", func(t *testing.T) { + for _, payload := range []map[string]any{ + {"slug": "no-tenant", "name": "No Tenant"}, + {"tenant_id": j.scopedTenantID, "slug": "Bad Slug", "name": "Bad Slug"}, + {"tenant_id": j.scopedTenantID, "slug": "", "name": "Empty Slug"}, + } { + response := j.admin.json(http.MethodPost, host, "/api/v1/applications", payload) + statusOnly(t, response, http.StatusBadRequest) + } + }) + + phase(t, "an API key is issued and shown exactly once", func(t *testing.T) { + type issued struct { + Key struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"key"` + Secret string `json:"secret"` + } + response := j.admin.json(http.MethodPost, host, "/api/v1/keys", map[string]any{ + "application_id": j.applicationID, "name": "integration key", + }) + result := decode[issued](t, response, http.StatusCreated) + if result.Secret == "" { + t.Fatal("no secret returned") + } + if result.Key.ID == "" { + t.Error("no key id returned") + } + j.apiKey, j.apiKeyID = result.Secret, result.Key.ID + + // The dashboard must never expose the secret again. + type keyListing struct { + Keys []map[string]any `json:"keys"` + } + listing := decode[keyListing](t, j.admin.get(host, "/api/v1/dashboard", nil), http.StatusOK) + for _, key := range listing.Keys { + for field, value := range key { + if text, ok := value.(string); ok && text == j.apiKey { + t.Errorf("the dashboard leaked the secret in %q", field) + } + } + } + }) + + phase(t, "a key in the wrong format is refused", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + nil, map[string]string{"X-API-Key": "not-a-key"}) + // The authorize endpoint is reached by application middleware, not by a + // browser session, so no session is involved here. + statusOnly(t, response, http.StatusUnauthorized) + }) + + phase(t, "an unknown key is denied", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)), + map[string]string{"X-API-Key": unknownButWellFormed, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "a valid key is authorized for its own tenant, application, and host", func(t *testing.T) { + type decision struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason"` + TenantSlug string `json:"tenant_slug"` + AppSlug string `json:"application_slug"` + APIKeyName string `json:"api_key_name"` + } + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + result := decode[decision](t, response, http.StatusOK) + if !result.Allowed { + t.Errorf("allowed = false, reason %q", result.Reason) + } + if result.TenantSlug != "behaviour" || result.AppSlug != "behaviour-app" { + t.Errorf("identity = %s/%s", result.TenantSlug, result.AppSlug) + } + if result.APIKeyName != "integration key" { + t.Errorf("key name = %q", result.APIKeyName) + } + }) + + phase(t, "a host outside the allow list is denied", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(`{"tenant":"{j.scopedTenant}","application":"behaviour-app","method":"GET","path":"/api/x","host":"evil.test"}`), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "another tenant is not reachable with this key", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.tenantSlug)), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "the key can also be presented as a bearer token", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)), + map[string]string{"Authorization": "Bearer " + j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusOK) + }) + + phase(t, "an incomplete authorization request is refused", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(`{"method":"GET"}`), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "every authorization attempt is recorded", func(t *testing.T) { + var total int + if err := j.h.db.QueryRowContext(t.Context(), + `SELECT count(*) FROM authorization_events WHERE api_key_id = $1`, j.apiKeyID).Scan(&total); err != nil { + t.Fatalf("count authorization events: %v", err) + } + if total < 4 { + t.Errorf("authorization events = %d, want the attempts above to be recorded", total) + } + }) + + phase(t, "revoking the key stops it working immediately", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.adminHost(), + "/api/v1/keys/"+j.apiKeyID+"/revoke", nil, map[string]string{ + "Origin": "http://" + j.adminHost(), "Content-Length": "0", + }) + if response.StatusCode != http.StatusNoContent { + t.Fatalf("revoke = %d: %s", response.StatusCode, bodyOf(t, response)) + } + response.Body.Close() + + response = j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "revoking an unknown key reports not found", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.adminHost(), + "/api/v1/keys/00000000-0000-0000-0000-000000000000/revoke", nil, map[string]string{ + "Origin": "http://" + j.adminHost(), "Content-Length": "0", + }) + statusOnly(t, response, http.StatusNotFound) + }) +} diff --git a/internal/behaviour/clientlogin_test.go b/internal/behaviour/clientlogin_test.go new file mode 100644 index 0000000..4babd69 --- /dev/null +++ b/internal/behaviour/clientlogin_test.go @@ -0,0 +1,368 @@ +package behaviour + +import ( + "encoding/base64" + "net/http" + "net/url" + "strings" + "testing" +) + +// clientPhases walks the whole hosted-login dance a tenant application performs: +// it configures a client, sends a member to the provider, comes back with a code, +// exchanges it for a bearer token, reads the identity, and signs out again. +func clientLoginPhases(t *testing.T, j *journey) { + t.Helper() + host := j.adminHost() + redirectURI := "https://app.behaviour.test/auth/callback" + + phase(t, "a redirect URI that is not HTTPS is refused", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, + "/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{ + "redirect_uris": []string{"http://app.behaviour.test/callback"}, + }) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "a redirect URI carrying a fragment is refused", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, + "/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{ + "redirect_uris": []string{"https://app.behaviour.test/callback#token"}, + }) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "duplicate redirect URIs are refused", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, + "/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{ + "redirect_uris": []string{redirectURI, redirectURI}, + }) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "client login is configured and the application becomes ready", func(t *testing.T) { + type configured struct { + Application struct { + ID string `json:"id"` + ClientID string `json:"client_id"` + RedirectURIs []string `json:"redirect_uris"` + ClientReady bool `json:"client_ready"` + } `json:"application"` + ClientSecret string `json:"client_secret"` + } + response := j.admin.json(http.MethodPost, host, + "/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{ + "redirect_uris": []string{redirectURI}, + }) + result := decode[configured](t, response, http.StatusOK) + if result.ClientSecret == "" { + t.Fatal("no client secret returned") + } + if result.Application.ClientID == "" { + t.Error("no client id assigned") + } + if !result.Application.ClientReady { + t.Error("the application is not reported as ready for client login") + } + if len(result.Application.RedirectURIs) != 1 { + t.Errorf("redirect URIs = %v", result.Application.RedirectURIs) + } + j.clientID, j.clientSecret = result.Application.ClientID, result.ClientSecret + }) + + phase(t, "an unknown client is refused at the authorize endpoint", func(t *testing.T) { + browser := j.browser() + response := browser.get(host, "/connect/authorize?response_type=code&client_id=glc_unknown"+ + "&redirect_uri="+url.QueryEscape(redirectURI)+"&state=xyz", nil) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "incomplete authorize parameters are refused", func(t *testing.T) { + browser := j.browser() + for _, query := range []string{ + "client_id=" + j.clientID, + "response_type=token&client_id=" + j.clientID + "&state=s", + "response_type=code&client_id=" + j.clientID + "&redirect_uri=" + url.QueryEscape(redirectURI), + } { + response := browser.get(host, "/connect/authorize?"+query, nil) + if response.StatusCode != http.StatusBadRequest { + t.Errorf("authorize with %q = %d, want 400", query, response.StatusCode) + } + response.Body.Close() + } + }) + + phase(t, "an apex request is redirected onto the tenant hostname", func(t *testing.T) { + browser := j.browser() + query := "response_type=code&client_id=" + j.clientID + + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=tenant-host" + response := browser.get(j.h.apex(), "/connect/authorize?"+query, nil) + if response.StatusCode != http.StatusTemporaryRedirect { + t.Fatalf("status = %d, want 307: %s", response.StatusCode, bodyOf(t, response)) + } + if !strings.Contains(location(t, response), j.h.tenantHost(j.scopedTenant)) { + t.Errorf("redirect = %q, want the tenant host", location(t, response)) + } + }) + + phase(t, "a signed-out visitor is sent to the tenant sign-in page", func(t *testing.T) { + browser := j.browser() + tenantHost := j.h.tenantHost(j.scopedTenant) + query := "response_type=code&client_id=" + j.clientID + + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=signin" + response := browser.get(tenantHost, "/connect/authorize?"+query, nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303: %s", response.StatusCode, bodyOf(t, response)) + } + target := location(t, response) + if !strings.Contains(target, "/client/login") { + t.Fatalf("redirect = %q, want the client sign-in page", target) + } + page := bodyOf(t, browser.get(tenantHost, target, nil)) + if !strings.Contains(page, "csrf_token") { + t.Errorf("the sign-in page has no form: %.200s", page) + } + }) + + phase(t, "a member without an application grant is refused", func(t *testing.T) { + // This person is a member of the tenant but was never granted the + // application, which is the distinction the endpoint has to enforce. + browser := j.browser() + tenantHost := j.h.tenantHost(j.scopedTenant) + query := "response_type=code&client_id=" + j.clientID + + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=grant" + response := browser.get(tenantHost, "/connect/authorize?"+query, nil) + target := location(t, response) + token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, target, nil))) + response = browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{ + "csrf_token": token, "email": j.ungrantedEmail, "password": j.ungrantedPassword, "return": returnParam(t, target), + }), nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) + } + response = browser.get(tenantHost, "/connect/authorize?"+query, nil) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "a granted member completes the flow and receives a session token", func(t *testing.T) { + browser := j.browser() + tenantHost := j.h.tenantHost(j.scopedTenant) + query := "response_type=code&client_id=" + j.clientID + + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=happy" + response := browser.get(tenantHost, "/connect/authorize?"+query, nil) + target := location(t, response) + token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, target, nil))) + response = browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{ + "csrf_token": token, "email": j.memberEmail, "password": j.memberPassword, "return": returnParam(t, target), + }), nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) + } + + // Back to the authorize endpoint, now with a session: a code comes back. + response = browser.get(tenantHost, "/connect/authorize?"+query, nil) + if response.StatusCode != http.StatusFound { + t.Fatalf("authorize = %d, want 302: %s", response.StatusCode, bodyOf(t, response)) + } + callback, err := url.Parse(location(t, response)) + if err != nil { + t.Fatalf("parse callback: %v", err) + } + if callback.Host != "app.behaviour.test" { + t.Errorf("callback host = %q", callback.Host) + } + if callback.Query().Get("state") != "happy" { + t.Errorf("state = %q, want happy", callback.Query().Get("state")) + } + code := callback.Query().Get("code") + if code == "" { + t.Fatal("no authorization code issued") + } + + // The application exchanges the code for a token using HTTP Basic auth. + type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + User struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + Permissions []string `json:"permissions"` + TenantSlug string `json:"tenant_slug"` + Application string `json:"application"` + } `json:"user"` + } + exchange := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", + strings.NewReader(url.Values{ + "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, + }.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + basicAuth(j.clientID, j.clientSecret), + }) + granted := decode[tokenResponse](t, exchange, http.StatusOK) + if granted.AccessToken == "" { + t.Fatal("no access token returned") + } + if granted.TokenType != "Bearer" { + t.Errorf("token_type = %q", granted.TokenType) + } + if granted.ExpiresIn <= 0 { + t.Errorf("expires_in = %d", granted.ExpiresIn) + } + if granted.User.Email != j.memberEmail { + t.Errorf("user email = %q, want %s", granted.User.Email, j.memberEmail) + } + if granted.User.TenantSlug != j.scopedTenant { + t.Errorf("tenant = %q, want %s", granted.User.TenantSlug, j.scopedTenant) + } + if granted.User.Role == "" || len(granted.User.Permissions) == 0 { + t.Errorf("the token payload carries no role or permissions: %+v", granted.User) + } + j.accessToken = granted.AccessToken + + // The code is single use. + replay := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", + strings.NewReader(url.Values{ + "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, + }.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + basicAuth(j.clientID, j.clientSecret), + }) + statusOnly(t, replay, http.StatusUnauthorized) + }) + + phase(t, "the token identifies the user at the userinfo endpoint", func(t *testing.T) { + type identity struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Permissions []string `json:"permissions"` + Application string `json:"application"` + } + 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", user.Email) + } + if user.DisplayName == "" { + t.Error("no display name") + } + if user.Application != "behaviour-app" { + t.Errorf("application = %q", user.Application) + } + }) + + phase(t, "a wrong client secret cannot exchange a code", func(t *testing.T) { + response := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", + strings.NewReader(url.Values{ + "grant_type": {"authorization_code"}, "code": {"glc_code_" + strings.Repeat("Q", 40)}, + "redirect_uri": {redirectURI}, + }.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + basicAuth(j.clientID, "glc_wrong_secret_value"), + }) + statusOnly(t, response, http.StatusUnauthorized) + }) + + phase(t, "a token without basic authentication is refused", func(t *testing.T) { + response := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", + strings.NewReader("grant_type=authorization_code"), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }) + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", response.StatusCode) + } + response.Body.Close() + if response.Header.Get("WWW-Authenticate") == "" { + t.Error("no WWW-Authenticate challenge was sent") + } + }) + + phase(t, "userinfo refuses a request without a bearer token", func(t *testing.T) { + statusOnly(t, j.browser().get(j.h.apex(), "/connect/userinfo", nil), http.StatusUnauthorized) + }) + + phase(t, "userinfo refuses a made-up token", func(t *testing.T) { + response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil, + map[string]string{"Authorization": "Bearer glc_session_" + strings.Repeat("Z", 43)}) + statusOnly(t, response, http.StatusUnauthorized) + }) + + phase(t, "signing out of the client invalidates the token at once", func(t *testing.T) { + response := j.browser().request(http.MethodPost, j.h.apex(), "/connect/logout", nil, + map[string]string{"Authorization": "Bearer " + j.accessToken}) + if response.StatusCode != http.StatusNoContent { + t.Fatalf("logout = %d: %s", response.StatusCode, bodyOf(t, response)) + } + response.Body.Close() + response = j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil, + map[string]string{"Authorization": "Bearer " + j.accessToken}) + statusOnly(t, response, http.StatusUnauthorized) + }) +} + +func basicAuth(user, password string) string { + return base64.StdEncoding.EncodeToString([]byte(user + ":" + password)) +} + +// returnParam extracts the authorize URL that the sign-in page will return to, +// which is carried in the sign-in page's own query string. +func returnParam(t *testing.T, signInURL string) string { + t.Helper() + parsed, err := url.Parse(signInURL) + if err != nil { + t.Fatalf("parse sign-in URL %q: %v", signInURL, err) + } + value := parsed.Query().Get("return") + if value == "" { + t.Fatalf("the sign-in URL carries no return target: %q", signInURL) + } + return value +} + +// establishClientSession runs the whole hosted-login dance and returns the bearer +// token, so a phase can assert on the token's lifetime rather than on the flow. +func establishClientSession(t *testing.T, j *journey, state string) string { + t.Helper() + browser := j.browser() + tenantHost := j.h.tenantHost(j.scopedTenant) + redirectURI := "https://app.behaviour.test/auth/callback" + query := "response_type=code&client_id=" + j.clientID + + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=" + state + + response := browser.get(tenantHost, "/connect/authorize?"+query, nil) + signIn := location(t, response) + token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, signIn, nil))) + response = browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{ + "csrf_token": token, "email": j.memberEmail, "password": j.memberPassword, + "return": returnParam(t, signIn), + }), nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) + } + + response = browser.get(tenantHost, "/connect/authorize?"+query, nil) + if response.StatusCode != http.StatusFound { + t.Fatalf("authorize = %d: %s", response.StatusCode, bodyOf(t, response)) + } + callback, err := url.Parse(location(t, response)) + if err != nil { + t.Fatalf("parse callback: %v", err) + } + + type tokenResponse struct { + AccessToken string `json:"access_token"` + } + exchange := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", + strings.NewReader(url.Values{ + "grant_type": {"authorization_code"}, "code": {callback.Query().Get("code")}, + "redirect_uri": {redirectURI}, + }.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + basicAuth(j.clientID, j.clientSecret), + }) + return decode[tokenResponse](t, exchange, http.StatusOK).AccessToken +} diff --git a/internal/behaviour/contract_test.go b/internal/behaviour/contract_test.go new file mode 100644 index 0000000..06b01e0 --- /dev/null +++ b/internal/behaviour/contract_test.go @@ -0,0 +1,127 @@ +package behaviour + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// openAPIDocument is a minimal but real OpenAPI 3.1 description, used both +// pasted into the console and served over HTTP for the scan-a-URL flow. +const openAPIDocument = `{ + "openapi": "3.1.0", + "info": {"title": "Guess API", "version": "1.0.0"}, + "paths": { + "/health": { + "get": { + "operationId": "health", + "summary": "Liveness probe", + "responses": {"200": {"description": "ok"}} + } + }, + "/guesses": { + "get": { + "operationId": "listGuesses", + "summary": "List guesses", + "responses": {"200": {"description": "ok"}} + }, + "post": { + "operationId": "createGuess", + "summary": "Create a guess", + "responses": {"201": {"description": "created"}} + } + } + } +}` + +// contractPhases imports a service contract from a pasted document and from a +// scanned URL, which is the path an operator takes when registering an +// application. +func contractPhases(t *testing.T, j *journey) { + t.Helper() + host := j.adminHost() + + phase(t, "a pasted contract is imported with its endpoints", func(t *testing.T) { + type saved struct { + ID string `json:"id"` + Version string `json:"version"` + EndpointCount int `json:"endpoint_count"` + } + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "document", "document": openAPIDocument, + }) + contract := decode[saved](t, response, http.StatusCreated) + if contract.Version != "3.1.0" { + t.Errorf("version = %q", contract.Version) + } + if contract.EndpointCount != 3 { + t.Errorf("endpoint_count = %d, want 3", contract.EndpointCount) + } + j.contractID = contract.ID + + // The endpoints themselves must be queryable, since the console lists them. + var scanned int + if err := j.h.db.QueryRowContext(t.Context(), + `SELECT count(*) FROM contract_endpoints WHERE contract_id = $1`, contract.ID).Scan(&scanned); err != nil { + t.Fatalf("count contract endpoints: %v", err) + } + if scanned != 3 { + t.Errorf("stored endpoints = %d, want 3", scanned) + } + }) + + phase(t, "a malformed contract is refused with a readable error", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "document", "document": `{"openapi":`, + }) + body := statusOnly(t, response, http.StatusUnprocessableEntity) + if body == "" { + t.Error("no explanation returned") + } + }) + + phase(t, "an empty contract is refused", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "document", "document": "", + }) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "a contract is scanned from a URL", func(t *testing.T) { + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(openAPIDocument)) + })) + defer source.Close() + + type scanned struct { + ID string `json:"id"` + SourceURL string `json:"source_url"` + } + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "url", "source_url": source.URL + "/openapi.json", + }) + result := decode[scanned](t, response, http.StatusCreated) + if result.SourceURL == "" { + t.Error("the source URL was not recorded") + } + }) + + phase(t, "an unreachable contract URL reports a gateway error", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "url", + "source_url": "http://127.0.0.1:1/openapi.json", + }) + statusOnly(t, response, http.StatusBadGateway) + }) + + phase(t, "the dashboard reports the stored contracts", func(t *testing.T) { + type listing struct { + Contracts []map[string]any `json:"contracts"` + } + result := decode[listing](t, j.admin.get(host, "/api/v1/dashboard", nil), http.StatusOK) + if len(result.Contracts) < 2 { + t.Errorf("contracts = %d, want the pasted and scanned documents", len(result.Contracts)) + } + }) +} diff --git a/internal/behaviour/harness_test.go b/internal/behaviour/harness_test.go new file mode 100644 index 0000000..daabd8d --- /dev/null +++ b/internal/behaviour/harness_test.go @@ -0,0 +1,460 @@ +// 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) } diff --git a/internal/behaviour/journey_test.go b/internal/behaviour/journey_test.go new file mode 100644 index 0000000..ab69270 --- /dev/null +++ b/internal/behaviour/journey_test.go @@ -0,0 +1,466 @@ +package behaviour + +import ( + "database/sql" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "os" + "strings" + "testing" +) + +// journey carries the identifiers one phase creates into the phases that use +// them, so each phase reads like a step a person would take. +type journey struct { + t *testing.T + h *harness + admin *browser + adminEmail string + + tenantID string + tenantSlug string + scopedTenant string + scopedTenantID string + applicationID string + clientID string + clientSecret string + apiKey string + apiKeyID string + contractID string + customRoleID string + permission string + memberEmail string + memberPassword string + ungrantedEmail string + ungrantedPassword string + ungrantedMemberID string + memberID string + userRoleID string + viewerRoleID string + avatarKey string + accessToken string + profileName string +} + +// browser returns a fresh, signed-out visitor pointed at the current instance. +func (j *journey) browser() *browser { return newBrowser(j.t, j.h.addr) } + +// restart replaces the running process and points the signed-in browser at the new +// one. The cookie jar is kept deliberately: it is the same person, and their +// session cookie is scoped to the host rather than to the port. +func (j *journey) restart(databaseURL string) { + j.h = j.h.restart(databaseURL) + if j.admin != nil { + j.admin.addr = j.h.addr + } +} + +// adminHost is the tenant hostname the local administrator belongs to. +func (j *journey) adminHost() string { return j.h.tenantHost(j.tenantSlug) } + +// phase runs one step. Steps run in order and share state, which is why this is +// a single test with subtests rather than a set of independent ones. +func phase(t *testing.T, name string, step func(t *testing.T)) { + t.Helper() + t.Run(name, step) +} + +func requireScratchDatabase(t *testing.T, databaseURL string) { + t.Helper() + if os.Getenv("GO_LOOSE_BEHAVIOUR_DESTRUCTIVE") == "1" { + return + } + parsed, err := url.Parse(databaseURL) + if err != nil { + t.Fatalf("parse database URL: %v", err) + } + name := strings.TrimPrefix(parsed.Path, "/") + if !strings.Contains(strings.ToLower(name), "test") { + t.Fatalf("refusing to run against database %q: the suite drops every table. "+ + "Point GO_LOOSE_BEHAVIOUR_DATABASE_URL at a scratch database whose name contains \"test\".", name) + } +} + +// TestApplicationBehaviour walks the entire application once, in the order a +// person would: install it, sign in, build out a tenant, wire an application, +// manage people and roles, edit a profile, then replace the process and check +// that everything is still there. +func TestApplicationBehaviour(t *testing.T) { + if os.Getenv("GO_LOOSE_BEHAVIOUR") != "1" { + t.Skip("set GO_LOOSE_BEHAVIOUR=1 and GO_LOOSE_BEHAVIOUR_DATABASE_URL to run the behaviour suite") + } + databaseURL := os.Getenv("GO_LOOSE_BEHAVIOUR_DATABASE_URL") + if databaseURL == "" { + t.Skip("set GO_LOOSE_BEHAVIOUR_DATABASE_URL to a scratch database") + } + requireScratchDatabase(t, databaseURL) + + // Start from nothing: the suite is meant to prove a first install works. + resetDatabase(t, databaseURL) + + credentials := loadGoogleCredentials(t) + j := &journey{t: t, h: start(t, databaseURL, false), adminEmail: localAdminMail} + j.admin = j.browser() + + installPhases(t, j, credentials) + + // The operator now adds the local administrator and restarts, which is also the + // first proof that the installation survived the process it was written by. + j.restart(databaseURL) + authenticationPhases(t, j) + consolePhases(t, j) + tenantPhases(t, j) + applicationPhases(t, j) + contractPhases(t, j) + membershipPhases(t, j) + clientLoginPhases(t, j) + rolePhases(t, j) + profilePhases(t, j) + securityPhases(t, j) + persistencePhases(t, j) +} + +// installPhases drives the first-run installer with the real Google credentials +// and then checks what reached PostgreSQL. +func installPhases(t *testing.T, j *journey, credentials googleCredentials) { + t.Helper() + + phase(t, "an uninstalled instance sends the console to the installer", func(t *testing.T) { + response := j.browser().get(j.h.apex(), "/", nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", response.StatusCode) + } + if got := location(t, response); got != "/install" { + t.Fatalf("redirect = %q, want /install", got) + } + }) + + phase(t, "the installer refuses a submission without its CSRF token", func(t *testing.T) { + browser := j.browser() + page := bodyOf(t, browser.get(j.h.apex(), "/install", nil)) + token := csrfFrom(t, page) + // The cookie was issued for the page, so the mismatch below is the token, + // not a missing cookie. + browser.cookies = map[string]map[string]*http.Cookie{} + response := browser.form(j.h.apex(), "/install", url.Values{ + "csrf_token": {token}, "field1": {"https://accounts.google.com"}, + "field2": {credentials.ClientID}, "field3": {credentials.ClientSecret}, + "field4": {j.h.baseURL + "/auth/callback"}, + }, nil) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "the installer refuses an incomplete submission", func(t *testing.T) { + browser := j.browser() + token := csrfFrom(t, bodyOf(t, browser.get(j.h.apex(), "/install", nil))) + response := browser.form(j.h.apex(), "/install", url.Values{ + "csrf_token": {token}, "field1": {"https://accounts.google.com"}, + }, nil) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "installing stores the provider settings and seeds the demo data", func(t *testing.T) { + browser := j.browser() + token := csrfFrom(t, bodyOf(t, browser.get(j.h.apex(), "/install", nil))) + response := browser.form(j.h.apex(), "/install", url.Values{ + "csrf_token": {token}, + "field1": {"https://accounts.google.com"}, + "field2": {credentials.ClientID}, + "field3": {credentials.ClientSecret}, + "field4": {j.h.baseURL + "/auth/callback"}, + "seed_demo": {"on"}, + }, nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303: %s", response.StatusCode, bodyOf(t, response)) + } + if got := location(t, response); got != "/auth/start" { + t.Fatalf("redirect = %q, want /auth/start", got) + } + + var issuer, clientID, ciphertext, redirect string + var seeded bool + row := j.h.db.QueryRowContext(t.Context(), ` + SELECT oidc_issuer, oidc_client_id, encode(oidc_client_secret_ciphertext, 'escape'), oidc_redirect_url, seeded_demo + FROM installation_settings WHERE singleton`) + if err := row.Scan(&issuer, &clientID, &ciphertext, &redirect, &seeded); err != nil { + t.Fatalf("read installation_settings: %v", err) + } + if issuer != "https://accounts.google.com" { + t.Errorf("issuer = %q", issuer) + } + if clientID != credentials.ClientID { + t.Errorf("client id = %q, want the credentials from google_secrets.json", clientID) + } + if redirect != j.h.baseURL+"/auth/callback" { + t.Errorf("redirect url = %q", redirect) + } + if !seeded { + t.Error("seeded_demo = false, want true") + } + // The secret must be encrypted at rest, never the plaintext from the form. + if strings.Contains(ciphertext, credentials.ClientSecret) { + t.Error("the client secret is stored in plaintext") + } + if ciphertext == "" { + t.Error("the client secret was not stored") + } + + for _, want := range []string{"nmbs", "ypto"} { + var slug string + if err := j.h.db.QueryRowContext(t.Context(), + `SELECT slug FROM tenants WHERE slug = $1`, want).Scan(&slug); err != nil { + t.Errorf("tenant %s was not seeded: %v", want, err) + } + } + var demoUsers int + if err := j.h.db.QueryRowContext(t.Context(), + `SELECT count(*) FROM users WHERE email LIKE '%@nmbs.auth.dev' OR email LIKE '%@ypto.auth.dev'`). + Scan(&demoUsers); err != nil { + t.Fatalf("count demo users: %v", err) + } + if demoUsers != 4 { + t.Errorf("demo users = %d, want 4", demoUsers) + } + }) + + phase(t, "an installed instance no longer offers the installer", func(t *testing.T) { + browser := j.browser() + response := browser.get(j.h.apex(), "/install", nil) + if response.StatusCode != http.StatusSeeOther || location(t, response) != "/login" { + t.Fatalf("GET /install = %d %q, want 303 to /login", response.StatusCode, response.Header.Get("Location")) + } + token := csrfFrom(t, bodyOf(t, browser.get(j.h.apex(), "/login", nil))) + response = browser.form(j.h.apex(), "/install", url.Values{ + "csrf_token": {token}, "field1": {"https://accounts.google.com"}, + "field2": {credentials.ClientID}, "field3": {credentials.ClientSecret}, + "field4": {j.h.baseURL + "/auth/callback"}, + }, nil) + statusOnly(t, response, http.StatusConflict) + }) + + phase(t, "sign-in redirects to Google with the installed client id", 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 302: %s", response.StatusCode, bodyOf(t, response)) + } + provider, err := url.Parse(location(t, response)) + if err != nil { + t.Fatalf("parse provider URL: %v", err) + } + if provider.Host != "accounts.google.com" { + t.Errorf("provider host = %q, want accounts.google.com", provider.Host) + } + query := provider.Query() + if got := query.Get("client_id"); got != credentials.ClientID { + t.Errorf("client_id = %q, want the installed credentials", got) + } + if got := query.Get("redirect_uri"); got != j.h.baseURL+"/auth/callback" { + t.Errorf("redirect_uri = %q", got) + } + if query.Get("response_type") != "code" { + t.Errorf("response_type = %q, want code", query.Get("response_type")) + } + // The signed state must be echoed back to the callback. + if query.Get("state") == "" { + t.Error("no state parameter in the provider redirect") + } + }) + + phase(t, "the callback refuses a forged state", func(t *testing.T) { + browser := j.browser() + browser.get(j.h.apex(), "/auth/start", nil) + response := browser.get(j.h.apex(), "/auth/callback?code=fake&state=forged", nil) + statusOnly(t, response, http.StatusBadRequest) + }) +} + +// authenticationPhases signs in as the bootstrapped local administrator and checks +// that the session is a real one. +func authenticationPhases(t *testing.T, j *journey) { + t.Helper() + j.tenantSlug = "nmbs" + + phase(t, "the sign-in page is served on the tenant host", func(t *testing.T) { + host := j.adminHost() + page := bodyOf(t, j.browser().get(host, "/login", nil)) + if !strings.Contains(page, "NMBS") { + t.Errorf("the sign-in page does not mention the tenant: %.200s", page) + } + }) + + phase(t, "a wrong password is rejected", 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", url.Values{ + "csrf_token": {token}, "email": {localAdminMail}, "password": {"wrong-password-entirely"}, + }, nil) + statusOnly(t, response, http.StatusUnauthorized) + }) + + phase(t, "password sign-in without a CSRF token is rejected", func(t *testing.T) { + browser := j.browser() + host := j.adminHost() + bodyOf(t, browser.get(host, "/login", nil)) + response := browser.form(host, "/auth/password", url.Values{ + "email": {localAdminMail}, "password": {localAdminPass}, + }, nil) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "repeated failures lock the account out", func(t *testing.T) { + // A distinct address keeps the lockout away from the other sign-ins. + browser := j.browser() + host := j.adminHost() + token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil))) + locked := false + for attempt := 0; attempt < 6; attempt++ { + response := browser.form(host, "/auth/password", url.Values{ + "csrf_token": {token}, "email": {"lockout@auth.test"}, "password": {"nope-nope-nope"}, + }, nil) + response.Body.Close() + if response.StatusCode == http.StatusTooManyRequests { + locked = true + break + } + } + if !locked { + t.Error("six wrong passwords never produced a 429") + } + }) + + phase(t, "the administrator signs in and reaches the console", 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", url.Values{ + "csrf_token": {token}, "email": {localAdminMail}, "password": {localAdminPass}, + }, nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303: %s", response.StatusCode, bodyOf(t, response)) + } + if got := location(t, response); got != "/" { + t.Fatalf("redirect = %q, want /", got) + } + j.admin = browser + + page := bodyOf(t, j.admin.get(j.adminHost(), "/", nil)) + if !strings.Contains(page, "", + "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) + } + }) +} diff --git a/internal/behaviour/persistence_test.go b/internal/behaviour/persistence_test.go new file mode 100644 index 0000000..7f740c0 --- /dev/null +++ b/internal/behaviour/persistence_test.go @@ -0,0 +1,248 @@ +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) + }) +} diff --git a/internal/behaviour/profile_test.go b/internal/behaviour/profile_test.go new file mode 100644 index 0000000..0664c92 --- /dev/null +++ b/internal/behaviour/profile_test.go @@ -0,0 +1,287 @@ +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, "