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:
55
admin/tests/fixtures/navigation.json
vendored
Normal file
55
admin/tests/fixtures/navigation.json
vendored
Normal 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" }
|
||||
}
|
||||
22
admin/tests/fixtures/widgets.list-schema.json
vendored
Normal file
22
admin/tests/fixtures/widgets.list-schema.json
vendored
Normal 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" }
|
||||
}
|
||||
7
admin/tests/fixtures/widgets.list.json
vendored
Normal file
7
admin/tests/fixtures/widgets.list.json
vendored
Normal 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
6
admin/tests/setup.ts
Normal 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)
|
||||
199
admin/tests/smoke/tracer.smoke.test.ts
Normal file
199
admin/tests/smoke/tracer.smoke.test.ts
Normal 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', '—'],
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user