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:
302
test/unit/validation.spec.js
Normal file
302
test/unit/validation.spec.js
Normal file
@@ -0,0 +1,302 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
validateDevice,
|
||||
validateUpdateDevice,
|
||||
validateUser,
|
||||
validateUpdateUser,
|
||||
validatePoi,
|
||||
validateUpdatePoi,
|
||||
} from '../../server/utils/validation.js'
|
||||
|
||||
describe('validation', () => {
|
||||
describe('validateDevice', () => {
|
||||
it('validates valid device data', () => {
|
||||
const result = validateDevice({
|
||||
name: 'Test Device',
|
||||
device_type: 'traffic',
|
||||
lat: 40.7128,
|
||||
lng: -74.0060,
|
||||
stream_url: 'https://example.com/stream',
|
||||
source_type: 'mjpeg',
|
||||
})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.device_type).toBe('traffic')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ name: 'Test', lat: 'invalid', lng: -74.0060 }, 'lat and lng required as finite numbers'],
|
||||
[null, 'body required'],
|
||||
])('rejects invalid input: %j', (input, errorMsg) => {
|
||||
const result = validateDevice(input)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain(errorMsg)
|
||||
})
|
||||
|
||||
it('defaults device_type to feed', () => {
|
||||
const result = validateDevice({ name: 'Test', lat: 40.7128, lng: -74.0060 })
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.device_type).toBe('feed')
|
||||
})
|
||||
|
||||
it('defaults stream_url to empty string', () => {
|
||||
const result = validateDevice({ name: 'Test', lat: 40.7128, lng: -74.0060 })
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.stream_url).toBe('')
|
||||
})
|
||||
|
||||
it('defaults invalid source_type to mjpeg', () => {
|
||||
const result = validateDevice({
|
||||
name: 'Test',
|
||||
lat: 40.7128,
|
||||
lng: -74.0060,
|
||||
source_type: 'invalid',
|
||||
})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.source_type).toBe('mjpeg')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ name: 'Test', lat: 40.7128, lng: -74.0060 }, null],
|
||||
[{ name: 'Test', lat: 40.7128, lng: -74.0060, config: { key: 'value' } }, '{"key":"value"}'],
|
||||
[{ name: 'Test', lat: 40.7128, lng: -74.0060, config: '{"key":"value"}' }, '{"key":"value"}'],
|
||||
[{ name: 'Test', lat: 40.7128, lng: -74.0060, config: null }, null],
|
||||
])('handles config: %j -> %s', (input, expected) => {
|
||||
const result = validateDevice(input)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.config).toBe(expected)
|
||||
})
|
||||
|
||||
it('defaults vendor to null', () => {
|
||||
const result = validateDevice({ name: 'Test', lat: 40.7128, lng: -74.0060 })
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.vendor).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUpdateDevice', () => {
|
||||
it('validates partial updates', () => {
|
||||
const result = validateUpdateDevice({ name: 'Updated', lat: 40.7128 })
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data).toMatchObject({ name: 'Updated', lat: 40.7128 })
|
||||
})
|
||||
|
||||
it('allows empty updates', () => {
|
||||
const result = validateUpdateDevice({})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(Object.keys(result.data).length).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ device_type: 'invalid' }, 'Invalid device_type'],
|
||||
])('rejects invalid input: %j', (input, errorMsg) => {
|
||||
const result = validateUpdateDevice(input)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain(errorMsg)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ name: 'Test' }, undefined],
|
||||
[{ device_type: 'traffic' }, 'traffic'],
|
||||
])('handles device_type: %j -> %s', (input, expected) => {
|
||||
const result = validateUpdateDevice(input)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.device_type).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ vendor: null }, null],
|
||||
[{ vendor: '' }, null],
|
||||
[{ vendor: 'Test Vendor' }, 'Test Vendor'],
|
||||
])('handles vendor: %j -> %s', (input, expected) => {
|
||||
const result = validateUpdateDevice(input)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.vendor).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ config: { key: 'value' } }, '{"key":"value"}'],
|
||||
[{ config: '{"key":"value"}' }, '{"key":"value"}'],
|
||||
[{ config: null }, null],
|
||||
[{ config: undefined }, undefined],
|
||||
[{ name: 'Test' }, undefined],
|
||||
])('handles config: %j', (input, expected) => {
|
||||
const result = validateUpdateDevice(input)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.config).toBe(expected)
|
||||
})
|
||||
|
||||
it('handles all field types', () => {
|
||||
const result = validateUpdateDevice({
|
||||
name: 'Test',
|
||||
device_type: 'traffic',
|
||||
vendor: 'Vendor',
|
||||
lat: 40.7128,
|
||||
lng: -74.0060,
|
||||
stream_url: 'https://example.com',
|
||||
source_type: 'hls',
|
||||
config: { key: 'value' },
|
||||
})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data).toMatchObject({
|
||||
name: 'Test',
|
||||
device_type: 'traffic',
|
||||
vendor: 'Vendor',
|
||||
lat: 40.7128,
|
||||
lng: -74.0060,
|
||||
stream_url: 'https://example.com',
|
||||
source_type: 'hls',
|
||||
config: '{"key":"value"}',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['source_type'],
|
||||
['lat'],
|
||||
['lng'],
|
||||
['stream_url'],
|
||||
])('handles %s undefined in updates', (field) => {
|
||||
const result = validateUpdateDevice({ name: 'Test' })
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data[field]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUser', () => {
|
||||
it('validates valid user data', () => {
|
||||
const result = validateUser({
|
||||
identifier: 'testuser',
|
||||
password: 'password123',
|
||||
role: 'admin',
|
||||
})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.identifier).toBe('testuser')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ password: 'password123', role: 'admin' }, 'identifier required'],
|
||||
[{ identifier: 'testuser', password: 'password123', role: 'invalid' }, 'role must be admin, leader, or member'],
|
||||
])('rejects invalid input: %j', (input, errorMsg) => {
|
||||
const result = validateUser(input)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain(errorMsg)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUpdateUser', () => {
|
||||
it('validates partial updates', () => {
|
||||
const result = validateUpdateUser({ role: 'leader' })
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.role).toBe('leader')
|
||||
})
|
||||
|
||||
it('rejects empty identifier', () => {
|
||||
const result = validateUpdateUser({ identifier: '' })
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('identifier cannot be empty')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ password: '' }, undefined],
|
||||
[{ password: undefined }, undefined],
|
||||
[{ password: 'newpassword' }, 'newpassword'],
|
||||
])('handles password: %j -> %s', (input, expected) => {
|
||||
const result = validateUpdateUser(input)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.password).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['role'],
|
||||
['identifier'],
|
||||
['password'],
|
||||
])('handles %s undefined', (field) => {
|
||||
const result = validateUpdateUser({})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data[field]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePoi', () => {
|
||||
it('validates valid POI data', () => {
|
||||
const result = validatePoi({
|
||||
lat: 40.7128,
|
||||
lng: -74.0060,
|
||||
label: 'Test POI',
|
||||
iconType: 'flag',
|
||||
})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data).toMatchObject({
|
||||
lat: 40.7128,
|
||||
lng: -74.0060,
|
||||
label: 'Test POI',
|
||||
icon_type: 'flag',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid coordinates', () => {
|
||||
const result = validatePoi({ lat: 'invalid', lng: -74.0060 })
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('lat and lng required as finite numbers')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ lat: 40.7128, lng: -74.0060 }, 'pin'],
|
||||
[{ lat: 40.7128, lng: -74.0060, iconType: 'invalid' }, 'pin'],
|
||||
])('defaults iconType to pin: %j -> %s', (input, expected) => {
|
||||
const result = validatePoi(input)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data.icon_type).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateUpdatePoi', () => {
|
||||
it('validates partial updates', () => {
|
||||
const result = validateUpdatePoi({ label: 'Updated', lat: 40.7128 })
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data).toMatchObject({ label: 'Updated', lat: 40.7128 })
|
||||
})
|
||||
|
||||
it('allows empty updates', () => {
|
||||
const result = validateUpdatePoi({})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(Object.keys(result.data).length).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ iconType: 'invalid' }, 'Invalid iconType'],
|
||||
[{ lat: 'invalid' }, 'lat must be a finite number'],
|
||||
[{ lng: 'invalid' }, 'lng must be a finite number'],
|
||||
])('rejects invalid input: %j', (input, errorMsg) => {
|
||||
const result = validateUpdatePoi(input)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain(errorMsg)
|
||||
})
|
||||
|
||||
it('handles all field types', () => {
|
||||
const result = validateUpdatePoi({
|
||||
label: 'Updated',
|
||||
iconType: 'waypoint',
|
||||
lat: 41.7128,
|
||||
lng: -75.0060,
|
||||
})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data).toMatchObject({
|
||||
label: 'Updated',
|
||||
icon_type: 'waypoint',
|
||||
lat: 41.7128,
|
||||
lng: -75.0060,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['label'],
|
||||
['icon_type'],
|
||||
['lat'],
|
||||
['lng'],
|
||||
])('handles %s undefined', (field) => {
|
||||
const result = validateUpdatePoi({})
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.data[field]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user