192 lines
8.1 KiB
Go
192 lines
8.1 KiB
Go
|
|
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)
|
||
|
|
})
|
||
|
|
}
|