test(09-12): add the phase 9 security matrix
- Guard isolation covers audience, secret, refresh, blacklist, and reset cutoff. - The mounted admin route table must carry the backend guard. - Fresh PostgreSQL migrate and rollback keep framework and plugin histories apart.
This commit is contained in:
171
bouncer/backend_guard_test.go
Normal file
171
bouncer/backend_guard_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package bouncer
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPhase09GuardIsolation is the phase gate for token crossover. Each
|
||||
// subtest fails closed when a frontend and backend credential can be used
|
||||
// on the other guard, including same-secret audience checks, distinct
|
||||
// secrets, refresh, blacklist, and password-reset cutoff.
|
||||
func TestPhase09GuardIsolation(t *testing.T) {
|
||||
const (
|
||||
userSecret = "frontend-secret"
|
||||
backendSecret = "backend-secret"
|
||||
sharedSecret = "same-secret-for-both-guards"
|
||||
issuer = "https://app.test"
|
||||
)
|
||||
frontendUsers := memUsers{byID: map[uint]*Principal{1: {ID: 1}}}
|
||||
backendUsers := memUsers{byID: map[uint]*Principal{2: {ID: 2, Backend: true}}}
|
||||
|
||||
userTok, _, err := MintAudience(sharedSecret, "1", issuer, time.Hour, AudienceUser)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backendTok, _, err := MintAudience(sharedSecret, "2", issuer, time.Hour, AudienceBackend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frontend := NewJWTGuard(sharedSecret, frontendUsers, nil)
|
||||
backend := NewBackendJWTGuard(sharedSecret, backendUsers, nil, nil)
|
||||
|
||||
t.Run("own audience", func(t *testing.T) {
|
||||
principal, err := frontend.Authenticate(withBearer(userTok))
|
||||
if err != nil || principal == nil || principal.ID != 1 {
|
||||
t.Fatalf("frontend guard rejected its own token: %v %+v", err, principal)
|
||||
}
|
||||
principal, err = backend.Authenticate(withBearer(backendTok))
|
||||
if err != nil || principal == nil || principal.ID != 2 || !principal.Backend {
|
||||
t.Fatalf("backend guard rejected its own token: %v %+v", err, principal)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audience crossover same secret", func(t *testing.T) {
|
||||
if principal, err := backend.Authenticate(withBearer(userTok)); err == nil || principal != nil {
|
||||
t.Fatal("backend guard accepted a frontend-audience token signed with the same secret")
|
||||
}
|
||||
if principal, err := frontend.Authenticate(withBearer(backendTok)); err == nil || principal != nil {
|
||||
t.Fatal("frontend guard accepted a backend-audience token signed with the same secret")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("distinct secrets and registries", func(t *testing.T) {
|
||||
userOnly, _, err := MintAudience(userSecret, "1", issuer, time.Hour, AudienceUser)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminOnly, _, err := MintAudience(backendSecret, "2", issuer, time.Hour, AudienceBackend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
front := NewJWTGuard(userSecret, frontendUsers, nil)
|
||||
back := NewBackendJWTGuard(backendSecret, backendUsers, nil, nil)
|
||||
if principal, err := back.Authenticate(withBearer(userOnly)); err == nil || principal != nil {
|
||||
t.Fatal("backend guard accepted a frontend token signed with the frontend secret")
|
||||
}
|
||||
if principal, err := front.Authenticate(withBearer(adminOnly)); err == nil || principal != nil {
|
||||
t.Fatal("frontend guard accepted a backend token signed with the backend secret")
|
||||
}
|
||||
if principal, err := back.Authenticate(withBearer(adminOnly)); err != nil || principal == nil || principal.ID != 2 {
|
||||
t.Fatalf("backend registry principal = %+v err=%v", principal, err)
|
||||
}
|
||||
if principal, err := back.Authenticate(withBearer(mustMint(t, backendSecret, "1", AudienceBackend))); err == nil || principal != nil {
|
||||
t.Fatal("backend guard resolved a subject from the frontend user registry")
|
||||
}
|
||||
reg := NewRegistry()
|
||||
if err := reg.Register("golem15.user", "jwt", front); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.Register("summercms.cabana", "backend", back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mw, err := reg.Middleware("backend")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("backend middleware ran the handler for a frontend token")
|
||||
})).ServeHTTP(rec, withBearer(userOnly))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("crossover status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed and wrong audience", func(t *testing.T) {
|
||||
if _, err := backend.Authenticate(withBearer("not-a-jwt")); err == nil {
|
||||
t.Fatal("malformed token was accepted")
|
||||
}
|
||||
other, _, err := MintAudience(sharedSecret, "2", issuer, time.Hour, "frontend")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if principal, err := backend.Authenticate(withBearer(other)); err == nil || principal != nil {
|
||||
t.Fatal("backend guard accepted an unexpected audience")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blacklist and reset cutoff", func(t *testing.T) {
|
||||
bl := NewMemoryBlacklist()
|
||||
revoked, jti, err := MintAudience(backendSecret, "2", issuer, time.Hour, AudienceBackend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bl.Add(t.Context(), jti, time.Now().Add(time.Hour), time.Now().Add(-time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guard := NewBackendJWTGuard(backendSecret, backendUsers, bl, nil)
|
||||
if principal, err := guard.Authenticate(withBearer(revoked)); err == nil || principal != nil {
|
||||
t.Fatal("blacklisted backend token was accepted")
|
||||
}
|
||||
if _, err := RefreshAudience(backendSecret, revoked, AudienceBackend, 2*time.Hour, bl, time.Minute, issuer); err == nil {
|
||||
t.Fatal("blacklisted backend token was refreshed")
|
||||
}
|
||||
cutoff := memUsers{byID: map[uint]*Principal{2: {
|
||||
ID: 2, Backend: true, TokensValidAfter: time.Now().Add(time.Minute),
|
||||
}}}
|
||||
stale, _, err := MintAudience(backendSecret, "2", issuer, time.Hour, AudienceBackend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if principal, err := NewBackendJWTGuard(backendSecret, cutoff, nil, nil).Authenticate(withBearer(stale)); err == nil || principal != nil {
|
||||
t.Fatal("token issued before the password-reset cutoff was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh does not cross audiences", func(t *testing.T) {
|
||||
access, _, err := MintAudience(userSecret, "1", issuer, time.Hour, AudienceUser)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _, err := MintAudience(backendSecret, "2", issuer, time.Hour, AudienceBackend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := RefreshAudience(userSecret, access, AudienceBackend, 2*time.Hour, nil, time.Minute, issuer); err == nil {
|
||||
t.Fatal("frontend token refreshed into the backend audience")
|
||||
}
|
||||
if _, err := RefreshAudience(backendSecret, admin, AudienceUser, 2*time.Hour, nil, time.Minute, issuer); err == nil {
|
||||
t.Fatal("backend token refreshed into the frontend audience")
|
||||
}
|
||||
if _, err := RefreshAudience(userSecret, admin, AudienceBackend, 2*time.Hour, nil, time.Minute, issuer); err == nil {
|
||||
t.Fatal("backend token refreshed with the frontend secret")
|
||||
}
|
||||
next, err := RefreshAudience(backendSecret, admin, AudienceBackend, 2*time.Hour, nil, time.Minute, issuer)
|
||||
if err != nil || next == "" {
|
||||
t.Fatalf("backend refresh failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustMint(t *testing.T, secret, sub, audience string) string {
|
||||
t.Helper()
|
||||
token, _, err := MintAudience(secret, sub, "https://app.test", time.Hour, audience)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
271
cabana/security_coverage_test.go
Normal file
271
cabana/security_coverage_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
)
|
||||
|
||||
// phase09Routes is the D-09 admin surface mounted by service.mount.
|
||||
// A handler added outside this set, or a protected handler missing the
|
||||
// backend guard, fails TestPhase09PermissionMatrix.
|
||||
var phase09Routes = []struct {
|
||||
key string
|
||||
public bool
|
||||
}{
|
||||
{"POST /_admin/api/v1/auth/login", true},
|
||||
{"POST /_admin/api/v1/auth/refresh", true},
|
||||
{"POST /_admin/api/v1/auth/logout", false},
|
||||
{"GET /_admin/api/v1/auth/me", false},
|
||||
{"GET /_admin/api/v1/navigation", false},
|
||||
{"GET /_admin/api/v1/settings", false},
|
||||
{"GET /_admin/api/v1/settings/{code}/schema", false},
|
||||
{"GET /_admin/api/v1/settings/{code}", false},
|
||||
{"PUT /_admin/api/v1/settings/{code}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
|
||||
{"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
|
||||
{"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false},
|
||||
}
|
||||
|
||||
func TestPhase09PermissionMatrix(t *testing.T) {
|
||||
router := &captureRouter{}
|
||||
(&service{}).mount(router)
|
||||
got := map[string][]string{}
|
||||
for _, key := range router.routes {
|
||||
if _, exists := got[key]; exists {
|
||||
t.Fatalf("route %s registered more than once", key)
|
||||
}
|
||||
got[key] = router.middleware[key]
|
||||
}
|
||||
if len(got) != len(phase09Routes) {
|
||||
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes)
|
||||
}
|
||||
for _, route := range phase09Routes {
|
||||
mw, ok := got[route.key]
|
||||
if !ok {
|
||||
t.Fatalf("missing mounted route %s", route.key)
|
||||
}
|
||||
hasBackend := false
|
||||
for _, name := range mw {
|
||||
if name == "backend" {
|
||||
hasBackend = true
|
||||
}
|
||||
}
|
||||
if route.public && hasBackend {
|
||||
t.Fatalf("%s is a public auth route but carries the backend guard", route.key)
|
||||
}
|
||||
if !route.public && !hasBackend {
|
||||
t.Fatalf("%s is missing the backend guard: %v", route.key, mw)
|
||||
}
|
||||
}
|
||||
|
||||
svc := phase09DeniedService()
|
||||
denied := &bouncer.Principal{ID: 4, Backend: true}
|
||||
frontend := &bouncer.Principal{ID: 4, Backend: false, PermissionGrants: map[string]bool{"acme.demo.access": true}}
|
||||
for _, call := range phase09ProtectedCalls() {
|
||||
t.Run("denied "+call.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
call.fn(svc, rec, phase09Request(denied))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||
})
|
||||
t.Run("frontend "+call.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
call.fn(svc, rec, phase09Request(frontend))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
|
||||
})
|
||||
}
|
||||
|
||||
for _, call := range []phase09Call{
|
||||
{"navigation", (*service).navigation},
|
||||
{"settings-list", (*service).settingsList},
|
||||
} {
|
||||
t.Run("filtered "+call.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
call.fn(svc, rec, phase09Request(denied))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"data":[]`) {
|
||||
t.Fatalf("permissionless metadata was not an empty list: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase09SecurityCoverage(t *testing.T) {
|
||||
t.Run("mass assignment", func(t *testing.T) {
|
||||
cc := &CompiledController{Writable: []WritableField{
|
||||
{Name: "name", FillKey: "name"},
|
||||
{Name: "password", FillKey: "password"},
|
||||
{Name: "role_id", FillKey: "role_id"},
|
||||
}}
|
||||
got := ProjectWritableFields(cc, map[string]any{
|
||||
"name": "Ada",
|
||||
"Name": "Case",
|
||||
"password": "hunter2",
|
||||
"role_id": 1,
|
||||
"extra": "nope",
|
||||
"nested": map[string]any{"id": 1},
|
||||
})
|
||||
if len(got) != 1 || got["name"] != "Ada" {
|
||||
t.Fatalf("projected = %#v, want only name", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("identifier injection", func(t *testing.T) {
|
||||
for _, raw := range []string{"", "1;drop", "1 OR 1", "../1", "-1", "1.5", "0x10"} {
|
||||
req := phase09Request(nil)
|
||||
req.SetPathValue("id", raw)
|
||||
if _, err := pathID(req); err == nil {
|
||||
t.Fatalf("path id %q was accepted", raw)
|
||||
}
|
||||
}
|
||||
req := phase09Request(nil)
|
||||
req.SetPathValue("id", "15")
|
||||
id, err := pathID(req)
|
||||
if err != nil || id != 15 {
|
||||
t.Fatalf("id=%d err=%v", id, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auth log redaction", func(t *testing.T) {
|
||||
const secret = "summercms-test-only-admin-hs256-secret"
|
||||
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature"
|
||||
var buf bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
req := phase09Request(nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.URL.RawQuery = "password=" + secret
|
||||
(&service{}).logAuth(req, "denied", 7)
|
||||
logged := buf.String()
|
||||
for _, leak := range []string{secret, token, "eyJ", "hunter2", "password="} {
|
||||
if strings.Contains(logged, leak) {
|
||||
t.Fatalf("auth log contains %q: %s", leak, logged)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logged, `"outcome":"denied"`) || !strings.Contains(logged, `"admin_id":7`) {
|
||||
t.Fatalf("auth log dropped the outcome: %s", logged)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pivot body", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/relations/editors/link", strings.NewReader(`{"ids":[1],"role":"developer"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if _, err := decodeRelationMutation(req); err == nil {
|
||||
t.Fatal("forged pivot field was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hook failure rolls back", func(t *testing.T) {
|
||||
svc, _, cc, db, hooks := hookFixture(t)
|
||||
hooks.fail = "form_before_create"
|
||||
ctx := principalCtx(hooks, superUser())
|
||||
if _, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada", "password": "hunter2"}}); err == nil {
|
||||
t.Fatal("failing before-create hook was ignored")
|
||||
}
|
||||
if n := countCrud(t, db); n != 0 {
|
||||
t.Fatalf("rows=%d after rejected create", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission before list query", func(t *testing.T) {
|
||||
listSvc, _ := newListService(t)
|
||||
locked := *listSvc
|
||||
current := locked.reg.byID["acme.demo.widgets"]
|
||||
locked.reg = &Registry{byID: map[string]*CompiledController{
|
||||
"acme.demo.widgets": {
|
||||
Controller: queryController{perms: []string{"acme.demo.access"}},
|
||||
List: current.List,
|
||||
},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
req := phase09Request(&bouncer.Principal{ID: 4, Backend: true})
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "widgets")
|
||||
req.URL.RawQuery = "sort=name%3Bdrop"
|
||||
locked.list(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "drop") {
|
||||
t.Fatalf("denial body echoed the identifier: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type phase09Call struct {
|
||||
name string
|
||||
fn func(*service, http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
func phase09ProtectedCalls() []phase09Call {
|
||||
return []phase09Call{
|
||||
{"list", (*service).list},
|
||||
{"create", (*service).create},
|
||||
{"bulk-delete", (*service).bulkDelete},
|
||||
{"show", (*service).show},
|
||||
{"update", (*service).update},
|
||||
{"delete", (*service).deleteRecord},
|
||||
{"list-schema", (*service).listSchema},
|
||||
{"form-schema", (*service).formSchema},
|
||||
{"relation-schema", (*service).relationSchema},
|
||||
{"relation-linked", (*service).relationLinked},
|
||||
{"relation-candidates", (*service).relationCandidates},
|
||||
{"relation-link", (*service).relationLink},
|
||||
{"relation-unlink", (*service).relationUnlink},
|
||||
{"settings-schema", (*service).settingsSchema},
|
||||
{"settings-get", (*service).settingsGet},
|
||||
{"settings-put", (*service).settingsPut},
|
||||
}
|
||||
}
|
||||
|
||||
func phase09DeniedService() *service {
|
||||
controller := orderController{perms: []string{"acme.demo.access"}}
|
||||
return &service{reg: &Registry{
|
||||
byID: map[string]*CompiledController{
|
||||
"acme.demo.widgets": {Controller: controller, Relations: map[string]*CompiledRelation{}},
|
||||
},
|
||||
settings: map[string]*CompiledSetting{
|
||||
"demo": {Item: pact.SettingsItem{Code: "demo", Permissions: []string{"acme.demo.manage_settings"}}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func phase09Request(principal *bouncer.Principal) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/_admin/api/v1/acme/demo/widgets/1/relations/editors/link", strings.NewReader(`{}`))
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "widgets")
|
||||
req.SetPathValue("id", "1")
|
||||
req.SetPathValue("name", "editors")
|
||||
req.SetPathValue("code", "demo")
|
||||
if principal != nil {
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
||||
}
|
||||
return req
|
||||
}
|
||||
@@ -12,6 +12,77 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPhase09MigrationsFreshRollback(t *testing.T) {
|
||||
assertNoAutoMigrate(t)
|
||||
db, _ := dedicatedDB(t, "phase09_fresh")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin := migPlugin{
|
||||
id: "demo.keep",
|
||||
migrations: []*gormigrate.Migration{{
|
||||
ID: "202609240010_keep",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE phase09_keep (id BIGSERIAL PRIMARY KEY)`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS phase09_keep`).Error
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := Migrate(gdb, []party.Plugin{plugin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("system_files") || !gdb.Migrator().HasTable("backend_users") || !gdb.Migrator().HasTable("phase09_keep") {
|
||||
t.Fatal("fresh migrate did not keep framework, admin, and plugin tables together")
|
||||
}
|
||||
before := systemRoles(t, gdb)
|
||||
if len(before) != 2 {
|
||||
t.Fatalf("seeded roles = %v", before)
|
||||
}
|
||||
if err := Migrate(gdb, []party.Plugin{plugin}); err != nil {
|
||||
t.Fatalf("repeated migrate: %v", err)
|
||||
}
|
||||
if after := systemRoles(t, gdb); len(after) != 2 || after["developer"].id != before["developer"].id || after["publisher"].id != before["publisher"].id {
|
||||
t.Fatalf("roles changed on repeat: before=%v after=%v", before, after)
|
||||
}
|
||||
admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := admin.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist"} {
|
||||
if gdb.Migrator().HasTable(name) {
|
||||
t.Fatalf("%s survived admin rollback", name)
|
||||
}
|
||||
}
|
||||
if !gdb.Migrator().HasTable("system_files") || !gdb.Migrator().HasTable("phase09_keep") {
|
||||
t.Fatal("admin rollback removed a framework or plugin table")
|
||||
}
|
||||
var attachIDs, pluginIDs []string
|
||||
if err := gdb.Table("summer_migrations_summercms_attach").Pluck("id", &attachIDs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Table("summer_migrations_demo_keep").Pluck("id", &pluginIDs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(attachIDs, ",") == "" || strings.Join(pluginIDs, ",") != "202609240010_keep" {
|
||||
t.Fatalf("histories attach=%v plugin=%v", attachIDs, pluginIDs)
|
||||
}
|
||||
if err := Migrate(gdb, []party.Plugin{plugin}); err != nil {
|
||||
t.Fatalf("migrate after rollback: %v", err)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("backend_users") {
|
||||
t.Fatal("admin tables were not recreated")
|
||||
}
|
||||
if again := systemRoles(t, gdb); len(again) != 2 {
|
||||
t.Fatalf("roles after recreate = %v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendAdminMigration(t *testing.T) {
|
||||
assertNoAutoMigrate(t)
|
||||
db, _ := dedicatedDB(t, "lagoon_admin_mig")
|
||||
|
||||
Reference in New Issue
Block a user