make kestrel a tak server, so that it can send and receive pois as cots data
Some checks failed
ci/woodpecker/pr/pr Pipeline failed
Some checks failed
ci/woodpecker/pr/pr Pipeline failed
This commit is contained in:
124
test/integration/server-and-cot.spec.js
Normal file
124
test/integration/server-and-cot.spec.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Integration test: built server on 3000 (API) and 8089 (CoT). Uses temp DB and bootstrap
|
||||
* user so CoT auth can be asserted (socket stays open on success).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { spawn, execSync } from 'node:child_process'
|
||||
import { connect } from 'node:tls'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { buildAuthCotXml } from '../helpers/fakeAtakClient.js'
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = join(__dirname, '../..')
|
||||
const devCertsDir = join(projectRoot, '.dev-certs')
|
||||
const devKey = join(devCertsDir, 'key.pem')
|
||||
const devCert = join(devCertsDir, 'cert.pem')
|
||||
|
||||
const API_PORT = 3000
|
||||
const COT_PORT = 8089
|
||||
const COT_AUTH_USER = 'test'
|
||||
const COT_AUTH_PASS = 'test'
|
||||
|
||||
function ensureDevCerts() {
|
||||
if (existsSync(devKey) && existsSync(devCert)) return
|
||||
mkdirSync(devCertsDir, { recursive: true })
|
||||
execSync(
|
||||
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName=IP:127.0.0.1,DNS:localhost"`,
|
||||
{ cwd: projectRoot, stdio: 'pipe' },
|
||||
)
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 5000
|
||||
|
||||
async function waitForHealth(timeoutMs = 90000) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
for (const protocol of ['https', 'http']) {
|
||||
try {
|
||||
const baseURL = `${protocol}://localhost:${API_PORT}`
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
|
||||
const res = await fetch(`${baseURL}/health`, { method: 'GET', signal: ctrl.signal })
|
||||
clearTimeout(t)
|
||||
if (res.ok) return baseURL
|
||||
}
|
||||
catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
throw new Error(`Health not OK on ${API_PORT} within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
describe('Server and CoT integration', () => {
|
||||
let serverProcess = null
|
||||
|
||||
beforeAll(async () => {
|
||||
ensureDevCerts()
|
||||
const serverPath = join(projectRoot, '.output', 'server', 'index.mjs')
|
||||
if (!existsSync(serverPath)) {
|
||||
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' })
|
||||
}
|
||||
const dbPath = join(tmpdir(), `kestrelos-it-${process.pid}-${Date.now()}.db`)
|
||||
const env = {
|
||||
...process.env,
|
||||
DB_PATH: dbPath,
|
||||
BOOTSTRAP_EMAIL: COT_AUTH_USER,
|
||||
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
|
||||
}
|
||||
serverProcess = spawn('node', ['.output/server/index.mjs'], {
|
||||
cwd: projectRoot,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
serverProcess.stdout?.on('data', d => process.stdout.write(d))
|
||||
serverProcess.stderr?.on('data', d => process.stderr.write(d))
|
||||
await waitForHealth(90000)
|
||||
}, 120000)
|
||||
|
||||
afterAll(() => {
|
||||
if (serverProcess?.pid) {
|
||||
serverProcess.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
|
||||
it('serves health on port 3000', async () => {
|
||||
let res
|
||||
for (const protocol of ['https', 'http']) {
|
||||
try {
|
||||
res = await fetch(`${protocol}://localhost:${API_PORT}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
|
||||
if (res?.ok) break
|
||||
}
|
||||
catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
expect(res?.ok).toBe(true)
|
||||
const body = await res.json()
|
||||
expect(body).toHaveProperty('status', 'ok')
|
||||
expect(body).toHaveProperty('endpoints')
|
||||
expect(body.endpoints).toHaveProperty('ready', '/health/ready')
|
||||
})
|
||||
|
||||
it('CoT on 8089: TAK client auth with username/password succeeds (socket stays open)', async () => {
|
||||
const payload = buildAuthCotXml({ username: COT_AUTH_USER, password: COT_AUTH_PASS })
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const s = connect(COT_PORT, '127.0.0.1', { rejectUnauthorized: false }, () => {
|
||||
s.write(payload, () => resolve(s))
|
||||
})
|
||||
s.on('error', reject)
|
||||
s.setTimeout(8000, () => reject(new Error('connect timeout')))
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
expect(socket.destroyed).toBe(false)
|
||||
socket.destroy()
|
||||
})
|
||||
})
|
||||
81
test/integration/shutdown.spec.js
Normal file
81
test/integration/shutdown.spec.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { registerCleanup, graceful, initShutdownHandlers, clearCleanup } from '../../server/utils/shutdown.js'
|
||||
|
||||
describe('shutdown integration', () => {
|
||||
let originalExit
|
||||
let exitCalls
|
||||
let originalOn
|
||||
|
||||
beforeEach(() => {
|
||||
clearCleanup()
|
||||
exitCalls = []
|
||||
originalExit = process.exit
|
||||
process.exit = vi.fn((code) => {
|
||||
exitCalls.push(code)
|
||||
})
|
||||
originalOn = process.on
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = originalExit
|
||||
process.on = originalOn
|
||||
clearCleanup()
|
||||
})
|
||||
|
||||
it('initializes signal handlers', () => {
|
||||
const handlers = {}
|
||||
process.on = vi.fn((signal, handler) => {
|
||||
handlers[signal] = handler
|
||||
})
|
||||
initShutdownHandlers()
|
||||
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
|
||||
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
|
||||
process.on = originalOn
|
||||
})
|
||||
|
||||
it('signal handler calls graceful', async () => {
|
||||
const handlers = {}
|
||||
process.on = vi.fn((signal, handler) => {
|
||||
handlers[signal] = handler
|
||||
})
|
||||
initShutdownHandlers()
|
||||
const sigtermHandler = handlers.SIGTERM
|
||||
expect(sigtermHandler).toBeDefined()
|
||||
await sigtermHandler()
|
||||
expect(exitCalls.length).toBeGreaterThan(0)
|
||||
process.on = originalOn
|
||||
})
|
||||
|
||||
it('signal handler handles graceful error', async () => {
|
||||
const handlers = {}
|
||||
process.on = vi.fn((signal, handler) => {
|
||||
handlers[signal] = handler
|
||||
})
|
||||
initShutdownHandlers()
|
||||
const sigintHandler = handlers.SIGINT
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
registerCleanup(async () => {
|
||||
throw new Error('Force error')
|
||||
})
|
||||
await sigintHandler()
|
||||
expect(exitCalls.length).toBeGreaterThan(0)
|
||||
process.on = originalOn
|
||||
})
|
||||
|
||||
it('covers timeout path in graceful', async () => {
|
||||
registerCleanup(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 40000))
|
||||
})
|
||||
graceful()
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(exitCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('covers graceful catch block', async () => {
|
||||
registerCleanup(async () => {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
await graceful()
|
||||
expect(exitCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user