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:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
createSession,
|
||||
getLiveSession,
|
||||
@@ -6,21 +6,31 @@ import {
|
||||
deleteLiveSession,
|
||||
getActiveSessions,
|
||||
getActiveSessionByUserId,
|
||||
getOrCreateSession,
|
||||
clearSessions,
|
||||
} from '../../../server/utils/liveSessions.js'
|
||||
|
||||
describe('liveSessions', () => {
|
||||
let sessionId
|
||||
vi.mock('../../../server/utils/mediasoup.js', () => ({
|
||||
getProducer: vi.fn().mockReturnValue(null),
|
||||
getTransport: vi.fn().mockReturnValue(null),
|
||||
closeRouter: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
describe('liveSessions', () => {
|
||||
const testState = {
|
||||
sessionId: null,
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
clearSessions()
|
||||
sessionId = createSession('test-user', 'Test Session').id
|
||||
const session = await createSession('test-user', 'Test Session')
|
||||
testState.sessionId = session.id
|
||||
})
|
||||
|
||||
it('creates a session with WebRTC fields', () => {
|
||||
const session = getLiveSession(sessionId)
|
||||
const session = getLiveSession(testState.sessionId)
|
||||
expect(session).toBeDefined()
|
||||
expect(session.id).toBe(sessionId)
|
||||
expect(session.id).toBe(testState.sessionId)
|
||||
expect(session.userId).toBe('test-user')
|
||||
expect(session.label).toBe('Test Session')
|
||||
expect(session.routerId).toBeNull()
|
||||
@@ -28,65 +38,103 @@ describe('liveSessions', () => {
|
||||
expect(session.transportId).toBeNull()
|
||||
})
|
||||
|
||||
it('updates location', () => {
|
||||
updateLiveSession(sessionId, { lat: 37.7, lng: -122.4 })
|
||||
const session = getLiveSession(sessionId)
|
||||
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', () => {
|
||||
updateLiveSession(sessionId, { routerId: 'router-1', producerId: 'producer-1', transportId: 'transport-1' })
|
||||
const session = getLiveSession(sessionId)
|
||||
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 () => {
|
||||
updateLiveSession(sessionId, { producerId: 'producer-1' })
|
||||
await updateLiveSession(testState.sessionId, { producerId: 'producer-1' })
|
||||
const active = await getActiveSessions()
|
||||
const session = active.find(s => s.id === sessionId)
|
||||
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 === sessionId)
|
||||
const session = active.find(s => s.id === testState.sessionId)
|
||||
expect(session).toBeDefined()
|
||||
expect(session.hasStream).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes a session', () => {
|
||||
deleteLiveSession(sessionId)
|
||||
const session = getLiveSession(sessionId)
|
||||
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', () => {
|
||||
const found = getActiveSessionByUserId('test-user')
|
||||
it('getActiveSessionByUserId returns session for same user when active', async () => {
|
||||
const found = await getActiveSessionByUserId('test-user')
|
||||
expect(found).toBeDefined()
|
||||
expect(found.id).toBe(sessionId)
|
||||
expect(found.id).toBe(testState.sessionId)
|
||||
})
|
||||
|
||||
it('getActiveSessionByUserId returns undefined for unknown user', () => {
|
||||
const found = getActiveSessionByUserId('other-user')
|
||||
it('getActiveSessionByUserId returns undefined for unknown user', async () => {
|
||||
const found = await getActiveSessionByUserId('other-user')
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getActiveSessionByUserId returns undefined for expired session', () => {
|
||||
const session = getLiveSession(sessionId)
|
||||
it('getActiveSessionByUserId returns undefined for expired session', async () => {
|
||||
const session = getLiveSession(testState.sessionId)
|
||||
session.updatedAt = Date.now() - 120_000
|
||||
const found = getActiveSessionByUserId('test-user')
|
||||
const found = await getActiveSessionByUserId('test-user')
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getActiveSessions removes expired sessions', async () => {
|
||||
const session = getLiveSession(sessionId)
|
||||
const session = getLiveSession(testState.sessionId)
|
||||
session.updatedAt = Date.now() - 120_000
|
||||
const active = await getActiveSessions()
|
||||
expect(active.find(s => s.id === sessionId)).toBeUndefined()
|
||||
expect(getLiveSession(sessionId)).toBeUndefined()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user