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

55
admin/tests/fixtures/navigation.json vendored Normal file
View File

@@ -0,0 +1,55 @@
{
"data": [
{
"code": "demo",
"label": "Demo",
"icon": "disc-3",
"order": 100,
"controller": "acme.demo.widgets",
"sideMenu": [
{
"code": "widgets",
"label": "Widgets",
"icon": "tags",
"order": 0,
"controller": "acme.demo.widgets",
"sideMenu": []
},
{
"code": "gadgets",
"label": "Gadgets",
"icon": "icon-archive",
"order": 0,
"controller": "acme.demo.gadgets",
"sideMenu": []
}
]
},
{
"code": "tools",
"label": "Tools",
"icon": "unknown-icon-name",
"order": 300,
"controller": "acme.tools.hammers",
"sideMenu": [
{
"code": "hammers",
"label": "Hammers",
"icon": "puzzle",
"order": 0,
"controller": "acme.tools.hammers",
"sideMenu": []
}
]
},
{
"code": "hidden",
"label": "Hidden",
"icon": "users",
"order": 200,
"controller": "acme.hidden.items",
"sideMenu": []
}
],
"meta": { "locale": "en" }
}

View File

@@ -0,0 +1,22 @@
{
"data": {
"title": "Widgets",
"recordsPerPage": 20,
"perPageOptions": [],
"showSearch": true,
"showSetup": false,
"showCheckboxes": true,
"showSorting": true,
"searchTerm": "",
"toolbarButtons": ["create"],
"columns": [
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
{ "key": "code", "label": "Code", "searchable": true, "sortable": true },
{ "key": "tags", "label": "Tags", "searchable": false, "sortable": false, "type": "relation", "relation": "tags", "select": "name" }
],
"filters": [],
"rowActions": [],
"bulkActions": []
},
"meta": { "locale": "en" }
}

View File

@@ -0,0 +1,7 @@
{
"data": [
{ "id": 1, "name": "Blue widget", "code": "W-01", "tags": ["small", "round"] },
{ "id": 2, "name": "Green widget", "code": "W-02", "tags": [] }
],
"meta": { "page": 1, "per_page": 20, "total": 2, "last_page": 1 }
}

6
admin/tests/setup.ts Normal file
View File

@@ -0,0 +1,6 @@
// The served index.html carries the admin base in a meta element; tests use a
// non-default base to prove the SPA reads it at runtime (D-02, D-03).
const meta = document.createElement('meta')
meta.setAttribute('name', 'summer-admin-base')
meta.setAttribute('content', '/admin-test')
document.head.appendChild(meta)

View File

