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:
2026-04-30 15:47:26 +02:00
parent c8a5f4cd68
commit 1e9219d14a
35 changed files with 6217 additions and 0 deletions
+54
View File
@@ -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/);
});
});
+74
View File
@@ -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();
});
});
+85
View File
@@ -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);
});
});
+19
View File
@@ -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');
});
});
View File
View File
View File
+218
View File
@@ -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);
});
});
+122
View File
@@ -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);
});
});
+282
View File
@@ -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);
});
});