249 lines
9.6 KiB
Go
249 lines
9.6 KiB
Go
|
|
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)
|
||
|
|
})
|
||
|
|
}
|