major: kestrel is now a tak server (#6)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
All checks were successful
ci/woodpecker/push/push Pipeline was successful
## Added - CoT (Cursor on Target) server on port 8089 enabling ATAK/iTAK device connectivity - Support for TAK stream protocol and traditional XML CoT messages - TLS/SSL support with automatic fallback to plain TCP - Username/password authentication for CoT connections - Real-time device position tracking with TTL-based expiration (90s default) - API endpoints: `/api/cot/config`, `/api/cot/server-package`, `/api/cot/truststore`, `/api/me/cot-password` - TAK Server section in Settings with QR code for iTAK setup - ATAK password management in Account page for OIDC users - CoT device markers on map showing real-time positions - Comprehensive documentation in `docs/` directory - Environment variables: `COT_PORT`, `COT_TTL_MS`, `COT_REQUIRE_AUTH`, `COT_SSL_CERT`, `COT_SSL_KEY`, `COT_DEBUG` - Dependencies: `fast-xml-parser`, `jszip`, `qrcode` ## Changed - Authentication system supports CoT password management for OIDC users - Database schema includes `cot_password_hash` field - Test suite refactored to follow functional design principles ## Removed - Consolidated utility modules: `authConfig.js`, `authSkipPaths.js`, `bootstrap.js`, `poiConstants.js`, `session.js` ## Security - XML entity expansion protection in CoT parser - Enhanced input validation and SQL injection prevention - Authentication timeout to prevent hanging connections ## Breaking Changes - Port 8089 must be exposed for CoT server. Update firewall rules and Docker/Kubernetes configurations. ## Migration Notes - OIDC users must set ATAK password via Account settings before connecting - Docker: expose port 8089 (`-p 8089:8089`) - Kubernetes: update Helm values to expose port 8089 Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -2,84 +2,58 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
||||
import CameraViewer from '../../app/components/CameraViewer.vue'
|
||||
|
||||
const createCamera = (overrides = {}) => ({
|
||||
id: 't1',
|
||||
name: 'Test Camera',
|
||||
streamUrl: 'https://example.com/stream.mjpg',
|
||||
sourceType: 'mjpeg',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('CameraViewer (device stream)', () => {
|
||||
it('renders device name and close button', async () => {
|
||||
const camera = {
|
||||
id: 't1',
|
||||
name: 'Test Camera',
|
||||
streamUrl: 'https://example.com/stream.mjpg',
|
||||
sourceType: 'mjpeg',
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera },
|
||||
props: { camera: createCamera({ name: 'Test Camera' }) },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Test Camera')
|
||||
expect(wrapper.find('button[aria-label="Close panel"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not set img src for non-http streamUrl', async () => {
|
||||
const camera = {
|
||||
id: 't2',
|
||||
name: 'Bad',
|
||||
streamUrl: 'javascript:alert(1)',
|
||||
sourceType: 'mjpeg',
|
||||
}
|
||||
it.each([
|
||||
['javascript:alert(1)', false],
|
||||
['https://example.com/cam.mjpg', true],
|
||||
])('handles streamUrl: %s -> img exists: %s', async (streamUrl, shouldExist) => {
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera },
|
||||
props: { camera: createCamera({ streamUrl }) },
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses safe http streamUrl for img', async () => {
|
||||
const camera = {
|
||||
id: 't3',
|
||||
name: 'OK',
|
||||
streamUrl: 'https://example.com/cam.mjpg',
|
||||
sourceType: 'mjpeg',
|
||||
expect(img.exists()).toBe(shouldExist)
|
||||
if (shouldExist) {
|
||||
expect(img.attributes('src')).toBe(streamUrl)
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera },
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('https://example.com/cam.mjpg')
|
||||
})
|
||||
|
||||
it('emits close when close button clicked', async () => {
|
||||
const camera = {
|
||||
id: 't5',
|
||||
name: 'Close me',
|
||||
streamUrl: '',
|
||||
sourceType: 'mjpeg',
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera: createCamera() },
|
||||
})
|
||||
await wrapper.find('button[aria-label="Close panel"]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows stream unavailable when img errors', async () => {
|
||||
const camera = {
|
||||
id: 't6',
|
||||
name: 'Broken',
|
||||
streamUrl: 'https://example.com/bad.mjpg',
|
||||
sourceType: 'mjpeg',
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
|
||||
const img = wrapper.find('img')
|
||||
await img.trigger('error')
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera: createCamera({ streamUrl: 'https://example.com/bad.mjpg' }) },
|
||||
})
|
||||
await wrapper.find('img').trigger('error')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('Stream unavailable')
|
||||
})
|
||||
|
||||
it('renders video element for hls sourceType', async () => {
|
||||
const camera = {
|
||||
id: 't7',
|
||||
name: 'HLS Camera',
|
||||
streamUrl: 'https://example.com/stream.m3u8',
|
||||
sourceType: 'hls',
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera: createCamera({ sourceType: 'hls', streamUrl: 'https://example.com/stream.m3u8' }) },
|
||||
})
|
||||
expect(wrapper.find('video').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,58 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import NavDrawer from '../../app/components/NavDrawer.vue'
|
||||
|
||||
const withAuth = () => {
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||
const mountDrawer = (props = {}) => {
|
||||
return mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true, ...props },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('NavDrawer', () => {
|
||||
it('renders navigation links with correct paths', async () => {
|
||||
withAuth()
|
||||
await mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const links = document.body.querySelectorAll('aside nav a[href]')
|
||||
const hrefs = [...links].map(a => a.getAttribute('href'))
|
||||
expect(hrefs).toContain('/')
|
||||
expect(hrefs).toContain('/account')
|
||||
expect(hrefs).toContain('/cameras')
|
||||
expect(hrefs).toContain('/poi')
|
||||
expect(hrefs).toContain('/members')
|
||||
expect(hrefs).toContain('/settings')
|
||||
expect(links.length).toBeGreaterThanOrEqual(6)
|
||||
beforeEach(() => {
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||
})
|
||||
|
||||
it('renders Map and Settings labels', async () => {
|
||||
withAuth()
|
||||
await mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(document.body.textContent).toContain('Map')
|
||||
expect(document.body.textContent).toContain('Settings')
|
||||
it('renders navigation links with correct paths', async () => {
|
||||
await mountDrawer()
|
||||
const hrefs = [...document.body.querySelectorAll('aside nav a[href]')].map(a => a.getAttribute('href'))
|
||||
expect(hrefs).toEqual(expect.arrayContaining(['/', '/account', '/cameras', '/poi', '/members', '/settings']))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Map'],
|
||||
['Settings'],
|
||||
])('renders %s label', async (label) => {
|
||||
await mountDrawer()
|
||||
expect(document.body.textContent).toContain(label)
|
||||
})
|
||||
|
||||
it('emits update:modelValue when close is triggered', async () => {
|
||||
withAuth()
|
||||
const wrapper = await mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const wrapper = await mountDrawer()
|
||||
expect(document.body.querySelector('aside button[aria-label="Close navigation"]')).toBeTruthy()
|
||||
await wrapper.vm.close()
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('applies active styling for current route', async () => {
|
||||
withAuth()
|
||||
await mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await mountDrawer()
|
||||
const mapLink = document.body.querySelector('aside nav a[href="/"]')
|
||||
expect(mapLink).toBeTruthy()
|
||||
expect(mapLink.className).toMatch(/kestrel-accent|border-kestrel-accent/)
|
||||
expect(mapLink?.className).toMatch(/kestrel-accent|border-kestrel-accent/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,13 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Index from '../../app/pages/index.vue'
|
||||
import Login from '../../app/pages/login.vue'
|
||||
|
||||
const wait = (ms = 200) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const setupProtectedEndpoints = () => {
|
||||
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
|
||||
registerEndpoint('/api/pois', () => [], { method: 'GET' })
|
||||
}
|
||||
|
||||
describe('auth middleware', () => {
|
||||
it('allows /login without redirect when unauthenticated', async () => {
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
@@ -11,28 +18,25 @@ describe('auth middleware', () => {
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('redirects to /login with redirect query when unauthenticated and visiting protected route', async () => {
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
|
||||
registerEndpoint('/api/pois', () => [], { method: 'GET' })
|
||||
it.each([
|
||||
[() => null, '/login', { redirect: '/' }],
|
||||
[
|
||||
() => {
|
||||
throw createError({ statusCode: 401 })
|
||||
},
|
||||
'/login',
|
||||
undefined,
|
||||
],
|
||||
])('redirects to /login when unauthenticated: %s', async (meResponse, expectedPath, expectedQuery) => {
|
||||
registerEndpoint('/api/me', meResponse, { method: 'GET' })
|
||||
setupProtectedEndpoints()
|
||||
await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
await wait(meResponse.toString().includes('401') ? 250 : 200)
|
||||
const router = useRouter()
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/')
|
||||
})
|
||||
|
||||
it('401 handler redirects to login when API returns 401', async () => {
|
||||
registerEndpoint('/api/me', () => {
|
||||
throw createError({ statusCode: 401 })
|
||||
}, { method: 'GET' })
|
||||
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
|
||||
registerEndpoint('/api/pois', () => [], { method: 'GET' })
|
||||
await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
const router = useRouter()
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.path).toBe(expectedPath)
|
||||
if (expectedQuery) {
|
||||
expect(router.currentRoute.value.query).toMatchObject(expectedQuery)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,46 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import DefaultLayout from '../../app/layouts/default.vue'
|
||||
import NavDrawer from '../../app/components/NavDrawer.vue'
|
||||
|
||||
const withAuth = () => {
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||
}
|
||||
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
describe('default layout', () => {
|
||||
it('renders KestrelOS header', async () => {
|
||||
withAuth()
|
||||
const wrapper = await mountSuspended(DefaultLayout)
|
||||
expect(wrapper.text()).toContain('KestrelOS')
|
||||
beforeEach(() => {
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||
})
|
||||
|
||||
it('renders drawer toggle with accessible label on mobile', async () => {
|
||||
withAuth()
|
||||
it.each([
|
||||
['KestrelOS header', 'KestrelOS'],
|
||||
['drawer toggle', 'button[aria-label="Toggle navigation"]'],
|
||||
])('renders %s', async (description, selector) => {
|
||||
const wrapper = await mountSuspended(DefaultLayout)
|
||||
const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
|
||||
expect(toggle.exists()).toBe(true)
|
||||
if (selector.startsWith('button')) {
|
||||
expect(wrapper.find(selector).exists()).toBe(true)
|
||||
}
|
||||
else {
|
||||
expect(wrapper.text()).toContain(selector)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders NavDrawer', async () => {
|
||||
withAuth()
|
||||
const wrapper = await mountSuspended(DefaultLayout)
|
||||
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders user menu and sign out navigates home', async () => {
|
||||
withAuth()
|
||||
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
|
||||
const wrapper = await mountSuspended(DefaultLayout)
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await wait()
|
||||
const menuTrigger = wrapper.find('button[aria-label="User menu"]')
|
||||
expect(menuTrigger.exists()).toBe(true)
|
||||
await menuTrigger.trigger('click')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
await wait(50)
|
||||
const signOut = wrapper.find('button[role="menuitem"]')
|
||||
expect(signOut.exists()).toBe(true)
|
||||
expect(signOut.text()).toContain('Sign out')
|
||||
await signOut.trigger('click')
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await wait()
|
||||
const router = useRouter()
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.path).toBe('/')
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Index from '../../app/pages/index.vue'
|
||||
|
||||
const wait = (ms = 150) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
describe('index page', () => {
|
||||
it('renders map and uses cameras', async () => {
|
||||
registerEndpoint('/api/cameras', () => ({
|
||||
@@ -11,7 +13,7 @@ describe('index page', () => {
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
await wait()
|
||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
102
test/nuxt/logger.spec.js
Normal file
102
test/nuxt/logger.spec.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import { readBody } from 'h3'
|
||||
import { initLogger, logError, logWarn, logInfo, logDebug } from '../../app/utils/logger.js'
|
||||
|
||||
const wait = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
describe('app/utils/logger', () => {
|
||||
const consoleMocks = {}
|
||||
const originalConsole = {}
|
||||
const testState = {
|
||||
serverCalls: [],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testState.serverCalls = []
|
||||
const calls = { log: [], error: [], warn: [], debug: [] }
|
||||
|
||||
Object.keys(calls).forEach((key) => {
|
||||
originalConsole[key] = console[key]
|
||||
consoleMocks[key] = vi.fn((...args) => calls[key].push(args))
|
||||
console[key] = consoleMocks[key]
|
||||
})
|
||||
|
||||
registerEndpoint('/api/log', async (event) => {
|
||||
const body = event.body || (await readBody(event).catch(() => ({})))
|
||||
testState.serverCalls.push(body)
|
||||
return { ok: true }
|
||||
}, { method: 'POST' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(originalConsole).forEach((key) => {
|
||||
console[key] = originalConsole[key]
|
||||
})
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initLogger', () => {
|
||||
it('sets sessionId and userId for server calls', async () => {
|
||||
initLogger('session-123', 'user-456')
|
||||
logError('Test message')
|
||||
await wait()
|
||||
|
||||
expect(testState.serverCalls[0]).toMatchObject({
|
||||
sessionId: 'session-123',
|
||||
userId: 'user-456',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('log functions', () => {
|
||||
it.each([
|
||||
['logError', logError, 'error', 'error'],
|
||||
['logWarn', logWarn, 'warn', 'warn'],
|
||||
['logInfo', logInfo, 'info', 'log'],
|
||||
['logDebug', logDebug, 'debug', 'log'],
|
||||
])('%s logs to console and sends to server', async (name, logFn, level, consoleKey) => {
|
||||
initLogger('session-123', 'user-456')
|
||||
logFn('Test message', { key: 'value' })
|
||||
await wait()
|
||||
|
||||
expect(consoleMocks[consoleKey]).toHaveBeenCalledWith(`[Test message]`, { key: 'value' })
|
||||
expect(testState.serverCalls[0]).toMatchObject({
|
||||
level,
|
||||
message: 'Test message',
|
||||
data: { key: 'value' },
|
||||
})
|
||||
})
|
||||
|
||||
it('handles server fetch failure gracefully', async () => {
|
||||
registerEndpoint('/api/log', () => {
|
||||
throw new Error('Network error')
|
||||
}, { method: 'POST' })
|
||||
|
||||
initLogger('session-123', 'user-456')
|
||||
expect(() => logError('Test error')).not.toThrow()
|
||||
await wait()
|
||||
expect(consoleMocks.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendToServer', () => {
|
||||
it('includes timestamp in server request', async () => {
|
||||
initLogger('session-123', 'user-456')
|
||||
logError('Test message')
|
||||
await wait()
|
||||
|
||||
expect(testState.serverCalls[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
})
|
||||
|
||||
it('handles null sessionId and userId', async () => {
|
||||
initLogger(null, null)
|
||||
logError('Test message')
|
||||
await wait()
|
||||
|
||||
const { sessionId, userId } = testState.serverCalls[0]
|
||||
expect(sessionId === null || sessionId === undefined).toBe(true)
|
||||
expect(userId === null || userId === undefined).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,31 +2,34 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Login from '../../app/pages/login.vue'
|
||||
|
||||
const wait = (ms = 50) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
describe('login page', () => {
|
||||
it('renders sign in form (local auth always shown)', async () => {
|
||||
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: false, label: '' } }), { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Login)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
await wait()
|
||||
expect(wrapper.text()).toContain('Sign in')
|
||||
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows OIDC button when OIDC is enabled', async () => {
|
||||
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with Authentik' } }), { method: 'GET' })
|
||||
it.each([
|
||||
[{ enabled: true, label: 'Sign in with Authentik' }, true, false],
|
||||
[{ enabled: true, label: 'Sign in with OIDC' }, true, true],
|
||||
])('shows OIDC when enabled: %j', async (oidcConfig, shouldShowButton, shouldShowPassword) => {
|
||||
registerEndpoint('/api/auth/config', () => ({ oidc: oidcConfig }), { method: 'GET' })
|
||||
await clearNuxtData('auth-config')
|
||||
const wrapper = await mountSuspended(Login)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
expect(wrapper.text()).toContain('Sign in with Authentik')
|
||||
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows both OIDC button and password form when OIDC is enabled', async () => {
|
||||
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with OIDC' } }), { method: 'GET' })
|
||||
await clearNuxtData('auth-config')
|
||||
const wrapper = await mountSuspended(Login)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
await wait(150)
|
||||
if (shouldShowButton) {
|
||||
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
|
||||
if (oidcConfig.label) {
|
||||
expect(wrapper.text()).toContain(oidcConfig.label)
|
||||
}
|
||||
}
|
||||
if (shouldShowPassword) {
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,18 +2,41 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Members from '../../app/pages/members.vue'
|
||||
|
||||
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const setupEndpoints = (userResponse) => {
|
||||
registerEndpoint('/api/me', userResponse, { method: 'GET' })
|
||||
registerEndpoint('/api/users', () => [])
|
||||
}
|
||||
|
||||
describe('members page', () => {
|
||||
it('renders Members heading', async () => {
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
registerEndpoint('/api/users', () => [])
|
||||
setupEndpoints(() => null)
|
||||
const wrapper = await mountSuspended(Members)
|
||||
expect(wrapper.text()).toContain('Members')
|
||||
})
|
||||
|
||||
it('shows sign in message when no user', async () => {
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
registerEndpoint('/api/users', () => [])
|
||||
setupEndpoints(() => null)
|
||||
const wrapper = await mountSuspended(Members)
|
||||
expect(wrapper.text()).toMatch(/Sign in to view members/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ id: '1', identifier: 'admin', role: 'admin', avatar_url: null },
|
||||
['Add user', /Only admins can change roles/],
|
||||
],
|
||||
[
|
||||
{ id: '2', identifier: 'leader', role: 'leader', avatar_url: null },
|
||||
['Members', 'Identifier'],
|
||||
],
|
||||
])('shows content for %s role', async (user, expectedTexts) => {
|
||||
setupEndpoints(() => user)
|
||||
const wrapper = await mountSuspended(Members)
|
||||
await wait(user.role === 'leader' ? 150 : 100)
|
||||
expectedTexts.forEach((text) => {
|
||||
expect(wrapper.text()).toMatch(text)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Poi from '../../app/pages/poi.vue'
|
||||
|
||||
describe('poi page', () => {
|
||||
it('renders POI placement heading', async () => {
|
||||
beforeEach(() => {
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Poi)
|
||||
expect(wrapper.text()).toContain('POI placement')
|
||||
})
|
||||
|
||||
it('shows view-only message when cannot edit', async () => {
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
it.each([
|
||||
['POI placement heading', 'POI placement'],
|
||||
['view-only message', /View-only|Sign in as admin/],
|
||||
])('renders %s', async (description, expected) => {
|
||||
const wrapper = await mountSuspended(Poi)
|
||||
expect(wrapper.text()).toMatch(/View-only|Sign in as admin/)
|
||||
expect(wrapper.text()).toMatch(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,27 +2,45 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Index from '../../app/pages/index.vue'
|
||||
|
||||
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const setupEndpoints = (camerasResponse) => {
|
||||
registerEndpoint('/api/cameras', camerasResponse)
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
}
|
||||
|
||||
describe('useCameras', () => {
|
||||
it('page uses cameras from API', async () => {
|
||||
registerEndpoint('/api/cameras', () => ({
|
||||
setupEndpoints(() => ({
|
||||
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
|
||||
liveSessions: [],
|
||||
cotEntities: [],
|
||||
}))
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await wait()
|
||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes cotEntities from API', async () => {
|
||||
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
|
||||
setupEndpoints(() => ({
|
||||
devices: [],
|
||||
liveSessions: [],
|
||||
cotEntities,
|
||||
}))
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await wait()
|
||||
const map = wrapper.findComponent({ name: 'KestrelMap' })
|
||||
expect(map.props('cotEntities')).toEqual(cotEntities)
|
||||
})
|
||||
|
||||
it('handles API error and falls back to empty devices and liveSessions', async () => {
|
||||
registerEndpoint('/api/cameras', () => {
|
||||
setupEndpoints(() => {
|
||||
throw new Error('network')
|
||||
})
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
await wait(150)
|
||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,38 +3,59 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { useLiveSessions } from '../../app/composables/useLiveSessions.js'
|
||||
|
||||
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const createTestComponent = (setupFn) => {
|
||||
return defineComponent({
|
||||
setup: setupFn,
|
||||
})
|
||||
}
|
||||
|
||||
const setupEndpoints = (liveResponse) => {
|
||||
registerEndpoint('/api/live', liveResponse)
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
||||
}
|
||||
|
||||
describe('useLiveSessions', () => {
|
||||
it('fetches sessions from API and returns sessions ref', async () => {
|
||||
registerEndpoint('/api/live', () => [
|
||||
{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 },
|
||||
])
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { sessions } = useLiveSessions()
|
||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||
},
|
||||
setupEndpoints(() => [{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 }])
|
||||
const TestComponent = createTestComponent(() => {
|
||||
const { sessions } = useLiveSessions()
|
||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||
})
|
||||
const wrapper = await mountSuspended(TestComponent)
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await wait()
|
||||
expect(wrapper.find('[data-sessions]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty array when fetch fails', async () => {
|
||||
registerEndpoint('/api/live', () => {
|
||||
setupEndpoints(() => {
|
||||
throw new Error('fetch failed')
|
||||
})
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { sessions } = useLiveSessions()
|
||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||
},
|
||||
const TestComponent = createTestComponent(() => {
|
||||
const { sessions } = useLiveSessions()
|
||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||
})
|
||||
const wrapper = await mountSuspended(TestComponent)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
const el = wrapper.find('[data-sessions]')
|
||||
expect(el.exists()).toBe(true)
|
||||
expect(JSON.parse(el.attributes('data-sessions'))).toEqual([])
|
||||
await wait(150)
|
||||
const sessions = JSON.parse(wrapper.find('[data-sessions]').attributes('data-sessions'))
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('startPolling and stopPolling manage interval', async () => {
|
||||
setupEndpoints(() => [])
|
||||
const TestComponent = createTestComponent(() => {
|
||||
const { startPolling, stopPolling } = useLiveSessions()
|
||||
return () => h('div', {
|
||||
onClick: () => {
|
||||
startPolling()
|
||||
startPolling()
|
||||
stopPolling()
|
||||
},
|
||||
})
|
||||
})
|
||||
const wrapper = await mountSuspended(TestComponent)
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user