test(16.3-04): pin deny-by-default contract at every layer
Scope match/Allows tables, route classification for every phase09Routes entry, SPA plaintext lifetime backstops, and Postgres owner lifecycle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
131
modules/cabana/token_postgres_test.go
Normal file
131
modules/cabana/token_postgres_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
func TestBackendTokenPostgres(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := tokenAdminHandler(t)
|
||||
user := insertAdmin(t, gdb, "tok-life", "tok-life@example.test", "secret-pass", true, false)
|
||||
listPath := adminAPI("/acme/demo/widgets")
|
||||
|
||||
plain, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK {
|
||||
t.Fatalf("initial: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// D-10: deactivate → 401, reactivate → 200.
|
||||
if err := gdb.Exec(`UPDATE backend_users SET is_activated = false WHERE id = ?`, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("deactivated: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := gdb.Exec(`UPDATE backend_users SET is_activated = true WHERE id = ?`, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK {
|
||||
t.Fatalf("reactivated: want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Password reset / tokens_valid_after leave the personal API token working.
|
||||
reset := commandByName(t, cabana.RuntimeCommands(commandApp(t, gdb)), "admin:reset-password")
|
||||
var buf bytes.Buffer
|
||||
out := bonfire.NewOutput(nil, &buf, &buf)
|
||||
if err := reset.Run(context.Background(), flagInput{
|
||||
args: []string{user.Login},
|
||||
flags: map[string]string{"password": "new-secret-pass"},
|
||||
}, out); err != nil {
|
||||
t.Fatalf("admin:reset-password: %v output=%s", err, buf.String())
|
||||
}
|
||||
var afterReset cabana.BackendUser
|
||||
if err := gdb.First(&afterReset, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterReset.TokensValidAfter == nil {
|
||||
t.Fatal("reset did not set tokens_valid_after")
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK {
|
||||
t.Fatalf("after password reset: want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(time.Hour)
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK {
|
||||
t.Fatalf("after tokens_valid_after cutoff: want 200, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Revoked → 401.
|
||||
now := time.Now().UTC()
|
||||
if err := gdb.Exec(`UPDATE backend_api_tokens SET revoked_at = ? WHERE backend_user_id = ?`, now, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Fresh token, expired → 401.
|
||||
expired := now.Add(-time.Hour)
|
||||
plain2, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, &expired)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain2); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expired: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Soft-delete owner → 401.
|
||||
plain3, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Delete(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain3); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("soft-deleted owner: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// last_used_at stamped once within a minute for two requests.
|
||||
user2 := insertAdmin(t, gdb, "tok-stamp", "tok-stamp@example.test", "secret-pass", true, false)
|
||||
plain4, err := cabana.IssueTestAPIToken(gdb, user2.ID, []string{"demo"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var tokenID uint
|
||||
if err := gdb.Raw(`SELECT id FROM backend_api_tokens WHERE backend_user_id = ? ORDER BY id DESC LIMIT 1`, user2.ID).Scan(&tokenID).Error; err != nil || tokenID == 0 {
|
||||
t.Fatalf("token id: %d err=%v", tokenID, err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain4); rec.Code != http.StatusOK {
|
||||
t.Fatalf("stamp first: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, plain4); rec.Code != http.StatusOK {
|
||||
t.Fatalf("stamp second: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var row cabana.BackendAPIToken
|
||||
if err := gdb.First(&row, tokenID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.LastUsedAt == nil {
|
||||
t.Fatal("last_used_at not set")
|
||||
}
|
||||
// Ensure the stamp path does not write plaintext scopes oddly.
|
||||
if !row.Scopes.Valid || len(row.Scopes.Get()) == 0 {
|
||||
t.Fatalf("scopes = %#v", row.Scopes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user