From a673e000f0d7a6888415f56baffd967b55a62ff9 Mon Sep 17 00:00:00 2001 From: Scafu Date: Fri, 3 Apr 2026 00:51:14 +0200 Subject: [PATCH 01/13] feat: tenant status --- package-lock.json | 140 +++++++++++++++++- package.json | 1 + src/app.module.ts | 10 +- src/auth/decorators/tenant-id.decorator.ts | 9 ++ .../tenant-access-context.interface.ts | 13 ++ src/auth/tenant-access.guard.spec.ts | 126 ++++++++++++++++ src/auth/tenant-access.guard.ts | 110 ++++++++++++++ .../controller/measure.controller.spec.ts | 14 ++ src/data-api/controller/measure.controller.ts | 32 ++-- src/data-api/controller/sensor.controller.ts | 3 + src/data-api/interfaces/get-sensors.input.ts | 1 + src/data-api/services/measure.service.spec.ts | 4 + src/data-api/services/measure.service.ts | 2 + src/data-api/services/sensor.service.spec.ts | 3 + src/data-api/services/sensor.service.ts | 1 + src/env.validation.spec.ts | 2 + src/env.validation.ts | 2 + 17 files changed, 453 insertions(+), 20 deletions(-) create mode 100644 src/auth/decorators/tenant-id.decorator.ts create mode 100644 src/auth/interfaces/tenant-access-context.interface.ts create mode 100644 src/auth/tenant-access.guard.spec.ts create mode 100644 src/auth/tenant-access.guard.ts diff --git a/package-lock.json b/package-lock.json index 9f33bc7..9df4a04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/typeorm": "^11.0.0", "class-validator": "^0.14.4", "nats": "^2.29.3", + "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "typeorm": "^0.3.28" @@ -4451,6 +4452,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -27908,6 +27910,104 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pgpass/node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -28289,6 +28389,45 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/postman2openapi": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/postman2openapi/-/postman2openapi-1.2.1.tgz", @@ -34106,7 +34245,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4" diff --git a/package.json b/package.json index df928f0..50286e0 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nestjs/typeorm": "^11.0.0", "class-validator": "^0.14.4", "nats": "^2.29.3", + "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "typeorm": "^0.3.28" diff --git a/src/app.module.ts b/src/app.module.ts index ec2cae0..fafabd8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { APP_GUARD } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { MeasureModule } from './data-api/measure.module'; import { SensorModule } from './data-api/sensor.module'; import { validate } from './env.validation'; +import { TenantAccessGuard } from './auth/tenant-access.guard'; const databaseImports = process.env.NODE_ENV === 'test' @@ -40,6 +42,12 @@ const databaseImports = SensorModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_GUARD, + useClass: TenantAccessGuard, + }, + ], }) export class AppModule {} diff --git a/src/auth/decorators/tenant-id.decorator.ts b/src/auth/decorators/tenant-id.decorator.ts new file mode 100644 index 0000000..507e3db --- /dev/null +++ b/src/auth/decorators/tenant-id.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { TenantAwareRequest } from '../interfaces/tenant-access-context.interface'; + +export const TenantId = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): string | undefined => { + const request = ctx.switchToHttp().getRequest(); + return request.tenantAccess?.tenantId; + }, +); diff --git a/src/auth/interfaces/tenant-access-context.interface.ts b/src/auth/interfaces/tenant-access-context.interface.ts new file mode 100644 index 0000000..1838dca --- /dev/null +++ b/src/auth/interfaces/tenant-access-context.interface.ts @@ -0,0 +1,13 @@ +import { Request } from 'express'; + +export type TenantStatus = 'active' | 'suspended'; + +export interface TenantAccessContext { + tenantId: string; + status: TenantStatus; + readOnly: boolean; +} + +export interface TenantAwareRequest extends Request { + tenantAccess?: TenantAccessContext; +} diff --git a/src/auth/tenant-access.guard.spec.ts b/src/auth/tenant-access.guard.spec.ts new file mode 100644 index 0000000..80144c4 --- /dev/null +++ b/src/auth/tenant-access.guard.spec.ts @@ -0,0 +1,126 @@ +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TenantAccessGuard } from './tenant-access.guard'; + +const createContext = (request: Record): ExecutionContext => + ({ + switchToHttp: () => ({ + getRequest: () => request, + }), + }) as unknown as ExecutionContext; + +const createResponse = (status: number, json?: unknown): Response => + ({ + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(json), + }) as unknown as Response; + +describe('TenantAccessGuard', () => { + let guard: TenantAccessGuard; + + beforeEach(() => { + const configService = { + get: jest.fn().mockReturnValue('http://management-api:3000'), + } as unknown as ConfigService; + guard = new TenantAccessGuard(configService); + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('allows root endpoint without authentication', async () => { + const request = { + method: 'GET', + path: '/', + headers: {}, + }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + }); + + it('rejects missing bearer token on protected endpoints', async () => { + const request = { + method: 'GET', + path: '/measures/query', + headers: {}, + }; + + await expect(guard.canActivate(createContext(request))).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('allows active tenant and stores tenant access context', async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + createResponse(200, { + tenant_id: 'tenant-1', + status: 'active', + read_only: false, + }), + ); + + const request = { + method: 'GET', + path: '/measures/query', + headers: { + authorization: 'Bearer valid-token', + }, + } as Record; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + expect(request.tenantAccess).toEqual({ + tenantId: 'tenant-1', + status: 'active', + readOnly: false, + }); + }); + + it('allows suspended tenant in read-only mode', async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + createResponse(200, { + tenant_id: 'tenant-1', + status: 'suspended', + read_only: true, + }), + ); + + const request = { + method: 'GET', + path: '/measures/export', + headers: { + authorization: 'Bearer valid-token', + }, + }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + }); + + it('rejects suspended tenant on write operations', async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + createResponse(200, { + tenant_id: 'tenant-1', + status: 'suspended', + read_only: true, + }), + ); + + const request = { + method: 'POST', + path: '/measures/query', + headers: { + authorization: 'Bearer valid-token', + }, + }; + + await expect(guard.canActivate(createContext(request))).rejects.toThrow( + ForbiddenException, + ); + }); +}); diff --git a/src/auth/tenant-access.guard.ts b/src/auth/tenant-access.guard.ts new file mode 100644 index 0000000..65f10db --- /dev/null +++ b/src/auth/tenant-access.guard.ts @@ -0,0 +1,110 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + TenantAccessContext, + TenantAwareRequest, + TenantStatus, +} from './interfaces/tenant-access-context.interface'; + +interface TenantStatusResponse { + tenant_id: string; + status: TenantStatus; + read_only: boolean; +} + +const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +@Injectable() +export class TenantAccessGuard implements CanActivate { + constructor(private readonly configService: ConfigService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + if (this.isPublicRequest(request)) { + return true; + } + + const authorization = request.headers.authorization; + const bearerToken = this.extractBearerToken(authorization); + + if (!bearerToken) { + throw new UnauthorizedException('Missing bearer token'); + } + + const tenantAccess = await this.resolveTenantAccess(authorization!); + request.tenantAccess = tenantAccess; + + if (tenantAccess.readOnly) { + const method = request.method?.toUpperCase() ?? 'GET'; + if (!READ_ONLY_METHODS.has(method)) { + throw new ForbiddenException('Tenant is suspended (read-only mode)'); + } + } + + return true; + } + + private isPublicRequest(request: TenantAwareRequest): boolean { + const method = request.method?.toUpperCase() ?? 'GET'; + if (method === 'OPTIONS') { + return true; + } + + const path = request.path ?? request.url; + return path === '/'; + } + + private extractBearerToken(authorization?: string): string | undefined { + if (!authorization?.startsWith('Bearer ')) { + return undefined; + } + + return authorization.slice('Bearer '.length).trim(); + } + + private async resolveTenantAccess( + authorization: string, + ): Promise { + const managementApiUrl = this.configService.get( + 'MGMT_API_URL', + 'http://management-api:3000', + ); + + const response = await fetch(`${managementApiUrl}/auth/tenant-status`, { + method: 'GET', + headers: { + Authorization: authorization, + }, + }); + + if (response.status === 401) { + throw new UnauthorizedException('Unauthorized'); + } + + if (response.status === 403) { + throw new ForbiddenException('Forbidden'); + } + + if (!response.ok) { + throw new ServiceUnavailableException('Tenant access policy unavailable'); + } + + const payload = (await response.json()) as TenantStatusResponse; + if (!payload.tenant_id || !payload.status) { + throw new ServiceUnavailableException('Invalid tenant access payload'); + } + + return { + tenantId: payload.tenant_id, + status: payload.status, + readOnly: payload.read_only, + }; + } +} diff --git a/src/data-api/controller/measure.controller.spec.ts b/src/data-api/controller/measure.controller.spec.ts index 456c135..0620328 100644 --- a/src/data-api/controller/measure.controller.spec.ts +++ b/src/data-api/controller/measure.controller.spec.ts @@ -107,6 +107,7 @@ describe('MeasureController', () => { sensorId, sensorType, cursor, + 'tenant-1', ); expect(serviceQueryMock).toHaveBeenCalledTimes(1); @@ -114,6 +115,7 @@ describe('MeasureController', () => { from, to, limit: 500, + tenantId: 'tenant-1', gatewayId, sensorId, sensorType, @@ -154,6 +156,7 @@ describe('MeasureController', () => { undefined, undefined, undefined, + 'tenant-1', ); expect(serviceQueryMock).toHaveBeenCalledTimes(1); @@ -161,6 +164,7 @@ describe('MeasureController', () => { from, to, limit: 999, + tenantId: 'tenant-1', gatewayId: undefined, sensorId: undefined, sensorType: undefined, @@ -181,12 +185,14 @@ describe('MeasureController', () => { 's-1' as unknown as string[], 'temp' as unknown as string[], undefined, + 'tenant-1', ); expect(serviceQueryMock).toHaveBeenCalledWith({ from: '2024-01-01T00:00:00Z', to: '2024-01-02T00:00:00Z', limit: 10, + tenantId: 'tenant-1', gatewayId: ['gw-1'], sensorId: ['s-1'], sensorType: ['temp'], @@ -241,12 +247,14 @@ describe('MeasureController', () => { gatewayId, sensorId, sensorType, + 'tenant-1', ); expect(serviceExportMock).toHaveBeenCalledTimes(1); expect(serviceExportMock).toHaveBeenCalledWith({ from, to, + tenantId: 'tenant-1', gatewayId, sensorId, sensorType, @@ -275,12 +283,14 @@ describe('MeasureController', () => { undefined, undefined, undefined, + 'tenant-1', ); expect(serviceExportMock).toHaveBeenCalledTimes(1); expect(serviceExportMock).toHaveBeenCalledWith({ from, to, + tenantId: 'tenant-1', gatewayId: undefined, sensorId: undefined, sensorType: undefined, @@ -298,11 +308,13 @@ describe('MeasureController', () => { 'gw-1' as unknown as string[], 's-1' as unknown as string[], 'temp' as unknown as string[], + 'tenant-1', ); expect(serviceExportMock).toHaveBeenCalledWith({ from: '2024-01-01T00:00:00Z', to: '2024-01-02T00:00:00Z', + tenantId: 'tenant-1', gatewayId: ['gw-1'], sensorId: ['s-1'], sensorType: ['temp'], @@ -348,6 +360,7 @@ describe('MeasureController', () => { 'sensor-1' as never, 'temperature' as never, '2026-03-23T09:50:00.000Z', + 'tenant-1', ), ); @@ -388,6 +401,7 @@ describe('MeasureController', () => { undefined, undefined, undefined, + 'tenant-1', ), ); diff --git a/src/data-api/controller/measure.controller.ts b/src/data-api/controller/measure.controller.ts index 6813d4e..e5a3594 100644 --- a/src/data-api/controller/measure.controller.ts +++ b/src/data-api/controller/measure.controller.ts @@ -18,6 +18,7 @@ import { ApiMeasureQueryDocs, ApiMeasureStreamDocs, } from '../openapi.decorators'; +import { TenantId } from '../../auth/decorators/tenant-id.decorator'; const DEFAULT_QUERY_LIMIT = 999; @@ -64,42 +65,33 @@ function decodeBase64Url(value: string): string | undefined { } } -function extractJwtContext(authorization?: string): { - tenantId?: string; - tokenExpiresAt?: number; -} { +function extractTokenExpiresAt(authorization?: string): number | undefined { const token = parseBearerToken(authorization); if (!token) { - return {}; + return undefined; } const [, payload] = token.split('.'); if (!payload) { - return {}; + return undefined; } const decoded = decodeBase64Url(payload); if (!decoded) { - return {}; + return undefined; } try { const parsed = JSON.parse(decoded) as { - tenantId?: string; - tenant_id?: string; exp?: number; }; - return { - tenantId: parsed.tenantId ?? parsed.tenant_id, - tokenExpiresAt: - typeof parsed.exp === 'number' ? parsed.exp * 1000 : undefined, - }; + return typeof parsed.exp === 'number' ? parsed.exp * 1000 : undefined; } catch { - return {}; + return undefined; } } @@ -140,11 +132,13 @@ export class MeasureController { @Query('sensorId') sensorId?: string | string[], @Query('sensorType') sensorType?: string | string[], @Query('cursor') cursor?: string, + @TenantId() tenantId?: string, ): Promise { const input: QueryInput = { from, to, limit: normalizeLimit(limit), + tenantId, gatewayId: normalizeArrayParam(gatewayId), sensorId: normalizeArrayParam(sensorId), sensorType: normalizeArrayParam(sensorType), @@ -177,15 +171,15 @@ export class MeasureController { @Query('sensorId') sensorId?: string | string[], @Query('sensorType') sensorType?: string | string[], @Query('since') since?: string, + @TenantId() tenantId?: string, ): Observable { - const jwtContext = extractJwtContext(request.headers.authorization); const input: StreamInput = { gatewayId: normalizeArrayParam(gatewayId), sensorId: normalizeArrayParam(sensorId), sensorType: normalizeArrayParam(sensorType), since, - tenantId: jwtContext.tenantId, - tokenExpiresAt: jwtContext.tokenExpiresAt, + tenantId, + tokenExpiresAt: extractTokenExpiresAt(request.headers.authorization), }; return this.sl.stream(input).pipe( @@ -230,10 +224,12 @@ export class MeasureController { @Query('gatewayId') gatewayId?: string | string[], @Query('sensorId') sensorId?: string | string[], @Query('sensorType') sensorType?: string | string[], + @TenantId() tenantId?: string, ): Promise { const input: ExportInput = { from, to, + tenantId, gatewayId: normalizeArrayParam(gatewayId), sensorId: normalizeArrayParam(sensorId), sensorType: normalizeArrayParam(sensorType), diff --git a/src/data-api/controller/sensor.controller.ts b/src/data-api/controller/sensor.controller.ts index f338ab8..a71071b 100644 --- a/src/data-api/controller/sensor.controller.ts +++ b/src/data-api/controller/sensor.controller.ts @@ -5,6 +5,7 @@ import { SensorDto } from '../dto/sensor.dto'; import { MeasureMapper } from '../measure.mapper'; import { GetSensorsInput } from '../interfaces/get-sensors.input'; import { ApiSensorListDocs } from '../openapi.decorators'; +import { TenantId } from '../../auth/decorators/tenant-id.decorator'; @ApiTags('sensors') @Controller('sensor') @@ -33,8 +34,10 @@ export class SensorController { }) async getSensors( @Query('gatewayId') gatewayId?: string, + @TenantId() tenantId?: string, ): Promise { const input: GetSensorsInput = { + tenantId, gatewayId, }; const sensorModel = await this.ss.getSensors(input); diff --git a/src/data-api/interfaces/get-sensors.input.ts b/src/data-api/interfaces/get-sensors.input.ts index 1192c0d..fbb1e39 100644 --- a/src/data-api/interfaces/get-sensors.input.ts +++ b/src/data-api/interfaces/get-sensors.input.ts @@ -1,3 +1,4 @@ export interface GetSensorsInput { + tenantId?: string; gatewayId?: string; } diff --git a/src/data-api/services/measure.service.spec.ts b/src/data-api/services/measure.service.spec.ts index 5ea344c..95bb77b 100644 --- a/src/data-api/services/measure.service.spec.ts +++ b/src/data-api/services/measure.service.spec.ts @@ -32,6 +32,7 @@ describe('MeasureService', () => { describe('query', () => { const input: QueryInput = { + tenantId: 'tenant-1', gatewayId: ['gw-1'], sensorId: ['sensor-1'], sensorType: ['temperature'], @@ -85,6 +86,7 @@ describe('MeasureService', () => { const result = await service.query(input); expect(mps.paginatedQuery.mock.calls[0]?.[0]).toEqual({ + tenantId: input.tenantId, gatewayId: input.gatewayId, sensorId: input.sensorId, sensorType: input.sensorType, @@ -216,6 +218,7 @@ describe('MeasureService', () => { describe('export', () => { const input: ExportInput = { + tenantId: 'tenant-1', gatewayId: ['gw-1'], sensorId: ['sensor-1'], sensorType: ['temperature'], @@ -260,6 +263,7 @@ describe('MeasureService', () => { const result = await service.export(input); expect(mps.nonPaginatedQuery.mock.calls[0]?.[0]).toEqual({ + tenantId: input.tenantId, gatewayId: input.gatewayId, sensorId: input.sensorId, sensorType: input.sensorType, diff --git a/src/data-api/services/measure.service.ts b/src/data-api/services/measure.service.ts index f5b92d2..5fdf9be 100644 --- a/src/data-api/services/measure.service.ts +++ b/src/data-api/services/measure.service.ts @@ -90,6 +90,7 @@ export class MeasureService { this.validateQueryInput(input); const pInput: PQueryPersistenceInput = { + tenantId: input.tenantId, gatewayId: input.gatewayId, sensorId: input.sensorId, sensorType: input.sensorType, @@ -115,6 +116,7 @@ export class MeasureService { this.validateExportInput(input); const pInput: NpQueryPersistenceInput = { + tenantId: input.tenantId, gatewayId: input.gatewayId, sensorId: input.sensorId, sensorType: input.sensorType, diff --git a/src/data-api/services/sensor.service.spec.ts b/src/data-api/services/sensor.service.spec.ts index deacf3e..492bc0f 100644 --- a/src/data-api/services/sensor.service.spec.ts +++ b/src/data-api/services/sensor.service.spec.ts @@ -26,6 +26,7 @@ describe('SensorService', () => { describe('getSensors', () => { const input: GetSensorsInput = { + tenantId: 'tenant-1', gatewayId: 'gw-1', }; @@ -40,12 +41,14 @@ describe('SensorService', () => { const [calledWith] = npqps.nonPaginatedQuery.mock.calls[0] as [ { + tenantId?: string; gatewayId?: string[]; from: string; to: string; }, ]; + expect(calledWith.tenantId).toBe('tenant-1'); expect(calledWith.gatewayId).toEqual(['gw-1']); expect(typeof calledWith.from).toBe('string'); expect(typeof calledWith.to).toBe('string'); diff --git a/src/data-api/services/sensor.service.ts b/src/data-api/services/sensor.service.ts index e37caa4..7b4521c 100644 --- a/src/data-api/services/sensor.service.ts +++ b/src/data-api/services/sensor.service.ts @@ -36,6 +36,7 @@ export class SensorService { const now = new Date(); const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000); const queryInput: NpQueryPersistenceInput = { + tenantId: input.tenantId, gatewayId: input.gatewayId ? [input.gatewayId] : undefined, from: tenMinutesAgo.toISOString(), to: now.toISOString(), diff --git a/src/env.validation.spec.ts b/src/env.validation.spec.ts index f1f7391..038ff8b 100644 --- a/src/env.validation.spec.ts +++ b/src/env.validation.spec.ts @@ -30,6 +30,7 @@ describe('validate', () => { NATS_TLS_CA: undefined, NATS_TLS_CERT: undefined, NATS_TLS_KEY: undefined, + MGMT_API_URL: 'http://management-api:3000', }); }); @@ -89,6 +90,7 @@ describe('validate', () => { NATS_TLS_CA: undefined, NATS_TLS_CERT: undefined, NATS_TLS_KEY: undefined, + MGMT_API_URL: 'http://management-api:3000', }); }); }); diff --git a/src/env.validation.ts b/src/env.validation.ts index 8475614..7e11ffb 100644 --- a/src/env.validation.ts +++ b/src/env.validation.ts @@ -14,6 +14,7 @@ type DataApiEnv = { NATS_TLS_CA?: string; NATS_TLS_CERT?: string; NATS_TLS_KEY?: string; + MGMT_API_URL: string; }; function parseNumber(value: string | undefined, name: string): number { @@ -71,5 +72,6 @@ export function validate(config: NodeJS.ProcessEnv): DataApiEnv { NATS_TLS_CA: config.NATS_TLS_CA, NATS_TLS_CERT: config.NATS_TLS_CERT, NATS_TLS_KEY: config.NATS_TLS_KEY, + MGMT_API_URL: config.MGMT_API_URL ?? 'http://management-api:3000', }; } From a28c551f937804f6627d976f6a42bfaf1a37cebb Mon Sep 17 00:00:00 2001 From: Scafu Date: Fri, 3 Apr 2026 14:21:29 +0200 Subject: [PATCH 02/13] fix: issues --- src/auth/tenant-access.guard.spec.ts | 2 +- src/auth/tenant-access.guard.ts | 2 +- src/env.validation.spec.ts | 4 ++-- src/env.validation.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/auth/tenant-access.guard.spec.ts b/src/auth/tenant-access.guard.spec.ts index 80144c4..c8075d7 100644 --- a/src/auth/tenant-access.guard.spec.ts +++ b/src/auth/tenant-access.guard.spec.ts @@ -25,7 +25,7 @@ describe('TenantAccessGuard', () => { beforeEach(() => { const configService = { - get: jest.fn().mockReturnValue('http://management-api:3000'), + get: jest.fn().mockReturnValue('https://management-api:3000'), } as unknown as ConfigService; guard = new TenantAccessGuard(configService); global.fetch = jest.fn(); diff --git a/src/auth/tenant-access.guard.ts b/src/auth/tenant-access.guard.ts index 65f10db..b103766 100644 --- a/src/auth/tenant-access.guard.ts +++ b/src/auth/tenant-access.guard.ts @@ -74,7 +74,7 @@ export class TenantAccessGuard implements CanActivate { ): Promise { const managementApiUrl = this.configService.get( 'MGMT_API_URL', - 'http://management-api:3000', + 'https://management-api:3000', ); const response = await fetch(`${managementApiUrl}/auth/tenant-status`, { diff --git a/src/env.validation.spec.ts b/src/env.validation.spec.ts index 038ff8b..18b6dc6 100644 --- a/src/env.validation.spec.ts +++ b/src/env.validation.spec.ts @@ -30,7 +30,7 @@ describe('validate', () => { NATS_TLS_CA: undefined, NATS_TLS_CERT: undefined, NATS_TLS_KEY: undefined, - MGMT_API_URL: 'http://management-api:3000', + MGMT_API_URL: 'https://management-api:3000', }); }); @@ -90,7 +90,7 @@ describe('validate', () => { NATS_TLS_CA: undefined, NATS_TLS_CERT: undefined, NATS_TLS_KEY: undefined, - MGMT_API_URL: 'http://management-api:3000', + MGMT_API_URL: 'https://management-api:3000', }); }); }); diff --git a/src/env.validation.ts b/src/env.validation.ts index 7e11ffb..26cd5c4 100644 --- a/src/env.validation.ts +++ b/src/env.validation.ts @@ -72,6 +72,6 @@ export function validate(config: NodeJS.ProcessEnv): DataApiEnv { NATS_TLS_CA: config.NATS_TLS_CA, NATS_TLS_CERT: config.NATS_TLS_CERT, NATS_TLS_KEY: config.NATS_TLS_KEY, - MGMT_API_URL: config.MGMT_API_URL ?? 'http://management-api:3000', + MGMT_API_URL: config.MGMT_API_URL ?? 'https://management-api:3000', }; } From 1ec380396ca67b3693698d59d65b81019512f966 Mon Sep 17 00:00:00 2001 From: Scafu Date: Fri, 3 Apr 2026 14:40:23 +0200 Subject: [PATCH 03/13] fix: issues --- .../decorators/tenant-id.decorator.spec.ts | 45 +++++ src/auth/tenant-access.guard.spec.ts | 86 ++++++++ .../controller/measure.controller.spec.ts | 184 ++++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 src/auth/decorators/tenant-id.decorator.spec.ts diff --git a/src/auth/decorators/tenant-id.decorator.spec.ts b/src/auth/decorators/tenant-id.decorator.spec.ts new file mode 100644 index 0000000..5cc122e --- /dev/null +++ b/src/auth/decorators/tenant-id.decorator.spec.ts @@ -0,0 +1,45 @@ +import { ExecutionContext } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; +import { TenantId } from './tenant-id.decorator'; + +const createContext = (request: Record): ExecutionContext => + ({ + switchToHttp: () => ({ + getRequest: () => request, + }), + }) as unknown as ExecutionContext; + +describe('TenantId decorator', () => { + class TestController { + test(@TenantId() _tenantId: string): void { + void _tenantId; + } + } + + const metadata = Reflect.getMetadata( + ROUTE_ARGS_METADATA, + TestController, + 'test', + ) as Record< + string, + { + factory: (_data: unknown, ctx: ExecutionContext) => string | undefined; + } + >; + + const factory = metadata[Object.keys(metadata)[0]].factory; + + it('returns tenant id when tenant access context exists', () => { + const request = { + tenantAccess: { + tenantId: 'tenant-1', + }, + }; + + expect(factory(undefined, createContext(request))).toBe('tenant-1'); + }); + + it('returns undefined when tenant access context is missing', () => { + expect(factory(undefined, createContext({}))).toBeUndefined(); + }); +}); diff --git a/src/auth/tenant-access.guard.spec.ts b/src/auth/tenant-access.guard.spec.ts index c8075d7..1b63afe 100644 --- a/src/auth/tenant-access.guard.spec.ts +++ b/src/auth/tenant-access.guard.spec.ts @@ -1,6 +1,7 @@ import { ExecutionContext, ForbiddenException, + ServiceUnavailableException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -45,6 +46,16 @@ describe('TenantAccessGuard', () => { await expect(guard.canActivate(createContext(request))).resolves.toBe(true); }); + it('allows OPTIONS requests without authentication', async () => { + const request = { + method: 'OPTIONS', + path: '/measures/query', + headers: {}, + }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + }); + it('rejects missing bearer token on protected endpoints', async () => { const request = { method: 'GET', @@ -123,4 +134,79 @@ describe('TenantAccessGuard', () => { ForbiddenException, ); }); + + it('propagates unauthorized when management API returns 401', async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + createResponse(401), + ); + + const request = { + method: 'GET', + path: '/measures/query', + headers: { + authorization: 'Bearer valid-token', + }, + }; + + await expect(guard.canActivate(createContext(request))).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('propagates forbidden when management API returns 403', async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + createResponse(403), + ); + + const request = { + method: 'GET', + path: '/measures/query', + headers: { + authorization: 'Bearer valid-token', + }, + }; + + await expect(guard.canActivate(createContext(request))).rejects.toThrow( + ForbiddenException, + ); + }); + + it('rejects when management API is unavailable', async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + createResponse(500), + ); + + const request = { + method: 'GET', + path: '/measures/query', + headers: { + authorization: 'Bearer valid-token', + }, + }; + + await expect(guard.canActivate(createContext(request))).rejects.toThrow( + ServiceUnavailableException, + ); + }); + + it('rejects when tenant access payload is invalid', async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + createResponse(200, { + tenant_id: 'tenant-1', + read_only: false, + }), + ); + + const request = { + method: 'GET', + path: '/measures/query', + headers: { + authorization: 'Bearer valid-token', + }, + }; + + await expect(guard.canActivate(createContext(request))).rejects.toThrow( + ServiceUnavailableException, + ); + }); }); diff --git a/src/data-api/controller/measure.controller.spec.ts b/src/data-api/controller/measure.controller.spec.ts index 0620328..b74c918 100644 --- a/src/data-api/controller/measure.controller.spec.ts +++ b/src/data-api/controller/measure.controller.spec.ts @@ -199,6 +199,58 @@ describe('MeasureController', () => { cursor: undefined, }); }); + + it('should preserve numeric limit values as-is', async () => { + service.query.mockResolvedValue([]); + + await controller.query( + '2024-01-01T00:00:00Z', + '2024-01-02T00:00:00Z', + 321, + undefined, + undefined, + undefined, + undefined, + 'tenant-1', + ); + + expect(serviceQueryMock).toHaveBeenCalledWith({ + from: '2024-01-01T00:00:00Z', + to: '2024-01-02T00:00:00Z', + limit: 321, + tenantId: 'tenant-1', + gatewayId: undefined, + sensorId: undefined, + sensorType: undefined, + cursor: undefined, + }); + }); + + it('should fallback to default limit when limit is not numeric', async () => { + service.query.mockResolvedValue([]); + + await controller.query( + '2024-01-01T00:00:00Z', + '2024-01-02T00:00:00Z', + 'not-a-number', + undefined, + undefined, + undefined, + undefined, + 'tenant-1', + ); + + expect(serviceQueryMock).toHaveBeenCalledWith({ + from: '2024-01-01T00:00:00Z', + to: '2024-01-02T00:00:00Z', + limit: 999, + tenantId: 'tenant-1', + gatewayId: undefined, + sensorId: undefined, + sensorType: undefined, + cursor: undefined, + }); + }); }); describe('export', () => { @@ -412,5 +464,137 @@ describe('MeasureController', () => { }, }); }); + + it('should ignore JWT exp when bearer token has no payload segment', async () => { + mockStreamListenerService.stream.mockReturnValue( + of({ + kind: 'data', + data: { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-03-23T10:00:00.000Z', + encryptedData: 'enc', + iv: 'iv', + authTag: 'tag', + keyVersion: 1, + }, + }), + ); + + await firstValueFrom( + controller.stream( + { + headers: { + authorization: 'Bearer token-without-payload', + }, + } as never, + undefined, + undefined, + undefined, + undefined, + 'tenant-1', + ), + ); + + expect(mockStreamListenerService.stream).toHaveBeenCalledWith({ + gatewayId: undefined, + sensorId: undefined, + sensorType: undefined, + since: undefined, + tenantId: 'tenant-1', + tokenExpiresAt: undefined, + }); + }); + + it('should ignore JWT exp when payload is not valid JSON', async () => { + const payload = Buffer.from('not-json').toString('base64url'); + + mockStreamListenerService.stream.mockReturnValue( + of({ + kind: 'data', + data: { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-03-23T10:00:00.000Z', + encryptedData: 'enc', + iv: 'iv', + authTag: 'tag', + keyVersion: 1, + }, + }), + ); + + await firstValueFrom( + controller.stream( + { + headers: { + authorization: `Bearer header.${payload}.signature`, + }, + } as never, + undefined, + undefined, + undefined, + undefined, + 'tenant-1', + ), + ); + + expect(mockStreamListenerService.stream).toHaveBeenCalledWith({ + gatewayId: undefined, + sensorId: undefined, + sensorType: undefined, + since: undefined, + tenantId: 'tenant-1', + tokenExpiresAt: undefined, + }); + }); + + it('should ignore JWT exp when base64url decoding fails', async () => { + jest.spyOn(Buffer, 'from').mockImplementation((() => { + throw new Error('decode failed'); + }) as unknown as typeof Buffer.from); + + mockStreamListenerService.stream.mockReturnValue( + of({ + kind: 'data', + data: { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-03-23T10:00:00.000Z', + encryptedData: 'enc', + iv: 'iv', + authTag: 'tag', + keyVersion: 1, + }, + }), + ); + + await firstValueFrom( + controller.stream( + { + headers: { + authorization: 'Bearer header.payload.signature', + }, + } as never, + undefined, + undefined, + undefined, + undefined, + 'tenant-1', + ), + ); + + expect(mockStreamListenerService.stream).toHaveBeenCalledWith({ + gatewayId: undefined, + sensorId: undefined, + sensorType: undefined, + since: undefined, + tenantId: 'tenant-1', + tokenExpiresAt: undefined, + }); + }); }); }); From 5b995cf452fbe68440d40baff2ec14b511e064fe Mon Sep 17 00:00:00 2001 From: Scafu Date: Sat, 4 Apr 2026 04:30:46 +0200 Subject: [PATCH 04/13] fix: bugs --- package.json | 4 + src/app.module.ts | 2 + src/auth/tenant-access.guard.ts | 2 +- src/data-api/measure.module.ts | 2 + .../cost-nats-responder.service.spec.ts | 257 ++++++++++++++++++ .../services/cost-nats-responder.service.ts | 173 ++++++++++++ .../measure.persistence.service.spec.ts | 32 +++ .../services/measure.persistence.service.ts | 24 ++ src/database/data-source.ts | 22 ++ src/env.validation.spec.ts | 4 +- src/env.validation.ts | 2 +- src/migrations/.gitkeep | 0 .../1775238000000-create-telemetry-data.ts | 51 ++++ 13 files changed, 571 insertions(+), 4 deletions(-) create mode 100644 src/data-api/services/cost-nats-responder.service.spec.ts create mode 100644 src/data-api/services/cost-nats-responder.service.ts create mode 100644 src/database/data-source.ts create mode 100644 src/migrations/.gitkeep create mode 100644 src/migrations/1775238000000-create-telemetry-data.ts diff --git a/package.json b/package.json index 50286e0..af1968e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,10 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "typecheck": "npx tsc --noEmit --project tsconfig.json", + "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts", + "migration:generate": "npm run typeorm -- migration:generate", + "migration:run": "npm run typeorm -- migration:run", + "migration:revert": "npm run typeorm -- migration:revert", "build:openapi-spec": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\",\"emitDecoratorMetadata\":true,\"experimentalDecorators\":true}' scripts/generate-openapi.ts", "fetch:openapi": "bash scripts/generate-openapi.sh", "fetch:asyncapi": "bash scripts/generate-asyncapi.sh" diff --git a/src/app.module.ts b/src/app.module.ts index fafabd8..db327ee 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,8 @@ const databaseImports = password: configService.get('MEASURES_DB_PASSWORD'), database: configService.get('MEASURES_DB_NAME'), ssl: configService.get('DB_SSL'), + synchronize: + configService.get('NODE_ENV') !== 'production', autoLoadEntities: true, }; }, diff --git a/src/auth/tenant-access.guard.ts b/src/auth/tenant-access.guard.ts index b103766..65f10db 100644 --- a/src/auth/tenant-access.guard.ts +++ b/src/auth/tenant-access.guard.ts @@ -74,7 +74,7 @@ export class TenantAccessGuard implements CanActivate { ): Promise { const managementApiUrl = this.configService.get( 'MGMT_API_URL', - 'https://management-api:3000', + 'http://management-api:3000', ); const response = await fetch(`${managementApiUrl}/auth/tenant-status`, { diff --git a/src/data-api/measure.module.ts b/src/data-api/measure.module.ts index 996e645..f92c215 100644 --- a/src/data-api/measure.module.ts +++ b/src/data-api/measure.module.ts @@ -6,6 +6,7 @@ import { MeasureEntity } from './entity/measure.entity'; import { MeasurePersistenceService } from './services/measure.persistence.service'; import { StreamListenerService } from './services/stream-listener.service'; import { TelemetryStreamBridgeService } from './services/telemetry-stream-bridge.service'; +import { CostNatsResponderService } from './services/cost-nats-responder.service'; @Module({ imports: [TypeOrmModule.forFeature([MeasureEntity])], @@ -15,6 +16,7 @@ import { TelemetryStreamBridgeService } from './services/telemetry-stream-bridge MeasurePersistenceService, StreamListenerService, TelemetryStreamBridgeService, + CostNatsResponderService, ], }) export class MeasureModule {} diff --git a/src/data-api/services/cost-nats-responder.service.spec.ts b/src/data-api/services/cost-nats-responder.service.spec.ts new file mode 100644 index 0000000..91515f8 --- /dev/null +++ b/src/data-api/services/cost-nats-responder.service.spec.ts @@ -0,0 +1,257 @@ +import { Logger } from '@nestjs/common'; +import type { ConnectionOptions, Subscription } from 'nats'; +import { CostNatsResponderService } from './cost-nats-responder.service'; +import { MeasurePersistenceService } from './measure.persistence.service'; + +type TestableCostResponderService = { + logger: Logger; + buildConnectionOptions: () => ConnectionOptions; + consumeMessages: (subscription: Subscription) => Promise; + extractTenantId: (data: Uint8Array) => string | undefined; +}; + +function asTestableService( + service: CostNatsResponderService, +): TestableCostResponderService { + return service as unknown as TestableCostResponderService; +} + +describe('CostNatsResponderService', () => { + const originalEnv = process.env; + + function loadServiceClass(): typeof CostNatsResponderService { + let ServiceClass!: typeof CostNatsResponderService; + + jest.isolateModules(() => { + const moduleExports = jest.requireActual< + typeof import('./cost-nats-responder.service') + >('./cost-nats-responder.service'); + ServiceClass = moduleExports.CostNatsResponderService; + }); + + return ServiceClass; + } + + afterEach(() => { + process.env = originalEnv; + jest.resetModules(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('subscribes to internal.cost and responds with real tenant storage size', async () => { + process.env = { + ...originalEnv, + NODE_ENV: 'development', + NATS_URL: 'nats://localhost:4222', + }; + + let messageConsumed!: () => void; + const consumed = new Promise((resolve) => { + messageConsumed = resolve; + }); + + const respond = jest.fn(); + const subscription = { + unsubscribe: jest.fn(), + [Symbol.asyncIterator]: async function* () { + await Promise.resolve(); + yield { + data: Buffer.from( + JSON.stringify({ + tenant_id: '00000000-0000-0000-0000-000000000001', + }), + ), + respond, + }; + messageConsumed(); + }, + } as unknown as Subscription; + + const subscribe = jest.fn().mockReturnValue(subscription); + const drain = jest.fn().mockResolvedValue(undefined); + const close = jest.fn().mockResolvedValue(undefined); + + jest.doMock('nats', () => ({ + connect: jest.fn().mockResolvedValue({ + subscribe, + drain, + close, + }), + })); + + const getTenantDataSizeAtRest = jest.fn().mockResolvedValue(4096); + const persistence = { + getTenantDataSizeAtRest, + } as unknown as MeasurePersistenceService; + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass(persistence); + + await service.onModuleInit(); + await consumed; + + expect(subscribe).toHaveBeenCalledWith('internal.cost'); + expect(getTenantDataSizeAtRest).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000001', + ); + + const response = JSON.parse( + (respond.mock.calls[0] as [Buffer])[0].toString('utf8'), + ) as { dataSizeAtRest: number }; + expect(response).toEqual({ dataSizeAtRest: 4096 }); + + await service.onModuleDestroy(); + expect(drain).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + }); + + it('skips NATS bootstrap in test mode', async () => { + process.env = { + ...originalEnv, + NODE_ENV: 'test', + }; + + const connect = jest.fn(); + jest.doMock('nats', () => ({ connect })); + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest.fn(), + } as unknown as MeasurePersistenceService); + + await service.onModuleInit(); + + expect(connect).not.toHaveBeenCalled(); + }); + + it('returns zero when payload is invalid', async () => { + jest.doMock('nats', () => ({ + connect: jest.fn(), + })); + + const respond = jest.fn(); + const subscription = { + [Symbol.asyncIterator]: async function* () { + await Promise.resolve(); + yield { + data: Buffer.from('{invalid'), + respond, + }; + }, + } as unknown as Subscription; + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest.fn(), + } as unknown as MeasurePersistenceService); + const testableService = asTestableService(service); + const warnSpy = jest + .spyOn(testableService.logger, 'warn') + .mockImplementation(); + + await testableService.consumeMessages(subscription); + + expect(warnSpy).toHaveBeenCalledWith( + 'Ignoring cost request with invalid payload', + ); + const response = JSON.parse( + (respond.mock.calls[0] as [Buffer])[0].toString('utf8'), + ) as { dataSizeAtRest: number }; + expect(response).toEqual({ dataSizeAtRest: 0 }); + expect( + testableService.extractTenantId(Buffer.from('{invalid')), + ).toBeUndefined(); + }); + + it('returns zero when cost processing fails', async () => { + jest.doMock('nats', () => ({ + connect: jest.fn(), + })); + + const respond = jest.fn(); + const subscription = { + [Symbol.asyncIterator]: async function* () { + await Promise.resolve(); + yield { + data: Buffer.from( + JSON.stringify({ + tenant_id: '00000000-0000-0000-0000-000000000001', + }), + ), + respond, + }; + }, + } as unknown as Subscription; + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest + .fn() + .mockRejectedValue(new Error('db down')), + } as unknown as MeasurePersistenceService); + const testableService = asTestableService(service); + const errorSpy = jest + .spyOn(testableService.logger, 'error') + .mockImplementation(); + + await testableService.consumeMessages(subscription); + + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to process cost request on internal.cost', + expect.any(Error), + ); + const response = JSON.parse( + (respond.mock.calls[0] as [Buffer])[0].toString('utf8'), + ) as { dataSizeAtRest: number }; + expect(response).toEqual({ dataSizeAtRest: 0 }); + }); + + it('builds connection options with token and TLS', () => { + process.env = { + ...originalEnv, + NODE_ENV: 'development', + NATS_SERVERS: ' nats://one:4222, nats://two:4222 ', + NATS_CLIENT_NAME: 'custom-client', + NATS_TOKEN: ' token-value ', + NATS_TLS_CA: '/tmp/ca.pem', + NATS_TLS_CERT: '/tmp/cert.pem', + NATS_TLS_KEY: '/tmp/key.pem', + }; + + const readFileSync = jest + .fn() + .mockReturnValueOnce(Buffer.from('ca')) + .mockReturnValueOnce(Buffer.from('cert')) + .mockReturnValueOnce(Buffer.from('key')); + + jest.doMock('node:fs', () => { + const actualFs = jest.requireActual('node:fs'); + return { + ...actualFs, + readFileSync, + }; + }); + jest.doMock('nats', () => ({ + connect: jest.fn(), + })); + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest.fn(), + } as unknown as MeasurePersistenceService); + const testableService = asTestableService(service); + + expect(testableService.buildConnectionOptions()).toEqual({ + servers: ['nats://one:4222', 'nats://two:4222'], + name: 'custom-client', + token: 'token-value', + tls: { + ca: [Buffer.from('ca')], + cert: Buffer.from('cert'), + key: Buffer.from('key'), + }, + }); + expect(readFileSync).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/data-api/services/cost-nats-responder.service.ts b/src/data-api/services/cost-nats-responder.service.ts new file mode 100644 index 0000000..3abeba5 --- /dev/null +++ b/src/data-api/services/cost-nats-responder.service.ts @@ -0,0 +1,173 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import * as fs from 'node:fs'; +import { + type ConnectionOptions, + connect, + type Msg, + type NatsConnection, + type Subscription, +} from 'nats'; +import { MeasurePersistenceService } from './measure.persistence.service'; + +const COST_SUBJECT = 'internal.cost'; + +type CostRequest = { + tenant_id?: string; +}; + +type CostResponse = { + dataSizeAtRest: number; +}; + +@Injectable() +export class CostNatsResponderService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(CostNatsResponderService.name); + private connection: NatsConnection | null = null; + private subscription: Subscription | null = null; + + constructor(private readonly persistence: MeasurePersistenceService) {} + + async onModuleInit(): Promise { + if (process.env.NODE_ENV === 'test') { + return; + } + + await this.connectAndSubscribe(); + } + + async onModuleDestroy(): Promise { + this.subscription?.unsubscribe(); + this.subscription = null; + + if (this.connection) { + await this.connection.drain(); + await this.connection.close(); + this.connection = null; + } + } + + private async connectAndSubscribe(): Promise { + try { + this.connection = await connect(this.buildConnectionOptions()); + this.subscription = this.connection.subscribe(COST_SUBJECT); + void this.consumeMessages(this.subscription); + this.logger.log(`Subscribed to cost subject ${COST_SUBJECT}`); + } catch (error) { + this.logger.error( + 'Failed to initialize cost responder NATS bridge', + error as Error, + ); + } + } + + private async consumeMessages(subscription: Subscription): Promise { + for await (const message of subscription) { + try { + const tenantId = this.extractTenantId(message.data); + if (!tenantId) { + this.logger.warn('Ignoring cost request with invalid payload'); + this.respondWithCost(message, 0); + continue; + } + + const dataSizeAtRest = + await this.persistence.getTenantDataSizeAtRest(tenantId); + this.respondWithCost(message, dataSizeAtRest); + } catch (error) { + this.logger.error( + `Failed to process cost request on ${COST_SUBJECT}`, + error as Error, + ); + this.respondWithCost(message, 0); + } + } + } + + private extractTenantId(data: Uint8Array): string | undefined { + try { + const payload = JSON.parse( + Buffer.from(data).toString('utf8'), + ) as CostRequest; + const tenantId = payload.tenant_id?.trim(); + + if (!tenantId) { + return undefined; + } + + return tenantId; + } catch { + return undefined; + } + } + + private respondWithCost(message: Msg, dataSizeAtRest: number): void { + const response: CostResponse = { dataSizeAtRest }; + + try { + message.respond(Buffer.from(JSON.stringify(response))); + } catch (error) { + this.logger.error( + 'Failed to respond to internal.cost request', + error as Error, + ); + } + } + + private buildConnectionOptions(): ConnectionOptions { + const options: ConnectionOptions = { + servers: this.resolveServers(), + name: process.env.NATS_CLIENT_NAME ?? 'data-api', + }; + + const caFile = process.env.NATS_TLS_CA; + const certFile = process.env.NATS_TLS_CERT; + const keyFile = process.env.NATS_TLS_KEY; + + if (caFile && certFile && keyFile) { + try { + (options as { tls: any }).tls = { + ca: [fs.readFileSync(caFile)], + cert: fs.readFileSync(certFile), + key: fs.readFileSync(keyFile), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to load NATS TLS certificates: ${message}`); + } + } + + const token = process.env.NATS_TOKEN?.trim(); + const user = process.env.NATS_USER?.trim(); + const pass = process.env.NATS_PASSWORD?.trim(); + + if (token) { + options.token = token; + return options; + } + + if (user && pass) { + options.user = user; + options.pass = pass; + } + + return options; + } + + private resolveServers(): string[] { + const raw = process.env.NATS_SERVERS ?? process.env.NATS_URL; + + if (!raw) { + return ['nats://localhost:4222']; + } + + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } +} diff --git a/src/data-api/services/measure.persistence.service.spec.ts b/src/data-api/services/measure.persistence.service.spec.ts index eb0cbfb..1f99f05 100644 --- a/src/data-api/services/measure.persistence.service.spec.ts +++ b/src/data-api/services/measure.persistence.service.spec.ts @@ -187,4 +187,36 @@ describe('MeasurePersistenceService', () => { expect(qb.orderBy).toHaveBeenCalledWith('m.time', 'DESC'); expect(result).toEqual(rows); }); + + it('returns tenant data size at rest in bytes', async () => { + const repository = { + query: jest.fn().mockResolvedValue([{ data_size_at_rest: '2048' }]), + }; + + const service = new MeasurePersistenceService(repository as never); + + const result = await service.getTenantDataSizeAtRest( + '00000000-0000-0000-0000-000000000001', + ); + + expect(repository.query).toHaveBeenCalledWith( + expect.stringContaining('SUM(pg_column_size(td))'), + ['00000000-0000-0000-0000-000000000001'], + ); + expect(result).toBe(2048); + }); + + it('falls back to zero when tenant data size cannot be parsed', async () => { + const repository = { + query: jest.fn().mockResolvedValue([{ data_size_at_rest: 'invalid' }]), + }; + + const service = new MeasurePersistenceService(repository as never); + + const result = await service.getTenantDataSizeAtRest( + '00000000-0000-0000-0000-000000000001', + ); + + expect(result).toBe(0); + }); }); diff --git a/src/data-api/services/measure.persistence.service.ts b/src/data-api/services/measure.persistence.service.ts index 1e82230..df80be4 100644 --- a/src/data-api/services/measure.persistence.service.ts +++ b/src/data-api/services/measure.persistence.service.ts @@ -92,4 +92,28 @@ export class MeasurePersistenceService implements NpQueryPersistenceService { return qb.getMany(); } + + async getTenantDataSizeAtRest(tenantId: string): Promise { + const rows: Array<{ data_size_at_rest?: number | string }> = + await this.r.query( + ` + SELECT COALESCE(SUM(pg_column_size(td)), 0)::bigint AS data_size_at_rest + FROM telemetry_data td + WHERE td.tenant_id = $1::uuid + `, + [tenantId], + ); + + const rawSize = rows[0]?.data_size_at_rest; + if (typeof rawSize === 'number') { + return Number.isFinite(rawSize) ? rawSize : 0; + } + + if (typeof rawSize === 'string') { + const parsed = Number(rawSize); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; + } } diff --git a/src/database/data-source.ts b/src/database/data-source.ts new file mode 100644 index 0000000..87bbbe4 --- /dev/null +++ b/src/database/data-source.ts @@ -0,0 +1,22 @@ +import 'reflect-metadata'; +import 'dotenv/config'; +import { DataSource } from 'typeorm'; +import { join } from 'node:path'; + +const port = Number(process.env.MEASURES_DB_PORT); +const ssl = process.env.DB_SSL === 'true' || process.env.DB_SSL === '1'; + +export default new DataSource({ + type: 'postgres', + host: process.env.MEASURES_DB_HOST ?? 'localhost', + port: Number.isNaN(port) ? 5432 : port, + username: process.env.MEASURES_DB_USER ?? 'postgres', + password: process.env.MEASURES_DB_PASSWORD ?? 'postgres', + database: process.env.MEASURES_DB_NAME ?? 'postgres', + ssl, + entities: [join(__dirname, '..', '**', '*.entity.{ts,js}')], + migrations: [ + join(__dirname, '..', 'migrations', '*.ts'), + join(__dirname, '..', 'migrations', '*.js'), + ], +}); diff --git a/src/env.validation.spec.ts b/src/env.validation.spec.ts index 18b6dc6..038ff8b 100644 --- a/src/env.validation.spec.ts +++ b/src/env.validation.spec.ts @@ -30,7 +30,7 @@ describe('validate', () => { NATS_TLS_CA: undefined, NATS_TLS_CERT: undefined, NATS_TLS_KEY: undefined, - MGMT_API_URL: 'https://management-api:3000', + MGMT_API_URL: 'http://management-api:3000', }); }); @@ -90,7 +90,7 @@ describe('validate', () => { NATS_TLS_CA: undefined, NATS_TLS_CERT: undefined, NATS_TLS_KEY: undefined, - MGMT_API_URL: 'https://management-api:3000', + MGMT_API_URL: 'http://management-api:3000', }); }); }); diff --git a/src/env.validation.ts b/src/env.validation.ts index 26cd5c4..7e11ffb 100644 --- a/src/env.validation.ts +++ b/src/env.validation.ts @@ -72,6 +72,6 @@ export function validate(config: NodeJS.ProcessEnv): DataApiEnv { NATS_TLS_CA: config.NATS_TLS_CA, NATS_TLS_CERT: config.NATS_TLS_CERT, NATS_TLS_KEY: config.NATS_TLS_KEY, - MGMT_API_URL: config.MGMT_API_URL ?? 'https://management-api:3000', + MGMT_API_URL: config.MGMT_API_URL ?? 'http://management-api:3000', }; } diff --git a/src/migrations/.gitkeep b/src/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/migrations/1775238000000-create-telemetry-data.ts b/src/migrations/1775238000000-create-telemetry-data.ts new file mode 100644 index 0000000..e31c1de --- /dev/null +++ b/src/migrations/1775238000000-create-telemetry-data.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTelemetryData1775238000000 implements MigrationInterface { + name = 'CreateTelemetryData1775238000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "telemetry_data" ( + "time" TIMESTAMPTZ NOT NULL, + "tenant_id" UUID NOT NULL, + "gateway_id" UUID NOT NULL, + "sensor_id" UUID NOT NULL, + "sensor_type" VARCHAR(255) NOT NULL, + "encrypted_data" VARCHAR(255) NOT NULL, + "iv" VARCHAR(255) NOT NULL, + "auth_tag" VARCHAR(255) NOT NULL, + "key_version" INTEGER NOT NULL, + CONSTRAINT "PK_telemetry_data_time" PRIMARY KEY ("time") + ) + `); + + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_telemetry_data_tenant_time" ON "telemetry_data" ("tenant_id", "time" DESC)', + ); + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_telemetry_data_gateway_time" ON "telemetry_data" ("gateway_id", "time" DESC)', + ); + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_telemetry_data_sensor_time" ON "telemetry_data" ("sensor_id", "time" DESC)', + ); + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_telemetry_data_sensor_type_time" ON "telemetry_data" ("sensor_type", "time" DESC)', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX IF EXISTS "IDX_telemetry_data_sensor_type_time"', + ); + await queryRunner.query( + 'DROP INDEX IF EXISTS "IDX_telemetry_data_sensor_time"', + ); + await queryRunner.query( + 'DROP INDEX IF EXISTS "IDX_telemetry_data_gateway_time"', + ); + await queryRunner.query( + 'DROP INDEX IF EXISTS "IDX_telemetry_data_tenant_time"', + ); + await queryRunner.query('DROP TABLE IF EXISTS "telemetry_data"'); + } +} From 970fc7be5fb0cdc600538b4d3fc982ff01d1675a Mon Sep 17 00:00:00 2001 From: Scafu Date: Sun, 5 Apr 2026 18:24:00 +0200 Subject: [PATCH 05/13] fix: telemetry naming table --- src/data-api/entity/measure.entity.ts | 2 +- .../services/measure.persistence.service.ts | 4 +- .../1775238000000-create-telemetry-data.ts | 51 ------------------- 3 files changed, 3 insertions(+), 54 deletions(-) delete mode 100644 src/migrations/1775238000000-create-telemetry-data.ts diff --git a/src/data-api/entity/measure.entity.ts b/src/data-api/entity/measure.entity.ts index c6ee66b..d4c504b 100644 --- a/src/data-api/entity/measure.entity.ts +++ b/src/data-api/entity/measure.entity.ts @@ -1,6 +1,6 @@ import { Column, Entity, PrimaryColumn } from 'typeorm'; -@Entity({ name: 'telemetry_data' }) +@Entity({ name: 'telemetry' }) export class MeasureEntity { @PrimaryColumn({ name: 'time', type: 'timestamptz' }) time: string; diff --git a/src/data-api/services/measure.persistence.service.ts b/src/data-api/services/measure.persistence.service.ts index df80be4..33bc9d8 100644 --- a/src/data-api/services/measure.persistence.service.ts +++ b/src/data-api/services/measure.persistence.service.ts @@ -98,8 +98,8 @@ export class MeasurePersistenceService implements NpQueryPersistenceService { await this.r.query( ` SELECT COALESCE(SUM(pg_column_size(td)), 0)::bigint AS data_size_at_rest - FROM telemetry_data td - WHERE td.tenant_id = $1::uuid + FROM telemetry td + WHERE td.tenant_id = $1 `, [tenantId], ); diff --git a/src/migrations/1775238000000-create-telemetry-data.ts b/src/migrations/1775238000000-create-telemetry-data.ts deleted file mode 100644 index e31c1de..0000000 --- a/src/migrations/1775238000000-create-telemetry-data.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateTelemetryData1775238000000 implements MigrationInterface { - name = 'CreateTelemetryData1775238000000'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TABLE IF NOT EXISTS "telemetry_data" ( - "time" TIMESTAMPTZ NOT NULL, - "tenant_id" UUID NOT NULL, - "gateway_id" UUID NOT NULL, - "sensor_id" UUID NOT NULL, - "sensor_type" VARCHAR(255) NOT NULL, - "encrypted_data" VARCHAR(255) NOT NULL, - "iv" VARCHAR(255) NOT NULL, - "auth_tag" VARCHAR(255) NOT NULL, - "key_version" INTEGER NOT NULL, - CONSTRAINT "PK_telemetry_data_time" PRIMARY KEY ("time") - ) - `); - - await queryRunner.query( - 'CREATE INDEX IF NOT EXISTS "IDX_telemetry_data_tenant_time" ON "telemetry_data" ("tenant_id", "time" DESC)', - ); - await queryRunner.query( - 'CREATE INDEX IF NOT EXISTS "IDX_telemetry_data_gateway_time" ON "telemetry_data" ("gateway_id", "time" DESC)', - ); - await queryRunner.query( - 'CREATE INDEX IF NOT EXISTS "IDX_telemetry_data_sensor_time" ON "telemetry_data" ("sensor_id", "time" DESC)', - ); - await queryRunner.query( - 'CREATE INDEX IF NOT EXISTS "IDX_telemetry_data_sensor_type_time" ON "telemetry_data" ("sensor_type", "time" DESC)', - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - 'DROP INDEX IF EXISTS "IDX_telemetry_data_sensor_type_time"', - ); - await queryRunner.query( - 'DROP INDEX IF EXISTS "IDX_telemetry_data_sensor_time"', - ); - await queryRunner.query( - 'DROP INDEX IF EXISTS "IDX_telemetry_data_gateway_time"', - ); - await queryRunner.query( - 'DROP INDEX IF EXISTS "IDX_telemetry_data_tenant_time"', - ); - await queryRunner.query('DROP TABLE IF EXISTS "telemetry_data"'); - } -} From 5480bcefe46bee0ad69f52e05b51d119fd6f33ae Mon Sep 17 00:00:00 2001 From: Scafu Date: Mon, 6 Apr 2026 20:59:57 +0200 Subject: [PATCH 06/13] fix: data convertion base64 into HEX for payload decryption --- .../controller/measure.controller.spec.ts | 33 +++++--- src/data-api/controller/measure.controller.ts | 4 +- src/data-api/measure.mapper.spec.ts | 43 ++++++---- src/data-api/measure.mapper.ts | 84 ++++++++++++++++--- src/data-api/services/measure.service.spec.ts | 2 +- src/data-api/services/measure.service.ts | 4 +- 6 files changed, 125 insertions(+), 45 deletions(-) diff --git a/src/data-api/controller/measure.controller.spec.ts b/src/data-api/controller/measure.controller.spec.ts index b74c918..eed089a 100644 --- a/src/data-api/controller/measure.controller.spec.ts +++ b/src/data-api/controller/measure.controller.spec.ts @@ -93,11 +93,11 @@ describe('MeasureController', () => { hasMore: true, }; - service.query.mockResolvedValue([queryModel]); + service.query.mockResolvedValue(queryModel); const mapperSpy = jest - .spyOn(MeasureMapper, 'toQueryResponseDtos') - .mockReturnValue([queryResponseDto]); + .spyOn(MeasureMapper, 'toQueryResponseDto') + .mockReturnValue(queryResponseDto); const result = await controller.query( from, @@ -122,8 +122,8 @@ describe('MeasureController', () => { cursor, }); expect(mapperSpy).toHaveBeenCalledTimes(1); - expect(mapperSpy).toHaveBeenCalledWith([queryModel]); - expect(result).toEqual([queryResponseDto]); + expect(mapperSpy).toHaveBeenCalledWith(queryModel); + expect(result).toEqual(queryResponseDto); }); it('should use default limit = 999 when limit is not provided', async () => { @@ -142,11 +142,11 @@ describe('MeasureController', () => { hasMore: false, }; - service.query.mockResolvedValue([queryModel]); + service.query.mockResolvedValue(queryModel); const mapperSpy = jest - .spyOn(MeasureMapper, 'toQueryResponseDtos') - .mockReturnValue([queryResponseDto]); + .spyOn(MeasureMapper, 'toQueryResponseDto') + .mockReturnValue(queryResponseDto); const result = await controller.query( from, @@ -171,11 +171,14 @@ describe('MeasureController', () => { cursor: undefined, }); expect(mapperSpy).toHaveBeenCalledTimes(1); - expect(result).toEqual([queryResponseDto]); + expect(result).toEqual(queryResponseDto); }); it('should normalize single-value filters into arrays', async () => { - service.query.mockResolvedValue([]); + service.query.mockResolvedValue({ + data: [], + hasMore: false, + }); await controller.query( '2024-01-01T00:00:00Z', @@ -201,7 +204,10 @@ describe('MeasureController', () => { }); it('should preserve numeric limit values as-is', async () => { - service.query.mockResolvedValue([]); + service.query.mockResolvedValue({ + data: [], + hasMore: false, + }); await controller.query( '2024-01-01T00:00:00Z', @@ -227,7 +233,10 @@ describe('MeasureController', () => { }); it('should fallback to default limit when limit is not numeric', async () => { - service.query.mockResolvedValue([]); + service.query.mockResolvedValue({ + data: [], + hasMore: false, + }); await controller.query( '2024-01-01T00:00:00Z', diff --git a/src/data-api/controller/measure.controller.ts b/src/data-api/controller/measure.controller.ts index e5a3594..0d702e3 100644 --- a/src/data-api/controller/measure.controller.ts +++ b/src/data-api/controller/measure.controller.ts @@ -133,7 +133,7 @@ export class MeasureController { @Query('sensorType') sensorType?: string | string[], @Query('cursor') cursor?: string, @TenantId() tenantId?: string, - ): Promise { + ): Promise { const input: QueryInput = { from, to, @@ -146,7 +146,7 @@ export class MeasureController { }; const queryModel = await this.ms.query(input); - return MeasureMapper.toQueryResponseDtos(queryModel); + return MeasureMapper.toQueryResponseDto(queryModel); } @ApiMeasureStreamDocs() diff --git a/src/data-api/measure.mapper.spec.ts b/src/data-api/measure.mapper.spec.ts index 39f59a0..29a8d9d 100644 --- a/src/data-api/measure.mapper.spec.ts +++ b/src/data-api/measure.mapper.spec.ts @@ -45,24 +45,6 @@ describe('MeasureMapper', () => { }); }); - it('maps paginated query models to dto array', () => { - expect( - MeasureMapper.toQueryResponseDtos([ - { - data: [model], - nextCursor: 'cursor-1', - hasMore: true, - }, - ]), - ).toEqual([ - { - data: [model], - nextCursor: 'cursor-1', - hasMore: true, - }, - ]); - }); - it('maps a paginated query model with no data to dto', () => { expect( MeasureMapper.toQueryResponseDto({ @@ -80,6 +62,31 @@ describe('MeasureMapper', () => { expect(MeasureMapper.toExportResponseDto([model])).toEqual([model]); }); + it('normalizes base64 envelope fields to hex and timestamp to ISO Z', () => { + const normalized = MeasureMapper.toEncryptedEnvelopeDto({ + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-04-06T16:41:58.10169+00:00', + encryptedData: 'sJfpJlOKzE8TjcIngGnBw2DjBYL5H6e7qNdzk2ftcVWhONhdqqiVLA==', + iv: 'KwLL8TQSYAAr98Ww', + authTag: 'O9dM93uOH4GifyJNayKghA==', + keyVersion: 1, + }); + + expect(normalized).toEqual({ + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-04-06T16:41:58.101Z', + encryptedData: + 'b097e926538acc4f138dc2278069c1c360e30582f91fa7bba8d7739367ed7155a138d85daaa8952c', + iv: '2b02cbf1341260002bf7c5b0', + authTag: '3bd74cf77b8e1f81a27f224d6b22a084', + keyVersion: 1, + }); + }); + it('maps stream item and stream response', async () => { expect(MeasureMapper.toStreamItemResponseDto(model)).toEqual(model); diff --git a/src/data-api/measure.mapper.ts b/src/data-api/measure.mapper.ts index d7b1631..1b94a6c 100644 --- a/src/data-api/measure.mapper.ts +++ b/src/data-api/measure.mapper.ts @@ -8,6 +8,72 @@ import { Observable, map } from 'rxjs'; import { SensorDto } from './dto/sensor.dto'; import { SensorModel } from './models/sensor.model'; +const IV_BYTES = 12; +const AUTH_TAG_BYTES = 16; +const MIN_ENCRYPTED_DATA_BYTES = 8; + +function isHexString(value: string): boolean { + return value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value); +} + +function decodeBase64(value: string): Buffer | undefined { + const normalized = value.trim().replaceAll('-', '+').replaceAll('_', '/'); + + if (normalized.length === 0 || normalized.length % 4 === 1) { + return undefined; + } + + const padding = (4 - (normalized.length % 4)) % 4; + const padded = normalized + '='.repeat(padding); + + try { + const decoded = Buffer.from(padded, 'base64'); + if (decoded.length === 0) { + return undefined; + } + + // Guard against false positives (arbitrary strings that happen to decode). + const roundTrip = decoded.toString('base64').replace(/=+$/u, ''); + const source = normalized.replace(/=+$/u, ''); + + return roundTrip === source ? decoded : undefined; + } catch { + return undefined; + } +} + +function toHexIfBase64( + value: string, + expectedBytes?: number, + minBytes = 1, +): string { + if (isHexString(value)) { + return value.toLowerCase(); + } + + const decoded = decodeBase64(value); + + if (!decoded) { + return value; + } + + if (decoded.length < minBytes) { + return value; + } + + if (expectedBytes !== undefined && decoded.length !== expectedBytes) { + return value; + } + + return decoded.toString('hex'); +} + +function normalizeTimestamp(value: string): string { + const parsed = new Date(value); + + return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString(); +} + export class MeasureMapper { static toEncryptedEnvelopeDto( model: EncryptedEnvelopeModel, @@ -16,10 +82,14 @@ export class MeasureMapper { gatewayId: model.gatewayId, sensorId: model.sensorId, sensorType: model.sensorType, - timestamp: model.timestamp, - encryptedData: model.encryptedData, - iv: model.iv, - authTag: model.authTag, + timestamp: normalizeTimestamp(model.timestamp), + encryptedData: toHexIfBase64( + model.encryptedData, + undefined, + MIN_ENCRYPTED_DATA_BYTES, + ), + iv: toHexIfBase64(model.iv, IV_BYTES), + authTag: toHexIfBase64(model.authTag, AUTH_TAG_BYTES), keyVersion: model.keyVersion, }; } @@ -32,12 +102,6 @@ export class MeasureMapper { }; } - static toQueryResponseDtos( - models: PaginatedQueryModel[], - ): QueryResponseDto[] { - return models.map((model) => this.toQueryResponseDto(model)); - } - static toExportResponseDto( models: EncryptedEnvelopeModel[], ): EncryptedEnvelopeDto[] { diff --git a/src/data-api/services/measure.service.spec.ts b/src/data-api/services/measure.service.spec.ts index 95bb77b..204357d 100644 --- a/src/data-api/services/measure.service.spec.ts +++ b/src/data-api/services/measure.service.spec.ts @@ -96,7 +96,7 @@ describe('MeasureService', () => { limit: input.limit, }); expect(mapperSpy).toHaveBeenCalledWith(persistenceResult); - expect(result).toEqual([mappedResult]); + expect(result).toEqual(mappedResult); }); it('should throw BadRequestException when limit is greater than or equal to 1000', async () => { diff --git a/src/data-api/services/measure.service.ts b/src/data-api/services/measure.service.ts index 5fdf9be..23ed7bb 100644 --- a/src/data-api/services/measure.service.ts +++ b/src/data-api/services/measure.service.ts @@ -86,7 +86,7 @@ function throwHttpException( export class MeasureService { constructor(private readonly mps: MeasurePersistenceService) {} - async query(input: QueryInput): Promise { + async query(input: QueryInput): Promise { this.validateQueryInput(input); const pInput: PQueryPersistenceInput = { @@ -102,7 +102,7 @@ export class MeasureService { try { const result = await this.mps.paginatedQuery(pInput); - return [MeasureMapper.toPaginatedQueryModel(result)]; + return MeasureMapper.toPaginatedQueryModel(result); } catch (error: unknown) { if (isServiceError(error)) { this.handleQueryError(error); From 4678529da1a5946efae46b932e831c72600feb3e Mon Sep 17 00:00:00 2001 From: marcon-effe Date: Mon, 6 Apr 2026 22:18:49 +0000 Subject: [PATCH 07/13] feat: add metrics module with controller, service, and interceptor for monitoring --- api-contracts/openapi/openapi.yaml | 40 +++++--- package-lock.json | 140 ++++++++------------------- package.json | 1 + src/app.module.ts | 9 +- src/auth/tenant-access.guard.spec.ts | 26 +++-- src/auth/tenant-access.guard.ts | 4 +- src/metrics/metrics.controller.ts | 14 +++ src/metrics/metrics.interceptor.ts | 49 ++++++++++ src/metrics/metrics.module.ts | 10 ++ src/metrics/metrics.service.ts | 112 +++++++++++++++++++++ 10 files changed, 281 insertions(+), 124 deletions(-) create mode 100644 src/metrics/metrics.controller.ts create mode 100644 src/metrics/metrics.interceptor.ts create mode 100644 src/metrics/metrics.module.ts create mode 100644 src/metrics/metrics.service.ts diff --git a/api-contracts/openapi/openapi.yaml b/api-contracts/openapi/openapi.yaml index 25375b0..94db91b 100644 --- a/api-contracts/openapi/openapi.yaml +++ b/api-contracts/openapi/openapi.yaml @@ -30,21 +30,21 @@ paths: schema: type: string format: date-time - - name: limit - required: false - in: query - description: Page size. Defaults to 1000 and cannot exceed 1000. - schema: - type: integer - minimum: 1 - maximum: 1000 - default: 1000 - name: cursor required: false in: query description: Opaque cursor returned by a previous query page schema: type: string + - name: limit + required: false + in: query + description: Page size. Defaults to 999 and must be less than 1000. + schema: + type: integer + minimum: 1 + maximum: 999 + default: 999 - name: gatewayId required: false in: query @@ -121,6 +121,15 @@ paths: text/event-stream media type. operationId: MeasureController_stream parameters: + - name: since + required: false + in: query + description: >- + Optional timestamp used to replay historical measures before + switching to real-time events + schema: + type: string + format: date-time - name: gatewayId required: false in: query @@ -167,6 +176,13 @@ paths: data: {"gatewayId":"gw-1","sensorId":"sensor-1","sensorType":"temperature","timestamp":"2026-03-23T09:58:00.000Z","encryptedData":"enc-3","iv":"iv-3","authTag":"tag-3","keyVersion":1} + + data: {"type":"error","reason":"token_expired"} + + '401': + description: Unauthorized + '403': + description: Forbidden summary: Open a live measure stream tags: - measures @@ -282,6 +298,8 @@ paths: type: array items: $ref: '#/components/schemas/SensorDto' + '400': + description: Bad Request - invalid gatewayId format '401': description: Authentication failed content: @@ -302,7 +320,7 @@ paths: $ref: '#/components/schemas/ErrorResponseDto' summary: List sensors seen in the last ten minutes tags: - - sensor + - sensors info: title: NoTIP Data API description: NoTIP Data API OpenAPI specification @@ -390,7 +408,7 @@ components: message: type: string description: Human-readable error message - example: limit must be less than or equal to 1000 + example: limit must be less than 1000 error: type: string description: Short HTTP error label when returned by Nest diff --git a/package-lock.json b/package-lock.json index 9df4a04..1f1fdf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "class-validator": "^0.14.4", "nats": "^2.29.3", "pg": "^8.20.0", + "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "typeorm": "^0.3.28" @@ -236,24 +237,6 @@ } } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/picomatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", @@ -267,22 +250,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -349,24 +316,6 @@ } } }, - "node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/picomatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", @@ -380,22 +329,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@angular-devkit/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -6577,24 +6510,6 @@ } } }, - "node_modules/@nestjs/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -6608,22 +6523,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@nestjs/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -8194,6 +8093,15 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -15071,6 +14979,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -28552,6 +28466,19 @@ "dev": true, "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/promise-all-reject-late": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", @@ -31858,6 +31785,15 @@ "dev": true, "license": "ISC" }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", diff --git a/package.json b/package.json index af1968e..a5a6f50 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "class-validator": "^0.14.4", "nats": "^2.29.3", "pg": "^8.20.0", + "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "typeorm": "^0.3.28" diff --git a/src/app.module.ts b/src/app.module.ts index db327ee..88d7481 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { MeasureModule } from './data-api/measure.module'; import { SensorModule } from './data-api/sensor.module'; +import { MetricsModule } from './metrics/metrics.module'; +import { MetricsInterceptor } from './metrics/metrics.interceptor'; import { validate } from './env.validation'; import { TenantAccessGuard } from './auth/tenant-access.guard'; @@ -39,6 +41,7 @@ const databaseImports = validate, expandVariables: true, }), + MetricsModule, ...databaseImports, MeasureModule, SensorModule, @@ -46,6 +49,10 @@ const databaseImports = controllers: [AppController], providers: [ AppService, + { + provide: APP_INTERCEPTOR, + useClass: MetricsInterceptor, + }, { provide: APP_GUARD, useClass: TenantAccessGuard, diff --git a/src/auth/tenant-access.guard.spec.ts b/src/auth/tenant-access.guard.spec.ts index 1b63afe..08f0c32 100644 --- a/src/auth/tenant-access.guard.spec.ts +++ b/src/auth/tenant-access.guard.spec.ts @@ -29,7 +29,7 @@ describe('TenantAccessGuard', () => { get: jest.fn().mockReturnValue('https://management-api:3000'), } as unknown as ConfigService; guard = new TenantAccessGuard(configService); - global.fetch = jest.fn(); + globalThis.fetch = jest.fn(); }); afterEach(() => { @@ -46,6 +46,16 @@ describe('TenantAccessGuard', () => { await expect(guard.canActivate(createContext(request))).resolves.toBe(true); }); + it('allows metrics endpoint without authentication', async () => { + const request = { + method: 'GET', + path: '/metrics', + headers: {}, + }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + }); + it('allows OPTIONS requests without authentication', async () => { const request = { method: 'OPTIONS', @@ -69,7 +79,7 @@ describe('TenantAccessGuard', () => { }); it('allows active tenant and stores tenant access context', async () => { - (global.fetch as jest.MockedFunction).mockResolvedValue( + (globalThis.fetch as jest.MockedFunction).mockResolvedValue( createResponse(200, { tenant_id: 'tenant-1', status: 'active', @@ -94,7 +104,7 @@ describe('TenantAccessGuard', () => { }); it('allows suspended tenant in read-only mode', async () => { - (global.fetch as jest.MockedFunction).mockResolvedValue( + (globalThis.fetch as jest.MockedFunction).mockResolvedValue( createResponse(200, { tenant_id: 'tenant-1', status: 'suspended', @@ -114,7 +124,7 @@ describe('TenantAccessGuard', () => { }); it('rejects suspended tenant on write operations', async () => { - (global.fetch as jest.MockedFunction).mockResolvedValue( + (globalThis.fetch as jest.MockedFunction).mockResolvedValue( createResponse(200, { tenant_id: 'tenant-1', status: 'suspended', @@ -136,7 +146,7 @@ describe('TenantAccessGuard', () => { }); it('propagates unauthorized when management API returns 401', async () => { - (global.fetch as jest.MockedFunction).mockResolvedValue( + (globalThis.fetch as jest.MockedFunction).mockResolvedValue( createResponse(401), ); @@ -154,7 +164,7 @@ describe('TenantAccessGuard', () => { }); it('propagates forbidden when management API returns 403', async () => { - (global.fetch as jest.MockedFunction).mockResolvedValue( + (globalThis.fetch as jest.MockedFunction).mockResolvedValue( createResponse(403), ); @@ -172,7 +182,7 @@ describe('TenantAccessGuard', () => { }); it('rejects when management API is unavailable', async () => { - (global.fetch as jest.MockedFunction).mockResolvedValue( + (globalThis.fetch as jest.MockedFunction).mockResolvedValue( createResponse(500), ); @@ -190,7 +200,7 @@ describe('TenantAccessGuard', () => { }); it('rejects when tenant access payload is invalid', async () => { - (global.fetch as jest.MockedFunction).mockResolvedValue( + (globalThis.fetch as jest.MockedFunction).mockResolvedValue( createResponse(200, { tenant_id: 'tenant-1', read_only: false, diff --git a/src/auth/tenant-access.guard.ts b/src/auth/tenant-access.guard.ts index 65f10db..d89fde1 100644 --- a/src/auth/tenant-access.guard.ts +++ b/src/auth/tenant-access.guard.ts @@ -57,8 +57,8 @@ export class TenantAccessGuard implements CanActivate { return true; } - const path = request.path ?? request.url; - return path === '/'; + const path = (request.path ?? request.url ?? '').split('?')[0]; + return path === '/' || path === '/metrics'; } private extractBearerToken(authorization?: string): string | undefined { diff --git a/src/metrics/metrics.controller.ts b/src/metrics/metrics.controller.ts new file mode 100644 index 0000000..5338c44 --- /dev/null +++ b/src/metrics/metrics.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import type { Response } from 'express'; +import { MetricsService } from './metrics.service'; + +@Controller() +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get('metrics') + async metrics(@Res() res: Response): Promise { + res.setHeader('Content-Type', this.metricsService.contentType); + res.send(await this.metricsService.getMetrics()); + } +} diff --git a/src/metrics/metrics.interceptor.ts b/src/metrics/metrics.interceptor.ts new file mode 100644 index 0000000..1b121a0 --- /dev/null +++ b/src/metrics/metrics.interceptor.ts @@ -0,0 +1,49 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { finalize, Observable } from 'rxjs'; +import { MetricsService } from './metrics.service'; + +@Injectable() +export class MetricsInterceptor implements NestInterceptor { + constructor(private readonly metricsService: MetricsService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType<'http'>() !== 'http') { + return next.handle(); + } + + const http = context.switchToHttp(); + const req = http.getRequest<{ + method?: string; + baseUrl?: string; + route?: { path?: unknown }; + }>(); + const res = http.getResponse<{ statusCode?: number }>(); + + const method = req.method ?? 'UNKNOWN'; + const start = process.hrtime.bigint(); + + this.metricsService.incInFlight(method); + + return next.handle().pipe( + finalize(() => { + const elapsedNs = process.hrtime.bigint() - start; + const durationSeconds = Number(elapsedNs) / 1_000_000_000; + const route = this.metricsService.resolveRouteLabel(req); + const statusCode = res.statusCode ?? 500; + + this.metricsService.observeHttpRequest( + method, + route, + statusCode, + durationSeconds, + ); + this.metricsService.decInFlight(method); + }), + ); + } +} diff --git a/src/metrics/metrics.module.ts b/src/metrics/metrics.module.ts new file mode 100644 index 0000000..0403c34 --- /dev/null +++ b/src/metrics/metrics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts new file mode 100644 index 0000000..cc11af4 --- /dev/null +++ b/src/metrics/metrics.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@nestjs/common'; +import { + collectDefaultMetrics, + Counter, + Gauge, + Histogram, + Registry, +} from 'prom-client'; + +type HTTPLabelValues = { + method: string; + route: string; + status_code: string; +}; + +@Injectable() +export class MetricsService { + private readonly registry: Registry; + + private readonly httpRequestsTotal: Counter; + private readonly httpRequestDurationSeconds: Histogram; + private readonly httpRequestsInFlight: Gauge<'method'>; + + constructor() { + this.registry = new Registry(); + + collectDefaultMetrics({ + register: this.registry, + prefix: 'notip_data_api_', + }); + + this.httpRequestsTotal = new Counter({ + name: 'notip_data_api_http_requests_total', + help: 'Total number of HTTP requests handled by the data API.', + labelNames: ['method', 'route', 'status_code'], + registers: [this.registry], + }); + + this.httpRequestDurationSeconds = new Histogram({ + name: 'notip_data_api_http_request_duration_seconds', + help: 'HTTP request duration in seconds.', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [this.registry], + }); + + this.httpRequestsInFlight = new Gauge({ + name: 'notip_data_api_http_requests_in_flight', + help: 'Number of currently in-flight HTTP requests.', + labelNames: ['method'], + registers: [this.registry], + }); + } + + get contentType(): string { + return this.registry.contentType; + } + + async getMetrics(): Promise { + return this.registry.metrics(); + } + + incInFlight(method: string): void { + this.httpRequestsInFlight.inc({ method }); + } + + decInFlight(method: string): void { + this.httpRequestsInFlight.dec({ method }); + } + + observeHttpRequest( + method: string, + route: string, + statusCode: number, + durationSeconds: number, + ): void { + const labels: HTTPLabelValues = { + method, + route, + status_code: String(statusCode), + }; + + this.httpRequestsTotal.inc(labels); + this.httpRequestDurationSeconds.observe(labels, durationSeconds); + } + + resolveRouteLabel(req: { + baseUrl?: string; + route?: { path?: unknown }; + }): string { + const baseUrl = typeof req.baseUrl === 'string' ? req.baseUrl : ''; + const routePath = this.extractRoutePath(req.route?.path); + + if (routePath === '') { + return '_unmatched'; + } + + return `${baseUrl}${routePath}`; + } + + private extractRoutePath(routePath: unknown): string { + if (typeof routePath === 'string') { + return routePath; + } + + if (Array.isArray(routePath)) { + return routePath.join('|'); + } + + return ''; + } +} From a3cff9898d0d80863f1d5f083f691f8ee7bd3b50 Mon Sep 17 00:00:00 2001 From: Scafu Date: Tue, 7 Apr 2026 04:18:50 +0200 Subject: [PATCH 08/13] fix: measures cost storage --- src/data-api/services/measure.persistence.service.spec.ts | 5 ++--- src/data-api/services/measure.persistence.service.ts | 7 ++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/data-api/services/measure.persistence.service.spec.ts b/src/data-api/services/measure.persistence.service.spec.ts index 1f99f05..8d86c9a 100644 --- a/src/data-api/services/measure.persistence.service.spec.ts +++ b/src/data-api/services/measure.persistence.service.spec.ts @@ -188,7 +188,7 @@ describe('MeasurePersistenceService', () => { expect(result).toEqual(rows); }); - it('returns tenant data size at rest in bytes', async () => { + it('returns measures DB occupied size in bytes', async () => { const repository = { query: jest.fn().mockResolvedValue([{ data_size_at_rest: '2048' }]), }; @@ -200,8 +200,7 @@ describe('MeasurePersistenceService', () => { ); expect(repository.query).toHaveBeenCalledWith( - expect.stringContaining('SUM(pg_column_size(td))'), - ['00000000-0000-0000-0000-000000000001'], + expect.stringContaining('pg_database_size(current_database())'), ); expect(result).toBe(2048); }); diff --git a/src/data-api/services/measure.persistence.service.ts b/src/data-api/services/measure.persistence.service.ts index 33bc9d8..d4c5edc 100644 --- a/src/data-api/services/measure.persistence.service.ts +++ b/src/data-api/services/measure.persistence.service.ts @@ -93,15 +93,12 @@ export class MeasurePersistenceService implements NpQueryPersistenceService { return qb.getMany(); } - async getTenantDataSizeAtRest(tenantId: string): Promise { + async getTenantDataSizeAtRest(_tenantId: string): Promise { const rows: Array<{ data_size_at_rest?: number | string }> = await this.r.query( ` - SELECT COALESCE(SUM(pg_column_size(td)), 0)::bigint AS data_size_at_rest - FROM telemetry td - WHERE td.tenant_id = $1 + SELECT pg_database_size(current_database())::bigint AS data_size_at_rest `, - [tenantId], ); const rawSize = rows[0]?.data_size_at_rest; From 089abe6efeb359fe09b7fb59a3a5facd9cebf51f Mon Sep 17 00:00:00 2001 From: Leonardo Preo <158464877+pr3o@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:52:33 +0200 Subject: [PATCH 09/13] fix: implementing composed primary key --- src/data-api/dto/query.response.dto.ts | 2 +- src/data-api/entity/measure.entity.ts | 6 ++- src/data-api/openapi.decorators.ts | 5 ++- .../measure.persistence.service.spec.ts | 41 ++++++++++++++++--- .../services/measure.persistence.service.ts | 41 ++++++++++++++++++- 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/data-api/dto/query.response.dto.ts b/src/data-api/dto/query.response.dto.ts index dd99720..f43929e 100644 --- a/src/data-api/dto/query.response.dto.ts +++ b/src/data-api/dto/query.response.dto.ts @@ -9,7 +9,7 @@ export class QueryResponseDto { data?: EncryptedEnvelopeDto[]; @ApiProperty({ description: 'Cursor to request the next page, if available', - example: '2026-03-23T09:58:00.000Z', + example: '2026-03-23T09:58:00.000Z|sensor-1', required: false, }) nextCursor?: string; diff --git a/src/data-api/entity/measure.entity.ts b/src/data-api/entity/measure.entity.ts index d4c504b..01c5931 100644 --- a/src/data-api/entity/measure.entity.ts +++ b/src/data-api/entity/measure.entity.ts @@ -4,12 +4,16 @@ import { Column, Entity, PrimaryColumn } from 'typeorm'; export class MeasureEntity { @PrimaryColumn({ name: 'time', type: 'timestamptz' }) time: string; + @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string; + @Column({ name: 'gateway_id', type: 'uuid' }) gatewayId: string; - @Column({ name: 'sensor_id', type: 'uuid' }) + + @PrimaryColumn({ name: 'sensor_id', type: 'uuid' }) sensorId: string; + @Column({ name: 'sensor_type', type: 'varchar', length: 255 }) sensorType: string; @Column({ name: 'encrypted_data', type: 'varchar', length: 255 }) diff --git a/src/data-api/openapi.decorators.ts b/src/data-api/openapi.decorators.ts index 48cb114..4e7c427 100644 --- a/src/data-api/openapi.decorators.ts +++ b/src/data-api/openapi.decorators.ts @@ -123,9 +123,10 @@ export function ApiMeasureQueryDocs() { ApiQuery({ name: 'cursor', required: false, - description: 'Opaque cursor returned by a previous query page', + description: + 'Opaque cursor returned by a previous query page. Current format: |.', schema: { type: 'string' }, - example: '2026-03-23T09:58:00.000Z', + example: '2026-03-23T09:58:00.000Z|sensor-1', }), ApiOkResponse({ description: 'Paginated measure page', diff --git a/src/data-api/services/measure.persistence.service.spec.ts b/src/data-api/services/measure.persistence.service.spec.ts index 8d86c9a..306cb55 100644 --- a/src/data-api/services/measure.persistence.service.spec.ts +++ b/src/data-api/services/measure.persistence.service.spec.ts @@ -6,6 +6,7 @@ describe('MeasurePersistenceService', () => { const qb = { andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), getMany: jest.fn(), }; @@ -13,7 +14,7 @@ describe('MeasurePersistenceService', () => { return qb; }; - it('builds a paginated query with all filters and returns next cursor', async () => { + it('builds a paginated query with all filters and returns a composite next cursor', async () => { const qb = createQueryBuilder(); const rows: MeasureEntity[] = [ { @@ -64,7 +65,7 @@ describe('MeasurePersistenceService', () => { sensorType: ['temperature'], from: '2026-03-23T09:50:00.000Z', to: '2026-03-23T10:00:00.000Z', - cursor: '2026-03-23T09:59:00.000Z', + cursor: '2026-03-23T09:59:00.000Z|sensor-9', limit: 2, }); @@ -96,18 +97,46 @@ describe('MeasurePersistenceService', () => { expect(qb.andWhere).toHaveBeenNthCalledWith(5, 'm.time <= :to', { to: '2026-03-23T10:00:00.000Z', }); - expect(qb.andWhere).toHaveBeenNthCalledWith(6, 'm.time < :cursor', { - cursor: '2026-03-23T09:59:00.000Z', - }); + expect(qb.andWhere).toHaveBeenNthCalledWith( + 6, + '(m.time < :cursorTime OR (m.time = :cursorTime AND m.sensorId < :cursorSensorId))', + { + cursorTime: '2026-03-23T09:59:00.000Z', + cursorSensorId: 'sensor-9', + }, + ); expect(qb.orderBy).toHaveBeenCalledWith('m.time', 'DESC'); + expect(qb.addOrderBy).toHaveBeenCalledWith('m.sensorId', 'DESC'); expect(qb.take).toHaveBeenCalledWith(3); expect(result).toEqual({ data: rows.slice(0, 2), - nextCursor: '2026-03-23T09:55:00.000Z', + nextCursor: '2026-03-23T09:55:00.000Z|sensor-1', hasMore: true, }); }); + it('supports legacy timestamp-only cursor values', async () => { + const qb = createQueryBuilder(); + qb.getMany.mockResolvedValue([]); + + const repository = { + createQueryBuilder: jest.fn().mockReturnValue(qb), + }; + + const service = new MeasurePersistenceService(repository as never); + + await service.paginatedQuery({ + from: '2026-03-23T09:50:00.000Z', + to: '2026-03-23T10:00:00.000Z', + cursor: '2026-03-23T09:59:00.000Z', + limit: 2, + }); + + expect(qb.andWhere).toHaveBeenNthCalledWith(3, 'm.time < :cursor', { + cursor: '2026-03-23T09:59:00.000Z', + }); + }); + it('builds a non paginated query with array filters', async () => { const qb = createQueryBuilder(); qb.getMany.mockResolvedValue([]); diff --git a/src/data-api/services/measure.persistence.service.ts b/src/data-api/services/measure.persistence.service.ts index d4c5edc..67a7c9a 100644 --- a/src/data-api/services/measure.persistence.service.ts +++ b/src/data-api/services/measure.persistence.service.ts @@ -7,6 +7,8 @@ import { Repository, SelectQueryBuilder } from 'typeorm'; import { PaginatedQuery } from './../interfaces/paginated-query'; import { NpQueryPersistenceService } from '../interfaces/np-query-persistence.service'; +const CURSOR_SEPARATOR = '|'; + function applyScalarFilter( qb: SelectQueryBuilder, column: string, @@ -37,6 +39,25 @@ function applyArrayFilter( }); } +function parseCompositeCursor( + cursor: string, +): { time: string; sensorId: string } | undefined { + const separatorIndex = cursor.lastIndexOf(CURSOR_SEPARATOR); + + if (separatorIndex <= 0 || separatorIndex >= cursor.length - 1) { + return undefined; + } + + return { + time: cursor.slice(0, separatorIndex), + sensorId: cursor.slice(separatorIndex + 1), + }; +} + +function toCompositeCursor(time: string, sensorId: string): string { + return `${time}${CURSOR_SEPARATOR}${sensorId}`; +} + @Injectable() export class MeasurePersistenceService implements NpQueryPersistenceService { constructor( @@ -56,17 +77,33 @@ export class MeasurePersistenceService implements NpQueryPersistenceService { qb.andWhere('m.time <= :to', { to: p.to }); if (p.cursor) { - qb.andWhere('m.time < :cursor', { cursor: p.cursor }); + const cursor = parseCompositeCursor(p.cursor); + + if (cursor) { + qb.andWhere( + '(m.time < :cursorTime OR (m.time = :cursorTime AND m.sensorId < :cursorSensorId))', + { + cursorTime: cursor.time, + cursorSensorId: cursor.sensorId, + }, + ); + } else { + // Backward compatibility for old timestamp-only cursors. + qb.andWhere('m.time < :cursor', { cursor: p.cursor }); + } } qb.orderBy('m.time', 'DESC'); + qb.addOrderBy('m.sensorId', 'DESC'); qb.take(p.limit + 1); const rows = await qb.getMany(); const hasMore = rows.length > p.limit; const data = hasMore ? rows.slice(0, p.limit) : rows; - const nextCursor = hasMore ? data.at(-1)?.time : undefined; + const lastRow = data.at(-1); + const nextCursor = + hasMore && lastRow ? toCompositeCursor(lastRow.time, lastRow.sensorId) : undefined; return { data, From 6152a55ab6469ebf78f79242ae6c93da49e92974 Mon Sep 17 00:00:00 2001 From: Scafu Date: Tue, 7 Apr 2026 16:56:05 +0200 Subject: [PATCH 10/13] fix: cursor problem string convertion --- src/data-api/services/measure.persistence.service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/data-api/services/measure.persistence.service.ts b/src/data-api/services/measure.persistence.service.ts index 67a7c9a..df8e51e 100644 --- a/src/data-api/services/measure.persistence.service.ts +++ b/src/data-api/services/measure.persistence.service.ts @@ -103,7 +103,14 @@ export class MeasurePersistenceService implements NpQueryPersistenceService { const data = hasMore ? rows.slice(0, p.limit) : rows; const lastRow = data.at(-1); const nextCursor = - hasMore && lastRow ? toCompositeCursor(lastRow.time, lastRow.sensorId) : undefined; + hasMore && lastRow + ? toCompositeCursor( + typeof lastRow.time === 'object' && lastRow.time !== null + ? (lastRow.time as Date).toISOString() + : String(lastRow.time), + lastRow.sensorId, + ) + : undefined; return { data, From a54fac27b756294ab58df80f217a6c914186b93e Mon Sep 17 00:00:00 2001 From: Leonardo Preo <158464877+pr3o@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:27:29 +0000 Subject: [PATCH 11/13] fix: bug regarding costs calculation: now correctly uses TenantId as filter --- api-contracts/openapi/openapi.yaml | 6 ++++-- package-lock.json | 2 +- .../services/measure.persistence.service.spec.ts | 10 +++++----- src/data-api/services/measure.persistence.service.ts | 7 +++++-- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/api-contracts/openapi/openapi.yaml b/api-contracts/openapi/openapi.yaml index 94db91b..eef8f58 100644 --- a/api-contracts/openapi/openapi.yaml +++ b/api-contracts/openapi/openapi.yaml @@ -33,7 +33,9 @@ paths: - name: cursor required: false in: query - description: Opaque cursor returned by a previous query page + description: >- + Opaque cursor returned by a previous query page. Current format: + |. schema: type: string - name: limit @@ -386,7 +388,7 @@ components: nextCursor: type: string description: Cursor to request the next page, if available - example: '2026-03-23T09:58:00.000Z' + example: 2026-03-23T09:58:00.000Z|sensor-1 hasMore: type: boolean description: Whether more pages are available diff --git a/package-lock.json b/package-lock.json index 1f1fdf0..8128a64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4385,7 +4385,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/src/data-api/services/measure.persistence.service.spec.ts b/src/data-api/services/measure.persistence.service.spec.ts index 306cb55..a3141a1 100644 --- a/src/data-api/services/measure.persistence.service.spec.ts +++ b/src/data-api/services/measure.persistence.service.spec.ts @@ -217,19 +217,19 @@ describe('MeasurePersistenceService', () => { expect(result).toEqual(rows); }); - it('returns measures DB occupied size in bytes', async () => { + it('returns tenant measures occupied size in bytes', async () => { + const tenantId = '00000000-0000-0000-0000-000000000001'; const repository = { query: jest.fn().mockResolvedValue([{ data_size_at_rest: '2048' }]), }; const service = new MeasurePersistenceService(repository as never); - const result = await service.getTenantDataSizeAtRest( - '00000000-0000-0000-0000-000000000001', - ); + const result = await service.getTenantDataSizeAtRest(tenantId); expect(repository.query).toHaveBeenCalledWith( - expect.stringContaining('pg_database_size(current_database())'), + expect.stringContaining('SUM(pg_column_size(t))'), + [tenantId], ); expect(result).toBe(2048); }); diff --git a/src/data-api/services/measure.persistence.service.ts b/src/data-api/services/measure.persistence.service.ts index df8e51e..bb02518 100644 --- a/src/data-api/services/measure.persistence.service.ts +++ b/src/data-api/services/measure.persistence.service.ts @@ -137,12 +137,15 @@ export class MeasurePersistenceService implements NpQueryPersistenceService { return qb.getMany(); } - async getTenantDataSizeAtRest(_tenantId: string): Promise { + async getTenantDataSizeAtRest(tenantId: string): Promise { const rows: Array<{ data_size_at_rest?: number | string }> = await this.r.query( ` - SELECT pg_database_size(current_database())::bigint AS data_size_at_rest + SELECT COALESCE(SUM(pg_column_size(t)), 0)::bigint AS data_size_at_rest + FROM telemetry t + WHERE t.tenant_id = $1 `, + [tenantId], ); const rawSize = rows[0]?.data_size_at_rest; From a9aaf41d9c3fca6cc264378dcad4840b8e71ddbf Mon Sep 17 00:00:00 2001 From: Leonardo Preo <158464877+pr3o@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:08:25 +0000 Subject: [PATCH 12/13] chore: extending test coverage --- src/auth/tenant-access.guard.spec.ts | 42 +++++ src/auth/tenant-access.guard.ts | 2 +- .../controller/measure.controller.spec.ts | 148 +++++++++++++++++ src/data-api/measure.mapper.spec.ts | 51 ++++++ src/data-api/measure.mapper.ts | 14 +- .../cost-nats-responder.service.spec.ts | 154 ++++++++++++++++++ .../measure.persistence.service.spec.ts | 59 +++++++ .../services/measure.persistence.service.ts | 18 +- src/database/data-source.spec.ts | 101 ++++++++++++ src/env.validation.spec.ts | 4 +- src/env.validation.ts | 2 +- src/metrics/metrics.controller.spec.ts | 30 ++++ src/metrics/metrics.interceptor.spec.ts | 102 ++++++++++++ src/metrics/metrics.service.spec.ts | 60 +++++++ 14 files changed, 774 insertions(+), 13 deletions(-) create mode 100644 src/database/data-source.spec.ts create mode 100644 src/metrics/metrics.controller.spec.ts create mode 100644 src/metrics/metrics.interceptor.spec.ts create mode 100644 src/metrics/metrics.service.spec.ts diff --git a/src/auth/tenant-access.guard.spec.ts b/src/auth/tenant-access.guard.spec.ts index 08f0c32..d9e9353 100644 --- a/src/auth/tenant-access.guard.spec.ts +++ b/src/auth/tenant-access.guard.spec.ts @@ -56,6 +56,15 @@ describe('TenantAccessGuard', () => { await expect(guard.canActivate(createContext(request))).resolves.toBe(true); }); + it('allows metrics endpoint when request url includes query string', async () => { + const request = { + url: '/metrics?format=prometheus', + headers: {}, + }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + }); + it('allows OPTIONS requests without authentication', async () => { const request = { method: 'OPTIONS', @@ -78,6 +87,20 @@ describe('TenantAccessGuard', () => { ); }); + it('rejects empty bearer token values on protected endpoints', async () => { + const request = { + method: 'GET', + path: '/measures/query', + headers: { + authorization: 'Bearer ', + }, + }; + + await expect(guard.canActivate(createContext(request))).rejects.toThrow( + UnauthorizedException, + ); + }); + it('allows active tenant and stores tenant access context', async () => { (globalThis.fetch as jest.MockedFunction).mockResolvedValue( createResponse(200, { @@ -123,6 +146,25 @@ describe('TenantAccessGuard', () => { await expect(guard.canActivate(createContext(request))).resolves.toBe(true); }); + it('defaults to GET when request method is missing in read-only mode', async () => { + (globalThis.fetch as jest.MockedFunction).mockResolvedValue( + createResponse(200, { + tenant_id: 'tenant-1', + status: 'suspended', + read_only: true, + }), + ); + + const request = { + path: '/measures/export', + headers: { + authorization: 'Bearer valid-token', + }, + }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + }); + it('rejects suspended tenant on write operations', async () => { (globalThis.fetch as jest.MockedFunction).mockResolvedValue( createResponse(200, { diff --git a/src/auth/tenant-access.guard.ts b/src/auth/tenant-access.guard.ts index d89fde1..0cfd40f 100644 --- a/src/auth/tenant-access.guard.ts +++ b/src/auth/tenant-access.guard.ts @@ -74,7 +74,7 @@ export class TenantAccessGuard implements CanActivate { ): Promise { const managementApiUrl = this.configService.get( 'MGMT_API_URL', - 'http://management-api:3000', + 'https://management-api:3000', ); const response = await fetch(`${managementApiUrl}/auth/tenant-status`, { diff --git a/src/data-api/controller/measure.controller.spec.ts b/src/data-api/controller/measure.controller.spec.ts index eed089a..6198082 100644 --- a/src/data-api/controller/measure.controller.spec.ts +++ b/src/data-api/controller/measure.controller.spec.ts @@ -260,6 +260,64 @@ describe('MeasureController', () => { cursor: undefined, }); }); + + it('should fallback to default limit when limit is empty string', async () => { + service.query.mockResolvedValue({ + data: [], + hasMore: false, + }); + + await controller.query( + '2024-01-01T00:00:00Z', + '2024-01-02T00:00:00Z', + '', + undefined, + undefined, + undefined, + undefined, + 'tenant-1', + ); + + expect(serviceQueryMock).toHaveBeenCalledWith({ + from: '2024-01-01T00:00:00Z', + to: '2024-01-02T00:00:00Z', + limit: 999, + tenantId: 'tenant-1', + gatewayId: undefined, + sensorId: undefined, + sensorType: undefined, + cursor: undefined, + }); + }); + + it('should fallback to default limit when limit is null', async () => { + service.query.mockResolvedValue({ + data: [], + hasMore: false, + }); + + await controller.query( + '2024-01-01T00:00:00Z', + '2024-01-02T00:00:00Z', + null as unknown as number, + undefined, + undefined, + undefined, + undefined, + 'tenant-1', + ); + + expect(serviceQueryMock).toHaveBeenCalledWith({ + from: '2024-01-01T00:00:00Z', + to: '2024-01-02T00:00:00Z', + limit: 999, + tenantId: 'tenant-1', + gatewayId: undefined, + sensorId: undefined, + sensorType: undefined, + cursor: undefined, + }); + }); }); describe('export', () => { @@ -605,5 +663,95 @@ describe('MeasureController', () => { tokenExpiresAt: undefined, }); }); + + it('should ignore JWT exp when authorization is not a bearer token', async () => { + mockStreamListenerService.stream.mockReturnValue( + of({ + kind: 'data', + data: { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-03-23T10:00:00.000Z', + encryptedData: 'enc', + iv: 'iv', + authTag: 'tag', + keyVersion: 1, + }, + }), + ); + + await firstValueFrom( + controller.stream( + { + headers: { + authorization: 'Basic abcdef', + }, + } as never, + undefined, + undefined, + undefined, + undefined, + 'tenant-1', + ), + ); + + expect(mockStreamListenerService.stream).toHaveBeenCalledWith({ + gatewayId: undefined, + sensorId: undefined, + sensorType: undefined, + since: undefined, + tenantId: 'tenant-1', + tokenExpiresAt: undefined, + }); + }); + + it('should ignore JWT exp when payload exp is not numeric', async () => { + const payload = Buffer.from( + JSON.stringify({ + exp: 'not-a-number', + }), + ).toString('base64url'); + + mockStreamListenerService.stream.mockReturnValue( + of({ + kind: 'data', + data: { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-03-23T10:00:00.000Z', + encryptedData: 'enc', + iv: 'iv', + authTag: 'tag', + keyVersion: 1, + }, + }), + ); + + await firstValueFrom( + controller.stream( + { + headers: { + authorization: `Bearer header.${payload}.signature`, + }, + } as never, + undefined, + undefined, + undefined, + undefined, + 'tenant-1', + ), + ); + + expect(mockStreamListenerService.stream).toHaveBeenCalledWith({ + gatewayId: undefined, + sensorId: undefined, + sensorType: undefined, + since: undefined, + tenantId: 'tenant-1', + tokenExpiresAt: undefined, + }); + }); }); }); diff --git a/src/data-api/measure.mapper.spec.ts b/src/data-api/measure.mapper.spec.ts index 29a8d9d..cac575f 100644 --- a/src/data-api/measure.mapper.spec.ts +++ b/src/data-api/measure.mapper.spec.ts @@ -87,6 +87,57 @@ describe('MeasureMapper', () => { }); }); + it('keeps values unchanged when base64 input has invalid encoded length', () => { + const normalized = MeasureMapper.toEncryptedEnvelopeDto({ + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-04-06T16:41:58.10169+00:00', + encryptedData: 'AQIDBA==', + iv: 'abcde', + authTag: 'AAAAAAAAAAAAAAAAAAAAAA==', + keyVersion: 1, + }); + + expect(normalized.encryptedData).toBe('AQIDBA=='); + expect(normalized.iv).toBe('abcde'); + expect(normalized.authTag).toBe('00000000000000000000000000000000'); + }); + + it('keeps values unchanged when base64 decodes to an empty payload', () => { + const normalized = MeasureMapper.toEncryptedEnvelopeDto({ + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-04-06T16:41:58.10169+00:00', + encryptedData: 'AQIDBA==', + iv: '====', + authTag: '====', + keyVersion: 1, + }); + + expect(normalized.encryptedData).toBe('AQIDBA=='); + expect(normalized.iv).toBe('===='); + expect(normalized.authTag).toBe('===='); + }); + + it('normalizes uppercase hex fields to lowercase hex', () => { + const normalized = MeasureMapper.toEncryptedEnvelopeDto({ + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-04-06T16:41:58.10169+00:00', + encryptedData: 'AABBCCDDEEFF00112233445566778899', + iv: 'AABBCCDDEEFF001122334455', + authTag: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + keyVersion: 1, + }); + + expect(normalized.encryptedData).toBe('aabbccddeeff00112233445566778899'); + expect(normalized.iv).toBe('aabbccddeeff001122334455'); + expect(normalized.authTag).toBe('ffffffffffffffffffffffffffffffff'); + }); + it('maps stream item and stream response', async () => { expect(MeasureMapper.toStreamItemResponseDto(model)).toEqual(model); diff --git a/src/data-api/measure.mapper.ts b/src/data-api/measure.mapper.ts index 1b94a6c..cb08471 100644 --- a/src/data-api/measure.mapper.ts +++ b/src/data-api/measure.mapper.ts @@ -16,6 +16,16 @@ function isHexString(value: string): boolean { return value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value); } +function stripBase64Padding(value: string): string { + let end = value.length; + + while (end > 0 && value.codePointAt(end - 1) === 61) { + end -= 1; + } + + return end === value.length ? value : value.slice(0, end); +} + function decodeBase64(value: string): Buffer | undefined { const normalized = value.trim().replaceAll('-', '+').replaceAll('_', '/'); @@ -33,8 +43,8 @@ function decodeBase64(value: string): Buffer | undefined { } // Guard against false positives (arbitrary strings that happen to decode). - const roundTrip = decoded.toString('base64').replace(/=+$/u, ''); - const source = normalized.replace(/=+$/u, ''); + const roundTrip = stripBase64Padding(decoded.toString('base64')); + const source = stripBase64Padding(normalized); return roundTrip === source ? decoded : undefined; } catch { diff --git a/src/data-api/services/cost-nats-responder.service.spec.ts b/src/data-api/services/cost-nats-responder.service.spec.ts index 91515f8..904536b 100644 --- a/src/data-api/services/cost-nats-responder.service.spec.ts +++ b/src/data-api/services/cost-nats-responder.service.spec.ts @@ -8,6 +8,10 @@ type TestableCostResponderService = { buildConnectionOptions: () => ConnectionOptions; consumeMessages: (subscription: Subscription) => Promise; extractTenantId: (data: Uint8Array) => string | undefined; + respondWithCost: ( + message: { respond: (payload: Buffer) => void }, + dataSizeAtRest: number, + ) => void; }; function asTestableService( @@ -125,6 +129,35 @@ describe('CostNatsResponderService', () => { expect(connect).not.toHaveBeenCalled(); }); + it('logs initialization errors when NATS connection fails', async () => { + process.env = { + ...originalEnv, + NODE_ENV: 'development', + NATS_URL: 'nats://localhost:4222', + }; + + const connectError = new Error('nats unavailable'); + jest.doMock('nats', () => ({ + connect: jest.fn().mockRejectedValue(connectError), + })); + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest.fn(), + } as unknown as MeasurePersistenceService); + const testableService = asTestableService(service); + const errorSpy = jest + .spyOn(testableService.logger, 'error') + .mockImplementation(); + + await service.onModuleInit(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to initialize cost responder NATS bridge', + connectError, + ); + }); + it('returns zero when payload is invalid', async () => { jest.doMock('nats', () => ({ connect: jest.fn(), @@ -207,6 +240,33 @@ describe('CostNatsResponderService', () => { expect(response).toEqual({ dataSizeAtRest: 0 }); }); + it('logs errors when replying to a message fails', () => { + jest.doMock('nats', () => ({ + connect: jest.fn(), + })); + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest.fn(), + } as unknown as MeasurePersistenceService); + const testableService = asTestableService(service); + const errorSpy = jest + .spyOn(testableService.logger, 'error') + .mockImplementation(); + + const respond = jest.fn(() => { + throw new Error('response channel closed'); + }); + + testableService.respondWithCost({ respond }, 512); + + expect(respond).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to respond to internal.cost request', + expect.any(Error), + ); + }); + it('builds connection options with token and TLS', () => { process.env = { ...originalEnv, @@ -254,4 +314,98 @@ describe('CostNatsResponderService', () => { }); expect(readFileSync).toHaveBeenCalledTimes(3); }); + + it('logs TLS loading errors and keeps options without tls payload', () => { + process.env = { + ...originalEnv, + NODE_ENV: 'development', + NATS_URL: 'nats://localhost:4222', + NATS_TLS_CA: '/tmp/ca.pem', + NATS_TLS_CERT: '/tmp/cert.pem', + NATS_TLS_KEY: '/tmp/key.pem', + }; + + const readFileSync = jest.fn(() => { + throw new Error('unable to read certificate'); + }); + + jest.doMock('node:fs', () => { + const actualFs = jest.requireActual('node:fs'); + return { + ...actualFs, + readFileSync, + }; + }); + jest.doMock('nats', () => ({ + connect: jest.fn(), + })); + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest.fn(), + } as unknown as MeasurePersistenceService); + const testableService = asTestableService(service); + const errorSpy = jest + .spyOn(testableService.logger, 'error') + .mockImplementation(); + + const options = testableService.buildConnectionOptions(); + + expect(readFileSync).toHaveBeenCalled(); + expect((options as { tls?: unknown }).tls).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to load NATS TLS certificates: unable to read certificate', + ); + }); + + it('builds connection options with basic auth when token is not set', () => { + process.env = { + ...originalEnv, + NODE_ENV: 'development', + NATS_SERVERS: ' nats://one:4222, , nats://two:4222 ', + NATS_USER: ' user ', + NATS_PASSWORD: ' pass ', + }; + + jest.doMock('nats', () => ({ + connect: jest.fn(), + })); + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest.fn(), + } as unknown as MeasurePersistenceService); + const testableService = asTestableService(service); + + expect(testableService.buildConnectionOptions()).toEqual({ + servers: ['nats://one:4222', 'nats://two:4222'], + name: 'data-api', + user: 'user', + pass: 'pass', + }); + }); + + it('uses localhost NATS server by default when no server variables are provided', () => { + process.env = { + ...originalEnv, + NODE_ENV: 'development', + NATS_URL: undefined, + NATS_SERVERS: undefined, + }; + + jest.doMock('nats', () => ({ + connect: jest.fn(), + })); + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest.fn(), + } as unknown as MeasurePersistenceService); + const testableService = asTestableService(service); + + expect(testableService.buildConnectionOptions()).toEqual({ + servers: ['nats://localhost:4222'], + name: 'data-api', + }); + }); }); diff --git a/src/data-api/services/measure.persistence.service.spec.ts b/src/data-api/services/measure.persistence.service.spec.ts index a3141a1..ff0bd57 100644 --- a/src/data-api/services/measure.persistence.service.spec.ts +++ b/src/data-api/services/measure.persistence.service.spec.ts @@ -217,6 +217,27 @@ describe('MeasurePersistenceService', () => { expect(result).toEqual(rows); }); + it('applies tenant filter for non paginated queries when tenantId is provided', async () => { + const qb = createQueryBuilder(); + qb.getMany.mockResolvedValue([]); + + const repository = { + createQueryBuilder: jest.fn().mockReturnValue(qb), + }; + + const service = new MeasurePersistenceService(repository as never); + + await service.nonPaginatedQuery({ + tenantId: 'tenant-1', + from: '2026-03-23T09:50:00.000Z', + to: '2026-03-23T10:00:00.000Z', + }); + + expect(qb.andWhere).toHaveBeenNthCalledWith(1, 'm.tenantId = :tenantId', { + tenantId: 'tenant-1', + }); + }); + it('returns tenant measures occupied size in bytes', async () => { const tenantId = '00000000-0000-0000-0000-000000000001'; const repository = { @@ -234,6 +255,32 @@ describe('MeasurePersistenceService', () => { expect(result).toBe(2048); }); + it('returns tenant measures occupied size when driver returns a finite number', async () => { + const repository = { + query: jest.fn().mockResolvedValue([{ data_size_at_rest: 1024 }]), + }; + + const service = new MeasurePersistenceService(repository as never); + + await expect( + service.getTenantDataSizeAtRest('00000000-0000-0000-0000-000000000001'), + ).resolves.toBe(1024); + }); + + it('falls back to zero when tenant data size is a non-finite number', async () => { + const repository = { + query: jest + .fn() + .mockResolvedValue([{ data_size_at_rest: Number.POSITIVE_INFINITY }]), + }; + + const service = new MeasurePersistenceService(repository as never); + + await expect( + service.getTenantDataSizeAtRest('00000000-0000-0000-0000-000000000001'), + ).resolves.toBe(0); + }); + it('falls back to zero when tenant data size cannot be parsed', async () => { const repository = { query: jest.fn().mockResolvedValue([{ data_size_at_rest: 'invalid' }]), @@ -247,4 +294,16 @@ describe('MeasurePersistenceService', () => { expect(result).toBe(0); }); + + it('falls back to zero when tenant data size query returns no rows', async () => { + const repository = { + query: jest.fn().mockResolvedValue([]), + }; + + const service = new MeasurePersistenceService(repository as never); + + await expect( + service.getTenantDataSizeAtRest('00000000-0000-0000-0000-000000000001'), + ).resolves.toBe(0); + }); }); diff --git a/src/data-api/services/measure.persistence.service.ts b/src/data-api/services/measure.persistence.service.ts index bb02518..2c53020 100644 --- a/src/data-api/services/measure.persistence.service.ts +++ b/src/data-api/services/measure.persistence.service.ts @@ -102,14 +102,18 @@ export class MeasurePersistenceService implements NpQueryPersistenceService { const hasMore = rows.length > p.limit; const data = hasMore ? rows.slice(0, p.limit) : rows; const lastRow = data.at(-1); + + let timeString: string | undefined; + if (hasMore && lastRow) { + timeString = + typeof lastRow.time === 'object' && lastRow.time !== null + ? (lastRow.time as Date).toISOString() + : String(lastRow.time); + } + const nextCursor = - hasMore && lastRow - ? toCompositeCursor( - typeof lastRow.time === 'object' && lastRow.time !== null - ? (lastRow.time as Date).toISOString() - : String(lastRow.time), - lastRow.sensorId, - ) + hasMore && lastRow && timeString + ? toCompositeCursor(timeString, lastRow.sensorId) : undefined; return { diff --git a/src/database/data-source.spec.ts b/src/database/data-source.spec.ts new file mode 100644 index 0000000..6dca341 --- /dev/null +++ b/src/database/data-source.spec.ts @@ -0,0 +1,101 @@ +import { DataSource } from 'typeorm'; + +describe('data source', () => { + const originalEnv = process.env; + + function loadDataSourceWithEnv(env: NodeJS.ProcessEnv): DataSource { + process.env = env; + jest.resetModules(); + + let dataSource!: DataSource; + jest.isolateModules(() => { + dataSource = + jest.requireActual( + './data-source', + ).default; + }); + + return dataSource; + } + + afterEach(() => { + process.env = originalEnv; + jest.resetModules(); + }); + + it('uses default database options when env is missing or invalid', () => { + const dataSource = loadDataSourceWithEnv({ + ...originalEnv, + MEASURES_DB_HOST: undefined, + MEASURES_DB_PORT: 'invalid', + MEASURES_DB_USER: undefined, + MEASURES_DB_PASSWORD: undefined, + MEASURES_DB_NAME: undefined, + DB_SSL: 'false', + }); + + const options = dataSource.options as { + type: string; + host: string; + port: number; + username: string; + password: string; + database: string; + ssl: boolean; + entities: string[]; + migrations: string[]; + }; + + expect(options.type).toBe('postgres'); + expect(options.host).toBe('localhost'); + expect(options.port).toBe(5432); + expect(options.username).toBe('postgres'); + expect(options.password).toBe('postgres'); + expect(options.database).toBe('postgres'); + expect(options.ssl).toBe(false); + expect(options.entities).toHaveLength(1); + expect(options.entities[0]).toContain('*.entity.{ts,js}'); + expect(options.migrations).toHaveLength(2); + expect(options.migrations[0]).toContain('migrations'); + expect(options.migrations[1]).toContain('migrations'); + }); + + it('uses environment-provided options and SSL when DB_SSL is 1', () => { + const dataSource = loadDataSourceWithEnv({ + ...originalEnv, + MEASURES_DB_HOST: 'db.internal', + MEASURES_DB_PORT: '6543', + MEASURES_DB_USER: 'app_user', + MEASURES_DB_PASSWORD: 'secret', + MEASURES_DB_NAME: 'app_db', + DB_SSL: '1', + }); + + const options = dataSource.options as { + host: string; + port: number; + username: string; + password: string; + database: string; + ssl: boolean; + }; + + expect(options.host).toBe('db.internal'); + expect(options.port).toBe(6543); + expect(options.username).toBe('app_user'); + expect(options.password).toBe('secret'); + expect(options.database).toBe('app_db'); + expect(options.ssl).toBe(true); + }); + + it('enables SSL when DB_SSL is true', () => { + const dataSource = loadDataSourceWithEnv({ + ...originalEnv, + MEASURES_DB_PORT: '5432', + DB_SSL: 'true', + }); + + const options = dataSource.options as { ssl: boolean }; + expect(options.ssl).toBe(true); + }); +}); diff --git a/src/env.validation.spec.ts b/src/env.validation.spec.ts index 038ff8b..18b6dc6 100644 --- a/src/env.validation.spec.ts +++ b/src/env.validation.spec.ts @@ -30,7 +30,7 @@ describe('validate', () => { NATS_TLS_CA: undefined, NATS_TLS_CERT: undefined, NATS_TLS_KEY: undefined, - MGMT_API_URL: 'http://management-api:3000', + MGMT_API_URL: 'https://management-api:3000', }); }); @@ -90,7 +90,7 @@ describe('validate', () => { NATS_TLS_CA: undefined, NATS_TLS_CERT: undefined, NATS_TLS_KEY: undefined, - MGMT_API_URL: 'http://management-api:3000', + MGMT_API_URL: 'https://management-api:3000', }); }); }); diff --git a/src/env.validation.ts b/src/env.validation.ts index 7e11ffb..26cd5c4 100644 --- a/src/env.validation.ts +++ b/src/env.validation.ts @@ -72,6 +72,6 @@ export function validate(config: NodeJS.ProcessEnv): DataApiEnv { NATS_TLS_CA: config.NATS_TLS_CA, NATS_TLS_CERT: config.NATS_TLS_CERT, NATS_TLS_KEY: config.NATS_TLS_KEY, - MGMT_API_URL: config.MGMT_API_URL ?? 'http://management-api:3000', + MGMT_API_URL: config.MGMT_API_URL ?? 'https://management-api:3000', }; } diff --git a/src/metrics/metrics.controller.spec.ts b/src/metrics/metrics.controller.spec.ts new file mode 100644 index 0000000..84f79d6 --- /dev/null +++ b/src/metrics/metrics.controller.spec.ts @@ -0,0 +1,30 @@ +import type { Response } from 'express'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +describe('MetricsController', () => { + it('sets Prometheus content type and writes metrics payload', async () => { + const getMetrics = jest.fn().mockResolvedValue('# mock_metrics 1'); + const metricsService = { + contentType: 'text/plain; version=0.0.4; charset=utf-8', + getMetrics, + } as unknown as MetricsService; + + const controller = new MetricsController(metricsService); + const setHeader = jest.fn(); + const send = jest.fn(); + const response = { + setHeader, + send, + } as unknown as Response; + + await controller.metrics(response); + + expect(setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/plain; version=0.0.4; charset=utf-8', + ); + expect(getMetrics).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith('# mock_metrics 1'); + }); +}); diff --git a/src/metrics/metrics.interceptor.spec.ts b/src/metrics/metrics.interceptor.spec.ts new file mode 100644 index 0000000..88ece62 --- /dev/null +++ b/src/metrics/metrics.interceptor.spec.ts @@ -0,0 +1,102 @@ +import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { firstValueFrom, of } from 'rxjs'; +import { MetricsInterceptor } from './metrics.interceptor'; +import { MetricsService } from './metrics.service'; + +type MockedMetricsService = jest.Mocked< + Pick< + MetricsService, + 'incInFlight' | 'decInFlight' | 'observeHttpRequest' | 'resolveRouteLabel' + > +>; + +describe('MetricsInterceptor', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('passes through non-http contexts without recording metrics', async () => { + const metricsService: MockedMetricsService = { + incInFlight: jest.fn(), + decInFlight: jest.fn(), + observeHttpRequest: jest.fn(), + resolveRouteLabel: jest.fn(), + }; + + const interceptor = new MetricsInterceptor( + metricsService as unknown as MetricsService, + ); + + const context = { + getType: jest.fn().mockReturnValue('rpc'), + } as unknown as ExecutionContext; + + const handle = jest.fn().mockReturnValue(of('ok')); + const next: CallHandler = { + handle, + }; + + await expect( + firstValueFrom(interceptor.intercept(context, next)), + ).resolves.toBe('ok'); + + expect(handle).toHaveBeenCalledTimes(1); + expect(metricsService.incInFlight).not.toHaveBeenCalled(); + expect(metricsService.observeHttpRequest).not.toHaveBeenCalled(); + expect(metricsService.decInFlight).not.toHaveBeenCalled(); + }); + + it('records metrics for http contexts and uses default method/status values', async () => { + const metricsService: MockedMetricsService = { + incInFlight: jest.fn(), + decInFlight: jest.fn(), + observeHttpRequest: jest.fn(), + resolveRouteLabel: jest.fn().mockReturnValue('/measures/query'), + }; + + const interceptor = new MetricsInterceptor( + metricsService as unknown as MetricsService, + ); + + const request = { + baseUrl: '/measures', + route: { path: '/query' }, + }; + const response = {}; + + const context = { + getType: jest.fn().mockReturnValue('http'), + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(request), + getResponse: jest.fn().mockReturnValue(response), + }), + } as unknown as ExecutionContext; + + const next: CallHandler = { + handle: jest.fn().mockReturnValue(of('ok')), + }; + + jest + .spyOn(process.hrtime, 'bigint') + .mockReturnValueOnce(1n) + .mockReturnValueOnce(2_000_000_001n); + + await expect( + firstValueFrom(interceptor.intercept(context, next)), + ).resolves.toBe('ok'); + + expect(metricsService.incInFlight).toHaveBeenCalledWith('UNKNOWN'); + expect(metricsService.resolveRouteLabel).toHaveBeenCalledWith(request); + expect(metricsService.observeHttpRequest).toHaveBeenCalledWith( + 'UNKNOWN', + '/measures/query', + 500, + expect.any(Number), + ); + expect(metricsService.decInFlight).toHaveBeenCalledWith('UNKNOWN'); + + const [, , , durationSeconds] = metricsService.observeHttpRequest.mock + .calls[0] as [string, string, number, number]; + expect(durationSeconds).toBeCloseTo(2, 6); + }); +}); diff --git a/src/metrics/metrics.service.spec.ts b/src/metrics/metrics.service.spec.ts new file mode 100644 index 0000000..2adfd81 --- /dev/null +++ b/src/metrics/metrics.service.spec.ts @@ -0,0 +1,60 @@ +import { MetricsService } from './metrics.service'; + +describe('MetricsService', () => { + it('exposes Prometheus content type and metrics output', async () => { + const service = new MetricsService(); + + expect(service.contentType).toContain('text/plain'); + + const metrics = await service.getMetrics(); + expect(metrics).toContain('notip_data_api_http_requests_total'); + expect(metrics).toContain('notip_data_api_http_request_duration_seconds'); + expect(metrics).toContain('notip_data_api_http_requests_in_flight'); + }); + + it('tracks in-flight requests and observed request metrics', async () => { + const service = new MetricsService(); + + service.incInFlight('GET'); + service.observeHttpRequest('GET', '/measures/query', 200, 0.123); + service.decInFlight('GET'); + + const metrics = await service.getMetrics(); + + expect(metrics).toContain( + 'notip_data_api_http_requests_total{method="GET",route="/measures/query",status_code="200"} 1', + ); + expect(metrics).toContain( + 'notip_data_api_http_requests_in_flight{method="GET"} 0', + ); + expect(metrics).toMatch( + /notip_data_api_http_request_duration_seconds_sum\{method="GET",route="\/measures\/query",status_code="200"\} [0-9.]+/, + ); + }); + + it('resolves route labels from string, array, and unmatched routes', () => { + const service = new MetricsService(); + + expect( + service.resolveRouteLabel({ + baseUrl: '/api', + route: { path: '/v1/health' }, + }), + ).toBe('/api/v1/health'); + + expect( + service.resolveRouteLabel({ + route: { path: ['/v1/items', '/v1/devices'] }, + }), + ).toBe('/v1/items|/v1/devices'); + + expect( + service.resolveRouteLabel({ + baseUrl: '/api', + route: { path: { unsupported: true } }, + }), + ).toBe('_unmatched'); + + expect(service.resolveRouteLabel({})).toBe('_unmatched'); + }); +}); From a15752a5df4212d1c6e0915faea715033b345ba1 Mon Sep 17 00:00:00 2001 From: Leonardo Preo <158464877+pr3o@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:52:07 +0000 Subject: [PATCH 13/13] chore: removing code duplication --- src/app.module.spec.ts | 37 ++ .../controller/sensor.controller.spec.ts | 50 +++ src/data-api/measure.mapper.spec.ts | 15 + .../cost-nats-responder.service.spec.ts | 22 ++ .../services/cost-nats-responder.service.ts | 98 ++--- .../measure.persistence.service.spec.ts | 84 +++++ src/data-api/services/measure.service.spec.ts | 44 +++ .../services/nats-connection.utils.spec.ts | 346 ++++++++++++++++++ .../services/nats-connection.utils.ts | 118 ++++++ src/data-api/services/sensor.service.spec.ts | 47 +++ .../services/stream-listener.service.spec.ts | 108 +++++- .../telemetry-stream-bridge.service.ts | 99 ++--- src/metrics/metrics.controller.spec.ts | 28 ++ 13 files changed, 955 insertions(+), 141 deletions(-) create mode 100644 src/data-api/services/nats-connection.utils.spec.ts create mode 100644 src/data-api/services/nats-connection.utils.ts diff --git a/src/app.module.spec.ts b/src/app.module.spec.ts index 05006cf..4165ea1 100644 --- a/src/app.module.spec.ts +++ b/src/app.module.spec.ts @@ -1,7 +1,44 @@ import { AppModule } from './app.module'; describe('AppModule', () => { + const originalNodeEnv = process.env.NODE_ENV; + + function loadModuleImportsFor(nodeEnv: string): unknown[] { + process.env.NODE_ENV = nodeEnv; + + let imports: unknown[] = []; + jest.isolateModules(() => { + const moduleExports = + jest.requireActual('./app.module'); + + imports = + (Reflect.getMetadata( + 'imports', + moduleExports.AppModule, + ) as unknown[]) ?? []; + }); + + return imports; + } + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + jest.resetModules(); + }); + it('should be defined', () => { expect(new AppModule()).toBeDefined(); }); + + it('does not include database module imports in test environment', () => { + const imports = loadModuleImportsFor('test'); + + expect(imports).toHaveLength(4); + }); + + it('includes database module imports outside test environment', () => { + const imports = loadModuleImportsFor('development'); + + expect(imports).toHaveLength(5); + }); }); diff --git a/src/data-api/controller/sensor.controller.spec.ts b/src/data-api/controller/sensor.controller.spec.ts index 91f39f5..06bc7f9 100644 --- a/src/data-api/controller/sensor.controller.spec.ts +++ b/src/data-api/controller/sensor.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SensorController } from './sensor.controller'; import { SensorService } from '../services/sensor.service'; +import { MeasureMapper } from '../measure.mapper'; describe('SensorController', () => { let controller: SensorController; @@ -23,7 +24,56 @@ describe('SensorController', () => { controller = module.get(SensorController); }); + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('maps sensor list with gateway filter', async () => { + const sensorModels = [ + { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + lastSeen: '2026-03-23T09:58:00.000Z', + }, + ]; + const sensorDtos = [ + { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + lastSeen: '2026-03-23T09:58:00.000Z', + }, + ]; + + mockSensorService.getSensors.mockResolvedValue(sensorModels); + const mapperSpy = jest + .spyOn(MeasureMapper, 'toSensorDtos') + .mockReturnValue(sensorDtos); + + const result = await controller.getSensors('gw-1', 'tenant-1'); + + expect(mockSensorService.getSensors).toHaveBeenCalledWith({ + tenantId: 'tenant-1', + gatewayId: 'gw-1', + }); + expect(mapperSpy).toHaveBeenCalledWith(sensorModels); + expect(result).toEqual(sensorDtos); + }); + + it('maps sensor list without gateway filter', async () => { + mockSensorService.getSensors.mockResolvedValue([]); + + await controller.getSensors(undefined, 'tenant-1'); + + expect(mockSensorService.getSensors).toHaveBeenCalledWith({ + tenantId: 'tenant-1', + gatewayId: undefined, + }); + }); }); diff --git a/src/data-api/measure.mapper.spec.ts b/src/data-api/measure.mapper.spec.ts index cac575f..62dbaae 100644 --- a/src/data-api/measure.mapper.spec.ts +++ b/src/data-api/measure.mapper.spec.ts @@ -138,6 +138,21 @@ describe('MeasureMapper', () => { expect(normalized.authTag).toBe('ffffffffffffffffffffffffffffffff'); }); + it('keeps timestamp unchanged when it is not a valid ISO date', () => { + const normalized = MeasureMapper.toEncryptedEnvelopeDto({ + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: 'not-a-date', + encryptedData: 'enc', + iv: 'iv', + authTag: 'tag', + keyVersion: 1, + }); + + expect(normalized.timestamp).toBe('not-a-date'); + }); + it('maps stream item and stream response', async () => { expect(MeasureMapper.toStreamItemResponseDto(model)).toEqual(model); diff --git a/src/data-api/services/cost-nats-responder.service.spec.ts b/src/data-api/services/cost-nats-responder.service.spec.ts index 904536b..36d15c3 100644 --- a/src/data-api/services/cost-nats-responder.service.spec.ts +++ b/src/data-api/services/cost-nats-responder.service.spec.ts @@ -197,6 +197,28 @@ describe('CostNatsResponderService', () => { ).toBeUndefined(); }); + it('returns undefined when tenant_id is blank after trimming', () => { + jest.doMock('nats', () => ({ + connect: jest.fn(), + })); + + const ServiceClass = loadServiceClass(); + const service = new ServiceClass({ + getTenantDataSizeAtRest: jest.fn(), + } as unknown as MeasurePersistenceService); + const testableService = asTestableService(service); + + expect( + testableService.extractTenantId( + Buffer.from( + JSON.stringify({ + tenant_id: ' ', + }), + ), + ), + ).toBeUndefined(); + }); + it('returns zero when cost processing fails', async () => { jest.doMock('nats', () => ({ connect: jest.fn(), diff --git a/src/data-api/services/cost-nats-responder.service.ts b/src/data-api/services/cost-nats-responder.service.ts index 3abeba5..f6e301e 100644 --- a/src/data-api/services/cost-nats-responder.service.ts +++ b/src/data-api/services/cost-nats-responder.service.ts @@ -4,15 +4,19 @@ import { OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; -import * as fs from 'node:fs'; import { type ConnectionOptions, - connect, type Msg, type NatsConnection, type Subscription, } from 'nats'; import { MeasurePersistenceService } from './measure.persistence.service'; +import { + buildNatsConnectionOptions, + shouldSkipNatsBootstrap, + startNatsSubscription, + shutdownNatsResources, +} from './nats-connection.utils'; const COST_SUBJECT = 'internal.cost'; @@ -33,7 +37,7 @@ export class CostNatsResponderService implements OnModuleInit, OnModuleDestroy { constructor(private readonly persistence: MeasurePersistenceService) {} async onModuleInit(): Promise { - if (process.env.NODE_ENV === 'test') { + if (shouldSkipNatsBootstrap()) { return; } @@ -41,28 +45,33 @@ export class CostNatsResponderService implements OnModuleInit, OnModuleDestroy { } async onModuleDestroy(): Promise { - this.subscription?.unsubscribe(); + const subscription = this.subscription; + const connection = this.connection; + this.subscription = null; + this.connection = null; - if (this.connection) { - await this.connection.drain(); - await this.connection.close(); - this.connection = null; - } + await shutdownNatsResources(subscription, connection); } private async connectAndSubscribe(): Promise { - try { - this.connection = await connect(this.buildConnectionOptions()); - this.subscription = this.connection.subscribe(COST_SUBJECT); - void this.consumeMessages(this.subscription); - this.logger.log(`Subscribed to cost subject ${COST_SUBJECT}`); - } catch (error) { - this.logger.error( - 'Failed to initialize cost responder NATS bridge', - error as Error, - ); + const started = await startNatsSubscription({ + connectionOptions: this.buildConnectionOptions(), + logger: this.logger, + subject: COST_SUBJECT, + successMessage: `Subscribed to cost subject ${COST_SUBJECT}`, + failureMessage: 'Failed to initialize cost responder NATS bridge', + onSubscription: (subscription) => { + void this.consumeMessages(subscription); + }, + }); + + if (!started) { + return; } + + this.connection = started.connection; + this.subscription = started.subscription; } private async consumeMessages(subscription: Subscription): Promise { @@ -119,55 +128,6 @@ export class CostNatsResponderService implements OnModuleInit, OnModuleDestroy { } private buildConnectionOptions(): ConnectionOptions { - const options: ConnectionOptions = { - servers: this.resolveServers(), - name: process.env.NATS_CLIENT_NAME ?? 'data-api', - }; - - const caFile = process.env.NATS_TLS_CA; - const certFile = process.env.NATS_TLS_CERT; - const keyFile = process.env.NATS_TLS_KEY; - - if (caFile && certFile && keyFile) { - try { - (options as { tls: any }).tls = { - ca: [fs.readFileSync(caFile)], - cert: fs.readFileSync(certFile), - key: fs.readFileSync(keyFile), - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to load NATS TLS certificates: ${message}`); - } - } - - const token = process.env.NATS_TOKEN?.trim(); - const user = process.env.NATS_USER?.trim(); - const pass = process.env.NATS_PASSWORD?.trim(); - - if (token) { - options.token = token; - return options; - } - - if (user && pass) { - options.user = user; - options.pass = pass; - } - - return options; - } - - private resolveServers(): string[] { - const raw = process.env.NATS_SERVERS ?? process.env.NATS_URL; - - if (!raw) { - return ['nats://localhost:4222']; - } - - return raw - .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); + return buildNatsConnectionOptions(this.logger); } } diff --git a/src/data-api/services/measure.persistence.service.spec.ts b/src/data-api/services/measure.persistence.service.spec.ts index ff0bd57..db4f0e2 100644 --- a/src/data-api/services/measure.persistence.service.spec.ts +++ b/src/data-api/services/measure.persistence.service.spec.ts @@ -115,6 +115,68 @@ describe('MeasurePersistenceService', () => { }); }); + it('supports tenant filter and Date instances when building next cursor', async () => { + const qb = createQueryBuilder(); + const rows: MeasureEntity[] = [ + { + time: new Date('2026-03-23T09:58:00.000Z') as unknown as string, + tenantId: 'tenant-1', + gatewayId: 'gw-1', + sensorId: 'sensor-3', + sensorType: 'temperature', + encryptedData: 'enc-1', + iv: 'iv-1', + authTag: 'tag-1', + keyVersion: 1, + }, + { + time: new Date('2026-03-23T09:55:00.000Z') as unknown as string, + tenantId: 'tenant-1', + gatewayId: 'gw-1', + sensorId: 'sensor-2', + sensorType: 'temperature', + encryptedData: 'enc-2', + iv: 'iv-2', + authTag: 'tag-2', + keyVersion: 1, + }, + { + time: new Date('2026-03-23T09:54:00.000Z') as unknown as string, + tenantId: 'tenant-1', + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + encryptedData: 'enc-3', + iv: 'iv-3', + authTag: 'tag-3', + keyVersion: 1, + }, + ]; + qb.getMany.mockResolvedValue(rows); + + const repository = { + createQueryBuilder: jest.fn().mockReturnValue(qb), + }; + + const service = new MeasurePersistenceService(repository as never); + + const result = await service.paginatedQuery({ + tenantId: 'tenant-1', + from: '2026-03-23T09:50:00.000Z', + to: '2026-03-23T10:00:00.000Z', + limit: 2, + }); + + expect(qb.andWhere).toHaveBeenNthCalledWith(1, 'm.tenantId = :tenantId', { + tenantId: 'tenant-1', + }); + expect(result).toEqual({ + data: rows.slice(0, 2), + nextCursor: '2026-03-23T09:55:00.000Z|sensor-2', + hasMore: true, + }); + }); + it('supports legacy timestamp-only cursor values', async () => { const qb = createQueryBuilder(); qb.getMany.mockResolvedValue([]); @@ -137,6 +199,28 @@ describe('MeasurePersistenceService', () => { }); }); + it('falls back to legacy cursor condition when composite cursor is malformed', async () => { + const qb = createQueryBuilder(); + qb.getMany.mockResolvedValue([]); + + const repository = { + createQueryBuilder: jest.fn().mockReturnValue(qb), + }; + + const service = new MeasurePersistenceService(repository as never); + + await service.paginatedQuery({ + from: '2026-03-23T09:50:00.000Z', + to: '2026-03-23T10:00:00.000Z', + cursor: '2026-03-23T09:59:00.000Z|', + limit: 2, + }); + + expect(qb.andWhere).toHaveBeenNthCalledWith(3, 'm.time < :cursor', { + cursor: '2026-03-23T09:59:00.000Z|', + }); + }); + it('builds a non paginated query with array filters', async () => { const qb = createQueryBuilder(); qb.getMany.mockResolvedValue([]); diff --git a/src/data-api/services/measure.service.spec.ts b/src/data-api/services/measure.service.spec.ts index 204357d..aa20818 100644 --- a/src/data-api/services/measure.service.spec.ts +++ b/src/data-api/services/measure.service.spec.ts @@ -132,6 +132,31 @@ describe('MeasureService', () => { expect(mps.paginatedQuery.mock.calls).toHaveLength(0); }); + it('should allow query execution when dates are invalid and cannot be parsed', async () => { + const mappedResult: PaginatedQueryModel = { + data: [], + hasMore: false, + }; + + mps.paginatedQuery.mockResolvedValue({ + data: [], + hasMore: false, + }); + jest + .spyOn(MeasureMapper, 'toPaginatedQueryModel') + .mockReturnValue(mappedResult); + + await expect( + service.query({ + ...input, + from: 'invalid-from', + to: 'invalid-to', + }), + ).resolves.toEqual(mappedResult); + + expect(mps.paginatedQuery.mock.calls).toHaveLength(1); + }); + it('should throw BadRequestException on status 400', async () => { const error = { status: 400, @@ -291,6 +316,25 @@ describe('MeasureService', () => { expect(mps.nonPaginatedQuery.mock.calls).toHaveLength(0); }); + it('should allow export execution when dates are invalid and cannot be parsed', async () => { + const mappedResult: EncryptedEnvelopeModel[] = []; + + mps.nonPaginatedQuery.mockResolvedValue([]); + jest + .spyOn(MeasureMapper, 'toEncryptedEnvelopeModels') + .mockReturnValue(mappedResult); + + await expect( + service.export({ + ...input, + from: 'invalid-from', + to: 'invalid-to', + }), + ).resolves.toEqual(mappedResult); + + expect(mps.nonPaginatedQuery.mock.calls).toHaveLength(1); + }); + it('should throw BadRequestException on status 400', async () => { const error = { status: 400, diff --git a/src/data-api/services/nats-connection.utils.spec.ts b/src/data-api/services/nats-connection.utils.spec.ts new file mode 100644 index 0000000..84fefb5 --- /dev/null +++ b/src/data-api/services/nats-connection.utils.spec.ts @@ -0,0 +1,346 @@ +import { Logger } from '@nestjs/common'; +import type { ConnectionOptions, NatsConnection, Subscription } from 'nats'; + +type NatsUtilsModule = typeof import('./nats-connection.utils'); + +function loadNatsUtilsModule(): NatsUtilsModule { + let moduleExports!: NatsUtilsModule; + + jest.isolateModules(() => { + moduleExports = jest.requireActual( + './nats-connection.utils', + ); + }); + + return moduleExports; +} + +function createLoggerMock(): { + logger: Logger; + log: jest.Mock; + error: jest.Mock; +} { + const log = jest.fn(); + const error = jest.fn(); + + return { + logger: { + log, + error, + } as unknown as Logger, + log, + error, + }; +} + +describe('nats-connection.utils', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = originalEnv; + jest.resetModules(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('shouldSkipNatsBootstrap', () => { + it('returns true when node environment is test', () => { + const { shouldSkipNatsBootstrap } = loadNatsUtilsModule(); + + expect(shouldSkipNatsBootstrap('test')).toBe(true); + }); + + it('reads process.env.NODE_ENV when argument is omitted', () => { + const { shouldSkipNatsBootstrap } = loadNatsUtilsModule(); + + process.env = { + ...originalEnv, + NODE_ENV: 'test', + }; + expect(shouldSkipNatsBootstrap()).toBe(true); + + process.env = { + ...originalEnv, + NODE_ENV: 'development', + }; + expect(shouldSkipNatsBootstrap()).toBe(false); + }); + }); + + describe('shutdownNatsResources', () => { + it('is a no-op when subscription and connection are null', async () => { + const { shutdownNatsResources } = loadNatsUtilsModule(); + + await expect(shutdownNatsResources(null, null)).resolves.toBeUndefined(); + }); + + it('unsubscribes and closes active NATS resources', async () => { + const { shutdownNatsResources } = loadNatsUtilsModule(); + + const unsubscribe = jest.fn(); + const drain = jest.fn().mockResolvedValue(undefined); + const close = jest.fn().mockResolvedValue(undefined); + + const subscription = { + unsubscribe, + } as unknown as Subscription; + const connection = { + drain, + close, + } as unknown as NatsConnection; + + await shutdownNatsResources(subscription, connection); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(drain).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); + }); + + describe('buildNatsConnectionOptions', () => { + it('uses defaults when NATS environment variables are missing', () => { + process.env = { + ...originalEnv, + NATS_SERVERS: undefined, + NATS_URL: undefined, + NATS_CLIENT_NAME: undefined, + NATS_TOKEN: undefined, + NATS_USER: undefined, + NATS_PASSWORD: undefined, + NATS_TLS_CA: undefined, + NATS_TLS_CERT: undefined, + NATS_TLS_KEY: undefined, + }; + + const { logger, error } = createLoggerMock(); + const { buildNatsConnectionOptions } = loadNatsUtilsModule(); + + const options = buildNatsConnectionOptions(logger); + + expect(options).toEqual({ + servers: ['nats://localhost:4222'], + name: 'data-api', + }); + expect(error).not.toHaveBeenCalled(); + }); + + it('normalizes server list and prefers token auth over user/password', () => { + process.env = { + ...originalEnv, + NATS_SERVERS: ' nats://one:4222, , nats://two:4222 ', + NATS_CLIENT_NAME: 'custom-client', + NATS_TOKEN: ' token-value ', + NATS_USER: ' demo-user ', + NATS_PASSWORD: ' demo-pass ', + }; + + const { logger } = createLoggerMock(); + const { buildNatsConnectionOptions } = loadNatsUtilsModule(); + + const options = buildNatsConnectionOptions(logger); + + expect(options).toEqual({ + servers: ['nats://one:4222', 'nats://two:4222'], + name: 'custom-client', + token: 'token-value', + }); + expect((options as { user?: string }).user).toBeUndefined(); + expect((options as { pass?: string }).pass).toBeUndefined(); + }); + + it('uses user/password auth when token is not provided', () => { + process.env = { + ...originalEnv, + NATS_URL: ' nats://single:4222 ', + NATS_TOKEN: undefined, + NATS_USER: ' demo-user ', + NATS_PASSWORD: ' demo-pass ', + }; + + const { logger } = createLoggerMock(); + const { buildNatsConnectionOptions } = loadNatsUtilsModule(); + + const options = buildNatsConnectionOptions(logger); + + expect(options).toEqual({ + servers: ['nats://single:4222'], + name: 'data-api', + user: 'demo-user', + pass: 'demo-pass', + }); + }); + + it('adds TLS options when all TLS file paths are configured', () => { + process.env = { + ...originalEnv, + NATS_TLS_CA: '/tmp/ca.pem', + NATS_TLS_CERT: '/tmp/cert.pem', + NATS_TLS_KEY: '/tmp/key.pem', + }; + + const readFileSync = jest + .fn() + .mockReturnValueOnce(Buffer.from('ca')) + .mockReturnValueOnce(Buffer.from('cert')) + .mockReturnValueOnce(Buffer.from('key')); + + jest.doMock('node:fs', () => { + const actualFs = + jest.requireActual('node:fs'); + return { + ...actualFs, + readFileSync, + }; + }); + + const { logger } = createLoggerMock(); + const { buildNatsConnectionOptions } = loadNatsUtilsModule(); + + const options = buildNatsConnectionOptions(logger); + + expect((options as { tls?: unknown }).tls).toEqual({ + ca: [Buffer.from('ca')], + cert: Buffer.from('cert'), + key: Buffer.from('key'), + }); + expect(readFileSync).toHaveBeenCalledTimes(3); + }); + + it('logs TLS read errors when fs throws an Error object', () => { + process.env = { + ...originalEnv, + NATS_TLS_CA: '/tmp/ca.pem', + NATS_TLS_CERT: '/tmp/cert.pem', + NATS_TLS_KEY: '/tmp/key.pem', + }; + + const readFileSync = jest.fn(() => { + throw new Error('missing cert'); + }); + + jest.doMock('node:fs', () => { + const actualFs = + jest.requireActual('node:fs'); + return { + ...actualFs, + readFileSync, + }; + }); + + const { logger, error } = createLoggerMock(); + const { buildNatsConnectionOptions } = loadNatsUtilsModule(); + + const options = buildNatsConnectionOptions(logger); + + expect((options as { tls?: unknown }).tls).toBeUndefined(); + expect(error).toHaveBeenCalledWith( + 'Failed to load NATS TLS certificates: missing cert', + ); + }); + + it('logs TLS read errors when fs throws a non-Error value', () => { + process.env = { + ...originalEnv, + NATS_TLS_CA: '/tmp/ca.pem', + NATS_TLS_CERT: '/tmp/cert.pem', + NATS_TLS_KEY: '/tmp/key.pem', + }; + + const readFileSync = jest.fn(() => { + throw 'io-failure' as unknown as Error; + }); + + jest.doMock('node:fs', () => { + const actualFs = + jest.requireActual('node:fs'); + return { + ...actualFs, + readFileSync, + }; + }); + + const { logger, error } = createLoggerMock(); + const { buildNatsConnectionOptions } = loadNatsUtilsModule(); + + buildNatsConnectionOptions(logger); + + expect(error).toHaveBeenCalledWith( + 'Failed to load NATS TLS certificates: io-failure', + ); + }); + }); + + describe('startNatsSubscription', () => { + it('starts the subscription and logs success', async () => { + const subscription = { + unsubscribe: jest.fn(), + } as unknown as Subscription; + const subscribe = jest.fn().mockReturnValue(subscription); + const connection = { + subscribe, + } as unknown as NatsConnection; + const connect = jest.fn().mockResolvedValue(connection); + const onSubscription = jest.fn(); + + jest.doMock('nats', () => ({ + connect, + })); + + const { logger, log, error } = createLoggerMock(); + const { startNatsSubscription } = loadNatsUtilsModule(); + + const connectionOptions: ConnectionOptions = { + servers: ['nats://one:4222'], + }; + + const result = await startNatsSubscription({ + connectionOptions, + logger, + subject: 'internal.cost', + successMessage: 'started', + failureMessage: 'failed', + onSubscription, + }); + + expect(connect).toHaveBeenCalledWith(connectionOptions); + expect(subscribe).toHaveBeenCalledWith('internal.cost'); + expect(onSubscription).toHaveBeenCalledWith(subscription); + expect(log).toHaveBeenCalledWith('started'); + expect(error).not.toHaveBeenCalled(); + expect(result).toEqual({ + connection, + subscription, + }); + }); + + it('logs errors and returns undefined when connect fails', async () => { + const connectError = new Error('nats down'); + const connect = jest.fn().mockRejectedValue(connectError); + const onSubscription = jest.fn(); + + jest.doMock('nats', () => ({ + connect, + })); + + const { logger, log, error } = createLoggerMock(); + const { startNatsSubscription } = loadNatsUtilsModule(); + + const result = await startNatsSubscription({ + connectionOptions: { + servers: ['nats://one:4222'], + }, + logger, + subject: 'internal.cost', + successMessage: 'started', + failureMessage: 'failed', + onSubscription, + }); + + expect(result).toBeUndefined(); + expect(connect).toHaveBeenCalledTimes(1); + expect(onSubscription).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith('failed', connectError); + }); + }); +}); diff --git a/src/data-api/services/nats-connection.utils.ts b/src/data-api/services/nats-connection.utils.ts new file mode 100644 index 0000000..c776386 --- /dev/null +++ b/src/data-api/services/nats-connection.utils.ts @@ -0,0 +1,118 @@ +import { Logger } from '@nestjs/common'; +import * as fs from 'node:fs'; +import { + type ConnectionOptions, + connect, + type NatsConnection, + type Subscription, +} from 'nats'; + +const DEFAULT_NATS_SERVER = 'nats://localhost:4222'; + +type StartSubscriptionInput = { + connectionOptions: ConnectionOptions; + logger: Logger; + subject: string; + successMessage: string; + failureMessage: string; + onSubscription: (subscription: Subscription) => void; +}; + +export function shouldSkipNatsBootstrap( + nodeEnv = process.env.NODE_ENV, +): boolean { + return nodeEnv === 'test'; +} + +export async function shutdownNatsResources( + subscription: Subscription | null, + connection: NatsConnection | null, +): Promise { + subscription?.unsubscribe(); + await connection?.drain(); + await connection?.close(); +} + +export function buildNatsConnectionOptions(logger: Logger): ConnectionOptions { + const options: ConnectionOptions = { + servers: resolveNatsServers(), + name: process.env.NATS_CLIENT_NAME ?? 'data-api', + }; + + applyTlsOptions(options, logger); + applyAuthOptions(options); + + return options; +} + +export async function startNatsSubscription( + input: StartSubscriptionInput, +): Promise< + { connection: NatsConnection; subscription: Subscription } | undefined +> { + try { + const connection = await connect(input.connectionOptions); + const subscription = connection.subscribe(input.subject); + + input.onSubscription(subscription); + input.logger.log(input.successMessage); + + return { + connection, + subscription, + }; + } catch (error) { + input.logger.error(input.failureMessage, error as Error); + return undefined; + } +} + +function resolveNatsServers(): string[] { + const raw = process.env.NATS_SERVERS ?? process.env.NATS_URL; + + if (!raw) { + return [DEFAULT_NATS_SERVER]; + } + + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function applyTlsOptions(options: ConnectionOptions, logger: Logger): void { + const caFile = process.env.NATS_TLS_CA; + const certFile = process.env.NATS_TLS_CERT; + const keyFile = process.env.NATS_TLS_KEY; + + if (!caFile || !certFile || !keyFile) { + return; + } + + try { + (options as { tls: any }).tls = { + ca: [fs.readFileSync(caFile)], + cert: fs.readFileSync(certFile), + key: fs.readFileSync(keyFile), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Failed to load NATS TLS certificates: ${message}`); + } +} + +function applyAuthOptions(options: ConnectionOptions): void { + const token = process.env.NATS_TOKEN?.trim(); + const user = process.env.NATS_USER?.trim(); + const pass = process.env.NATS_PASSWORD?.trim(); + + if (token) { + options.token = token; + return; + } + + if (user && pass) { + options.user = user; + options.pass = pass; + } +} diff --git a/src/data-api/services/sensor.service.spec.ts b/src/data-api/services/sensor.service.spec.ts index 492bc0f..974f243 100644 --- a/src/data-api/services/sensor.service.spec.ts +++ b/src/data-api/services/sensor.service.spec.ts @@ -61,6 +61,26 @@ describe('SensorService', () => { expect(toMs - fromMs).toBe(10 * 60 * 1000); }); + it('should call nonPaginatedQuery without gateway filter when gatewayId is not provided', async () => { + npqps.nonPaginatedQuery.mockResolvedValue([]); + + await service.getSensors({ + tenantId: 'tenant-1', + }); + + const [calledWith] = npqps.nonPaginatedQuery.mock.calls[0] as [ + { + tenantId?: string; + gatewayId?: string[]; + from: string; + to: string; + }, + ]; + + expect(calledWith.tenantId).toBe('tenant-1'); + expect(calledWith.gatewayId).toBeUndefined(); + }); + it('should return unique sensors from measures', async () => { const measures: MeasureEntity[] = [ { @@ -217,6 +237,17 @@ describe('SensorService', () => { ); }); + it('should use response.status and error.message for unauthorized errors', async () => { + npqps.nonPaginatedQuery.mockRejectedValue({ + response: { + status: 401, + }, + message: 'Token expired', + }); + + await expect(service.getSensors(input)).rejects.toThrow('Token expired'); + }); + it('should throw ForbiddenException on status 403', async () => { npqps.nonPaginatedQuery.mockRejectedValue({ status: 403, @@ -230,6 +261,14 @@ describe('SensorService', () => { ); }); + it('should fallback to default forbidden message when service error has no details', async () => { + npqps.nonPaginatedQuery.mockRejectedValue({ + status: 403, + }); + + await expect(service.getSensors(input)).rejects.toThrow('Forbidden'); + }); + it('should throw NotFoundException on status 404', async () => { npqps.nonPaginatedQuery.mockRejectedValue({ status: 404, @@ -243,6 +282,14 @@ describe('SensorService', () => { ); }); + it('should fallback to default not found message when service error has no details', async () => { + npqps.nonPaginatedQuery.mockRejectedValue({ + status: 404, + }); + + await expect(service.getSensors(input)).rejects.toThrow('Not found'); + }); + it('should rethrow unknown errors', async () => { const error = new Error('Unexpected error'); npqps.nonPaginatedQuery.mockRejectedValue(error); diff --git a/src/data-api/services/stream-listener.service.spec.ts b/src/data-api/services/stream-listener.service.spec.ts index b21541a..13c72b9 100644 --- a/src/data-api/services/stream-listener.service.spec.ts +++ b/src/data-api/services/stream-listener.service.spec.ts @@ -1,5 +1,8 @@ -import { firstValueFrom, take } from 'rxjs'; -import { StreamListenerService } from './stream-listener.service'; +import { firstValueFrom, of, take, type Observable } from 'rxjs'; +import { + StreamListenerService, + type StreamEmission, +} from './stream-listener.service'; import { MeasurePersistenceService } from './measure.persistence.service'; import { MeasureEntity } from '../entity/measure.entity'; @@ -112,6 +115,107 @@ describe('StreamListenerService', () => { keyVersion: 3, }, }); + expect(persistence.nonPaginatedQuery.mock.calls).toHaveLength(0); + }); + + it('filters out live measures that do not match gateway/sensor/type filters', async () => { + persistence.nonPaginatedQuery.mockResolvedValue([]); + + const eventPromise = firstValueFrom( + service + .stream({ + tenantId: 'tenant-1', + gatewayId: ['gw-1'], + sensorId: ['sensor-1'], + sensorType: ['temperature'], + }) + .pipe(take(1)), + ); + + service.publishLiveMeasure('tenant-1', { + gatewayId: 'gw-2', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-03-23T10:00:01.000Z', + encryptedData: 'enc-ignored-1', + iv: 'iv-ignored-1', + authTag: 'tag-ignored-1', + keyVersion: 1, + }); + + service.publishLiveMeasure('tenant-1', { + gatewayId: 'gw-1', + sensorId: 'sensor-2', + sensorType: 'temperature', + timestamp: '2026-03-23T10:00:02.000Z', + encryptedData: 'enc-ignored-2', + iv: 'iv-ignored-2', + authTag: 'tag-ignored-2', + keyVersion: 1, + }); + + service.publishLiveMeasure('tenant-1', { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'humidity', + timestamp: '2026-03-23T10:00:03.000Z', + encryptedData: 'enc-ignored-3', + iv: 'iv-ignored-3', + authTag: 'tag-ignored-3', + keyVersion: 1, + }); + + service.publishLiveMeasure('tenant-1', { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-03-23T10:00:04.000Z', + encryptedData: 'enc-live', + iv: 'iv-live', + authTag: 'tag-live', + keyVersion: 2, + }); + + await expect(eventPromise).resolves.toEqual({ + kind: 'data', + data: { + gatewayId: 'gw-1', + sensorId: 'sensor-1', + sensorType: 'temperature', + timestamp: '2026-03-23T10:00:04.000Z', + encryptedData: 'enc-live', + iv: 'iv-live', + authTag: 'tag-live', + keyVersion: 2, + }, + }); + }); + + it('passes through error emissions from the source stream', async () => { + const listenToSourceSpy = jest.spyOn( + service as unknown as { + listenToSource: (_input: unknown) => Observable; + }, + 'listenToSource', + ); + + listenToSourceSpy.mockReturnValue( + of({ + kind: 'error', + reason: 'token_expired', + }), + ); + + await expect( + firstValueFrom( + service.stream({ + tenantId: 'tenant-1', + }), + ), + ).resolves.toEqual({ + kind: 'error', + reason: 'token_expired', + }); }); it('emits token_expired immediately when the JWT is already expired', async () => { diff --git a/src/data-api/services/telemetry-stream-bridge.service.ts b/src/data-api/services/telemetry-stream-bridge.service.ts index 9bbca8c..174263f 100644 --- a/src/data-api/services/telemetry-stream-bridge.service.ts +++ b/src/data-api/services/telemetry-stream-bridge.service.ts @@ -4,15 +4,19 @@ import { OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; -import * as fs from 'node:fs'; import { type ConnectionOptions, - connect, type NatsConnection, type Subscription, } from 'nats'; import { EncryptedEnvelopeModel } from '../models/encrypted-envelope.model'; import { StreamListenerService } from './stream-listener.service'; +import { + buildNatsConnectionOptions, + shouldSkipNatsBootstrap, + startNatsSubscription, + shutdownNatsResources, +} from './nats-connection.utils'; const TELEMETRY_SUBJECT = 'telemetry.data.*.*'; @@ -27,7 +31,7 @@ export class TelemetryStreamBridgeService constructor(private readonly streamListener: StreamListenerService) {} async onModuleInit(): Promise { - if (process.env.NODE_ENV === 'test') { + if (shouldSkipNatsBootstrap()) { return; } @@ -35,29 +39,33 @@ export class TelemetryStreamBridgeService } async onModuleDestroy(): Promise { - this.subscription?.unsubscribe(); + const subscription = this.subscription; + const connection = this.connection; + this.subscription = null; + this.connection = null; - if (this.connection) { - await this.connection.drain(); - await this.connection.close(); - this.connection = null; - } + await shutdownNatsResources(subscription, connection); } private async connectAndSubscribe(): Promise { - try { - this.connection = await connect(this.buildConnectionOptions()); - this.subscription = this.connection.subscribe(TELEMETRY_SUBJECT); - - void this.consumeMessages(this.subscription); - this.logger.log(`Subscribed to telemetry subject ${TELEMETRY_SUBJECT}`); - } catch (error) { - this.logger.error( - 'Failed to initialize telemetry NATS bridge', - error as Error, - ); + const started = await startNatsSubscription({ + connectionOptions: this.buildConnectionOptions(), + logger: this.logger, + subject: TELEMETRY_SUBJECT, + successMessage: `Subscribed to telemetry subject ${TELEMETRY_SUBJECT}`, + failureMessage: 'Failed to initialize telemetry NATS bridge', + onSubscription: (subscription) => { + void this.consumeMessages(subscription); + }, + }); + + if (!started) { + return; } + + this.connection = started.connection; + this.subscription = started.subscription; } private async consumeMessages(subscription: Subscription): Promise { @@ -81,56 +89,7 @@ export class TelemetryStreamBridgeService } private buildConnectionOptions(): ConnectionOptions { - const options: ConnectionOptions = { - servers: this.resolveServers(), - name: process.env.NATS_CLIENT_NAME ?? 'data-api', - }; - - const caFile = process.env.NATS_TLS_CA; - const certFile = process.env.NATS_TLS_CERT; - const keyFile = process.env.NATS_TLS_KEY; - - if (caFile && certFile && keyFile) { - try { - (options as { tls: any }).tls = { - ca: [fs.readFileSync(caFile)], - cert: fs.readFileSync(certFile), - key: fs.readFileSync(keyFile), - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to load NATS TLS certificates: ${message}`); - } - } - - const token = process.env.NATS_TOKEN?.trim(); - const user = process.env.NATS_USER?.trim(); - const pass = process.env.NATS_PASSWORD?.trim(); - - if (token) { - options.token = token; - return options; - } - - if (user && pass) { - options.user = user; - options.pass = pass; - } - - return options; - } - - private resolveServers(): string[] { - const raw = process.env.NATS_SERVERS ?? process.env.NATS_URL; - - if (!raw) { - return ['nats://localhost:4222']; - } - - return raw - .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); + return buildNatsConnectionOptions(this.logger); } private extractTenantId(subject: string): string | undefined { diff --git a/src/metrics/metrics.controller.spec.ts b/src/metrics/metrics.controller.spec.ts index 84f79d6..8c6a8dd 100644 --- a/src/metrics/metrics.controller.spec.ts +++ b/src/metrics/metrics.controller.spec.ts @@ -27,4 +27,32 @@ describe('MetricsController', () => { expect(getMetrics).toHaveBeenCalledTimes(1); expect(send).toHaveBeenCalledWith('# mock_metrics 1'); }); + + it('propagates metrics retrieval errors after setting content type', async () => { + const getMetrics = jest + .fn() + .mockRejectedValue(new Error('registry failed')); + const metricsService = { + contentType: 'text/plain; version=0.0.4; charset=utf-8', + getMetrics, + } as unknown as MetricsService; + + const controller = new MetricsController(metricsService); + const setHeader = jest.fn(); + const send = jest.fn(); + const response = { + setHeader, + send, + } as unknown as Response; + + await expect(controller.metrics(response)).rejects.toThrow( + 'registry failed', + ); + + expect(setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/plain; version=0.0.4; charset=utf-8', + ); + expect(send).not.toHaveBeenCalled(); + }); });