Files
pages/internal/adminapi/keys_test.go
T
2026-08-15 07:13:00 +00:00

362 lines
14 KiB
Go

package adminapi
import (
"bytes"
"net/http"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/store"
)
// createKey posts to path and returns the minted credential.
func (e *env) createKey(t *testing.T, path, token string, req api.CreateKeyRequest) api.CreateKeyResponse {
t.Helper()
status, body := e.do(t, http.MethodPost, path, token, req)
var out api.CreateKeyResponse
mustJSON(t, status, http.StatusCreated, body, &out)
return out
}
func TestCreateAdminKeyReturnsWorkingToken(t *testing.T) {
e := newEnv(t)
out := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "ci"})
if out.Key.Scope != api.ScopeAdmin {
t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeAdmin)
}
if out.Key.Project != "" {
t.Errorf("admin key reports project %q", out.Key.Project)
}
if !strings.HasPrefix(out.Token, "pgs_"+out.Key.ID+"_") {
t.Errorf("token does not carry its own key id %q", out.Key.ID)
}
// The new key authenticates and reports itself.
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil)
var who api.WhoAmI
mustJSON(t, status, http.StatusOK, body, &who)
if who.KeyID != out.Key.ID || who.Scope != api.ScopeAdmin || who.Name != "ci" {
t.Errorf("whoami = %+v, want key %q scope admin name ci", who, out.Key.ID)
}
}
// The token exists on the wire exactly once. If it were cacheable, a shared
// proxy in front of the management API would keep a live credential on disk.
func TestCreateKeyResponseIsNoStore(t *testing.T) {
e := newEnv(t)
resp := e.doResp(t, http.MethodPost, api.PathKeys(), e.adminToken, api.CreateKeyRequest{})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q, want it to contain no-store", cc)
}
}
// The one invariant that matters most in this package: no endpoint other than
// creation may ever put a secret on the wire. Asserted against the raw bytes,
// not a decoded struct, so a field added to api.Key later cannot leak one past
// this test.
func TestNoEndpointEverReturnsASecret(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
admin := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"})
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
secrets := []string{
admin.Token, secretOf(t, admin.Token),
proj.Token, secretOf(t, proj.Token),
e.adminToken, secretOf(t, e.adminToken),
}
for _, tc := range []struct{ method, path, token string }{
{http.MethodGet, api.PathKeys(), e.adminToken},
{http.MethodGet, api.PathProjectKeys("demo"), e.adminToken},
{http.MethodGet, api.PathProjectKeys("demo"), proj.Token},
{http.MethodGet, api.PathWhoAmI(), proj.Token},
{http.MethodGet, api.PathProject("demo"), e.adminToken},
{http.MethodGet, api.PathProjects(), e.adminToken},
} {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
status, body := e.do(t, tc.method, tc.path, tc.token, nil)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", status, body)
}
for _, s := range secrets {
if bytes.Contains(body, []byte(s)) {
t.Fatalf("response contains a credential")
}
}
// The public half is fine to return, and the listings would be
// useless without it — check the test is actually looking at keys.
if strings.HasSuffix(tc.path, "/keys") && !bytes.Contains(body, []byte(proj.Key.ID)) &&
!bytes.Contains(body, []byte(admin.Key.ID)) {
t.Errorf("key listing mentions no key id at all: %s", body)
}
})
}
// And the log, which sees every request, never saw one either.
if logged := e.logBuf.String(); logged != "" {
for _, s := range secrets {
if strings.Contains(logged, s) {
t.Fatal("a credential reached the log")
}
}
}
}
// secretOf returns the half of a token that must never be seen again. The split
// is bounded at three because the base64url secret may itself contain "_".
func secretOf(t *testing.T, token string) string {
t.Helper()
parts := strings.SplitN(token, "_", 3)
if len(parts) != 3 {
t.Fatalf("token has %d parts, want 3", len(parts))
}
return parts[2]
}
func TestCreateProjectKey(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
out := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
if out.Key.Scope != api.ScopeProject {
t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeProject)
}
if out.Key.Project != "demo" {
t.Errorf("project = %q, want demo", out.Key.Project)
}
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil)
var who api.WhoAmI
mustJSON(t, status, http.StatusOK, body, &who)
if who.Scope != api.ScopeProject || who.Project != "demo" {
t.Errorf("whoami = %+v, want scope project on demo", who)
}
}
func TestCreateProjectKeyForUnknownProject(t *testing.T) {
e := newEnv(t)
status, body := e.do(t, http.MethodPost, api.PathProjectKeys("ghost"), e.adminToken,
api.CreateKeyRequest{})
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
}
func TestCreateKeyRejectsBadInput(t *testing.T) {
e := newEnv(t)
past := time.Now().Add(-time.Minute)
t.Run("expired on arrival", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
api.CreateKeyRequest{ExpiresAt: &past})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
t.Run("control characters in name", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
api.CreateKeyRequest{Name: "ci\x1b[2Jrunner"})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
t.Run("oversized name", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
api.CreateKeyRequest{Name: strings.Repeat("a", maxKeyNameLen+1)})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
}
func TestExpiredKeyDoesNotAuthenticate(t *testing.T) {
e := newEnv(t)
// The API refuses to mint one already expired, so this goes in through the
// store to exercise the verifier's expiry check rather than the validator's.
future := time.Now().Add(time.Hour)
live := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{ExpiresAt: &future})
if status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), live.Token, nil); status != http.StatusOK {
t.Fatalf("key expiring in an hour: status = %d, body: %s", status, body)
}
past := time.Now().Add(-time.Hour)
token := e.mintWith(t, store.ScopeAdmin, nil, "stale", &past)
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), token, nil); status != http.StatusUnauthorized {
t.Errorf("expired key authenticated: status = %d", status)
}
}
// Revocation must be visible on the next request, not when a cache entry ages
// out — an operator revoking a leaked token is racing an attacker who has it.
func TestRevokeTakesEffectImmediately(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
victim := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
// Warm the auth cache: without Invalidate() the revocation would not be
// observed until the entry expired.
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusOK {
t.Fatalf("key did not work before revocation")
}
status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil)
if status != http.StatusNoContent {
t.Fatalf("revoke: status = %d, want 204; body: %s", status, body)
}
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusUnauthorized {
t.Errorf("revoked key still authenticates: status = %d", status)
}
// Revoking again is a no-op rather than an error: a retrying CI step must
// not fail on the second attempt.
if status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil); status != http.StatusNoContent {
t.Errorf("second revoke: status = %d, want 204; body: %s", status, body)
}
// The listing keeps it, with a revocation timestamp, so an operator can see
// what happened.
status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil)
var list api.KeyList
mustJSON(t, status, http.StatusOK, body, &list)
var found bool
for _, k := range list.Keys {
if k.ID == victim.Key.ID {
found = true
if !k.Revoked() {
t.Errorf("key %s is listed without revoked_at", k.ID)
}
}
}
if !found {
t.Errorf("revoked key vanished from the listing")
}
}
// A CI runner that believes its token leaked should be able to burn it without
// waiting for an operator.
func TestProjectKeyMayRevokeItself(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
k := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
status, body := e.do(t, http.MethodDelete, api.PathKey(k.Key.ID), k.Token, nil)
if status != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", status, body)
}
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), k.Token, nil); status != http.StatusUnauthorized {
t.Errorf("key survived revoking itself: status = %d", status)
}
}
func TestRevokeAuthorisation(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
e.createProject(t, "other")
mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
sibling := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci-2"})
foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
adminKey := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"})
t.Run("sibling in the same project", func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(sibling.Key.ID), mine.Token, nil)
if status != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", status, body)
}
})
t.Run("another project's key", func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(foreign.Key.ID), mine.Token, nil)
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
})
t.Run("an admin key", func(t *testing.T) {
// An admin key has no project, so a project-scoped caller can never own
// it. Escalating by revoking the operator's credentials is the attack
// this closes.
status, body := e.do(t, http.MethodDelete, api.PathKey(adminKey.Key.ID), mine.Token, nil)
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), adminKey.Token, nil); status != http.StatusOK {
t.Errorf("the admin key stopped working: status = %d", status)
}
})
}
// 404 versus 403 is an oracle: a project key must not be able to walk the key
// id space and learn which ones exist.
func TestRevokeUnknownKey(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
const unknown = "abcdefghijklmnop" // well-formed, never minted
t.Run("admin", func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), e.adminToken, nil)
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
})
t.Run("project", func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), proj.Token, nil)
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
})
}
func TestRevokeMalformedKeyID(t *testing.T) {
e := newEnv(t)
for _, id := range []string{"nope", "ABCDEFGHIJKLMNOP", "abcdefghijklmno1", strings.Repeat("a", 17)} {
t.Run(id, func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(id), e.adminToken, nil)
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
}
}
func TestListProjectKeysIsScoped(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
e.createProject(t, "other")
mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
status, body := e.do(t, http.MethodGet, api.PathProjectKeys("demo"), mine.Token, nil)
var list api.KeyList
mustJSON(t, status, http.StatusOK, body, &list)
if len(list.Keys) != 1 || list.Keys[0].ID != mine.Key.ID {
t.Fatalf("listing = %+v, want just %s", list.Keys, mine.Key.ID)
}
if list.Keys[0].Project != "demo" {
t.Errorf("project = %q, want demo", list.Keys[0].Project)
}
if status, _ := e.do(t, http.MethodGet, api.PathProjectKeys("other"), mine.Token, nil); status != http.StatusForbidden {
t.Errorf("read another project's keys: status = %d, want 403", status)
}
// The admin listing spans projects and names each key's project.
status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil)
var all api.KeyList
mustJSON(t, status, http.StatusOK, body, &all)
byID := make(map[string]api.Key, len(all.Keys))
for _, k := range all.Keys {
byID[k.ID] = k
}
if got := byID[foreign.Key.ID].Project; got != "other" {
t.Errorf("foreign key's project = %q, want other", got)
}
if _, ok := byID[mine.Key.ID]; !ok {
t.Errorf("admin listing is missing %s", mine.Key.ID)
}
}