Files
kestrelos/test/unit/liveSessions.spec.js
Keli Grubb e61e6bc7e3
All checks were successful
ci/woodpecker/push/push Pipeline was successful
major: kestrel is now a tak server (#6)
## 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
2026-02-17 16:41:41 +00:00

141 lines
5.3 KiB
JavaScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
createSession,
getLiveSession,
updateLiveSession,
deleteLiveSession,
getActiveSessions,
getActiveSessionByUserId,
getOrCreateSession,
clearSessions,
} from '../../../server/utils/liveSessions.js'
vi.mock('../../../server/utils/mediasoup.js', () => ({
getProducer: vi.fn().mockReturnValue(null),
getTransport: vi.fn().mockReturnValue(null),
closeRouter: vi.fn().mockResolvedValue(undefined),
}))
describe('liveSessions', () => {
const testState = {
sessionId: null,
}
beforeEach(async () => {
clearSessions()
const session = await createSession('test-user', 'Test Session')
testState.sessionId = session.id
})
it('creates a session with WebRTC fields', () => {
const session = getLiveSession(testState.sessionId)
expect(session).toBeDefined()
expect(session.id).toBe(testState.sessionId)
expect(session.userId).toBe('test-user')
expect(session.label).toBe('Test Session')
expect(session.routerId).toBeNull()
expect(session.producerId).toBeNull()
expect(session.transportId).toBeNull()
})
it('updates location', async () => {
await updateLiveSession(testState.sessionId, { lat: 37.7, lng: -122.4 })
const session = getLiveSession(testState.sessionId)
expect(session.lat).toBe(37.7)
expect(session.lng).toBe(-122.4)
})
it('updates WebRTC fields', async () => {
await updateLiveSession(testState.sessionId, { routerId: 'router-1', producerId: 'producer-1', transportId: 'transport-1' })
const session = getLiveSession(testState.sessionId)
expect(session.routerId).toBe('router-1')
expect(session.producerId).toBe('producer-1')
expect(session.transportId).toBe('transport-1')
})
it('returns hasStream instead of hasSnapshot', async () => {
await updateLiveSession(testState.sessionId, { producerId: 'producer-1' })
const active = await getActiveSessions()
const session = active.find(s => s.id === testState.sessionId)
expect(session).toBeDefined()
expect(session.hasStream).toBe(true)
})
it('returns hasStream false when no producer', async () => {
const active = await getActiveSessions()
const session = active.find(s => s.id === testState.sessionId)
expect(session).toBeDefined()
expect(session.hasStream).toBe(false)
})
it('deletes a session', async () => {
await deleteLiveSession(testState.sessionId)
const session = getLiveSession(testState.sessionId)
expect(session).toBeUndefined()
})
it('getActiveSessionByUserId returns session for same user when active', async () => {
const found = await getActiveSessionByUserId('test-user')
expect(found).toBeDefined()
expect(found.id).toBe(testState.sessionId)
})
it('getActiveSessionByUserId returns undefined for unknown user', async () => {
const found = await getActiveSessionByUserId('other-user')
expect(found).toBeUndefined()
})
it('getActiveSessionByUserId returns undefined for expired session', async () => {
const session = getLiveSession(testState.sessionId)
session.updatedAt = Date.now() - 120_000
const found = await getActiveSessionByUserId('test-user')
expect(found).toBeUndefined()
})
it('getActiveSessions removes expired sessions', async () => {
const session = getLiveSession(testState.sessionId)
session.updatedAt = Date.now() - 120_000
const active = await getActiveSessions()
expect(active.find(s => s.id === testState.sessionId)).toBeUndefined()
expect(getLiveSession(testState.sessionId)).toBeUndefined()
})
it('getActiveSessions runs cleanup for expired session with producer and transport', async () => {
const { getProducer, getTransport, closeRouter } = await import('../../../server/utils/mediasoup.js')
const mockProducer = { close: vi.fn() }
const mockTransport = { close: vi.fn() }
getProducer.mockReturnValue(mockProducer)
getTransport.mockReturnValue(mockTransport)
closeRouter.mockResolvedValue(undefined)
await updateLiveSession(testState.sessionId, { producerId: 'p1', transportId: 't1', routerId: 'r1' })
const session = getLiveSession(testState.sessionId)
session.updatedAt = Date.now() - 120_000
const active = await getActiveSessions()
expect(active.find(s => s.id === testState.sessionId)).toBeUndefined()
expect(mockProducer.close).toHaveBeenCalled()
expect(mockTransport.close).toHaveBeenCalled()
expect(closeRouter).toHaveBeenCalledWith(testState.sessionId)
})
it('getOrCreateSession returns existing active session', async () => {
const session = await getOrCreateSession('test-user', 'New Label')
expect(session.id).toBe(testState.sessionId)
expect(session.userId).toBe('test-user')
})
it('getOrCreateSession creates new session when none exists', async () => {
const session = await getOrCreateSession('new-user', 'New Session')
expect(session.userId).toBe('new-user')
expect(session.label).toBe('New Session')
})
it('getOrCreateSession handles concurrent calls atomically', async () => {
const promises = Array.from({ length: 5 }, () =>
getOrCreateSession('concurrent-user', 'Concurrent'),
)
const sessions = await Promise.all(promises)
const uniqueIds = new Set(sessions.map(s => s.id))
expect(uniqueIds.size).toBe(1)
})
})