@@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory } from 'vue-router'
import App from '../../src/App.vue'
import LoginView from '../../src/views/LoginView.vue'
import { createAdminRouter, safeRedirect } from '../../src/app/router'
import { runtime } from '../../src/app/runtime'
import { onUnauthorized } from '../../src/api/client'
import { clearUser, me, useAuth } from '../../src/state/useAuth'
import { loadNavigation, setNavigation } from '../../src/state/useNavigation'
import navigation from '../fixtures/navigation.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
const API = '/admin-test/api/v1'
const profile = {
data: {
id: 7,
login: 'dev',
email: 'dev@example.test',
first_name: 'Dana',
last_name: 'Dev',
is_superuser: false,
role: { id: 2, code: 'developer', name: 'Developer' },
},
meta: {},
}
type Routes = Record<string, { status?: number; body: unknown }>
function mockApi(routes: Routes): Request[] {
const calls: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const request = input as Request
calls.push(request)
const key = `${request.method} ${new URL(request.url).pathname}`
const route = routes[key]
const status = route ? (route.status ?? 200) : 404
const body = route ? route.body : { error: { code: 'not_found', message: 'Not found', details: {} } }
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
})
return calls
}
function pathOf(request: Request): string {
return new URL(request.url).pathname
}
beforeEach(() => {
clearUser()
setNavigation([])
onUnauthorized(null)
})
afterEach(() => {
localStorage.clear()
sessionStorage.clear()
})
describe('runtime', () => {
it('reads the admin base and API base from the served meta', () => {
expect(runtime.base).toBe('/admin-test')
expect(runtime.api).toBe(API)
})
})
describe('login', () => {
it('posts the CSRF header with same-origin credentials and stores no token', async () => {
const calls = mockApi({
[`POST ${API}/auth/login`]: {
body: { data: { token_type: 'cookie', expires_in: 3600, access_token: 'must-not-be-kept' }, meta: {} },
},
})
const auth = useAuth()
expect(await auth.login('dev', 'secret')).toBe(true)
expect(calls).toHaveLength(1)
const request = calls[0]!
expect(request.method).toBe('POST')
expect(pathOf(request)).toBe(`${API}/auth/login`)
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(request.credentials).toBe('same-origin')
expect(await request.json()).toEqual({ login: 'dev', password: 'secret' })
expect(auth.expiresIn.value).toBe(3600)
expect(localStorage.length).toBe(0)
expect(sessionStorage.length).toBe(0)
expect(document.cookie).not.toContain('must-not-be-kept')
expect(JSON.stringify({ user: auth.user.value, expiresIn: auth.expiresIn.value })).not.toContain('must-not-be-kept')
})
it('shows the invalid-credentials alert and marks the password invalid', async () => {
mockApi({
[`POST ${API}/auth/login`]: {
status: 401,
body: { error: { code: 'unauthenticated', message: 'Invalid credentials', details: {} } },
},
})
const router = createAdminRouter(createMemoryHistory())
await router.push('/login')
const wrapper = mount(LoginView, { global: { plugins: [router] } })
await wrapper.find('input[name="login"]').setValue('dev')
await wrapper.find('input[name="password"]').setValue('wrong')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('input[name="password"]').attributes('aria-invalid')).toBe('true')
expect(router.currentRoute.value.name).toBe('login')
})
})
describe('redirects', () => {
it('accepts only in-app paths that start with exactly one slash', () => {
expect(safeRedirect('/acme/demo/widgets?page=2')).toBe('/acme/demo/widgets?page=2')
expect(safeRedirect('https://evil.example/steal')).toBeNull()
expect(safeRedirect('//evil.example/steal')).toBeNull()
expect(safeRedirect('/\\evil.example')).toBeNull()
expect(safeRedirect('acme/demo')).toBeNull()
expect(safeRedirect(['/acme'])).toBeNull()
})
it('sends an unauthenticated visitor to login with the requested path', async () => {
const router = createAdminRouter(createMemoryHistory())
await router.push('/acme/demo/widgets?page=2')
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets?page=2')
})
it('reports a 401 on any call other than login', async () => {
mockApi({
[`GET ${API}/auth/me`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } } },
[`POST ${API}/auth/login`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'Invalid credentials', details: {} } } },
})
const handler = vi.fn()
onUnauthorized(handler)
await useAuth().login('dev', 'wrong')
expect(handler).not.toHaveBeenCalled()
await me()
expect(handler).toHaveBeenCalledTimes(1)
})
})
describe('navigation shell and list', () => {
async function mountApp() {
const calls = mockApi({
[`GET ${API}/auth/me`]: { body: profile },
[`GET ${API}/navigation`]: { body: navigation },
[`GET ${API}/acme/demo/widgets/schema/list`]: { body: listSchema },
[`GET ${API}/acme/demo/widgets`]: { body: listRows },
})
await me()
await loadNavigation()
const router = createAdminRouter(createMemoryHistory())
await router.push('/acme/demo/widgets')
const wrapper = mount(App, { global: { plugins: [router] } })
await flushPromises()
return { wrapper, calls, router }
}
it('renders navigation grouped by plugin and omits a plugin with an empty side menu', async () => {
const { wrapper } = await mountApp()
const rail = wrapper.find('nav[aria-label="backend::lang.nav.plugins"]')
expect(rail.exists()).toBe(true)
const railLinks = rail.findAll('a')
expect(railLinks.map((link) => link.text())).toEqual(['Demo', 'Tools'])
expect(railLinks[0]!.attributes('aria-current')).toBe('page')
expect(railLinks[1]!.attributes('aria-current')).toBeUndefined()
expect(railLinks[1]!.attributes('href')).toBe('/acme/tools/hammers')
// An unknown icon name renders the neutral fallback instead of failing.
expect(railLinks[1]!.find('svg').exists()).toBe(true)
const panel = wrapper.find('aside')
const panelLinks = panel.findAll('a')
expect(panel.text()).toContain('Demo')
expect(panelLinks.map((link) => link.text())).toEqual(['Widgets', 'Gadgets'])
expect(panelLinks[0]!.attributes('aria-current')).toBe('page')
expect(panelLinks[1]!.attributes('href')).toBe('/acme/demo/gadgets')
})
it('renders the list schema columns and rows through the typed client', async () => {
const { wrapper, calls } = await mountApp()
const requested = calls.map(pathOf)
expect(requested).toContain(`${API}/acme/demo/widgets/schema/list`)
expect(requested).toContain(`${API}/acme/demo/widgets`)
for (const request of calls) {
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
}
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
const rows = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
expect(rows).toEqual([
['Blue widget', 'W-01', 'small, round'],
['Green widget', 'W-02', '—'],
])
})
})