Files
kestrelos/test/unit/cotParser.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

148 lines
5.7 KiB
JavaScript

import { describe, it, expect } from 'vitest'
import { parseTakStreamFrame, parseTraditionalXmlFrame, parseCotPayload } from '../../../server/utils/cotParser.js'
const encodeVarint = (value, bytes = []) => {
const byte = value & 0x7F
const remaining = value >>> 7
if (remaining === 0) {
return [...bytes, byte]
}
return encodeVarint(remaining, [...bytes, byte | 0x80])
}
function buildTakFrame(payload) {
const buf = Buffer.from(payload, 'utf8')
const varint = encodeVarint(buf.length)
return Buffer.concat([Buffer.from([0xBF]), Buffer.from(varint), buf])
}
describe('cotParser', () => {
describe('parseTakStreamFrame', () => {
it('parses valid frame', () => {
const payload = '<event uid="x" type="a-f-G"><point lat="1" lon="2"/></event>'
const frame = buildTakFrame(payload)
const result = parseTakStreamFrame(frame)
expect(result).not.toBeNull()
expect(result.payload.toString('utf8')).toBe(payload)
expect(result.bytesConsumed).toBe(frame.length)
})
it('returns null for incomplete buffer', () => {
const frame = buildTakFrame('<e/>')
const partial = frame.subarray(0, 2)
expect(parseTakStreamFrame(partial)).toBeNull()
})
it('returns null for wrong magic', () => {
const payload = '<e/>'
const buf = Buffer.concat([Buffer.from([0x00]), Buffer.from([payload.length]), Buffer.from(payload)])
expect(parseTakStreamFrame(buf)).toBeNull()
})
it('returns null for payload length exceeding max', () => {
const hugeLen = 64 * 1024 + 1
const varint = encodeVarint(hugeLen)
const buf = Buffer.concat([Buffer.from([0xBF]), Buffer.from(varint)])
expect(parseTakStreamFrame(buf)).toBeNull()
})
})
describe('parseTraditionalXmlFrame', () => {
it('parses one XML message delimited by </event>', () => {
const xml = '<event uid="x" type="a-f-G"><point lat="1" lon="2"/></event>'
const buf = Buffer.from(xml, 'utf8')
const result = parseTraditionalXmlFrame(buf)
expect(result).not.toBeNull()
expect(result.payload.toString('utf8')).toBe(xml)
expect(result.bytesConsumed).toBe(buf.length)
})
it('returns null when buffer does not start with <', () => {
expect(parseTraditionalXmlFrame(Buffer.from('x<event></event>'))).toBeNull()
expect(parseTraditionalXmlFrame(Buffer.from([0xBF, 0x00]))).toBeNull()
})
it('returns null when </event> not yet received', () => {
const partial = Buffer.from('<event uid="x"><point lat="1" lon="2"/>', 'utf8')
expect(parseTraditionalXmlFrame(partial)).toBeNull()
})
it('extracted payload parses as auth CoT', () => {
const xml = '<event><detail><auth username="itak" password="mypass"/></detail></event>'
const buf = Buffer.from(xml, 'utf8')
const result = parseTraditionalXmlFrame(buf)
expect(result).not.toBeNull()
const parsed = parseCotPayload(result.payload)
expect(parsed).not.toBeNull()
expect(parsed.type).toBe('auth')
expect(parsed.username).toBe('itak')
expect(parsed.password).toBe('mypass')
})
})
describe('parseCotPayload', () => {
it('parses position CoT XML', () => {
const xml = '<event uid="device-1" type="a-f-G-U-C"><point lat="37.7" lon="-122.4"/><detail><contact callsign="Bravo"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('cot')
expect(result.id).toBe('device-1')
expect(result.lat).toBe(37.7)
expect(result.lng).toBe(-122.4)
expect(result.label).toBe('Bravo')
})
it('parses auth CoT with detail.auth', () => {
const xml = '<event><detail><auth username="user1" password="secret123"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('auth')
expect(result.username).toBe('user1')
expect(result.password).toBe('secret123')
})
it('parses auth CoT with __auth', () => {
const xml = '<event><detail><__auth username="u2" password="p2"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('auth')
expect(result.username).toBe('u2')
expect(result.password).toBe('p2')
})
it('returns null for auth with empty username', () => {
const xml = '<event><detail><auth username=" " password="p"/></detail></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).toBeNull()
})
it('parses position with point.lat and point.lon (no @_ prefix)', () => {
const xml = '<event uid="x" type="a-f-G"><point lat="5" lon="10"/></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.lat).toBe(5)
expect(result.lng).toBe(10)
})
it('returns null for non-XML payload', () => {
expect(parseCotPayload(Buffer.from('not xml'))).toBeNull()
})
it('uses uid as label when no contact/callsign', () => {
const xml = '<event uid="device-99" type="a-f-G"><point lat="1" lon="2"/></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.type).toBe('cot')
expect(result.label).toBe('device-99')
})
it('uses point inside event when not at root', () => {
const xml = '<event uid="x" type="a-f-G"><point lat="10" lon="20"/></event>'
const result = parseCotPayload(Buffer.from(xml, 'utf8'))
expect(result).not.toBeNull()
expect(result.lat).toBe(10)
expect(result.lng).toBe(20)
})
})
})