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.
This commit is contained in:
parent
c7c81fb65c
commit
8d8970d5a7
18 changed files with 3050 additions and 4 deletions
191
internal/behaviour/application_test.go
Normal file
191
internal/behaviour/application_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
368
internal/behaviour/clientlogin_test.go
Normal file
368
internal/behaviour/clientlogin_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
127
internal/behaviour/contract_test.go
Normal file
127
internal/behaviour/contract_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
460
internal/behaviour/harness_test.go
Normal file
460
internal/behaviour/harness_test.go
Normal file
|
|
@ -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) }
|
||||
466
internal/behaviour/journey_test.go
Normal file
466
internal/behaviour/journey_test.go
Normal file
|
|
@ -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, "<html") {
|
||||
t.Error("the console page is not HTML")
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "the management session does not leak to the apex host", func(t *testing.T) {
|
||||
// The session cookie is issued without a Domain, so it belongs to the host
|
||||
// that signed in. That is deliberate: a tenant session must not be usable on
|
||||
// the installation-wide host.
|
||||
response := j.admin.get(j.h.apex(), "/api/v1/dashboard", nil)
|
||||
statusOnly(t, response, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
phase(t, "the session is refused without its cookie", func(t *testing.T) {
|
||||
stranger := j.browser()
|
||||
response := stranger.get(j.adminHost(), "/api/v1/dashboard", nil)
|
||||
statusOnly(t, response, http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
// consolePhases checks that every console screen and its data load.
|
||||
func consolePhases(t *testing.T, j *journey) {
|
||||
t.Helper()
|
||||
|
||||
phase(t, "the dashboard reports the installed state", func(t *testing.T) {
|
||||
type dashboard struct {
|
||||
Tenants []map[string]any `json:"tenants"`
|
||||
Applications []map[string]any `json:"applications"`
|
||||
Keys []map[string]any `json:"keys"`
|
||||
Contracts []map[string]any `json:"contracts"`
|
||||
Users []map[string]any `json:"users"`
|
||||
PendingUsers []map[string]any `json:"pending_users"`
|
||||
Permissions []map[string]any `json:"permissions"`
|
||||
Profile map[string]any `json:"profile"`
|
||||
SystemAdministrator bool `json:"is_system_administrator"`
|
||||
}
|
||||
response := j.admin.get(j.adminHost(), "/api/v1/dashboard", nil)
|
||||
data := decode[dashboard](t, response, http.StatusOK)
|
||||
if len(data.Tenants) == 0 {
|
||||
t.Error("the dashboard lists no tenants")
|
||||
}
|
||||
if len(data.Permissions) == 0 {
|
||||
t.Error("the dashboard lists no permissions")
|
||||
}
|
||||
if data.Profile["email"] != localAdminMail {
|
||||
t.Errorf("profile email = %v, want %s", data.Profile["email"], localAdminMail)
|
||||
}
|
||||
if !data.SystemAdministrator {
|
||||
t.Error("the bootstrapped administrator is not reported as a system administrator")
|
||||
}
|
||||
// Find the tenant this journey works in.
|
||||
for _, tenant := range data.Tenants {
|
||||
if tenant["slug"] == j.tenantSlug {
|
||||
j.tenantID, _ = tenant["id"].(string)
|
||||
}
|
||||
}
|
||||
if j.tenantID == "" {
|
||||
t.Fatalf("tenant %s is missing from the dashboard: %v", j.tenantSlug, data.Tenants)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "every console screen is served", func(t *testing.T) {
|
||||
for _, path := range []string{"/", "/profile", "/roles", "/docs", "/openapi.json"} {
|
||||
page := bodyOf(t, j.admin.get(j.adminHost(), path, nil))
|
||||
if !strings.Contains(page, "<html") && path != "/openapi.json" {
|
||||
t.Errorf("%s did not return a page", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "every script and stylesheet is served and nothing else is", func(t *testing.T) {
|
||||
for _, asset := range []string{"style.css", "app.js", "profile.js", "roles.js", "account.js"} {
|
||||
response := j.admin.get(j.adminHost(), "/assets/"+asset, nil)
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET /assets/%s = %d", asset, response.StatusCode)
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
for _, asset := range []string{"secret.js", "style.css.map"} {
|
||||
response := j.admin.get(j.adminHost(), "/assets/"+asset, nil)
|
||||
if response.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("GET /assets/%s = %d, want 404", asset, response.StatusCode)
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "the published specification is valid JSON", func(t *testing.T) {
|
||||
var spec map[string]any
|
||||
if err := json.Unmarshal([]byte(bodyOf(t, j.admin.get(j.adminHost(), "/openapi.json", nil))), &spec); err != nil {
|
||||
t.Fatalf("parse openapi.json: %v", err)
|
||||
}
|
||||
if _, ok := spec["paths"]; !ok {
|
||||
t.Error("the specification has no paths")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pngBase64 is a tiny valid PNG used for the profile picture flows, and
|
||||
// decodeBase64PNG returns the same bytes for checks that need the raw image.
|
||||
const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=="
|
||||
|
||||
func decodeBase64PNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw, err := base64.StdEncoding.DecodeString(pngBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("decode fixture: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
var _ = sql.ErrNoRows
|
||||
|
||||
// The remaining phases are defined in their own files so each one stays
|
||||
// readable: tenant, application, contract, client login, role, membership,
|
||||
// profile, security, and persistence.
|
||||
171
internal/behaviour/membership_test.go
Normal file
171
internal/behaviour/membership_test.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package behaviour
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// membershipPhases creates the person the hosted-login flow signs in as, and gives
|
||||
// them exactly the two things a tenant application needs: membership of the tenant
|
||||
// and access to its application.
|
||||
func membershipPhases(t *testing.T, j *journey) {
|
||||
t.Helper()
|
||||
host := j.adminHost()
|
||||
j.memberEmail = "member@behaviour.test"
|
||||
j.memberPassword = "member-password-2026"
|
||||
|
||||
phase(t, "a role is chosen from the tenant's catalogue", func(t *testing.T) {
|
||||
type role struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
System bool `json:"system"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Assignable bool `json:"assignable"`
|
||||
}
|
||||
type listing struct {
|
||||
Roles []role `json:"roles"`
|
||||
}
|
||||
response := j.admin.get(host, "/api/v1/roles?tenant_id="+j.scopedTenantID, nil)
|
||||
roles := decode[listing](t, response, http.StatusOK)
|
||||
if len(roles.Roles) < 6 {
|
||||
t.Fatalf("roles = %d, want at least the six built-ins", len(roles.Roles))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range roles.Roles {
|
||||
seen[item.Slug] = true
|
||||
if item.Slug == "user" {
|
||||
j.userRoleID = item.ID
|
||||
}
|
||||
if item.Slug == "viewer" {
|
||||
j.viewerRoleID = item.ID
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"owner", "admin", "developer", "operator", "viewer", "user"} {
|
||||
if !seen[want] {
|
||||
t.Errorf("built-in role %q is missing", want)
|
||||
}
|
||||
}
|
||||
if j.userRoleID == "" || j.viewerRoleID == "" {
|
||||
t.Fatal("could not resolve the built-in roles")
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "an email address that is not a plain address is refused", func(t *testing.T) {
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "email": "Real Name <member@behaviour.test>",
|
||||
"role_id": j.viewerRoleID, "password": j.memberPassword,
|
||||
})
|
||||
statusOnly(t, response, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
phase(t, "a short password is refused", func(t *testing.T) {
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "email": "weak@behaviour.test",
|
||||
"role_id": j.viewerRoleID, "password": "short",
|
||||
})
|
||||
statusOnly(t, response, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
phase(t, "a member is invited and given access to the application", func(t *testing.T) {
|
||||
type invited struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
RoleID string `json:"role_id"`
|
||||
Status string `json:"status"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "email": j.memberEmail,
|
||||
"display_name": "Behaviour Member", "role_id": j.viewerRoleID,
|
||||
"password": j.memberPassword,
|
||||
})
|
||||
member := decode[invited](t, response, http.StatusCreated)
|
||||
if member.Email != j.memberEmail {
|
||||
t.Errorf("email = %q", member.Email)
|
||||
}
|
||||
if member.Status != "invited" {
|
||||
// An invitation is not a login: the account stays "invited" until the
|
||||
// person signs in for the first time.
|
||||
t.Errorf("status = %q, want invited", member.Status)
|
||||
}
|
||||
if member.Role != "viewer" {
|
||||
t.Errorf("role = %q, want viewer", member.Role)
|
||||
}
|
||||
j.memberID = member.ID
|
||||
|
||||
// Membership alone is not enough: the client login flow also needs an
|
||||
// application grant.
|
||||
access := j.admin.json(http.MethodPut, host, "/api/v1/users/"+member.ID+"/access", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "role_id": j.viewerRoleID,
|
||||
"application_ids": []string{j.applicationID},
|
||||
})
|
||||
if access.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("grant access = %d: %s", access.StatusCode, bodyOf(t, access))
|
||||
}
|
||||
|
||||
var granted int
|
||||
if err := j.h.db.QueryRowContext(t.Context(), `
|
||||
SELECT count(*) FROM user_application_access
|
||||
WHERE user_id = $1 AND application_id = $2`, member.ID, j.applicationID).Scan(&granted); err != nil {
|
||||
t.Fatalf("count grants: %v", err)
|
||||
}
|
||||
if granted != 1 {
|
||||
t.Errorf("application grants = %d, want 1", granted)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a second member is invited without any application grant", func(t *testing.T) {
|
||||
// Membership and application access are separate decisions, and the client
|
||||
// login flow depends on the difference.
|
||||
j.ungrantedEmail = "ungranted@behaviour.test"
|
||||
j.ungrantedPassword = "ungranted-password-2026"
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "email": j.ungrantedEmail,
|
||||
"display_name": "Ungranted Member", "role_id": j.userRoleID,
|
||||
"password": j.ungrantedPassword,
|
||||
})
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("invite = %d: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
var grants int
|
||||
if err := j.h.db.QueryRowContext(t.Context(), `
|
||||
SELECT count(*) FROM user_application_access WHERE user_id = (
|
||||
SELECT id FROM users WHERE email = $1)`, j.ungrantedEmail).Scan(&grants); err != nil {
|
||||
t.Fatalf("count grants: %v", err)
|
||||
}
|
||||
if grants != 0 {
|
||||
t.Errorf("grants = %d, want none", grants)
|
||||
}
|
||||
if err := j.h.db.QueryRowContext(t.Context(),
|
||||
`SELECT id FROM users WHERE email = $1`, j.ungrantedEmail).Scan(&j.ungrantedMemberID); err != nil {
|
||||
t.Fatalf("look up the ungranted member: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "the member appears in the tenant's user list", func(t *testing.T) {
|
||||
type listing struct {
|
||||
Users []struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
ApplicationIDs []string `json:"application_ids"`
|
||||
} `json:"users"`
|
||||
}
|
||||
result := decode[listing](t, j.admin.get(host, "/api/v1/dashboard", nil), http.StatusOK)
|
||||
found := false
|
||||
for _, user := range result.Users {
|
||||
if user.Email == j.memberEmail {
|
||||
found = true
|
||||
if len(user.ApplicationIDs) != 1 {
|
||||
t.Errorf("application grants reported = %v", user.ApplicationIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s is missing from the dashboard", j.memberEmail)
|
||||
}
|
||||
})
|
||||
}
|
||||
248
internal/behaviour/persistence_test.go
Normal file
248
internal/behaviour/persistence_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
287
internal/behaviour/profile_test.go
Normal file
287
internal/behaviour/profile_test.go
Normal file
|
|
@ -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, "<html") {
|
||||
t.Error("the profile page did not render")
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a member without console access cannot read the dashboard", func(t *testing.T) {
|
||||
browser := j.browser()
|
||||
tenantHost := j.h.tenantHost(j.scopedTenant)
|
||||
token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, "/login", nil)))
|
||||
response := browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{
|
||||
"csrf_token": token, "email": j.ungrantedEmail, "password": j.ungrantedPassword,
|
||||
}), nil)
|
||||
if response.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
statusOnly(t, browser.get(tenantHost, "/api/v1/dashboard", nil), http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
279
internal/behaviour/role_test.go
Normal file
279
internal/behaviour/role_test.go
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
package behaviour
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rolePhases exercises the database-driven role and permission model: the shared
|
||||
// catalog, tenant-defined roles, and the rule that a role may never grant more
|
||||
// than the administrator already holds.
|
||||
func rolePhases(t *testing.T, j *journey) {
|
||||
t.Helper()
|
||||
host := j.adminHost()
|
||||
|
||||
phase(t, "the permission catalog lists the enforced capabilities", func(t *testing.T) {
|
||||
type entry struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
System bool `json:"system"`
|
||||
Enforced bool `json:"enforced"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
type listing struct {
|
||||
Permissions []entry `json:"permissions"`
|
||||
}
|
||||
result := decode[listing](t, j.admin.get(host, "/api/v1/permissions", nil), http.StatusOK)
|
||||
names := map[string]entry{}
|
||||
for _, item := range result.Permissions {
|
||||
names[item.Name] = item
|
||||
}
|
||||
for _, want := range []string{
|
||||
"console.read", "users.read", "users.manage", "applications.manage",
|
||||
"keys.manage", "contracts.manage", "roles.manage", "tenants.manage", "profile.edit",
|
||||
} {
|
||||
entry, ok := names[want]
|
||||
if !ok {
|
||||
t.Errorf("permission %q is missing from the catalog", want)
|
||||
continue
|
||||
}
|
||||
if !entry.System || !entry.Enforced {
|
||||
t.Errorf("permission %q is not marked system and enforced: %+v", want, entry)
|
||||
}
|
||||
if entry.Label == "" || entry.Description == "" {
|
||||
t.Errorf("permission %q has no label or description the console can render", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a malformed permission name is refused", func(t *testing.T) {
|
||||
for _, name := range []string{"Reports Export", "reports/export", "reports..export", ""} {
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
|
||||
"name": name, "label": "Reports export",
|
||||
})
|
||||
statusOnly(t, response, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a permission without a label is refused", func(t *testing.T) {
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
|
||||
"name": "reports.export", "label": " ",
|
||||
})
|
||||
statusOnly(t, response, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
phase(t, "a tenant administrator may extend the catalog", func(t *testing.T) {
|
||||
// Adding a name does not enforce anything; it only makes the name grantable.
|
||||
j.permission = "reports.export"
|
||||
type created struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
System bool `json:"system"`
|
||||
Enforced bool `json:"enforced"`
|
||||
}
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
|
||||
"name": j.permission, "label": "Export reports", "description": "Download tenant reports",
|
||||
})
|
||||
permission := decode[created](t, response, http.StatusCreated)
|
||||
if permission.Name != j.permission {
|
||||
t.Errorf("name = %q", permission.Name)
|
||||
}
|
||||
if permission.System || permission.Enforced {
|
||||
t.Errorf("a tenant permission must not be system or enforced: %+v", permission)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a duplicate permission name is refused", func(t *testing.T) {
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
|
||||
"name": j.permission, "label": "Export reports again",
|
||||
})
|
||||
statusOnly(t, response, http.StatusConflict)
|
||||
})
|
||||
|
||||
phase(t, "a built-in permission cannot be deleted", func(t *testing.T) {
|
||||
response := j.admin.request(http.MethodDelete, host, "/api/v1/permissions/console.read", nil,
|
||||
map[string]string{"Origin": "http://" + host})
|
||||
statusOnly(t, response, http.StatusConflict)
|
||||
})
|
||||
|
||||
phase(t, "a tenant defines its own role", func(t *testing.T) {
|
||||
type created struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
System bool `json:"system"`
|
||||
Permissions []string `json:"permissions"`
|
||||
MemberCount int `json:"member_count"`
|
||||
}
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "slug": "reporter", "name": "Reporter",
|
||||
"description": "Reads the console and exports reports",
|
||||
"permissions": []string{"console.read", "users.read", j.permission},
|
||||
})
|
||||
role := decode[created](t, response, http.StatusCreated)
|
||||
if role.Slug != "reporter" {
|
||||
t.Errorf("slug = %q", role.Slug)
|
||||
}
|
||||
if role.System {
|
||||
t.Error("a tenant role must not be marked as a system role")
|
||||
}
|
||||
if len(role.Permissions) != 3 {
|
||||
t.Errorf("permissions = %v, want three", role.Permissions)
|
||||
}
|
||||
j.customRoleID = role.ID
|
||||
})
|
||||
|
||||
phase(t, "a permission a role grants cannot be deleted", func(t *testing.T) {
|
||||
response := j.admin.request(http.MethodDelete, host, "/api/v1/permissions/"+j.permission, nil,
|
||||
map[string]string{"Origin": "http://" + host})
|
||||
statusOnly(t, response, http.StatusConflict)
|
||||
})
|
||||
|
||||
phase(t, "a duplicate role slug is refused", func(t *testing.T) {
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "slug": "reporter", "name": "Reporter again",
|
||||
"permissions": []string{"console.read"},
|
||||
})
|
||||
statusOnly(t, response, http.StatusConflict)
|
||||
})
|
||||
|
||||
phase(t, "a malformed role slug or name is refused", func(t *testing.T) {
|
||||
for _, payload := range []map[string]any{
|
||||
{"tenant_id": j.scopedTenantID, "slug": "Reporter", "name": "Upper"},
|
||||
{"tenant_id": j.scopedTenantID, "slug": "reporter_2", "name": "Underscore"},
|
||||
{"tenant_id": j.scopedTenantID, "slug": "reporter2", "name": ""},
|
||||
} {
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", payload)
|
||||
statusOnly(t, response, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a role naming an unknown permission is refused", func(t *testing.T) {
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "slug": "invented", "name": "Invented",
|
||||
"permissions": []string{"not.a.real.permission"},
|
||||
})
|
||||
statusOnly(t, response, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
phase(t, "a role cannot grant a capability the caller does not hold", func(t *testing.T) {
|
||||
// tenants.manage belongs to owners alone. A tenant administrator asking for a
|
||||
// role that grants it must not be able to mint their way above their own
|
||||
// authority, and must not even learn that the capability exists.
|
||||
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": "interview@nmbs.auth.dev", "password": "admin123",
|
||||
}), nil)
|
||||
if response.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
response = browser.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
||||
"tenant_id": j.tenantID, "slug": "escalation", "name": "Escalation",
|
||||
"permissions": []string{"tenants.manage"},
|
||||
})
|
||||
if response.StatusCode != http.StatusNotFound && response.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("escalation attempt = %d, want a refusal: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a role's permissions and name can be changed", func(t *testing.T) {
|
||||
type updated struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
response := j.admin.json(http.MethodPut, host, "/api/v1/roles/"+j.customRoleID, map[string]any{
|
||||
"name": "Report reader", "description": "Now with fewer capabilities",
|
||||
"permissions": []string{"console.read"},
|
||||
})
|
||||
role := decode[updated](t, response, http.StatusOK)
|
||||
if role.Name != "Report reader" {
|
||||
t.Errorf("name = %q", role.Name)
|
||||
}
|
||||
if len(role.Permissions) != 1 {
|
||||
t.Errorf("permissions = %v, want just console.read", role.Permissions)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "the permission is deletable once no role grants it", func(t *testing.T) {
|
||||
// The update above left the custom permission unreferenced.
|
||||
response := j.admin.request(http.MethodDelete, host, "/api/v1/permissions/"+j.permission, nil,
|
||||
map[string]string{"Origin": "http://" + host})
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete permission = %d: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
response.Body.Close()
|
||||
})
|
||||
|
||||
phase(t, "a role still held by a member cannot be deleted", func(t *testing.T) {
|
||||
// Give the custom role to the ungranted member, then try to remove it.
|
||||
access := j.admin.json(http.MethodPut, host, "/api/v1/users/"+j.ungrantedMemberID+"/access", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "role_id": j.customRoleID, "application_ids": []string{},
|
||||
})
|
||||
if access.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("assign custom role = %d: %s", access.StatusCode, bodyOf(t, access))
|
||||
}
|
||||
response := j.admin.request(http.MethodDelete, host, "/api/v1/roles/"+j.customRoleID, nil,
|
||||
map[string]string{"Origin": "http://" + host})
|
||||
statusOnly(t, response, http.StatusConflict)
|
||||
})
|
||||
|
||||
phase(t, "an unused role is deleted, and its permission with it", func(t *testing.T) {
|
||||
type created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
||||
"tenant_id": j.scopedTenantID, "slug": "temporary", "name": "Temporary",
|
||||
"permissions": []string{"console.read"},
|
||||
})
|
||||
temporary := decode[created](t, response, http.StatusCreated)
|
||||
|
||||
response = j.admin.request(http.MethodDelete, host, "/api/v1/roles/"+temporary.ID, nil,
|
||||
map[string]string{"Origin": "http://" + host})
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete role = %d: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
response.Body.Close()
|
||||
|
||||
// The custom permission is now unreferenced, so it can go too.
|
||||
response.Body.Close()
|
||||
})
|
||||
|
||||
phase(t, "a built-in role cannot be edited or deleted", func(t *testing.T) {
|
||||
type listing struct {
|
||||
Roles []struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
System bool `json:"system"`
|
||||
} `json:"roles"`
|
||||
}
|
||||
result := decode[listing](t, j.admin.get(host, "/api/v1/roles?tenant_id="+j.scopedTenantID, nil), http.StatusOK)
|
||||
var ownerID string
|
||||
for _, role := range result.Roles {
|
||||
if role.Slug == "owner" {
|
||||
ownerID = role.ID
|
||||
}
|
||||
}
|
||||
if ownerID == "" {
|
||||
t.Fatal("the owner role is missing")
|
||||
}
|
||||
response := j.admin.json(http.MethodPut, host, "/api/v1/roles/"+ownerID, map[string]any{
|
||||
"name": "Hijacked", "permissions": []string{"console.read"},
|
||||
})
|
||||
if response.StatusCode == http.StatusOK {
|
||||
t.Error("a built-in role was renamed")
|
||||
response.Body.Close()
|
||||
} else {
|
||||
response.Body.Close()
|
||||
}
|
||||
response = j.admin.request(http.MethodDelete, host, "/api/v1/roles/"+ownerID, nil,
|
||||
map[string]string{"Origin": "http://" + host})
|
||||
if response.StatusCode == http.StatusNoContent {
|
||||
t.Error("a built-in role was deleted")
|
||||
response.Body.Close()
|
||||
} else {
|
||||
response.Body.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
162
internal/behaviour/security_test.go
Normal file
162
internal/behaviour/security_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package behaviour
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// securityPhases checks the defences that sit in front of every flow above.
|
||||
func securityPhases(t *testing.T, j *journey) {
|
||||
t.Helper()
|
||||
host := j.adminHost()
|
||||
|
||||
phase(t, "a write from another origin is refused", func(t *testing.T) {
|
||||
// The Origin header is what a browser sets and an attacker cannot forge, so
|
||||
// this is the check that stops a cross-site form post.
|
||||
for _, target := range []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/tenants"},
|
||||
{http.MethodPut, "/api/v1/profile"},
|
||||
{http.MethodPost, "/api/v1/permissions"},
|
||||
{http.MethodPost, "/api/v1/applications/" + j.applicationID + "/client-config"},
|
||||
} {
|
||||
response := j.admin.request(target.method, host, target.path,
|
||||
strings.NewReader(`{"slug":"csrf","name":"CSRF"}`), map[string]string{
|
||||
"Content-Type": "application/json", "Origin": "https://evil.test",
|
||||
})
|
||||
if response.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s from a foreign origin = %d, want 403", target.method, target.path, response.StatusCode)
|
||||
}
|
||||
bodyOf(t, response)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a write with no origin or referrer is refused", func(t *testing.T) {
|
||||
response := j.admin.request(http.MethodPost, host, "/api/v1/tenants",
|
||||
strings.NewReader(`{"slug":"noorigin","name":"No Origin"}`), map[string]string{
|
||||
"Content-Type": "application/json", "Origin": "", "Referer": "",
|
||||
})
|
||||
statusOnly(t, response, http.StatusForbidden)
|
||||
})
|
||||
|
||||
phase(t, "the configured base URL is also accepted as its own origin", func(t *testing.T) {
|
||||
// A deployment reached through a different hostname must still be able to
|
||||
// write, or every legitimate form post fails.
|
||||
response := j.admin.request(http.MethodPost, host, "/api/v1/tenants",
|
||||
strings.NewReader(`{"slug":"viabaseurl","name":"Via Base URL"}`), map[string]string{
|
||||
"Content-Type": "application/json", "Origin": j.h.baseURL,
|
||||
})
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "responses carry the hardening headers", func(t *testing.T) {
|
||||
response := j.admin.get(host, "/", nil)
|
||||
bodyOf(t, response)
|
||||
policy := response.Header.Get("Content-Security-Policy")
|
||||
for _, want := range []string{"default-src 'self'", "connect-src 'self'", "img-src 'self'"} {
|
||||
if !strings.Contains(policy, want) {
|
||||
t.Errorf("Content-Security-Policy lacks %q: %s", want, policy)
|
||||
}
|
||||
}
|
||||
if got := response.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q", got)
|
||||
}
|
||||
// Clickjacking is blocked with X-Frame-Options rather than frame-ancestors.
|
||||
if got := response.Header.Get("X-Frame-Options"); got != "DENY" {
|
||||
t.Errorf("X-Frame-Options = %q, want DENY", got)
|
||||
}
|
||||
if got := response.Header.Get("Referrer-Policy"); got != "same-origin" {
|
||||
t.Errorf("Referrer-Policy = %q", got)
|
||||
}
|
||||
if got := response.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
|
||||
t.Errorf("Permissions-Policy = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "the health check needs no session", func(t *testing.T) {
|
||||
response := j.browser().get(j.h.apex(), "/healthz", nil)
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.StatusCode)
|
||||
}
|
||||
bodyOf(t, response)
|
||||
})
|
||||
|
||||
phase(t, "a console API call without a session answers 401 rather than a redirect", func(t *testing.T) {
|
||||
// An XHR that receives a 303 to the login page cannot tell it apart from a
|
||||
// real result, so the API answers with a status the caller can act on.
|
||||
for _, path := range []string{"/api/v1/dashboard", "/api/v1/profile", "/api/v1/permissions"} {
|
||||
response := j.browser().get(host, path, nil)
|
||||
statusOnly(t, response, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a console page without a session redirects to sign in", func(t *testing.T) {
|
||||
for _, path := range []string{"/", "/profile", "/roles"} {
|
||||
response := j.browser().get(host, path, nil)
|
||||
if response.StatusCode != http.StatusSeeOther || location(t, response) != "/login" {
|
||||
t.Errorf("GET %s = %d %q, want 303 to /login", path, response.StatusCode, response.Header.Get("Location"))
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "signing out ends the management session", func(t *testing.T) {
|
||||
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", response.StatusCode)
|
||||
}
|
||||
response.Body.Close()
|
||||
statusOnly(t, browser.get(host, "/api/v1/dashboard", nil), http.StatusOK)
|
||||
|
||||
response = browser.form(host, "/auth/logout", neturlValues(map[string]string{}), nil)
|
||||
if response.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("sign-out = %d: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
statusOnly(t, browser.get(host, "/api/v1/dashboard", nil), http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
phase(t, "signing out ends the session when the provider has no logout endpoint", func(t *testing.T) {
|
||||
// Google does not advertise end_session_endpoint, so the documented fallback
|
||||
// applies: the local sign-out still happens and the browser returns to the
|
||||
// sign-in page rather than to the provider.
|
||||
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 = %d: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
response = browser.form(host, "/auth/logout", neturlValues(map[string]string{}), nil)
|
||||
if response.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("sign-out = %d: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
target := location(t, response)
|
||||
if target != "/login" && !strings.Contains(target, "accounts.google.com") {
|
||||
t.Errorf("sign-out redirect = %q, want the sign-in page or the provider", target)
|
||||
}
|
||||
if target == "/login" {
|
||||
// The session must really be gone.
|
||||
statusOnly(t, browser.get(host, "/api/v1/dashboard", nil), http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "an oversized profile upload is refused", func(t *testing.T) {
|
||||
big := strings.Repeat("A", 5<<20)
|
||||
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]string{"avatar": big})
|
||||
if response.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", response.StatusCode)
|
||||
}
|
||||
bodyOf(t, response)
|
||||
})
|
||||
}
|
||||
77
internal/behaviour/tenant_test.go
Normal file
77
internal/behaviour/tenant_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package behaviour
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// neturlValues builds a form body from plain strings, so phases do not have to
|
||||
// repeat the url.Values literal for every field.
|
||||
func neturlValues(fields map[string]string) url.Values {
|
||||
values := url.Values{}
|
||||
for name, value := range fields {
|
||||
values.Set(name, value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// tenantPhases exercises the installation-wide tenant management, which is the
|
||||
// one console action restricted to system administrators.
|
||||
func tenantPhases(t *testing.T, j *journey) {
|
||||
t.Helper()
|
||||
|
||||
phase(t, "a system administrator creates a tenant", func(t *testing.T) {
|
||||
type tenant struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
response := j.admin.json(http.MethodPost, j.adminHost(), "/api/v1/tenants", map[string]string{
|
||||
"slug": "behaviour", "name": "Behaviour Tenant",
|
||||
})
|
||||
created := decode[tenant](t, response, http.StatusCreated)
|
||||
if created.Slug != "behaviour" {
|
||||
t.Errorf("slug = %q", created.Slug)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Error("no tenant id returned")
|
||||
}
|
||||
// Later phases build their own application in this tenant, so it isolates
|
||||
// authorization from the seeded demonstration tenant.
|
||||
j.scopedTenant, j.scopedTenantID = created.Slug, created.ID
|
||||
})
|
||||
|
||||
phase(t, "a duplicate tenant slug is refused", func(t *testing.T) {
|
||||
response := j.admin.json(http.MethodPost, j.adminHost(), "/api/v1/tenants", map[string]string{
|
||||
"slug": "behaviour", "name": "Duplicate",
|
||||
})
|
||||
statusOnly(t, response, http.StatusConflict)
|
||||
})
|
||||
|
||||
phase(t, "a malformed tenant slug is refused", func(t *testing.T) {
|
||||
for _, slug := range []string{"Has Uppercase", "has/slash", "-leading", ""} {
|
||||
response := j.admin.json(http.MethodPost, j.adminHost(), "/api/v1/tenants", map[string]string{
|
||||
"slug": slug, "name": "Invalid",
|
||||
})
|
||||
statusOnly(t, response, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
phase(t, "a tenant administrator cannot create a tenant", func(t *testing.T) {
|
||||
// The seeded NMBS interviewer is a tenant administrator, not a system one.
|
||||
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": "interview@nmbs.auth.dev", "password": "admin123",
|
||||
}), nil)
|
||||
if response.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("seeded administrator sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
|
||||
}
|
||||
response = browser.json(http.MethodPost, host, "/api/v1/tenants", map[string]string{
|
||||
"slug": "unauthorised", "name": "Unauthorised",
|
||||
})
|
||||
statusOnly(t, response, http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
|
|
@ -32,7 +32,45 @@ func Open(ctx context.Context, databaseURL string) (*sql.DB, error) {
|
|||
return db, nil
|
||||
}
|
||||
|
||||
// migrationLockKey serialises migration runs between instances of this service.
|
||||
// It is deliberately distinct from the advisory lock used to claim the first
|
||||
// system administrator.
|
||||
const migrationLockKey int64 = 554628171918
|
||||
|
||||
// querier is the subset of database/sql that migrations need, satisfied by both
|
||||
// *sql.DB and *sql.Conn.
|
||||
type querier interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
|
||||
}
|
||||
|
||||
// Migrate brings the schema up to date.
|
||||
//
|
||||
// The whole run holds a session-level advisory lock, because PostgreSQL's
|
||||
// CREATE TABLE IF NOT EXISTS is not race free: two instances starting against an
|
||||
// empty database at the same time can collide on the pg_type unique index and one
|
||||
// of them fails. The same race would let two instances apply the same migration
|
||||
// concurrently. Rolling a deployment with more than one replica is enough to
|
||||
// trigger it.
|
||||
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reserve migration connection: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, migrationLockKey); err != nil {
|
||||
return fmt.Errorf("lock migrations: %w", err)
|
||||
}
|
||||
// The unlock runs on a context detached from the caller's, so an application
|
||||
// shutting down mid-migration still releases the lock for the next instance.
|
||||
defer func() {
|
||||
_, _ = conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, migrationLockKey)
|
||||
}()
|
||||
return applyMigrations(ctx, conn)
|
||||
}
|
||||
|
||||
func applyMigrations(ctx context.Context, db querier) error {
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
|
|
|
|||
|
|
@ -542,3 +542,128 @@ func contains(values []string, want string) bool {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestBootstrapGrantsOwnershipOnlyWhileTheTenantHasNone pins the rule that a
|
||||
// bootstrapped account becomes a tenant's owner only when nobody owns it yet. The
|
||||
// installer's demonstration seed creates tenants that already hold members, and
|
||||
// refusing ownership there used to leave a local administrator with an account
|
||||
// that could never reach the console.
|
||||
func TestBootstrapGrantsOwnershipOnlyWhileTheTenantHasNone(t *testing.T) {
|
||||
s, db, ctx := integrationStore(t)
|
||||
owner := newUser(t, s, ctx, "owner@example.test")
|
||||
tenant := newTenant(t, s, ctx, owner, "bootstrap")
|
||||
// Leave the tenant holding administrators but no owner, which is the state the
|
||||
// installer's demonstration seed produces.
|
||||
adminRole, err := s.SystemRoleID(ctx, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("look up the admin role: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`UPDATE memberships SET role_id = $1 WHERE tenant_id = $2`, adminRole, tenant.ID); err != nil {
|
||||
t.Fatalf("demote the owner: %v", err)
|
||||
}
|
||||
addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "admin")
|
||||
late := newUser(t, s, ctx, "late@example.test")
|
||||
|
||||
if err := s.Bootstrap(ctx, late, tenant.Slug, "guess"); err != nil {
|
||||
t.Fatalf("bootstrap into a tenant with no owner: %v", err)
|
||||
}
|
||||
assertRole(t, db, ctx, late, tenant.ID, "owner")
|
||||
|
||||
// A further account must not displace the owner that now exists.
|
||||
third := newUser(t, s, ctx, "third@example.test")
|
||||
if err := s.Bootstrap(ctx, third, tenant.Slug, "guess"); err != nil {
|
||||
t.Fatalf("bootstrap again: %v", err)
|
||||
}
|
||||
var memberships int
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT count(*) FROM memberships m
|
||||
JOIN roles r ON r.id = m.role_id
|
||||
WHERE m.tenant_id = $1 AND r.slug = 'owner'`, tenant.ID).Scan(&memberships); err != nil {
|
||||
t.Fatalf("count owners: %v", err)
|
||||
}
|
||||
if memberships != 1 {
|
||||
t.Errorf("owners = %d, want exactly one", memberships)
|
||||
}
|
||||
var thirdMemberships int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM memberships WHERE user_id = $1`, third).Scan(&thirdMemberships); err != nil {
|
||||
t.Fatalf("count memberships: %v", err)
|
||||
}
|
||||
if thirdMemberships != 0 {
|
||||
t.Errorf("a later bootstrap joined a tenant that already had an owner")
|
||||
}
|
||||
}
|
||||
|
||||
func assertRole(t *testing.T, db *sql.DB, ctx context.Context, userID, tenantID, want string) {
|
||||
t.Helper()
|
||||
var got string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT r.slug FROM memberships m JOIN roles r ON r.id = m.role_id
|
||||
WHERE m.user_id = $1 AND m.tenant_id = $2`, userID, tenantID).Scan(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("read membership: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("role = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTenantReportsADuplicateSlug pins that a slug already in use is a
|
||||
// conflict the caller can act on rather than an opaque server error.
|
||||
func TestCreateTenantReportsADuplicateSlug(t *testing.T) {
|
||||
s, _, ctx := integrationStore(t)
|
||||
owner := newUser(t, s, ctx, "owner@example.test")
|
||||
newTenant(t, s, ctx, owner, "nmbs")
|
||||
if _, err := s.CreateTenant(ctx, owner, "nmbs", "Duplicate"); !errors.Is(err, store.ErrTenantSlugTaken) {
|
||||
t.Fatalf("CreateTenant with a used slug = %v, want ErrTenantSlugTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentMigrationRunsDoNotCollide pins that instances starting together
|
||||
// against an empty database all succeed. CREATE TABLE IF NOT EXISTS is not race
|
||||
// free in PostgreSQL, so the run is serialised with an advisory lock.
|
||||
func TestConcurrentMigrationRunsDoNotCollide(t *testing.T) {
|
||||
databaseURL := os.Getenv("GO_LOOSE_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("set GO_LOOSE_TEST_DATABASE_URL to run PostgreSQL integration tests")
|
||||
}
|
||||
requireScratchDatabase(t, databaseURL)
|
||||
resetSchema(context.Background(), mustOpen(t, databaseURL))
|
||||
|
||||
const instances = 4
|
||||
connections := make([]*sql.DB, instances)
|
||||
for index := range connections {
|
||||
connections[index] = mustOpen(t, databaseURL)
|
||||
}
|
||||
errs := make(chan error, instances)
|
||||
for _, connection := range connections {
|
||||
go func(conn *sql.DB) { errs <- database.Migrate(context.Background(), conn) }(connection)
|
||||
}
|
||||
for range connections {
|
||||
if err := <-errs; err != nil {
|
||||
t.Errorf("concurrent Migrate: %v", err)
|
||||
}
|
||||
}
|
||||
for _, connection := range connections {
|
||||
var applied int
|
||||
if err := connection.QueryRowContext(context.Background(),
|
||||
`SELECT count(*) FROM schema_migrations`).Scan(&applied); err != nil {
|
||||
t.Errorf("count migrations: %v", err)
|
||||
continue
|
||||
}
|
||||
if applied == 0 {
|
||||
t.Error("no migration was recorded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustOpen(t *testing.T, databaseURL string) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := database.Open(context.Background(), databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue