Implement Phase 1 tasks 1.1-1.4 (scaffold + core shell + Teltonika framing)
- Project scaffold (Node 22 + TS 5 + pnpm + vitest + ESLint flat config) - Core shell: TCP server, session loop, adapter registry, types - Configuration (zod-validated env) and pino logger - Teltonika adapter: IMEI handshake, frame envelope, CRC-16/IBM, codec dispatch registry, DeviceAuthority seam (AllowAllAuthority default) Codec data parsers (1.5-1.7), Redis publisher (1.8), and downstream tasks remain. 36 tests covering CRC, framing, handshake, device authority, config, and core server. typecheck/lint/test/build all clean.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { loadConfig } from '../src/config/load.js';
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('throws when REDIS_URL is unset and names REDIS_URL in the error', () => {
|
||||
expect(() => loadConfig({})).toThrowError(/REDIS_URL/);
|
||||
});
|
||||
|
||||
it('returns a valid Config with sensible defaults when only REDIS_URL is set in development', () => {
|
||||
const config = loadConfig({
|
||||
NODE_ENV: 'development',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
});
|
||||
|
||||
expect(config.NODE_ENV).toBe('development');
|
||||
expect(config.REDIS_URL).toBe('redis://localhost:6379');
|
||||
expect(config.TELTONIKA_PORT).toBe(5027);
|
||||
expect(config.REDIS_TELEMETRY_STREAM).toBe('telemetry:teltonika');
|
||||
expect(config.REDIS_STREAM_MAXLEN).toBe(1_000_000);
|
||||
expect(config.METRICS_PORT).toBe(9090);
|
||||
expect(config.LOG_LEVEL).toBe('info');
|
||||
expect(config.STRICT_DEVICE_AUTH).toBe(false);
|
||||
expect(config.INSTANCE_ID).toMatch(/^local-[0-9a-f]{8}$/);
|
||||
});
|
||||
|
||||
it('parses TELTONIKA_PORT as a number from a string env var', () => {
|
||||
const config = loadConfig({
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
TELTONIKA_PORT: '5555',
|
||||
});
|
||||
expect(config.TELTONIKA_PORT).toBe(5555);
|
||||
});
|
||||
|
||||
it('enables STRICT_DEVICE_AUTH when set to "true"', () => {
|
||||
const config = loadConfig({
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
STRICT_DEVICE_AUTH: 'true',
|
||||
});
|
||||
expect(config.STRICT_DEVICE_AUTH).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an invalid NODE_ENV value', () => {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
NODE_ENV: 'staging',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a non-URL REDIS_URL', () => {
|
||||
expect(() => loadConfig({ REDIS_URL: 'not-a-url' })).toThrowError(/REDIS_URL/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import * as net from 'node:net';
|
||||
import type { Logger } from 'pino';
|
||||
import type { Adapter, AdapterContext, Metrics, Position } from '../../src/core/types.js';
|
||||
import { startServer } from '../../src/core/server.js';
|
||||
|
||||
function makeMockContext(): AdapterContext {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const metrics: Metrics = {
|
||||
inc: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
publish: vi.fn(async (_p: Position) => {}),
|
||||
logger,
|
||||
metrics,
|
||||
};
|
||||
}
|
||||
|
||||
describe('startServer', () => {
|
||||
const servers: net.Server[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.close();
|
||||
}
|
||||
servers.length = 0;
|
||||
});
|
||||
|
||||
it('invokes handleSession with a real socket when a client connects', async () => {
|
||||
const handleSession = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const adapter: Adapter = {
|
||||
name: 'test-adapter',
|
||||
ports: [0], // port 0 = OS-assigned ephemeral port
|
||||
handleSession,
|
||||
};
|
||||
|
||||
const ctx = makeMockContext();
|
||||
const server = startServer(0, adapter, ctx);
|
||||
servers.push(server);
|
||||
|
||||
// Wait for the server to start listening
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.on('listening', () => {
|
||||
const addr = server.address();
|
||||
resolve(typeof addr === 'object' && addr !== null ? addr.port : 0);
|
||||
});
|
||||
});
|
||||
|
||||
// Connect a client
|
||||
const client = new net.Socket();
|
||||
client.connect(port, '127.0.0.1');
|
||||
|
||||
// Wait for handleSession to be called
|
||||
await vi.waitFor(() => expect(handleSession).toHaveBeenCalledOnce(), { timeout: 2000 });
|
||||
|
||||
const [socketArg, ctxArg] = handleSession.mock.calls[0] as [net.Socket, AdapterContext];
|
||||
expect(socketArg).toBeInstanceOf(net.Socket);
|
||||
expect(ctxArg).toBeDefined();
|
||||
expect(typeof ctxArg.publish).toBe('function');
|
||||
expect(ctxArg.logger).toBeDefined();
|
||||
|
||||
client.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { crc16Ibm } from '../src/adapters/teltonika/crc.js';
|
||||
|
||||
describe('crc16Ibm', () => {
|
||||
it('returns 0x0000 for an empty buffer', () => {
|
||||
expect(crc16Ibm(Buffer.alloc(0))).toBe(0x0000);
|
||||
});
|
||||
|
||||
it('matches the canonical Teltonika Codec 8 first example (0xC7CF)', () => {
|
||||
// Full frame (hex stream from the Teltonika doc, first example):
|
||||
// 000000000000003608010000016B40D8EA30010000000000000000000000000000000105021503010101425E0F01F10000601A014E0000000000000000010000C7CF
|
||||
//
|
||||
// Structure:
|
||||
// 00 00 00 00 preamble
|
||||
// 00 00 00 36 DataFieldLength = 0x36 = 54
|
||||
// 08 01 ... body (CodecID through N2) — 54 bytes
|
||||
// 00 00 C7 CF CRC field
|
||||
//
|
||||
// CRC is computed over the 54-byte body only.
|
||||
const body = Buffer.from(
|
||||
'08' + // CodecID
|
||||
'01' + // N1 = 1
|
||||
'0000016B40D8EA30' + // Timestamp
|
||||
'01' + // Priority
|
||||
'00000000' + // Longitude
|
||||
'00000000' + // Latitude
|
||||
'0000' + // Altitude
|
||||
'0000' + // Angle
|
||||
'00' + // Satellites
|
||||
'0000' + // Speed
|
||||
'01' + // Event IO ID
|
||||
'05' + // N total IO
|
||||
'02' + // N1 IO (1-byte values)
|
||||
'15' + '03' + // IO 21 = 3
|
||||
'01' + '01' + // IO 1 = 1
|
||||
'01' + // N2 IO (2-byte values)
|
||||
'42' + '5E0F' + // IO 66 = 0x5E0F
|
||||
'01' + // N4 IO (4-byte values)
|
||||
'F1' + '0000601A' + // IO 241 = 0x0000601A
|
||||
'01' + // N8 IO (8-byte values)
|
||||
'4E' + '0000000000000000' + // IO 78 = 0
|
||||
'01', // N2 = 1
|
||||
'hex',
|
||||
);
|
||||
|
||||
expect(body.length).toBe(0x36); // sanity-check: must be 54 bytes
|
||||
expect(crc16Ibm(body)).toBe(0xc7cf);
|
||||
});
|
||||
|
||||
it('matches the second Teltonika Codec 8 example (0xF22A)', () => {
|
||||
// Full frame hex:
|
||||
// 000000000000002808010000016B40D9AD80010000000000000000000000000000000103021503010101425E100000010000F22A
|
||||
// Body = bytes from CodecID through N2 (0x28 = 40 bytes)
|
||||
const body = Buffer.from(
|
||||
'08' +
|
||||
'01' +
|
||||
'0000016B40D9AD80' +
|
||||
'01' +
|
||||
'00000000' +
|
||||
'00000000' +
|
||||
'0000' +
|
||||
'0000' +
|
||||
'00' +
|
||||
'0000' +
|
||||
'01' +
|
||||
'03' +
|
||||
'02' +
|
||||
'15' + '03' +
|
||||
'01' + '01' +
|
||||
'01' +
|
||||
'42' + '5E10' +
|
||||
'00' +
|
||||
'00' +
|
||||
'01',
|
||||
'hex',
|
||||
);
|
||||
|
||||
expect(body.length).toBe(0x28); // 40 bytes
|
||||
expect(crc16Ibm(body)).toBe(0xf22a);
|
||||
});
|
||||
|
||||
it('produces a non-zero CRC for a single 0xFF byte', () => {
|
||||
expect(crc16Ibm(Buffer.from([0xff]))).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AllowAllAuthority } from '../src/adapters/teltonika/device-authority.js';
|
||||
|
||||
describe('AllowAllAuthority', () => {
|
||||
it('returns "known" for any IMEI', async () => {
|
||||
const authority = new AllowAllAuthority();
|
||||
expect(await authority.check('356307042441013')).toBe('known');
|
||||
});
|
||||
|
||||
it('returns "known" for an empty string (authority ignores content)', async () => {
|
||||
const authority = new AllowAllAuthority();
|
||||
expect(await authority.check('')).toBe('known');
|
||||
});
|
||||
|
||||
it('returns "known" for an unknown/unseen IMEI', async () => {
|
||||
const authority = new AllowAllAuthority();
|
||||
expect(await authority.check('999999999999999')).toBe('known');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type * as net from 'node:net';
|
||||
import { BufferedReader, readNextFrame, FrameDropError } from '../src/adapters/teltonika/frame.js';
|
||||
import { crc16Ibm } from '../src/adapters/teltonika/crc.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal mock socket
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockSocket(): net.Socket & { push(buf: Buffer): void; simulateClose(): void } {
|
||||
const emitter = new EventEmitter() as net.Socket & {
|
||||
push(buf: Buffer): void;
|
||||
simulateClose(): void;
|
||||
destroyed: boolean;
|
||||
remoteAddress?: string;
|
||||
remotePort?: number;
|
||||
setNoDelay: ReturnType<typeof vi.fn>;
|
||||
setKeepAlive: ReturnType<typeof vi.fn>;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
emitter.destroyed = false;
|
||||
emitter.remoteAddress = '127.0.0.1';
|
||||
emitter.remotePort = 12345;
|
||||
emitter.setNoDelay = vi.fn();
|
||||
emitter.setKeepAlive = vi.fn();
|
||||
emitter.write = vi.fn().mockReturnValue(true);
|
||||
emitter.destroy = vi.fn(() => {
|
||||
emitter.destroyed = true;
|
||||
emitter.emit('close', false);
|
||||
});
|
||||
|
||||
emitter.push = (buf: Buffer) => emitter.emit('data', buf);
|
||||
emitter.simulateClose = () => {
|
||||
emitter.destroyed = true;
|
||||
emitter.emit('close', false);
|
||||
};
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BufferedReader tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BufferedReader.readExact', () => {
|
||||
it('resolves immediately when enough bytes are already buffered', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const reader = new BufferedReader(socket);
|
||||
|
||||
socket.push(Buffer.from([1, 2, 3, 4, 5]));
|
||||
|
||||
const result = await reader.readExact(3);
|
||||
expect(result).toEqual(Buffer.from([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('correctly accumulates bytes across multiple data events', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const reader = new BufferedReader(socket);
|
||||
|
||||
const readPromise = reader.readExact(6);
|
||||
|
||||
// Send data in three separate chunks
|
||||
socket.push(Buffer.from([0xaa, 0xbb]));
|
||||
socket.push(Buffer.from([0xcc, 0xdd]));
|
||||
socket.push(Buffer.from([0xee, 0xff]));
|
||||
|
||||
const result = await readPromise;
|
||||
expect(result).toEqual(Buffer.from([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]));
|
||||
});
|
||||
|
||||
it('handles byte-by-byte arrival', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const reader = new BufferedReader(socket);
|
||||
|
||||
const readPromise = reader.readExact(4);
|
||||
|
||||
for (const byte of [0x01, 0x02, 0x03, 0x04]) {
|
||||
socket.push(Buffer.from([byte]));
|
||||
}
|
||||
|
||||
const result = await readPromise;
|
||||
expect(result).toEqual(Buffer.from([0x01, 0x02, 0x03, 0x04]));
|
||||
});
|
||||
|
||||
it('leaves unconsumed bytes available for subsequent reads', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const reader = new BufferedReader(socket);
|
||||
|
||||
socket.push(Buffer.from([1, 2, 3, 4, 5, 6]));
|
||||
|
||||
const first = await reader.readExact(3);
|
||||
const second = await reader.readExact(3);
|
||||
|
||||
expect(first).toEqual(Buffer.from([1, 2, 3]));
|
||||
expect(second).toEqual(Buffer.from([4, 5, 6]));
|
||||
});
|
||||
|
||||
it('rejects with FrameDropError when socket closes before read completes', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const reader = new BufferedReader(socket);
|
||||
|
||||
const readPromise = reader.readExact(10);
|
||||
socket.push(Buffer.from([1, 2, 3])); // only 3 of 10 bytes
|
||||
socket.simulateClose();
|
||||
|
||||
await expect(readPromise).rejects.toBeInstanceOf(FrameDropError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame builder helper (ESM-safe, no require())
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildFrame(options: {
|
||||
codecId?: number;
|
||||
n1?: number;
|
||||
n2?: number;
|
||||
records?: Buffer;
|
||||
crcOverride?: number;
|
||||
}): Buffer {
|
||||
const codecId = options.codecId ?? 0x08;
|
||||
const n1 = options.n1 ?? 1;
|
||||
const n2 = options.n2 ?? n1; // default N2 = N1 for valid frames
|
||||
const records = options.records ?? Buffer.alloc(0);
|
||||
|
||||
// Body = CodecID (1B) + N1 (1B) + records + N2 (1B)
|
||||
const body = Buffer.concat([Buffer.from([codecId, n1]), records, Buffer.from([n2])]);
|
||||
|
||||
const realCrc = crc16Ibm(body);
|
||||
const crc = options.crcOverride !== undefined ? options.crcOverride : realCrc;
|
||||
|
||||
const preamble = Buffer.alloc(4, 0);
|
||||
const lengthBuf = Buffer.alloc(4);
|
||||
lengthBuf.writeUInt32BE(body.length, 0);
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt16BE(0, 0); // upper 2 bytes always 0
|
||||
crcBuf.writeUInt16BE(crc, 2);
|
||||
|
||||
return Buffer.concat([preamble, lengthBuf, body, crcBuf]);
|
||||
}
|
||||
|
||||
async function feedFrameToReader(frameBytes: Buffer): Promise<ReturnType<typeof readNextFrame>> {
|
||||
const socket = makeMockSocket();
|
||||
const reader = new BufferedReader(socket);
|
||||
const readPromise = readNextFrame(reader);
|
||||
socket.push(frameBytes);
|
||||
return readPromise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readNextFrame tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('readNextFrame', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('parses a minimal valid frame with correct CRC', async () => {
|
||||
const frameBytes = buildFrame({ codecId: 0x08, n1: 1, records: Buffer.alloc(0) });
|
||||
const frame = await feedFrameToReader(frameBytes);
|
||||
|
||||
expect(frame.codecId).toBe(0x08);
|
||||
expect(frame.crcValid).toBe(true);
|
||||
});
|
||||
|
||||
it('reports crcValid=false on CRC mismatch without throwing', async () => {
|
||||
const frameBytes = buildFrame({ codecId: 0x08, n1: 1, crcOverride: 0xdead });
|
||||
const frame = await feedFrameToReader(frameBytes);
|
||||
|
||||
expect(frame.crcValid).toBe(false);
|
||||
expect(frame.expectedCrc).toBe(0xdead);
|
||||
});
|
||||
|
||||
it('throws FrameDropError(invalid_preamble) when preamble is non-zero', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const reader = new BufferedReader(socket);
|
||||
const readPromise = readNextFrame(reader);
|
||||
|
||||
const badFrame = Buffer.concat([
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x01]), // bad preamble
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x03]), // length
|
||||
Buffer.from([0x08, 0x01, 0x01]), // body
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x00]), // CRC
|
||||
]);
|
||||
socket.push(badFrame);
|
||||
|
||||
await expect(readPromise).rejects.toMatchObject({
|
||||
reason: 'invalid_preamble',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws FrameDropError(n1_n2_mismatch) when N1 ≠ N2', async () => {
|
||||
const frameBytes = buildFrame({ codecId: 0x08, n1: 2, n2: 1 });
|
||||
await expect(feedFrameToReader(frameBytes)).rejects.toMatchObject({
|
||||
reason: 'n1_n2_mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles frame bytes arriving across multiple data events', async () => {
|
||||
const frameBytes = buildFrame({ codecId: 0x08, n1: 1 });
|
||||
const socket = makeMockSocket();
|
||||
const reader = new BufferedReader(socket);
|
||||
const readPromise = readNextFrame(reader);
|
||||
|
||||
// Split frame into 3-byte chunks
|
||||
for (let offset = 0; offset < frameBytes.length; offset += 3) {
|
||||
socket.push(frameBytes.subarray(offset, offset + 3));
|
||||
}
|
||||
|
||||
const frame = await readPromise;
|
||||
expect(frame.crcValid).toBe(true);
|
||||
expect(frame.codecId).toBe(0x08);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type * as net from 'node:net';
|
||||
import { readImeiHandshake, HandshakeError } from '../src/adapters/teltonika/handshake.js';
|
||||
|
||||
function makeMockSocket(): net.Socket & { push(buf: Buffer): void } {
|
||||
const emitter = new EventEmitter() as net.Socket & {
|
||||
push(buf: Buffer): void;
|
||||
destroyed: boolean;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
emitter.destroyed = false;
|
||||
emitter.write = vi.fn().mockReturnValue(true);
|
||||
emitter.destroy = vi.fn(() => {
|
||||
emitter.destroyed = true;
|
||||
emitter.emit('close', false);
|
||||
});
|
||||
emitter.push = (buf: Buffer) => emitter.emit('data', buf);
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a Teltonika IMEI handshake frame:
|
||||
* [2B length big-endian][IMEI ASCII bytes]
|
||||
*/
|
||||
function encodeImeiHandshake(imei: string): Buffer {
|
||||
const imeiBytes = Buffer.from(imei, 'ascii');
|
||||
const header = Buffer.alloc(2);
|
||||
header.writeUInt16BE(imeiBytes.length, 0);
|
||||
return Buffer.concat([header, imeiBytes]);
|
||||
}
|
||||
|
||||
describe('readImeiHandshake', () => {
|
||||
it('parses a valid 15-digit IMEI without writing to the socket', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const imei = '356307042441013';
|
||||
const handshakeBytes = encodeImeiHandshake(imei);
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(handshakeBytes);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toBe(imei);
|
||||
|
||||
// Must NOT write anything — accept/reject is the session loop's job
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('parses a valid IMEI arriving in two chunks (header + body split)', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const imei = '123456789012345';
|
||||
const handshakeBytes = encodeImeiHandshake(imei);
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
// Split: first push just the 2-byte header, then the IMEI bytes
|
||||
socket.push(handshakeBytes.subarray(0, 2));
|
||||
socket.push(handshakeBytes.subarray(2));
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toBe(imei);
|
||||
});
|
||||
|
||||
it('parses the Teltonika doc example IMEI (356307042441013)', async () => {
|
||||
// From the doc: IMEI 356307042441013 encoded as 000F333536333037303432343431303133
|
||||
const socket = makeMockSocket();
|
||||
const frame = Buffer.from('000F333536333037303432343431303133', 'hex');
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toBe('356307042441013');
|
||||
});
|
||||
|
||||
it('throws HandshakeError for IMEI with non-digit characters', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const badImei = 'ABCDEFGHIJKLMNO'; // 15 chars, not digits
|
||||
|
||||
const frame = encodeImeiHandshake(badImei);
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(HandshakeError);
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws HandshakeError for IMEI that is too short (< 14 digits)', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const shortImei = '1234567890123'; // 13 digits
|
||||
|
||||
const frame = encodeImeiHandshake(shortImei);
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(HandshakeError);
|
||||
});
|
||||
|
||||
it('throws HandshakeError for zero-length IMEI', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const frame = Buffer.from([0x00, 0x00]); // length = 0
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(HandshakeError);
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws HandshakeError for excessively long IMEI length field (> 32)', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const frame = Buffer.alloc(2);
|
||||
frame.writeUInt16BE(33, 0); // length = 33
|
||||
|
||||
const resultPromise = readImeiHandshake(socket);
|
||||
socket.push(frame);
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(HandshakeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type * as net from 'node:net';
|
||||
import type { Logger } from 'pino';
|
||||
import type { AdapterContext, Metrics, Position } from '../src/core/types.js';
|
||||
import { createTeltonikaAdapter } from '../src/adapters/teltonika/index.js';
|
||||
import { CodecRegistry } from '../src/adapters/teltonika/codec/registry.js';
|
||||
import type { DeviceAuthority } from '../src/adapters/teltonika/device-authority.js';
|
||||
import { crc16Ibm } from '../src/adapters/teltonika/crc.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MockSocket = net.Socket & {
|
||||
push(buf: Buffer): void;
|
||||
simulateClose(): void;
|
||||
getWritten(): Buffer[];
|
||||
};
|
||||
|
||||
function makeMockSocket(): MockSocket {
|
||||
const written: Buffer[] = [];
|
||||
|
||||
const emitter = new EventEmitter() as MockSocket & {
|
||||
destroyed: boolean;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
setNoDelay: ReturnType<typeof vi.fn>;
|
||||
setKeepAlive: ReturnType<typeof vi.fn>;
|
||||
remoteAddress: string;
|
||||
remotePort: number;
|
||||
};
|
||||
|
||||
emitter.destroyed = false;
|
||||
emitter.remoteAddress = '127.0.0.1';
|
||||
emitter.remotePort = 9999;
|
||||
emitter.setNoDelay = vi.fn();
|
||||
emitter.setKeepAlive = vi.fn();
|
||||
emitter.write = vi.fn((data: Buffer | string) => {
|
||||
written.push(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
||||
return true;
|
||||
});
|
||||
emitter.destroy = vi.fn(() => {
|
||||
if (!emitter.destroyed) {
|
||||
emitter.destroyed = true;
|
||||
emitter.emit('close', false);
|
||||
}
|
||||
});
|
||||
emitter.push = (buf: Buffer) => emitter.emit('data', buf);
|
||||
emitter.simulateClose = () => {
|
||||
if (!emitter.destroyed) {
|
||||
emitter.destroyed = true;
|
||||
emitter.emit('close', false);
|
||||
}
|
||||
};
|
||||
emitter.getWritten = () => written;
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
function makeMockContext(): AdapterContext {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const metrics: Metrics = {
|
||||
inc: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
publish: vi.fn(async (_p: Position) => {}),
|
||||
logger,
|
||||
metrics,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the Teltonika IMEI handshake wire format.
|
||||
*/
|
||||
function encodeImei(imei: string): Buffer {
|
||||
const body = Buffer.from(imei, 'ascii');
|
||||
const header = Buffer.alloc(2);
|
||||
header.writeUInt16BE(body.length, 0);
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete AVL frame. Body is CodecID + N1 + empty records + N2.
|
||||
*/
|
||||
function buildFrame(options: { codecId: number; recordCount?: number; crcOverride?: number }): Buffer {
|
||||
const n = options.recordCount ?? 1;
|
||||
const body = Buffer.from([options.codecId, n, n]);
|
||||
const realCrc = crc16Ibm(body);
|
||||
const crc = options.crcOverride !== undefined ? options.crcOverride : realCrc;
|
||||
|
||||
const preamble = Buffer.alloc(4, 0);
|
||||
const lengthBuf = Buffer.alloc(4);
|
||||
lengthBuf.writeUInt32BE(body.length, 0);
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt16BE(0, 0);
|
||||
crcBuf.writeUInt16BE(crc, 2);
|
||||
|
||||
return Buffer.concat([preamble, lengthBuf, body, crcBuf]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IMEI = '356307042441013';
|
||||
|
||||
describe('Teltonika adapter — session', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('unknown codec path', () => {
|
||||
it('destroys the socket without writing an ACK when codec ID is not registered', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const ctx = makeMockContext();
|
||||
const registry = new CodecRegistry(); // empty — no codecs registered
|
||||
const adapter = createTeltonikaAdapter({ port: 5027, codecRegistry: registry });
|
||||
|
||||
const sessionPromise = adapter.handleSession(socket, ctx);
|
||||
|
||||
// Push IMEI handshake — session writes 0x01 accept
|
||||
socket.push(encodeImei(IMEI));
|
||||
|
||||
// Wait until the 0x01 accept byte is written
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(socket.getWritten().length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
// Push a frame with an unregistered codec (0x99)
|
||||
socket.push(buildFrame({ codecId: 0x99 }));
|
||||
|
||||
// Session should destroy the socket after unknown codec
|
||||
await sessionPromise;
|
||||
|
||||
expect(socket.destroy).toHaveBeenCalled();
|
||||
|
||||
// The ONLY write must be the 0x01 accept byte — no 4-byte ACK
|
||||
const allWritten = Buffer.concat(socket.getWritten());
|
||||
expect(allWritten).toEqual(Buffer.from([0x01]));
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('CRC mismatch path', () => {
|
||||
it('does NOT write an ACK on CRC mismatch and keeps the socket open', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const ctx = makeMockContext();
|
||||
const registry = new CodecRegistry();
|
||||
const adapter = createTeltonikaAdapter({ port: 5027, codecRegistry: registry });
|
||||
|
||||
const sessionPromise = adapter.handleSession(socket, ctx);
|
||||
|
||||
// Handshake
|
||||
socket.push(encodeImei(IMEI));
|
||||
|
||||
// Wait for the accept byte
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(socket.getWritten().length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
const writtenBeforeFrame = socket.getWritten().length;
|
||||
|
||||
// Push a frame with a deliberately wrong CRC
|
||||
socket.push(buildFrame({ codecId: 0x08, crcOverride: 0xdead }));
|
||||
|
||||
// Give the event loop time to process the frame
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Socket must still be open
|
||||
expect(socket.destroy).not.toHaveBeenCalled();
|
||||
expect(socket.destroyed).toBe(false);
|
||||
|
||||
// No new writes after the frame (no ACK sent)
|
||||
expect(socket.getWritten().length).toBe(writtenBeforeFrame);
|
||||
|
||||
// Clean up — simulate device disconnect
|
||||
socket.simulateClose();
|
||||
await sessionPromise;
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('STRICT_DEVICE_AUTH', () => {
|
||||
it('writes 0x00 and destroys socket for unknown device when STRICT_DEVICE_AUTH=true', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const ctx = makeMockContext();
|
||||
|
||||
const strictAuthority: DeviceAuthority = {
|
||||
check: vi.fn().mockResolvedValue('unknown'),
|
||||
};
|
||||
|
||||
const adapter = createTeltonikaAdapter({
|
||||
port: 5027,
|
||||
deviceAuthority: strictAuthority,
|
||||
strictDeviceAuth: true,
|
||||
});
|
||||
|
||||
const sessionPromise = adapter.handleSession(socket, ctx);
|
||||
socket.push(encodeImei(IMEI));
|
||||
|
||||
await sessionPromise;
|
||||
|
||||
const allWritten = Buffer.concat(socket.getWritten());
|
||||
expect(allWritten).toEqual(Buffer.from([0x00]));
|
||||
expect(socket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts unknown device when STRICT_DEVICE_AUTH=false (default)', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const ctx = makeMockContext();
|
||||
|
||||
const unknownAuthority: DeviceAuthority = {
|
||||
check: vi.fn().mockResolvedValue('unknown'),
|
||||
};
|
||||
|
||||
const adapter = createTeltonikaAdapter({
|
||||
port: 5027,
|
||||
deviceAuthority: unknownAuthority,
|
||||
strictDeviceAuth: false,
|
||||
});
|
||||
|
||||
const sessionPromise = adapter.handleSession(socket, ctx);
|
||||
socket.push(encodeImei(IMEI));
|
||||
|
||||
// Wait for the accept byte
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(socket.getWritten().length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
const allWritten = Buffer.concat(socket.getWritten());
|
||||
expect(allWritten[0]).toBe(0x01);
|
||||
|
||||
// Not destroyed
|
||||
expect(socket.destroy).not.toHaveBeenCalled();
|
||||
|
||||
socket.simulateClose();
|
||||
await sessionPromise;
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('known device acceptance', () => {
|
||||
it('writes 0x01 for a known device and stays connected', async () => {
|
||||
const socket = makeMockSocket();
|
||||
const ctx = makeMockContext();
|
||||
const adapter = createTeltonikaAdapter({ port: 5027 });
|
||||
|
||||
const sessionPromise = adapter.handleSession(socket, ctx);
|
||||
socket.push(encodeImei(IMEI));
|
||||
|
||||
// Wait for the accept byte
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(socket.getWritten().length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
const allWritten = Buffer.concat(socket.getWritten());
|
||||
expect(allWritten[0]).toBe(0x01);
|
||||
|
||||
socket.simulateClose();
|
||||
await sessionPromise;
|
||||
}, 10_000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user