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:
Jakub Zych
2026-09-27 15:21:48 +02:00
parent 8c3e131111
commit 5f9353841b
79 changed files with 10745 additions and 147 deletions

View File

@@ -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
}