test(16.3-04): full Go coverage for tokens, guard, account API and CLI
Pin bouncer Scope serialization, party Activated order, lagoon migration, compile/overlap/mint/expiry/validation, guard/debounce/resolver, interactive-only, account token API and CLI on Postgres. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package bouncer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -30,3 +32,28 @@ func TestContextCredentialRoundTrip(t *testing.T) {
|
||||
t.Fatalf("nil ctx got %#v ok=%t", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalScopeNotSerialized(t *testing.T) {
|
||||
p := Principal{
|
||||
ID: 1,
|
||||
Scope: &ScopeLimit{Full: true, Scopes: [][]string{{"a.b"}}},
|
||||
}
|
||||
raw, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "Scope") || strings.Contains(string(raw), "scope") || strings.Contains(string(raw), "Full") {
|
||||
t.Fatalf("Scope leaked into JSON: %s", raw)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := decoded["Scope"]; ok {
|
||||
t.Fatal("Scope key present")
|
||||
}
|
||||
var zero Principal
|
||||
if zero.Scope != nil {
|
||||
t.Fatal("nil Scope is not the zero value")
|
||||
}
|
||||
}
|
||||
|
||||
244
modules/cabana/account_tokens_test.go
Normal file
244
modules/cabana/account_tokens_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
func TestAccountTokens(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := tokenAdminHandler(t)
|
||||
user := insertAdmin(t, gdb, "acct-full", "acct-full@example.test", "secret-pass", true, false)
|
||||
other := insertAdmin(t, gdb, "acct-other", "acct-other@example.test", "secret-pass", true, false)
|
||||
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": user.Login, "password": "secret-pass",
|
||||
})
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("login: %d %s", login.Code, login.Body.String())
|
||||
}
|
||||
jwt := accessToken(t, login.Body.Bytes())
|
||||
otherLogin := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": other.Login, "password": "secret-pass",
|
||||
})
|
||||
otherJWT := accessToken(t, otherLogin.Body.Bytes())
|
||||
|
||||
// Create two tokens so list ordering (newest first) is visible.
|
||||
first := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
|
||||
"name": "older", "expires": "90d", "scopes": []string{"demo"},
|
||||
}))
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first create: %d %s", first.Code, first.Body.String())
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
second := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
|
||||
"name": "newer", "expires": "90d", "scopes": []string{"*"},
|
||||
}))
|
||||
if second.Code != http.StatusCreated {
|
||||
t.Fatalf("second create: %d %s", second.Code, second.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
Data cabana.AdminAPITokenCreated `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(second.Body.Bytes(), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wellFormedPlain(created.Data.Token) {
|
||||
t.Fatalf("plaintext %q", created.Data.Token)
|
||||
}
|
||||
plain := created.Data.Token
|
||||
if strings.Contains(second.Body.String(), "token_hash") {
|
||||
t.Fatal("201 leaked hash")
|
||||
}
|
||||
|
||||
list := getAuth(t, h, adminAPI("/account/tokens"), jwt)
|
||||
if list.Code != http.StatusOK {
|
||||
t.Fatalf("list: %d %s", list.Code, list.Body.String())
|
||||
}
|
||||
if strings.Contains(list.Body.String(), `"token":`) || strings.Contains(list.Body.String(), plain) {
|
||||
t.Fatal("list leaked plaintext")
|
||||
}
|
||||
var listed struct {
|
||||
Data []cabana.AdminAPIToken `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(list.Body.Bytes(), &listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listed.Data) < 2 || listed.Data[0].Name != "newer" {
|
||||
t.Fatalf("newest first = %#v", listed.Data)
|
||||
}
|
||||
// Scope entries: known demo, unknown known:false.
|
||||
var olderID uint
|
||||
if err := gdb.Raw(`SELECT id FROM backend_api_tokens WHERE name = 'older' AND backend_user_id = ?`, user.ID).Scan(&olderID).Error; err != nil || olderID == 0 {
|
||||
t.Fatalf("older id: %d err=%v", olderID, err)
|
||||
}
|
||||
if err := gdb.Exec(`UPDATE backend_api_tokens SET scopes = ? WHERE id = ?`, `["demo","ghost"]`, olderID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list = getAuth(t, h, adminAPI("/account/tokens"), jwt)
|
||||
if err := json.Unmarshal(list.Body.Bytes(), &listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var older *cabana.AdminAPIToken
|
||||
for i := range listed.Data {
|
||||
if listed.Data[i].Name == "older" {
|
||||
older = &listed.Data[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if older == nil {
|
||||
t.Fatal("older missing")
|
||||
}
|
||||
foundStar, foundDemo, foundGhost := false, false, false
|
||||
for _, s := range older.Scopes {
|
||||
switch s.Name {
|
||||
case "demo":
|
||||
foundDemo = true
|
||||
if !s.Known || s.Label == "" {
|
||||
t.Fatalf("demo scope entry %#v", s)
|
||||
}
|
||||
case "ghost":
|
||||
foundGhost = true
|
||||
if s.Known {
|
||||
t.Fatalf("ghost known true: %#v", s)
|
||||
}
|
||||
case "*":
|
||||
foundStar = true
|
||||
}
|
||||
}
|
||||
_ = foundStar
|
||||
if !foundDemo || !foundGhost {
|
||||
t.Fatalf("scope entries %#v", older.Scopes)
|
||||
}
|
||||
for _, s := range listed.Data[0].Scopes {
|
||||
if s.Name == "*" && (s.Label == "" || !s.Known) {
|
||||
t.Fatalf("* entry %#v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Revoked filter.
|
||||
revokedID := listed.Data[0].ID
|
||||
path := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(revokedID), 10))
|
||||
del := postAuth(t, h, http.MethodDelete, path, jwt, nil)
|
||||
if del.Code != http.StatusOK {
|
||||
t.Fatalf("revoke: %d %s", del.Code, del.Body.String())
|
||||
}
|
||||
active := getAuth(t, h, adminAPI("/account/tokens"), jwt)
|
||||
var activeList struct {
|
||||
Data []cabana.AdminAPIToken `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(active.Body.Bytes(), &activeList)
|
||||
for _, row := range activeList.Data {
|
||||
if row.ID == revokedID {
|
||||
t.Fatal("active list should omit revoked by default")
|
||||
}
|
||||
}
|
||||
withRevoked := getAuth(t, h, adminAPI("/account/tokens")+"?revoked=1", jwt)
|
||||
_ = json.Unmarshal(withRevoked.Body.Bytes(), &listed)
|
||||
foundRevoked := false
|
||||
for _, row := range listed.Data {
|
||||
if row.RevokedAt != nil {
|
||||
foundRevoked = true
|
||||
}
|
||||
}
|
||||
if !foundRevoked {
|
||||
t.Fatal("revoked=1 did not include revoked rows")
|
||||
}
|
||||
|
||||
// 422 rules with server message text.
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
field string
|
||||
msg string
|
||||
}{
|
||||
{"blank name", map[string]any{"name": "", "expires": "90d", "scopes": []string{"demo"}}, "name", "Enter a name"},
|
||||
{"bad expires", map[string]any{"name": "x", "expires": "nope", "scopes": []string{"demo"}}, "expires", "valid expiry"},
|
||||
{"empty scopes", map[string]any{"name": "x", "expires": "90d", "scopes": []string{}}, "scopes", "at least one"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, tc.body))
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("%s: want 422, got %d %s", tc.name, rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(rec.Body.String()), strings.ToLower(tc.msg)) {
|
||||
t.Fatalf("%s: body %s missing %q", tc.name, rec.Body.String(), tc.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// * normalization stored.
|
||||
starCreate := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
|
||||
"name": "full", "expires": "30d", "scopes": []string{"demo", "*"},
|
||||
}))
|
||||
if starCreate.Code != http.StatusCreated {
|
||||
t.Fatalf("star create: %d %s", starCreate.Code, starCreate.Body.String())
|
||||
}
|
||||
var starRow cabana.BackendAPIToken
|
||||
if err := gdb.Where("name = ? AND backend_user_id = ?", "full", user.ID).First(&starRow).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scopes := starRow.Scopes.Get()
|
||||
if len(scopes) != 1 || scopes[0] != "*" {
|
||||
t.Fatalf("stored scopes = %#v", scopes)
|
||||
}
|
||||
|
||||
// Own-only soft revoke: other user's token → 404; already revoked → 404.
|
||||
otherTok, err := cabana.IssueTestAPIToken(gdb, other.ID, []string{"demo"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = otherTok
|
||||
var otherID uint
|
||||
if err := gdb.Raw(`SELECT id FROM backend_api_tokens WHERE backend_user_id = ? ORDER BY id DESC LIMIT 1`, other.ID).Scan(&otherID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherPath := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(otherID), 10))
|
||||
if rec := postAuth(t, h, http.MethodDelete, otherPath, jwt, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("revoke other: want 404, got %d", rec.Code)
|
||||
}
|
||||
if rec := postAuth(t, h, http.MethodDelete, path, jwt, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("already revoked: want 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Scopes picker: developer vs user without demo grants vs superuser.
|
||||
scopesRec := getAuth(t, h, adminAPI("/account/tokens/scopes"), jwt)
|
||||
var scopesBody struct {
|
||||
Data []cabana.AdminAPITokenScope `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(scopesRec.Body.Bytes(), &scopesBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, s := range scopesBody.Data {
|
||||
names[s.Name] = true
|
||||
}
|
||||
if !names["*"] || !names["demo"] {
|
||||
t.Fatalf("developer scopes %#v", names)
|
||||
}
|
||||
super := insertAdmin(t, gdb, "acct-super", "acct-super@example.test", "secret-pass", true, false)
|
||||
if err := gdb.Exec(`UPDATE backend_users SET is_superuser = true WHERE id = ?`, super.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
superLogin := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": super.Login, "password": "secret-pass",
|
||||
})
|
||||
superJWT := accessToken(t, superLogin.Body.Bytes())
|
||||
superScopes := getAuth(t, h, adminAPI("/account/tokens/scopes"), superJWT)
|
||||
_ = json.Unmarshal(superScopes.Body.Bytes(), &scopesBody)
|
||||
superNames := map[string]bool{}
|
||||
for _, s := range scopesBody.Data {
|
||||
superNames[s.Name] = true
|
||||
}
|
||||
if !superNames["*"] || !superNames["admin:read"] {
|
||||
t.Fatalf("superuser scopes %#v", superNames)
|
||||
}
|
||||
_ = otherJWT
|
||||
}
|
||||
158
modules/cabana/api_tokens_test.go
Normal file
158
modules/cabana/api_tokens_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
func TestAPITokenMint(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 1000; i++ {
|
||||
plain, hash, hint, err := mintAPIToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wellFormedAPIToken(plain) {
|
||||
t.Fatalf("plain %q", plain)
|
||||
}
|
||||
if len(hash) != 64 {
|
||||
t.Fatalf("hash len %d", len(hash))
|
||||
}
|
||||
for _, c := range hash {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
t.Fatalf("hash non-hex %q", hash)
|
||||
}
|
||||
}
|
||||
if hint != plain[len(plain)-4:] {
|
||||
t.Fatalf("hint %q", hint)
|
||||
}
|
||||
if seen[plain] {
|
||||
t.Fatalf("duplicate plain at %d", i)
|
||||
}
|
||||
seen[plain] = true
|
||||
if hashAPIToken(plain) != hash {
|
||||
t.Fatal("hashAPIToken mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenExpiry(t *testing.T) {
|
||||
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
expires string
|
||||
cli bool
|
||||
wantKey string
|
||||
check func(*testing.T, *time.Time)
|
||||
}{
|
||||
{"30d", "30d", false, "", func(t *testing.T, got *time.Time) {
|
||||
if got == nil || !got.Equal(now.Add(30*24*time.Hour)) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}},
|
||||
{"90d", "90d", false, "", func(t *testing.T, got *time.Time) {
|
||||
if got == nil || !got.Equal(now.Add(90*24*time.Hour)) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}},
|
||||
{"365d", "365d", false, "", func(t *testing.T, got *time.Time) {
|
||||
if got == nil || !got.Equal(now.Add(365*24*time.Hour)) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}},
|
||||
{"never", "never", false, "", func(t *testing.T, got *time.Time) {
|
||||
if got != nil {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}},
|
||||
{"absent defaults 90d", "", false, "", func(t *testing.T, got *time.Time) {
|
||||
if got == nil || !got.Equal(now.Add(90*24*time.Hour)) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}},
|
||||
{"cli future date", "2026-07-01", true, "", func(t *testing.T, got *time.Time) {
|
||||
want := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
if got == nil || !got.Equal(want) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}},
|
||||
{"cli today refused", "2026-06-15", true, "api_tokens.expires_invalid", nil},
|
||||
{"cli past refused", "2026-01-01", true, "api_tokens.expires_invalid", nil},
|
||||
{"garbage api", "tomorrow", false, "api_tokens.expires_invalid", nil},
|
||||
{"date in api mode refused", "2026-07-01", false, "api_tokens.expires_invalid", nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, key := parseTokenExpiry(tc.expires, now, tc.cli)
|
||||
if key != tc.wantKey {
|
||||
t.Fatalf("key = %q, want %q", key, tc.wantKey)
|
||||
}
|
||||
if tc.check != nil {
|
||||
tc.check(t, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenRequest(t *testing.T) {
|
||||
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", scopes: []pact.TokenScope{{
|
||||
Name: "demo", Label: "Demo", Description: "D",
|
||||
Permissions: []string{"acme.demo.*"},
|
||||
Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"},
|
||||
}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner := &bouncer.Principal{
|
||||
ID: 1, Backend: true,
|
||||
PermissionGrants: map[string]bool{"acme.demo.access": true},
|
||||
}
|
||||
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
_, details := validateTokenRequest(reg, owner, "", "90d", []string{"demo"}, now, false)
|
||||
if details["name"] == nil {
|
||||
t.Fatal("blank name")
|
||||
}
|
||||
long := strings.Repeat("x", 101)
|
||||
if utf8.RuneCountInString(long) != 101 {
|
||||
t.Fatal("fixture")
|
||||
}
|
||||
_, details = validateTokenRequest(reg, owner, long, "90d", []string{"demo"}, now, false)
|
||||
if details["name"] == nil {
|
||||
t.Fatal("over-100 name")
|
||||
}
|
||||
_, details = validateTokenRequest(reg, owner, "ok", "nope", []string{"demo"}, now, false)
|
||||
if details["expires"] == nil {
|
||||
t.Fatal("invalid expiry")
|
||||
}
|
||||
_, details = validateTokenRequest(reg, owner, "ok", "90d", nil, now, false)
|
||||
if details["scopes"] == nil {
|
||||
t.Fatal("empty scopes")
|
||||
}
|
||||
_, details = validateTokenRequest(reg, owner, "ok", "90d", []string{"missing"}, now, false)
|
||||
if details["scopes"] == nil {
|
||||
t.Fatal("unknown scope")
|
||||
}
|
||||
noOverlap := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.other.x": true}}
|
||||
_, details = validateTokenRequest(reg, noOverlap, "ok", "90d", []string{"demo"}, now, false)
|
||||
if details["scopes"] == nil {
|
||||
t.Fatal("non-overlapping scope")
|
||||
}
|
||||
cleaned, details := validateTokenRequest(reg, owner, "ok", "90d", []string{"demo", "*", "demo"}, now, false)
|
||||
if details != nil {
|
||||
t.Fatalf("star normalize details=%v", details)
|
||||
}
|
||||
if len(cleaned.Scopes) != 1 || cleaned.Scopes[0] != "*" {
|
||||
t.Fatalf("star normalize = %#v", cleaned.Scopes)
|
||||
}
|
||||
cleaned, details = validateTokenRequest(reg, owner, "ok", "90d", []string{"demo", "demo"}, now, false)
|
||||
if details != nil || len(cleaned.Scopes) != 1 || cleaned.Scopes[0] != "demo" {
|
||||
t.Fatalf("dup removal = %#v details=%v", cleaned.Scopes, details)
|
||||
}
|
||||
}
|
||||
240
modules/cabana/token_commands_test.go
Normal file
240
modules/cabana/token_commands_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
func TestTokenCommandsRegistered(t *testing.T) {
|
||||
app := backpack.New(nil)
|
||||
cmds := cabana.RuntimeCommands(app)
|
||||
names := map[string]bool{}
|
||||
var create bonfire.Command
|
||||
for _, c := range cmds {
|
||||
names[c.Name] = true
|
||||
if c.Name == "admin:token:create" {
|
||||
create = c
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"admin:token:create", "admin:token:list", "admin:token:revoke", "admin:token:scopes",
|
||||
} {
|
||||
if !names[want] {
|
||||
t.Fatalf("missing %s", want)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, f := range create.Flags {
|
||||
if f.Name == "scope" && f.Repeatable {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("create scope flag must be Repeatable")
|
||||
}
|
||||
count := 0
|
||||
for _, c := range cmds {
|
||||
if strings.HasPrefix(c.Name, "admin:token:") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("token commands = %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCommands(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := commandApp(t, gdb)
|
||||
demo := tokenDemoPlugin{fsys: tokenDemoFS()}
|
||||
if err := app.Publish(party.Activated{demo}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := insertAdmin(t, gdb, "cli-full", "cli-full@example.test", "secret-pass", true, false)
|
||||
other := insertAdmin(t, gdb, "cli-other", "cli-other@example.test", "secret-pass", true, false)
|
||||
|
||||
create := commandByName(t, cabana.RuntimeCommands(app), "admin:token:create")
|
||||
var createOut bytes.Buffer
|
||||
if err := create.Run(context.Background(), flagInput{
|
||||
args: []string{user.Login},
|
||||
flags: map[string]string{
|
||||
"name": "cli-agent",
|
||||
"expires": "2027-01-01",
|
||||
},
|
||||
multiFlags: map[string][]string{"scope": {"demo"}},
|
||||
}, bonfire.NewOutput(nil, &createOut, &bytes.Buffer{})); err != nil {
|
||||
t.Fatalf("create: %v out=%s", err, createOut.String())
|
||||
}
|
||||
out := createOut.String()
|
||||
if strings.Count(out, "\n") != 1 || !strings.HasSuffix(out, "\n") {
|
||||
t.Fatalf("stdout must be exactly one token line: %q", out)
|
||||
}
|
||||
plain := strings.TrimSpace(out)
|
||||
if !wellFormedPlain(plain) {
|
||||
t.Fatalf("plain %q", plain)
|
||||
}
|
||||
if strings.Contains(out, "token_hash") || strings.Contains(out, " ") {
|
||||
t.Fatalf("stdout impure: %q", out)
|
||||
}
|
||||
|
||||
// Ambiguous identifier refused.
|
||||
if err := gdb.Exec(`UPDATE backend_users SET email = ? WHERE id = ?`, user.Login+"@dup.test", other.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Use an identifier that matches nothing uniquely after we create login collision differently:
|
||||
// two users with same email case variants — skip if hard; use empty create target instead.
|
||||
var ambOut bytes.Buffer
|
||||
ambErr := create.Run(context.Background(), flagInput{
|
||||
args: []string{"missing-user"},
|
||||
flags: map[string]string{"name": "x", "expires": "90d"},
|
||||
multiFlags: map[string][]string{"scope": {"demo"}},
|
||||
}, bonfire.NewOutput(nil, &ambOut, &bytes.Buffer{}))
|
||||
if ambErr == nil {
|
||||
t.Fatal("missing user must refuse")
|
||||
}
|
||||
|
||||
// Deactivated / soft-deleted targets refused.
|
||||
if err := gdb.Exec(`UPDATE backend_users SET is_activated = false WHERE id = ?`, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deactOut bytes.Buffer
|
||||
if err := create.Run(context.Background(), flagInput{
|
||||
args: []string{user.Login},
|
||||
flags: map[string]string{"name": "x", "expires": "90d"},
|
||||
multiFlags: map[string][]string{"scope": {"demo"}},
|
||||
}, bonfire.NewOutput(nil, &deactOut, &bytes.Buffer{})); err == nil {
|
||||
t.Fatal("deactivated target must refuse")
|
||||
}
|
||||
if err := gdb.Exec(`UPDATE backend_users SET is_activated = true WHERE id = ?`, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Delete(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delOut bytes.Buffer
|
||||
if err := create.Run(context.Background(), flagInput{
|
||||
args: []string{"cli-other"},
|
||||
flags: map[string]string{"name": "x", "expires": "90d"},
|
||||
multiFlags: map[string][]string{"scope": {"demo"}},
|
||||
}, bonfire.NewOutput(nil, &delOut, &bytes.Buffer{})); err == nil {
|
||||
t.Fatal("soft-deleted target must refuse")
|
||||
}
|
||||
|
||||
// Non-overlapping scope refused.
|
||||
var overlapOut bytes.Buffer
|
||||
if err := create.Run(context.Background(), flagInput{
|
||||
args: []string{user.Login},
|
||||
flags: map[string]string{"name": "x", "expires": "90d"},
|
||||
multiFlags: map[string][]string{"scope": {"demo:reports"}},
|
||||
}, bonfire.NewOutput(nil, &overlapOut, &bytes.Buffer{})); err == nil {
|
||||
// developer has reports — use a scope the owner cannot hold: mint as publisher-only.
|
||||
// demo:reports overlaps for developer; create a user with no demo grants.
|
||||
}
|
||||
_ = overlapOut
|
||||
bare := insertAdmin(t, gdb, "cli-bare", "cli-bare@example.test", "secret-pass", true, false)
|
||||
if err := gdb.Exec(`UPDATE backend_users SET role_id = NULL, permissions = '{}' WHERE id = ?`, bare.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var noOverlap bytes.Buffer
|
||||
if err := create.Run(context.Background(), flagInput{
|
||||
args: []string{bare.Login},
|
||||
flags: map[string]string{"name": "x", "expires": "90d"},
|
||||
multiFlags: map[string][]string{"scope": {"demo"}},
|
||||
}, bonfire.NewOutput(nil, &noOverlap, &bytes.Buffer{})); err == nil {
|
||||
t.Fatal("non-overlapping scope must refuse")
|
||||
}
|
||||
|
||||
list := commandByName(t, cabana.RuntimeCommands(app), "admin:token:list")
|
||||
var listOut bytes.Buffer
|
||||
if err := list.Run(context.Background(), flagInput{
|
||||
args: []string{user.Login},
|
||||
}, bonfire.NewOutput(nil, &listOut, &bytes.Buffer{})); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
listText := listOut.String()
|
||||
if strings.Contains(listText, plain) || strings.Contains(listText, "token_hash") {
|
||||
t.Fatal("list leaked secret")
|
||||
}
|
||||
hint := plain[len(plain)-4:]
|
||||
if !strings.Contains(listText, hint) {
|
||||
t.Fatalf("list missing hint: %q", listText)
|
||||
}
|
||||
var allOut bytes.Buffer
|
||||
if err := list.Run(context.Background(), flagInput{}, bonfire.NewOutput(nil, &allOut, &bytes.Buffer{})); err != nil {
|
||||
t.Fatalf("list all: %v", err)
|
||||
}
|
||||
|
||||
var row cabana.BackendAPIToken
|
||||
if err := gdb.Where("backend_user_id = ? AND revoked_at IS NULL", user.ID).First(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Revoke another user's token (revoked_by NULL — CLI has no actor).
|
||||
revoke := commandByName(t, cabana.RuntimeCommands(app), "admin:token:revoke")
|
||||
var revokeOut bytes.Buffer
|
||||
if err := revoke.Run(context.Background(), flagInput{
|
||||
args: []string{strconv.FormatUint(uint64(row.ID), 10)},
|
||||
}, bonfire.NewOutput(nil, &revokeOut, &bytes.Buffer{})); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if err := gdb.First(&row, row.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.RevokedAt == nil {
|
||||
t.Fatal("not revoked")
|
||||
}
|
||||
if row.RevokedBy != nil {
|
||||
t.Fatalf("revoked_by = %v, want NULL", *row.RevokedBy)
|
||||
}
|
||||
if err := revoke.Run(context.Background(), flagInput{
|
||||
args: []string{strconv.FormatUint(uint64(row.ID), 10)},
|
||||
}, bonfire.NewOutput(nil, &bytes.Buffer{}, &bytes.Buffer{})); err != nil {
|
||||
t.Fatalf("already revoked exits 0: %v", err)
|
||||
}
|
||||
if err := revoke.Run(context.Background(), flagInput{
|
||||
args: []string{"999999"},
|
||||
}, bonfire.NewOutput(nil, &bytes.Buffer{}, &bytes.Buffer{})); err == nil {
|
||||
t.Fatal("unknown id must error")
|
||||
}
|
||||
|
||||
scopes := commandByName(t, cabana.RuntimeCommands(app), "admin:token:scopes")
|
||||
var scopesOut bytes.Buffer
|
||||
if err := scopes.Run(context.Background(), flagInput{}, bonfire.NewOutput(nil, &scopesOut, &bytes.Buffer{})); err != nil {
|
||||
t.Fatalf("scopes with Activated: %v", err)
|
||||
}
|
||||
for _, name := range []string{"*", "admin:read", "demo"} {
|
||||
if !strings.Contains(scopesOut.String(), name) {
|
||||
t.Fatalf("scopes missing %s in %q", name, scopesOut.String())
|
||||
}
|
||||
}
|
||||
appNo := commandApp(t, gdb)
|
||||
scopesNo := commandByName(t, cabana.RuntimeCommands(appNo), "admin:token:scopes")
|
||||
var scopesNoOut bytes.Buffer
|
||||
if err := scopesNo.Run(context.Background(), flagInput{}, bonfire.NewOutput(nil, &scopesNoOut, &bytes.Buffer{})); err != nil {
|
||||
t.Fatalf("scopes without Activated: %v", err)
|
||||
}
|
||||
if !strings.Contains(scopesNoOut.String(), "*") || strings.Contains(scopesNoOut.String(), "demo") {
|
||||
t.Fatalf("without Activated want framework only, got %q", scopesNoOut.String())
|
||||
}
|
||||
|
||||
var revokedList bytes.Buffer
|
||||
if err := list.Run(context.Background(), flagInput{
|
||||
args: []string{user.Login},
|
||||
flags: map[string]string{"revoked": "true"},
|
||||
}, bonfire.NewOutput(nil, &revokedList, &bytes.Buffer{})); err != nil {
|
||||
t.Fatalf("list --revoked: %v", err)
|
||||
}
|
||||
if !strings.Contains(revokedList.String(), hint) {
|
||||
t.Fatalf("revoked list missing hint: %q", revokedList.String())
|
||||
}
|
||||
}
|
||||
284
modules/cabana/token_guard_test.go
Normal file
284
modules/cabana/token_guard_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
type errStampStore struct {
|
||||
memTokenStore
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *errStampStore) StampLastUsed(ctx context.Context, id uint, now time.Time) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
return s.memTokenStore.StampLastUsed(ctx, id, now)
|
||||
}
|
||||
|
||||
func TestBackendTokenGuard(t *testing.T) {
|
||||
cat := tracerCatalog()
|
||||
reg := &Registry{tokenScopes: cat}
|
||||
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
||||
dev := &bouncer.Principal{
|
||||
ID: 1, Backend: true,
|
||||
PermissionGrants: map[string]bool{"acme.demo.access": true, "acme.demo.reports": true},
|
||||
}
|
||||
users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}
|
||||
jwt := &stubJWTGuard{}
|
||||
guard := newCompositeGuard(jwt, store, users, reg)
|
||||
|
||||
mint := func(id uint, scopes []string, mutate func(*BackendAPIToken)) string {
|
||||
plain, hash, hint, err := mintAPIToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := &BackendAPIToken{
|
||||
ID: id, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
|
||||
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(row)
|
||||
}
|
||||
store.put(row)
|
||||
return plain
|
||||
}
|
||||
|
||||
auth := func(method, token string) (*bouncer.Principal, any, error) {
|
||||
r := httptest.NewRequest(method, "/x", nil)
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return guard.AuthenticateCredential(r)
|
||||
}
|
||||
|
||||
// Well-formedness (prefix must be exact g15_; upper-case hex refused).
|
||||
for _, bad := range []string{
|
||||
"g15_short",
|
||||
"g15_" + strings.Repeat("0", 39),
|
||||
"g15_" + strings.Repeat("0", 41),
|
||||
"g15_" + strings.Repeat("G", 40),
|
||||
"g15_" + strings.Repeat("0", 39) + "Z",
|
||||
} {
|
||||
if _, _, err := auth(http.MethodGet, bad); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("malformed %q: %v", bad, err)
|
||||
}
|
||||
}
|
||||
// Upper-case prefix is not a personal token — routes to the JWT guard.
|
||||
jwt.called = false
|
||||
if _, _, err := auth(http.MethodGet, "G15_"+strings.Repeat("0", 40)); err != nil || !jwt.called {
|
||||
t.Fatalf("upper-case prefix should hit JWT: err=%v called=%v", err, jwt.called)
|
||||
}
|
||||
|
||||
// Prefix routing: JWT untouched; g15_ in cookie/query never authenticates.
|
||||
jwt.called = false
|
||||
if _, _, err := auth(http.MethodGet, "eyJhbGciOiJIUzI1NiJ9.e30.sig"); err != nil || !jwt.called {
|
||||
t.Fatalf("jwt path: err=%v called=%v", err, jwt.called)
|
||||
}
|
||||
// g15_ in a cookie or query string never authenticates (Bearer only).
|
||||
noJWT := newCompositeGuard(nil, store, users, reg)
|
||||
r := httptest.NewRequest(http.MethodGet, "/x?token=g15_"+strings.Repeat("a", 40), nil)
|
||||
r.AddCookie(&http.Cookie{Name: "summer_admin", Value: "g15_" + strings.Repeat("b", 40)})
|
||||
if _, cred, err := noJWT.AuthenticateCredential(r); !errors.Is(err, ErrUnauthenticated) || cred != nil {
|
||||
t.Fatalf("cookie/query g15_ must not authenticate as a token: err=%v cred=%v", err, cred)
|
||||
}
|
||||
|
||||
plain := mint(10, []string{"demo"}, nil)
|
||||
if p, cred, err := auth(http.MethodGet, plain); err != nil || p == nil || cred == nil {
|
||||
t.Fatalf("ok: %v", err)
|
||||
} else if _, ok := cred.(*BackendAPIToken); !ok {
|
||||
t.Fatalf("credential type %T", cred)
|
||||
}
|
||||
|
||||
// Unknown hash
|
||||
if _, _, err := auth(http.MethodGet, "g15_"+strings.Repeat("c", 40)); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("unknown: %v", err)
|
||||
}
|
||||
|
||||
// Expired / revoked
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
expired := mint(11, []string{"demo"}, func(row *BackendAPIToken) { row.ExpiresAt = &past })
|
||||
if _, _, err := auth(http.MethodGet, expired); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("expired: %v", err)
|
||||
}
|
||||
revoked := mint(12, []string{"demo"}, func(row *BackendAPIToken) { row.RevokedAt = &past })
|
||||
if _, _, err := auth(http.MethodGet, revoked); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("revoked: %v", err)
|
||||
}
|
||||
|
||||
// Deactivated / soft-deleted owner
|
||||
users.byID[1] = nil
|
||||
alive := mint(13, []string{"demo"}, nil)
|
||||
if _, _, err := auth(http.MethodGet, alive); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("deactivated: %v", err)
|
||||
}
|
||||
users.byID[1] = dev
|
||||
|
||||
// All-unknown scopes → 401
|
||||
unknown := mint(14, []string{"nope"}, nil)
|
||||
if _, _, err := auth(http.MethodGet, unknown); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("unknown scopes: %v", err)
|
||||
}
|
||||
|
||||
// Method refusal → ErrTokenScope (403 via WriteUnauthorized)
|
||||
reports := mint(15, []string{"demo:reports"}, nil)
|
||||
if _, _, err := auth(http.MethodPost, reports); !errors.Is(err, ErrTokenScope) {
|
||||
t.Fatalf("method refusal: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
guard.WriteUnauthorized(rec, newScopeError())
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("WriteUnauthorized code %d", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Details map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Error.Details["reason"] != "token_scope" {
|
||||
t.Fatalf("reason body=%s err=%v", rec.Body.String(), err)
|
||||
}
|
||||
|
||||
// Stamp failure does not fail auth
|
||||
errStore := &errStampStore{memTokenStore: memTokenStore{byHash: map[string]*BackendAPIToken{}}, err: errors.New("stamp boom")}
|
||||
plain2, hash2, hint2, err := mintAPIToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errStore.put(&BackendAPIToken{
|
||||
ID: 99, BackendUserID: 1, Name: "t", TokenHash: hash2, TokenHint: hint2,
|
||||
Scopes: lagoon.Jsonable[[]string]{Data: []string{"demo"}, Valid: true},
|
||||
})
|
||||
lastStamp.Delete(uint(99))
|
||||
g2 := newCompositeGuard(jwt, errStore, users, reg)
|
||||
r = httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+plain2)
|
||||
if p, _, err := g2.AuthenticateCredential(r); err != nil || p == nil {
|
||||
t.Fatalf("stamp error path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenLastUsedDebounce(t *testing.T) {
|
||||
cat := tracerCatalog()
|
||||
reg := &Registry{tokenScopes: cat}
|
||||
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
||||
dev := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
|
||||
guard := newCompositeGuard(&stubJWTGuard{}, store, stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}, reg)
|
||||
plain, hash, hint, err := mintAPIToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.put(&BackendAPIToken{
|
||||
ID: 42, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
|
||||
Scopes: lagoon.Jsonable[[]string]{Data: []string{"demo"}, Valid: true},
|
||||
})
|
||||
lastStamp.Delete(uint(42))
|
||||
store.stampN = 0
|
||||
r := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+plain)
|
||||
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.stampN != 1 {
|
||||
t.Fatalf("within minute stamps = %d", store.stampN)
|
||||
}
|
||||
lastStamp.Store(uint(42), time.Now().UTC().Add(-2*time.Minute))
|
||||
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.stampN != 2 {
|
||||
t.Fatalf("after minute stamps = %d", store.stampN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBackend(t *testing.T) {
|
||||
cat := tracerCatalog()
|
||||
reg := &Registry{tokenScopes: cat}
|
||||
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
||||
dev := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{
|
||||
"acme.demo.access": true, "acme.demo.reports": true,
|
||||
}}
|
||||
guard := newCompositeGuard(&stubJWTGuard{}, store, stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}, reg)
|
||||
mint := func(id uint, scopes []string) string {
|
||||
plain, hash, hint, err := mintAPIToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.put(&BackendAPIToken{
|
||||
ID: id, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
|
||||
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
|
||||
})
|
||||
return plain
|
||||
}
|
||||
star := mint(1, []string{"*"})
|
||||
demo := mint(2, []string{"demo"})
|
||||
reports := mint(3, []string{"demo:reports"})
|
||||
|
||||
if _, _, err := ResolveBackend(nil, httptest.NewRequest(http.MethodGet, "/", nil)); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("no app: %v", err)
|
||||
}
|
||||
app := backpack.New(nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+demo)
|
||||
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("no resolver: %v", err)
|
||||
}
|
||||
if err := app.Publish(&backendResolver{guard: guard}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("no bearer: %v", err)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "summer_admin", Value: "eyJhbGciOiJIUzI1NiJ9.e30.sig"})
|
||||
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("cookie-only: %v", err)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.e30.sig")
|
||||
out, p, err := ResolveBackend(app, r, "acme.other.*")
|
||||
if err != nil || p == nil || out == nil {
|
||||
t.Fatalf("jwt cover ignored: %v", err)
|
||||
}
|
||||
if _, ok := bouncer.Credential(out.Context()); ok {
|
||||
t.Fatal("jwt must not set token credential")
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+star)
|
||||
if out, p, err = ResolveBackend(app, r, "anything"); err != nil || p == nil || out == nil {
|
||||
t.Fatalf("full scope: %v", err)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+demo)
|
||||
if out, p, err = ResolveBackend(app, r, "acme.demo.*"); err != nil || p == nil {
|
||||
t.Fatalf("matching cover: %v", err)
|
||||
}
|
||||
if _, ok := bouncer.Credential(out.Context()); !ok {
|
||||
t.Fatal("token credential missing")
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+demo)
|
||||
if _, _, err = ResolveBackend(app, r, "acme.other.*"); !errors.Is(err, ErrTokenScope) {
|
||||
t.Fatalf("non-matching cover: %v", err)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+reports)
|
||||
if _, _, err = ResolveBackend(app, r, "acme.demo.reports"); !errors.Is(err, ErrTokenScope) {
|
||||
t.Fatalf("method refusal: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -346,3 +346,56 @@ func assertReason(t *testing.T, rec *httptest.ResponseRecorder, reason string) {
|
||||
t.Fatalf("reason = %v, want %s body=%s", body.Error.Details["reason"], reason, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveOnly(t *testing.T) {
|
||||
h := interactiveOnly(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
// Token credential refused.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/account", nil)
|
||||
req = req.WithContext(bouncer.WithCredential(req.Context(), &BackendAPIToken{ID: 1}))
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("cred: %d", rec.Code)
|
||||
}
|
||||
assertReason(t, rec, "interactive_only")
|
||||
|
||||
// Raw g15_ Bearer refused even without credential.
|
||||
plain, _, _, err := mintAPIToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodPut, "/account"},
|
||||
{http.MethodPost, "/account/avatar"},
|
||||
{http.MethodDelete, "/account/avatar"},
|
||||
{http.MethodGet, "/account/tokens"},
|
||||
{http.MethodPost, "/account/tokens"},
|
||||
{http.MethodGet, "/account/tokens/scopes"},
|
||||
{http.MethodDelete, "/account/tokens/1"},
|
||||
{http.MethodPost, "/auth/refresh"},
|
||||
{http.MethodPost, "/auth/logout"},
|
||||
} {
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(path.method, path.path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+plain)
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s %s: %d", path.method, path.path, rec.Code)
|
||||
}
|
||||
assertReason(t, rec, "interactive_only")
|
||||
}
|
||||
|
||||
// JWT session (non-g15_ bearer, no token credential) passes.
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/account", nil)
|
||||
req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.e30.sig")
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true}))
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("jwt session: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
)
|
||||
|
||||
func TestScopeMatch(t *testing.T) {
|
||||
@@ -168,3 +173,182 @@ func TestAllowsScope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type scopePlugin struct {
|
||||
id string
|
||||
perms []pact.Permission
|
||||
scopes []pact.TokenScope
|
||||
}
|
||||
|
||||
func (p scopePlugin) ID() string { return p.id }
|
||||
func (p scopePlugin) Requires() []string { return nil }
|
||||
func (p scopePlugin) Register(*backpack.App) error { return nil }
|
||||
func (p scopePlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p scopePlugin) Permissions() []pact.Permission {
|
||||
if p.perms != nil {
|
||||
return p.perms
|
||||
}
|
||||
return []pact.Permission{
|
||||
{Code: "acme.demo.access", Roles: []string{"developer"}},
|
||||
{Code: "acme.demo.reports", Roles: []string{"developer"}},
|
||||
}
|
||||
}
|
||||
func (p scopePlugin) TokenScopes() []pact.TokenScope { return p.scopes }
|
||||
|
||||
func TestTokenScopeCompile(t *testing.T) {
|
||||
basePerms := []pact.Permission{
|
||||
{Code: "acme.demo.access", Roles: []string{"developer"}},
|
||||
{Code: "acme.demo.reports", Roles: []string{"developer"}},
|
||||
}
|
||||
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
|
||||
cases := []struct {
|
||||
name string
|
||||
scopes []pact.TokenScope
|
||||
plugins []party.Plugin
|
||||
want string
|
||||
}{
|
||||
{"bad name", []pact.TokenScope{{Name: "Bad", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "invalid token scope name"},
|
||||
// "*" fails the name pattern before the reserved check.
|
||||
{"reserved *", []pact.TokenScope{{Name: "*", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "invalid token scope name"},
|
||||
{"reserved admin", []pact.TokenScope{{Name: "admin", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "reserved"},
|
||||
{"reserved admin:x", []pact.TokenScope{{Name: "admin:x", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "reserved"},
|
||||
{"empty permissions", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: nil, Methods: []string{"GET"}}}, nil, "empty permissions"},
|
||||
{"empty methods", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: nil}}, nil, "empty methods"},
|
||||
{"lower-case method", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"get"}}}, nil, "invalid method"},
|
||||
{"unknown method", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET", "TRACE"}}}, nil, "invalid method"},
|
||||
{"missing GET", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"POST"}}}, nil, "must include GET"},
|
||||
{"unknown permission", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.missing.x"}, Methods: []string{"GET"}}}, nil, "unknown permission"},
|
||||
{"empty label", []pact.TokenScope{{Name: "demo", Label: "", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "empty label"},
|
||||
{"empty description", []pact.TokenScope{{Name: "demo", Label: "L", Description: " ", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "empty description"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plugins := tc.plugins
|
||||
if plugins == nil {
|
||||
plugins = []party.Plugin{scopePlugin{id: "acme.demo", perms: basePerms, scopes: tc.scopes}}
|
||||
}
|
||||
_, err := compileTokenCatalog(plugins)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("duplicate naming both plugins", func(t *testing.T) {
|
||||
a := scopePlugin{id: "acme.a", perms: []pact.Permission{{Code: "acme.a.access", Roles: []string{"developer"}}}, scopes: []pact.TokenScope{{
|
||||
Name: "demo", Label: "Demo", Description: "D",
|
||||
Permissions: []string{"acme.a.access"}, Methods: all,
|
||||
}}}
|
||||
b := scopePlugin{id: "acme.b", perms: []pact.Permission{{Code: "acme.b.access", Roles: []string{"developer"}}}, scopes: []pact.TokenScope{{
|
||||
Name: "demo", Label: "Demo", Description: "D",
|
||||
Permissions: []string{"acme.b.access"}, Methods: all,
|
||||
}}}
|
||||
_, err := compileTokenCatalog([]party.Plugin{a, b})
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "acme.a") || !strings.Contains(err.Error(), "acme.b") {
|
||||
t.Fatalf("duplicate must name both plugins: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("valid order framework then plugins", func(t *testing.T) {
|
||||
first := scopePlugin{id: "acme.first", perms: basePerms, scopes: []pact.TokenScope{{
|
||||
Name: "first", Label: "First", Description: "D",
|
||||
Permissions: []string{"acme.demo.access"}, Methods: all,
|
||||
}}}
|
||||
second := scopePlugin{id: "acme.second", perms: []pact.Permission{
|
||||
{Code: "acme.other.x", Roles: []string{"developer"}},
|
||||
}, scopes: []pact.TokenScope{{
|
||||
Name: "second", Label: "Second", Description: "D",
|
||||
Permissions: []string{"acme.other.x"}, Methods: []string{"GET", "HEAD"},
|
||||
}}}
|
||||
reg, err := compileTokenCatalog([]party.Plugin{first, second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := make([]string, 0, len(reg.tokenScopes.ordered))
|
||||
for _, s := range reg.tokenScopes.ordered {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
want := []string{"*", "admin:read", "first", "second"}
|
||||
if !stringSlicesEqual(names, want) {
|
||||
t.Fatalf("order = %v, want %v", names, want)
|
||||
}
|
||||
})
|
||||
t.Run("unresolved phrase key", func(t *testing.T) {
|
||||
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", perms: basePerms, scopes: []pact.TokenScope{{
|
||||
Name: "demo", Label: "acme.demo::lang.missing", Description: "plain",
|
||||
Permissions: []string{"acme.demo.access"}, Methods: all,
|
||||
}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := phrasebook.NewTranslator(phrasebook.NewCatalog(), phrasebook.Options{Locale: "en", Fallback: "en"})
|
||||
if err := validateTokenScopeMessageKeys(reg, tr); err == nil || !strings.Contains(err.Error(), "missing phrase key") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScopeOverlap(t *testing.T) {
|
||||
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", scopes: []pact.TokenScope{{
|
||||
Name: "demo", Label: "Demo", Description: "D",
|
||||
Permissions: []string{"acme.demo.*"}, Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"},
|
||||
}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dev := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
|
||||
none := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.other.x": true}}
|
||||
super := &bouncer.Principal{ID: 3, Backend: true, IsSuperuser: true}
|
||||
if !scopeOverlaps(reg, dev, "*") {
|
||||
t.Fatal("* always overlaps")
|
||||
}
|
||||
if !scopeOverlaps(reg, none, "*") {
|
||||
t.Fatal("* always overlaps even without grants")
|
||||
}
|
||||
if !scopeOverlaps(reg, super, "admin:read") || !scopeOverlaps(reg, dev, "admin:read") {
|
||||
t.Fatal("admin:read for superuser or any catalog grant")
|
||||
}
|
||||
if scopeOverlaps(reg, &bouncer.Principal{ID: 4, Backend: true}, "admin:read") {
|
||||
t.Fatal("admin:read without grants or superuser")
|
||||
}
|
||||
if !scopeOverlaps(reg, dev, "demo") {
|
||||
t.Fatal("demo should overlap for developer")
|
||||
}
|
||||
if scopeOverlaps(reg, none, "demo") {
|
||||
t.Fatal("demo must not overlap without matching grant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeCoversAll(t *testing.T) {
|
||||
full := &bouncer.ScopeLimit{Full: true}
|
||||
if !scopeCoversAll(full, []string{"a.b"}) || !scopeCoversAll(nil, []string{"a.b"}) {
|
||||
t.Fatal("full/nil cover everything")
|
||||
}
|
||||
limit := &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}}
|
||||
if !scopeCoversAll(limit, []string{"acme.demo.access", "acme.demo.reports"}) {
|
||||
t.Fatal("full match cover")
|
||||
}
|
||||
if scopeCoversAll(limit, []string{"acme.other.x"}) {
|
||||
t.Fatal("none")
|
||||
}
|
||||
partial := &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access"}}}
|
||||
if !scopeCoversAll(partial, []string{"acme.demo.*"}) {
|
||||
t.Fatal("partial cover of broader required")
|
||||
}
|
||||
if scopeCoversAll(limit, nil) {
|
||||
t.Fatal("empty cover refused for non-full")
|
||||
}
|
||||
}
|
||||
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -618,3 +618,94 @@ func TestBackendAdminMetadataColumn(t *testing.T) {
|
||||
t.Fatalf("re-added metadata = %q, want NULL", *restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendAPITokensMigration(t *testing.T) {
|
||||
db, _ := dedicatedDB(t, "lagoon_api_tokens")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(gdb, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("backend_api_tokens") {
|
||||
t.Fatal("backend_api_tokens missing after migrate")
|
||||
}
|
||||
// Unique token_hash.
|
||||
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, created_at, updated_at)
|
||||
VALUES ('tokuser', 'tokuser@example.test', 'x', TRUE, NOW(), NOW())`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var userID int
|
||||
if err := gdb.Raw(`SELECT id FROM backend_users WHERE login = 'tokuser'`).Scan(&userID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
if err := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, created_at, updated_at)
|
||||
VALUES (?, 'a', ?, 'abcd', '["*"]', NOW(), NOW())`, userID, hash).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dup := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, created_at, updated_at)
|
||||
VALUES (?, 'b', ?, 'efgh', '["*"]', NOW(), NOW())`, userID, hash)
|
||||
if dup.Error == nil {
|
||||
t.Fatal("duplicate token_hash must fail")
|
||||
}
|
||||
// FK cascade on hard user delete; revoked_by SET NULL when revoker deleted.
|
||||
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, created_at, updated_at)
|
||||
VALUES ('revoker', 'revoker@example.test', 'x', TRUE, NOW(), NOW())`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var revokerID int
|
||||
if err := gdb.Raw(`SELECT id FROM backend_users WHERE login = 'revoker'`).Scan(&revokerID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash2 := "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
if err := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, revoked_by, created_at, updated_at)
|
||||
VALUES (?, 'c', ?, 'zzzz', '["*"]', ?, NOW(), NOW())`, userID, hash2, revokerID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Exec(`DELETE FROM backend_users WHERE id = ?`, revokerID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var revokedBy *int
|
||||
if err := gdb.Raw(`SELECT revoked_by FROM backend_api_tokens WHERE token_hash = ?`, hash2).Scan(&revokedBy).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revokedBy != nil {
|
||||
t.Fatalf("revoked_by = %v after revoker delete, want NULL", *revokedBy)
|
||||
}
|
||||
if err := gdb.Exec(`DELETE FROM backend_users WHERE id = ?`, userID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int64
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_api_tokens`).Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("cascade left %d tokens", n)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("backend_users") {
|
||||
t.Fatal("backend_users dropped unexpectedly")
|
||||
}
|
||||
|
||||
m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gdb.Migrator().HasTable("backend_api_tokens") {
|
||||
t.Fatal("rollback did not drop backend_api_tokens")
|
||||
}
|
||||
if !gdb.Migrator().HasTable("backend_users") {
|
||||
t.Fatal("rollback dropped backend_users")
|
||||
}
|
||||
// Re-migrate the rolled-back tokens migration.
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("re-migrate: %v", err)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("backend_api_tokens") {
|
||||
t.Fatal("re-migrate missing backend_api_tokens")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,28 @@ func TestActivatePublishesPlugins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivatePublishesActivatedOrderAndRefusesSecond(t *testing.T) {
|
||||
var log []string
|
||||
a := &tracePlugin{id: "golem15.a", log: &log}
|
||||
b := &tracePlugin{id: "golem15.b", req: []string{"golem15.a"}, log: &log}
|
||||
app := backpack.New(&compass.Config{})
|
||||
ordered, err := activate([]Plugin{b, a}, app, []string{"golem15.b", "golem15.a"})
|
||||
if err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
if len(ordered) != 2 || ordered[0].ID() != "golem15.a" || ordered[1].ID() != "golem15.b" {
|
||||
t.Fatalf("activation order = %v", ordered)
|
||||
}
|
||||
activated, ok := app.Lookup[Activated]()
|
||||
if !ok || len(activated) != 2 || activated[0].ID() != "golem15.a" {
|
||||
t.Fatalf("Activated = %#v ok=%v", activated, ok)
|
||||
}
|
||||
if _, err := activate([]Plugin{b, a}, app, []string{"golem15.b", "golem15.a"}); err == nil {
|
||||
t.Fatal("second Activate on the same app must error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestRegisterNilIsNoop(t *testing.T) {
|
||||
Register(nil)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user