feat(10-01): serve the embedded admin SPA at backend.uri with cookie login
- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
through the openapi-fetch client typed by the generated schema
This commit is contained in:
@@ -42,7 +42,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false)
|
||||
login := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "life",
|
||||
"password": adminTestPassword,
|
||||
})
|
||||
@@ -63,7 +63,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if !stamped.Valid {
|
||||
t.Fatal("successful login did not stamp last_login")
|
||||
}
|
||||
byEmail := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
byEmail := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"email": "life@example.test",
|
||||
"password": adminTestPassword,
|
||||
})
|
||||
@@ -71,12 +71,12 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
|
||||
}
|
||||
emailToken := accessToken(t, byEmail.Body.Bytes())
|
||||
me := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
|
||||
me := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
||||
if me.Code != http.StatusOK {
|
||||
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
|
||||
}
|
||||
assertSafeProfile(t, me.Body.Bytes(), user)
|
||||
refreshed := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", emailToken, nil)
|
||||
refreshed := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), emailToken, nil)
|
||||
if refreshed.Code != http.StatusOK {
|
||||
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if jwtAudience(t, next) != "backend" {
|
||||
t.Fatal("refreshed token lost the backend audience")
|
||||
}
|
||||
oldMe := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
|
||||
oldMe := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
||||
if oldMe.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String())
|
||||
}
|
||||
@@ -98,14 +98,14 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if blacklisted != 1 {
|
||||
t.Fatalf("previous jti blacklist rows=%d", blacklisted)
|
||||
}
|
||||
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", next, nil)
|
||||
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), next, nil)
|
||||
if out.Code != http.StatusOK {
|
||||
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
||||
}
|
||||
if strings.Contains(out.Body.String(), next) {
|
||||
t.Fatal("logout body contains the token")
|
||||
}
|
||||
after := getAuth(t, h, "/_admin/api/v1/auth/me", next)
|
||||
after := getAuth(t, h, adminAPI("/auth/me"), next)
|
||||
if after.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me after logout status=%d", after.Code)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
fresh := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "life", "password": adminTestPassword,
|
||||
}).Body.Bytes())
|
||||
// Login mints after the cutoff, so this token is current. Move the cutoff
|
||||
@@ -121,7 +121,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := getAuth(t, h, "/_admin/api/v1/auth/me", fresh)
|
||||
stale := getAuth(t, h, adminAPI("/auth/me"), fresh)
|
||||
if stale.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
|
||||
}
|
||||
@@ -131,9 +131,9 @@ func TestAdminInactive(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false)
|
||||
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody", "password": adminTestPassword})
|
||||
wrong := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": "wrong-password"})
|
||||
right := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": adminTestPassword})
|
||||
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody", "password": adminTestPassword})
|
||||
wrong := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": "wrong-password"})
|
||||
right := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": adminTestPassword})
|
||||
assertSameOpaque(t, unknown, wrong, right)
|
||||
var stamped sql.NullTime
|
||||
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil {
|
||||
@@ -148,8 +148,8 @@ func TestAdminDeleted(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true)
|
||||
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody-else", "password": adminTestPassword})
|
||||
deleted := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "deleted", "password": adminTestPassword})
|
||||
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody-else", "password": adminTestPassword})
|
||||
deleted := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "deleted", "password": adminTestPassword})
|
||||
assertSameOpaque(t, unknown, deleted)
|
||||
if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") {
|
||||
t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String())
|
||||
@@ -160,14 +160,14 @@ func TestAdminBlacklist(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false)
|
||||
token := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
token := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "revoke", "password": adminTestPassword,
|
||||
}).Body.Bytes())
|
||||
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", token, nil)
|
||||
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
|
||||
if out.Code != http.StatusOK {
|
||||
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
||||
}
|
||||
again := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", token, nil)
|
||||
again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
|
||||
if again.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func TestAdminLoginThrottle(t *testing.T) {
|
||||
})
|
||||
var last *httptest.ResponseRecorder
|
||||
for i := 0; i < 3; i++ {
|
||||
last = postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
last = postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "throttle-user", "password": adminTestPassword,
|
||||
})
|
||||
}
|
||||
@@ -217,16 +217,16 @@ func TestAdminAuthLogging(t *testing.T) {
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
ok := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": adminTestPassword})
|
||||
ok := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": adminTestPassword})
|
||||
token := accessToken(t, ok.Body.Bytes())
|
||||
assertLog(t, &buf, "success", user.ID, adminTestPassword, token)
|
||||
buf.Reset()
|
||||
|
||||
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": "not-the-password"})
|
||||
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": "not-the-password"})
|
||||
assertLog(t, &buf, "failed", user.ID, "not-the-password", "")
|
||||
buf.Reset()
|
||||
|
||||
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "missing-logged", "password": "not-the-password"})
|
||||
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "missing-logged", "password": "not-the-password"})
|
||||
failedUnknown := buf.String()
|
||||
if !strings.Contains(failedUnknown, `"outcome":"failed"`) {
|
||||
t.Fatalf("unknown login log = %s", failedUnknown)
|
||||
@@ -236,7 +236,7 @@ func TestAdminAuthLogging(t *testing.T) {
|
||||
}
|
||||
buf.Reset()
|
||||
|
||||
denied := getAuth(t, h, "/_admin/api/v1/acme/demo/widgets", token)
|
||||
denied := getAuth(t, h, adminAPI("/acme/demo/widgets"), token)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
|
||||
}
|
||||
@@ -551,3 +551,9 @@ func jwtClaims(t *testing.T, token string) map[string]any {
|
||||
func itoa(id uint) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// adminAPI mirrors the internal helper in admin_paths_test.go for this
|
||||
// external test package: a full admin API path under the default prefix.
|
||||
func adminAPI(rel string) string {
|
||||
return cabana.DefaultAdminPrefix + "/api/v1" + rel
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user