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
141 lines
5.3 KiB
JavaScript
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)
|
|
})
|
|
})
|