diff --git a/README.md b/README.md index 2d3e952..c9fa316 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ Copy the `.env.example` file to `.env`. Adjust the variable values based to your needs. - ## Running the server ```bash @@ -45,6 +44,52 @@ $ npm run start:dev $ npm run start:prod ``` +## TripNotify live updates + +Owners can list and manage all their own trips, including scheduled and expired +ones. Guest access still follows each share's dates, Premium status, and access +after expiry setting. + +Shared history selects sessions by their recorded sample timestamps, including +delayed uploads created after the share ends when historical access is retained. +Samples are limited to `start <= timestamp < end`. Displayed session dates include +the selected samples and are clamped to the sharing window. Partial-session +measurements are recalculated from those samples without changing the stored +log. Missing measurements are omitted from the response, and the website shows +"–" instead of a misleading zero. Trip totals are omitted when any contributing +session lacks the necessary measurements. Energy uses the difference between +recorded counters inside the window; values between samples or across the window +boundaries are not estimated. + +The bundled website lives in `public/tripnotify/index.html` and is served at +`/frontend/tripnotify/?code=SHARE_CODE`. It sends the required socket credentials +and displays live connection errors separately from trip-loading errors. + +Socket.IO clients must supply the trip's share code when connecting: + +```ts +const socket = io(serverUrl, { auth: { code: shareCode } }); +socket.on(`route-update:${shareCode}`, ({ logId, data }) => { + // Handle this trip's update. +}); +socket.on('connect_error', (error) => { + // Unauthorized shares receive the generic message "Unauthorized". +}); +``` + +Each connection receives only its authorized share's updates. Reconnect with a new +connection to switch shares. Live delivery requires an active trip and active +owner Premium; permissions are checked again before each update. Each sample must +also have a valid timestamp within `start <= timestamp < end`; uploading old data +during an active share does not authorize sharing it. When location +sharing is disabled, updates include only timestamp and allowed battery/charging +telemetry, including temperature readings. Historical access after a trip ends +does not extend live delivery. + +**Deployment:** update consuming clients to send `auth.code` before deploying this +server change. Clients that omit it will be rejected. There is no unrestricted +compatibility fallback. See the [implementation plan](docs/plans/tripnotify-socket-privacy.md). + ## Test ```bash diff --git a/docs/plans/tripnotify-socket-privacy.md b/docs/plans/tripnotify-socket-privacy.md new file mode 100644 index 0000000..210e20a --- /dev/null +++ b/docs/plans/tripnotify-socket-privacy.md @@ -0,0 +1,88 @@ +# TripNotify sharing fixes + +## Summary + +Prevent live trip updates from reaching unauthorized clients, restore the owner's +complete trip list, and include history from sessions crossing sharing boundaries. +Enforce sharing dates, active Premium, and location preferences. + +## Implementation + +- List all trips belonging to the authenticated owner, independent of guest + sharing dates and retained-access settings. Keep guest authorization unchanged. +- Select sessions by their sample timestamps, including delayed uploads whose + server-created session dates do not overlap the share. Safely convert stored + dates and timestamp strings; omit logs with no valid samples in the window. +- Build a response-only view: expand recorded session bounds to include selected + samples, clamp dates to the share, and mark a running + session's shared portion finished once the share ends. Leave stored logs intact. +- Recalculate partial-session SOC/counter endpoints, energy, averages, and distance + from sorted in-window samples. Preserve valid zeros, omit insufficient or reset + counter measurements, and do not interpolate across the boundaries. Preserve + stored measurements for fully contained sessions. Omit aggregate totals if any + contributing session lacks the required data; display unavailable values as + "–" on the website. +- Classify unknown sessions with zero battery power and a finite average speed + as driving; preserve zero measurements and explicit session types. +- Share the battery-field allowlist between HTTP history and live updates so + mixed telemetry cannot expose location when location sharing is disabled. +- Require `auth: { code: string }` in the Socket.IO handshake. Authorize through + `TripNotifyService.findAccessibleByCode()` before accepting the connection. + Reject missing, malformed, unknown, or inaccessible codes with a generic + `connect_error` whose message is `Unauthorized`. +- Assign each socket to one server-controlled share room. Expose no client room + joining messages. Replace global broadcasts with delivery to that room. +- Preserve `route-update:${code}` and the `{ logId, data }` payload structure. +- Adapt the bundled `public/tripnotify/index.html` website to send `auth.code` to + the same backend as its HTTP requests. Display generic live connection errors + separately from trip-loading errors; clear only the live error on reconnection. +- Before every broadcast, revalidate the share and Premium. Suppress updates on + authorization failure, deletion, or database errors. Live access requires + `startDate <= now < endDate`, even when historical access is retained. Each + sample also needs a valid timestamp in `startDate <= timestamp < endDate`. +- For location-disabled shares, copy only timestamp and explicitly allowed + battery/charging telemetry, including temperature readings. Exclude coordinates, + altitude, accuracy, speed, odometer, and unknown fields. Never modify the source + sync object or another share's payload. +- Await all per-share delivery checks in the sync handler. Record failures + without share codes, account identifiers, location data, or raw database errors. + +## Verification + +Use isolated MongoDB, real Premium queries, and real Socket.IO clients. Add +`socket.io-client` as a development dependency. + +- Verify all owner trip states are listed while account isolation and + authentication remain enforced. +- Cover both overlap boundaries, running and spanning sessions, exact endpoint + timestamps, invalid/unsorted samples, empty windows, zero readings, counter + resets, partial totals, and stored-log immutability. +- Ingest delayed samples through the real sync service after share expiry and + verify retained history, bounded dates, and running/finished logs. Cover stored + dates, timestamp strings, malformed histories, and account isolation. +- Check unknown driving sessions with zero battery power and explicit zero-power + charging sessions without losing zero measurements. +- Check website formatting for zero and unavailable totals, including charge-stop + summaries, alongside socket handshake and error-handling smoke checks. +- Reject unauthorized handshakes; verify exact start/end boundaries and both + historical-access settings. +- Verify room isolation with catch-all listeners and multiple clients per share. +- Verify the existing event and payload structure for authorized clients. +- Verify sample timestamps independently of upload time, including exact start + and end boundaries, delayed samples, and missing/invalid timestamps. +- Verify mixed GPS/battery payloads are filtered without modifying the input. +- Verify changes to location preferences, Premium expiry, deletion, and trip + expiry take effect for already-connected clients. +- Verify database failures suppress delivery and do not expose error details. +- Run guest HTTP regressions, socket regressions, and the build. Report the + existing account-guard test failure separately if it persists. + +## Compatibility and rollout + +Share codes remain bearer credentials. Code strength and distribution are outside +this change. Update consuming clients to send `auth.code` on every connection and +reconnection before deploying enforcement. Clients without it will lose live +updates; retain no unrestricted fallback. + +Historical access after expiry may authorize a connection, but produces no live +updates. No application deployment or client repository changes are included here. diff --git a/package-lock.json b/package-lock.json index 1e9e148..9ec99da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "eslint-plugin-prettier": "^5.4.0", "jest": "^29.7.0", "prettier": "^3.5.3", + "socket.io-client": "^4.8.4", "source-map-support": "^0.5.21", "supertest": "^7.1.1", "ts-jest": "^29.3.4", @@ -7917,6 +7918,42 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -14542,6 +14579,22 @@ } } }, + "node_modules/socket.io-client": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz", + "integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -16624,6 +16677,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 1614077..fcd534e 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "eslint-plugin-prettier": "^5.4.0", "jest": "^29.7.0", "prettier": "^3.5.3", + "socket.io-client": "^4.8.4", "source-map-support": "^0.5.21", "supertest": "^7.1.1", "ts-jest": "^29.3.4", @@ -85,6 +86,9 @@ "ts" ], "rootDir": "src", + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" diff --git a/public/tripnotify/index.html b/public/tripnotify/index.html index 43ec685..ea1555c 100644 --- a/public/tripnotify/index.html +++ b/public/tripnotify/index.html @@ -99,14 +99,22 @@

Trip information

const stopsEl = document.getElementById("chargingStops"); const errorEl = document.getElementById("error"); const infoEl = document.getElementById("info"); + const errors = { trip: '', socket: '' }; - function showError(message) { - errorEl.style.display = 'block'; - errorEl.textContent = message; + function renderErrors() { + const messages = Object.values(errors).filter(Boolean); + errorEl.style.display = messages.length ? 'block' : 'none'; + errorEl.textContent = messages.join(' '); } - function hideError() { - errorEl.style.display = 'none'; + function showError(message, source = 'trip') { + errors[source] = message; + renderErrors(); + } + + function hideError(source = 'trip') { + errors[source] = ''; + renderErrors(); } const map = L.map('map').setView([51.505, -0.09], 13); @@ -149,8 +157,21 @@

Trip information

showError("Trip could not be loaded"); }); - const socket = io({ - query: { code } + const socket = io(baseUrl || window.location.origin, { + auth: { code } + }); + + socket.on('connect', () => { + hideError('socket'); + }); + + socket.on('connect_error', (error) => { + showError( + error.message === 'Unauthorized' + ? 'Live updates unavailable: this share is not authorized' + : 'Live updates unavailable: could not connect to the server', + 'socket' + ); }); socket.on(`route-update:${code}`, (payload) => { @@ -174,18 +195,26 @@

Trip information

endEl.textContent = info.end ? new Date(info.end).toLocaleString() : "–"; } + function formatMetric(value, unit, decimals) { + if (!Number.isFinite(value)) { + return "–"; + } + return `${decimals === undefined ? value : value.toFixed(decimals)} ${unit}`; + } + function updateSidebarMetadataAndChargingStops(info) { - consumedEl.textContent = info.metadata.consumedEnergy ? info.metadata.consumedEnergy + ' kWh' : "–"; - chargedEl.textContent = info.metadata.rechargedEnergy ? info.metadata.rechargedEnergy + ' kWh' : "–"; - avgSpeedEl.textContent = info.metadata.avgDriveSpeed ? info.metadata.avgDriveSpeed + " km/h" : "–"; - avgChargeEl.textContent = info.metadata.avgChargeSpeed ? info.metadata.avgChargeSpeed + " kW" : "–"; + consumedEl.textContent = formatMetric(info.metadata.consumedEnergy, 'kWh'); + chargedEl.textContent = formatMetric(info.metadata.rechargedEnergy, 'kWh'); + avgSpeedEl.textContent = formatMetric(info.metadata.avgDriveSpeed, 'km/h'); + avgChargeEl.textContent = formatMetric(info.metadata.avgChargeSpeed, 'kW'); stopsEl.innerHTML = ''; (info.logs || []).forEach(((log) => { if (log.type === 'charge') { const li = document.createElement("li"); - const text = `${new Date(log.startDate).toLocaleDateString()}
${new Date(log.startDate).toLocaleTimeString()} ${log.endDate ? '- ' + new Date(log.endDate).toLocaleTimeString() : ' (still active)'}
(charged: ${(log.rechargedKWh || 0).toFixed(2)} kWh, ⌀ ${Math.abs(log.averageKW || 0).toFixed(2)} kW)`; + const averageKW = Number.isFinite(log.averageKW) ? Math.abs(log.averageKW) : undefined; + const text = `${new Date(log.startDate).toLocaleDateString()}
${new Date(log.startDate).toLocaleTimeString()} ${log.endDate ? '- ' + new Date(log.endDate).toLocaleTimeString() : ' (still active)'}
(charged: ${formatMetric(log.rechargedKWh, 'kWh', 2)}, ⌀ ${formatMetric(averageKW, 'kW', 2)})`; li.innerHTML = text; stopsEl.appendChild(li); @@ -220,4 +249,4 @@

Trip information

- \ No newline at end of file + diff --git a/src/logs/logs.service.ts b/src/logs/logs.service.ts index 52b4434..b2c2b6e 100644 --- a/src/logs/logs.service.ts +++ b/src/logs/logs.service.ts @@ -93,21 +93,52 @@ export class LogsService { return Promise.resolve(new LogDto(log)); } - async findLogsWithinDateRange(akey: string, start: Date, end: Date): Promise { - const query = { - akey, - startDate: { $gte: start }, - $and: [ - { - $or: [ - { endDate: { $lte: end } }, - { endDate: null, status: STATUS.RUNNING }, - ] - } - ] - }; - - return this.logModel.find(query).select('-history').sort({startDate: 'desc'}); + async findLogsWithinDateRange( + akey: string, + start: Date, + end: Date, + ): Promise { + // Session dates track server activity; delayed samples may belong to an + // earlier sharing window. Match their recorded times instead. + return this.logModel.aggregate([ + { $match: { akey } }, + { + $match: { + $expr: { + $anyElementTrue: [ + { + $map: { + input: { $ifNull: ['$history', []] }, + as: 'entry', + in: { + $let: { + vars: { + timestamp: { + $convert: { + input: '$$entry.timestamp', + to: 'date', + onError: null, + onNull: null, + }, + }, + }, + in: { + $and: [ + { $gte: ['$$timestamp', start] }, + { $lt: ['$$timestamp', end] }, + ], + }, + }, + }, + }, + }, + ], + }, + }, + }, + { $project: { history: 0 } }, + { $sort: { startDate: -1 } }, + ]); } async findAll(akey: string, type?: TYPE): Promise { diff --git a/src/tripnotify/dto/log-with-history.dto.ts b/src/tripnotify/dto/log-with-history.dto.ts index b45e9d0..5e4a682 100644 --- a/src/tripnotify/dto/log-with-history.dto.ts +++ b/src/tripnotify/dto/log-with-history.dto.ts @@ -1,6 +1,6 @@ -import { LogDto } from "src/logs/dto/log.dto"; -import { Log } from "src/logs/schemas/log.schema"; -import { Sync } from "src/logs/schemas/sync.schema"; +import { LogDto } from 'src/logs/dto/log.dto'; +import { Log } from 'src/logs/schemas/log.schema'; +import { Sync } from 'src/logs/schemas/sync.schema'; export class LogWithHistoryDto extends LogDto { constructor(log: Log) { @@ -8,5 +8,5 @@ export class LogWithHistoryDto extends LogDto { this.history = log.history; } - history: Sync[]; -} \ No newline at end of file + history: Partial[]; +} diff --git a/src/tripnotify/dto/trip-information.dto.ts b/src/tripnotify/dto/trip-information.dto.ts index 4da7126..368b8d4 100644 --- a/src/tripnotify/dto/trip-information.dto.ts +++ b/src/tripnotify/dto/trip-information.dto.ts @@ -1,7 +1,28 @@ -import { LogWithHistoryDto } from "./log-with-history.dto"; -import { Expose } from "class-transformer"; -import { TripMetadataDto } from "./trip-metadata.dto"; -import { TYPE } from "src/logs/entities/type.entity"; +import { LogWithHistoryDto } from './log-with-history.dto'; +import { Expose } from 'class-transformer'; +import { TripMetadataDto } from './trip-metadata.dto'; +import { TYPE } from 'src/logs/entities/type.entity'; + +function finite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function aggregate( + logs: LogWithHistoryDto[], + field: 'dischargedKWh' | 'rechargedKWh' | 'averageSpeed' | 'averageKW', + average = false, +): number | undefined { + // Missing measurements are unknown, not zero. Do not label partial sums as totals. + if (!logs.length || logs.some((log) => !finite(log[field]))) { + return undefined; + } + const sum = logs.reduce( + (total, log) => + total + (field === 'averageKW' ? Math.abs(log[field]) : log[field]), + 0, + ); + return Number((average ? sum / logs.length : sum).toFixed(2)); +} export class TripInformationDto { constructor(logs: LogWithHistoryDto[]) { @@ -12,34 +33,33 @@ export class TripInformationDto { @Expose() get metadata(): TripMetadataDto { - let consumedEnergy = 0; - let rechargedEnergy = 0; - let avgDriveSpeed = 0; - let avgChargeSpeed = 0; - let countChargeLogs = 0; - let countDriveLogs = 0; - - this.logs.forEach((log) => { - consumedEnergy += log.dischargedKWh || 0; - rechargedEnergy += log.rechargedKWh || 0; - - if (log.type === TYPE.CHARGE || (log.type === TYPE.UNKNOWN && log.averageKW)) { - avgChargeSpeed += Math.abs(log.averageKW || 0); - countChargeLogs++; - } else if (log.type === TYPE.DRIVE || (log.type === TYPE.UNKNOWN && log.averageSpeed)) { - avgDriveSpeed += log.averageSpeed || 0; - countDriveLogs++; - } - }); - - avgDriveSpeed = countDriveLogs ? avgDriveSpeed / countDriveLogs : 0; - avgChargeSpeed = countChargeLogs ? avgChargeSpeed / countChargeLogs : 0; + const unclassified = (log: LogWithHistoryDto) => + log.type === TYPE.UNKNOWN && + !finite(log.averageKW) && + !finite(log.averageSpeed); + // Zero power must not override speed evidence for an unclassified drive. + const inferredDrive = (log: LogWithHistoryDto) => + log.type === TYPE.UNKNOWN && + (!finite(log.averageKW) || log.averageKW === 0) && + finite(log.averageSpeed); + const chargeLogs = this.logs.filter( + (log) => + log.type === TYPE.CHARGE || + (log.type === TYPE.UNKNOWN && + finite(log.averageKW) && + !inferredDrive(log)) || + unclassified(log), + ); + const driveLogs = this.logs.filter( + (log) => + log.type === TYPE.DRIVE || inferredDrive(log) || unclassified(log), + ); return new TripMetadataDto( - parseFloat((consumedEnergy || 0).toFixed(2)), - parseFloat((rechargedEnergy || 0).toFixed(2)), - parseFloat((avgDriveSpeed || 0).toFixed(2)), - parseFloat((avgChargeSpeed || 0).toFixed(2)), + aggregate(this.logs, 'dischargedKWh'), + aggregate(this.logs, 'rechargedKWh'), + aggregate(driveLogs, 'averageSpeed', true), + aggregate(chargeLogs, 'averageKW', true), ); } -} \ No newline at end of file +} diff --git a/src/tripnotify/dto/trip-metadata.dto.ts b/src/tripnotify/dto/trip-metadata.dto.ts index cec2cb3..9a1d757 100644 --- a/src/tripnotify/dto/trip-metadata.dto.ts +++ b/src/tripnotify/dto/trip-metadata.dto.ts @@ -1,9 +1,9 @@ export class TripMetadataDto { constructor( - consumedEnergy: number, - rechargedEnergy: number, - avgDriveSpeed: number, - avgChargeSpeed: number, + consumedEnergy?: number, + rechargedEnergy?: number, + avgDriveSpeed?: number, + avgChargeSpeed?: number, ) { this.consumedEnergy = consumedEnergy; this.rechargedEnergy = rechargedEnergy; @@ -11,11 +11,11 @@ export class TripMetadataDto { this.avgChargeSpeed = avgChargeSpeed; } - consumedEnergy: number; + consumedEnergy?: number; - rechargedEnergy: number; + rechargedEnergy?: number; - avgDriveSpeed: number; + avgDriveSpeed?: number; - avgChargeSpeed: number; -} \ No newline at end of file + avgChargeSpeed?: number; +} diff --git a/src/tripnotify/handler/tripnotify.handler.ts b/src/tripnotify/handler/tripnotify.handler.ts index 4480c56..ed4c80b 100644 --- a/src/tripnotify/handler/tripnotify.handler.ts +++ b/src/tripnotify/handler/tripnotify.handler.ts @@ -1,13 +1,15 @@ -import { Injectable } from "@nestjs/common"; -import { OnEvent } from "@nestjs/event-emitter"; -import { LOG_DATA_SYNCED_EVENT } from "src/logs/entities/log.entity"; -import { Log } from "src/logs/schemas/log.schema"; -import { Sync } from "src/logs/schemas/sync.schema"; -import { TripNotifyService } from "../tripnotify.service"; -import { TripNotifyGateway } from "../tripnotify.gateway"; +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { LOG_DATA_SYNCED_EVENT } from 'src/logs/entities/log.entity'; +import { Log } from 'src/logs/schemas/log.schema'; +import { Sync } from 'src/logs/schemas/sync.schema'; +import { TripNotifyService } from '../tripnotify.service'; +import { TripNotifyGateway } from '../tripnotify.gateway'; @Injectable() export class TripNotifyHandler { + private readonly logger = new Logger(TripNotifyHandler.name); + constructor( private readonly tripNotifyGateway: TripNotifyGateway, private readonly tripNotifyService: TripNotifyService, @@ -16,11 +18,21 @@ export class TripNotifyHandler { @OnEvent(LOG_DATA_SYNCED_EVENT) async handleSyncEvent(payload: { log: Log; sync: Sync }) { try { - const activeTrips = await this.tripNotifyService.findActives(payload.log.akey); + const activeTrips = await this.tripNotifyService.findActives( + payload.log.akey, + ); - activeTrips.forEach((trip) => { - this.tripNotifyGateway.handleSyncUpdate(trip.code, payload.log._id, payload.sync); - }); - } catch (_) {} + await Promise.all( + activeTrips.map((trip) => + this.tripNotifyGateway.handleSyncUpdate( + trip.code, + payload.log._id, + payload.sync, + ), + ), + ); + } catch { + this.logger.warn('TripNotify live trip lookup failed'); + } } -} \ No newline at end of file +} diff --git a/src/tripnotify/tripnotify.controller.spec.ts b/src/tripnotify/tripnotify.controller.spec.ts new file mode 100644 index 0000000..cb766ce --- /dev/null +++ b/src/tripnotify/tripnotify.controller.spec.ts @@ -0,0 +1,685 @@ +import { ClassSerializerInterceptor, INestApplication } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Test } from '@nestjs/testing'; +import { randomBytes } from 'crypto'; +import { Connection, createConnection, Model } from 'mongoose'; +import * as request from 'supertest'; +import { AccountService } from '../account/account.service'; +import { Account, AccountSchema } from '../account/schemas/account.schema'; +import { HISTORY_TYPE } from '../logs/entities/history-type.entity'; +import { STATUS } from '../logs/entities/status.entity'; +import { TYPE } from '../logs/entities/type.entity'; +import { LogsService } from '../logs/logs.service'; +import { LastSync } from '../logs/schemas/last-sync.schema'; +import { Log, LogSchema } from '../logs/schemas/log.schema'; +import { PremiumService } from '../premium/premium.service'; +import { Subscription } from '../premium/schemas/subscription.schema'; +import { Voucher } from '../premium/schemas/voucher.schema'; +import { TripType } from './entitites/trip-type.entity'; +import { TripNotStartedException } from './exceptions/trip-not-started.exception'; +import { Trip, TripSchema } from './schemas/trip.schema'; +import { TripNotifyController } from './tripnotify.controller'; +import { TripNotifyService } from './tripnotify.service'; + +describe('TripNotify guest access', () => { + const start = new Date('2030-01-01T12:00:00.000Z'); + const end = new Date('2030-01-01T13:00:00.000Z'); + const during = new Date('2030-01-01T12:30:00.000Z'); + const owner = 'abcdef'; + const batteryEntry = { timestamp: during, socDisplay: 80 }; + const locationEntry = { timestamp: during, latitude: 52.5, longitude: 13.4 }; + const sharingModes = [ + { code: 'expire', accessibleAfterEnd: false }, + { code: 'retain', accessibleAfterEnd: true }, + ]; + const boundaries = [ + { name: 'before start', time: new Date(start.getTime() - 1), status: 403 }, + { name: 'exactly at start', time: start, status: 200 }, + { name: 'during the trip', time: during, status: 200 }, + { name: 'exactly at end', time: end, status: 404 }, + { name: 'after end', time: new Date(end.getTime() + 1), status: 404 }, + ]; + + let app: INestApplication; + let connection: Connection; + let trips: Model; + let accounts: Model; + let logs: Model; + let service: TripNotifyService; + let logsService: LogsService; + + function freezeAt(time: Date) { + // Keep MongoDB and HTTP timers real; control only the application clock. + jest.useFakeTimers({ + now: time, + doNotFake: [ + 'hrtime', + 'nextTick', + 'performance', + 'queueMicrotask', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + ], + }); + } + + beforeAll(async () => { + await ConfigModule.forRoot(); + connection = await createConnection(process.env.DATABASE_URI).asPromise(); + const prefix = `tripnotify_guest_${randomBytes(6).toString('hex')}`; + trips = connection.model(Trip.name, TripSchema, `${prefix}_trips`); + accounts = connection.model( + Account.name, + AccountSchema, + `${prefix}_accounts`, + ); + logs = connection.model(Log.name, LogSchema, `${prefix}_logs`); + + // Only getExpiryDate is used, so purchase/voucher models need no behavior. + const premiumService = new PremiumService( + accounts, + {} as Model, + {} as Model, + ); + service = new TripNotifyService(trips, premiumService); + logsService = new LogsService( + logs, + {} as Model, + premiumService, + new EventEmitter2(), + ); + const module = await Test.createTestingModule({ + controllers: [TripNotifyController], + providers: [ + { provide: TripNotifyService, useValue: service }, + { provide: LogsService, useValue: logsService }, + { provide: PremiumService, useValue: premiumService }, + { + provide: AccountService, + useValue: { + async loginWithToken(akey, dto) { + if (!(await accounts.exists({ akey, token: dto.token }))) { + throw new Error('Unauthorized'); + } + }, + }, + }, + ], + }).compile(); + app = module.createNestApplication(); + app.useGlobalInterceptors(new ClassSerializerInterceptor(new Reflector())); + await app.init(); + }); + + beforeEach(async () => { + await accounts.create({ + akey: owner, + passwordHash: 'unused', + token: '0'.repeat(20), + premiumUntil: new Date(end.getTime() + 86400000), + }); + await trips.create( + sharingModes.map((mode) => ({ + ...mode, + akey: owner, + startDate: start, + endDate: end, + locationEnabled: true, + type: TripType.CHARGE_AND_DRIVE, + })), + ); + await logs.create({ + akey: owner, + startDate: start, + endDate: end, + status: STATUS.FINISHED, + history: [batteryEntry, locationEntry], + }); + }); + + afterEach(async () => { + jest.useRealTimers(); + jest.restoreAllMocks(); + await Promise.all([ + trips.deleteMany({}), + accounts.deleteMany({}), + logs.deleteMany({}), + ]); + }); + + afterAll(async () => { + await app?.close(); + if (connection) { + await Promise.all( + [trips, accounts, logs] + .filter(Boolean) + .map((model) => model.collection.drop()), + ); + await connection.close(); + } + }); + + describe.each(sharingModes)( + 'accessibleAfterEnd: $accessibleAfterEnd', + ({ code, accessibleAfterEnd }) => { + describe.each(['', '/info'])('GET /trips/:code%s', (suffix) => { + it.each(boundaries)('$name', async ({ time, status }) => { + freezeAt(time); + const expectedStatus = + accessibleAfterEnd && time >= end ? 200 : status; + const response = await request(app.getHttpServer()) + .get(`/trips/${code}${suffix}`) + .expect(expectedStatus); + + if (expectedStatus === 200) { + if (suffix) { + expect(response.body.logs).toHaveLength(1); + expect(response.body.logs[0].history).toEqual([ + { ...batteryEntry, timestamp: during.toISOString() }, + { ...locationEntry, timestamp: during.toISOString() }, + ]); + } else { + expect(response.body).toMatchObject({ + code, + start: start.toISOString(), + end: end.toISOString(), + }); + } + } else { + expect(response.body).not.toHaveProperty('logs'); + expect(response.body.message).toBe( + expectedStatus === 404 + ? 'Trip does not exist' + : new TripNotStartedException(start).message, + ); + } + }); + + it.each([ + { name: 'missing', expiry: null }, + { name: 'expired', expiry: new Date(during.getTime() - 1) }, + { name: 'expiring exactly now', expiry: during }, + ])('rejects $name Premium', async ({ expiry }) => { + await accounts.updateOne({ akey: owner }, { premiumUntil: expiry }); + freezeAt(during); + + const response = await request(app.getHttpServer()) + .get(`/trips/${code}${suffix}`) + .expect(403); + + expect(response.body.message).toBe( + 'Trip owner does not have an active premium subscription.', + ); + expect(response.body).not.toHaveProperty('logs'); + }); + }); + }, + ); + + it.each(['', '/info'])( + 'rejects unknown codes on /trips/:code%s', + async (suffix) => { + freezeAt(during); + const response = await request(app.getHttpServer()) + .get(`/trips/absent${suffix}`) + .expect(404); + + expect(response.body.message).toBe('Trip does not exist'); + }, + ); + + it.each([end, new Date(end.getTime() + 1)])( + 'does not read logs or history for an expired /info request at %s', + async (time) => { + const findLogs = jest.spyOn(logsService, 'findLogsWithinDateRange'); + const findHistory = jest.spyOn(logsService, 'findOneWithHistory'); + freezeAt(time); + + const response = await request(app.getHttpServer()) + .get('/trips/expire/info') + .expect(404); + + expect(response.body).not.toHaveProperty('logs'); + expect(findLogs).not.toHaveBeenCalled(); + expect(findHistory).not.toHaveBeenCalled(); + }, + ); + + it.each(sharingModes)( + 'excludes location history when locationEnabled is false for $code', + async ({ code }) => { + await trips.updateOne({ code }, { locationEnabled: false }); + const findHistory = jest.spyOn(logsService, 'findOneWithHistory'); + freezeAt(during); + + const response = await request(app.getHttpServer()) + .get(`/trips/${code}/info`) + .expect(200); + + expect(findHistory).toHaveBeenCalledWith( + owner, + expect.any(String), + HISTORY_TYPE.ALL, + ); + expect(response.body.logs).toHaveLength(1); + expect(response.body.logs[0].history).toEqual([ + { ...batteryEntry, timestamp: during.toISOString() }, + ]); + }, + ); + + describe('owner management list', () => { + it('includes scheduled, active, and expired trips with either access setting', async () => { + freezeAt(during); + await accounts.updateOne({ akey: owner }, { premiumUntil: null }); + await trips.create([ + ...[false, true].flatMap((accessibleAfterEnd, index) => [ + { + code: `old00${index}`, + akey: owner, + startDate: new Date(start.getTime() - 7200000), + endDate: new Date(start.getTime() - 3600000), + accessibleAfterEnd, + locationEnabled: true, + type: TripType.CHARGE_AND_DRIVE, + }, + { + code: `new00${index}`, + akey: owner, + startDate: new Date(end.getTime() + 3600000), + endDate: new Date(end.getTime() + 7200000), + accessibleAfterEnd, + locationEnabled: true, + type: TripType.CHARGE_AND_DRIVE, + }, + ]), + { + code: 'other1', + akey: 'fedcba', + startDate: start, + endDate: end, + accessibleAfterEnd: true, + locationEnabled: true, + type: TripType.CHARGE_AND_DRIVE, + }, + ]); + const response = await request(app.getHttpServer()) + .get(`/trips/${owner}/list`) + .set('Authorization', `${owner} ${'0'.repeat(20)}`) + .expect(200); + expect(response.body.map((trip) => trip.code).sort()).toEqual( + ['expire', 'retain', 'old000', 'old001', 'new000', 'new001'].sort(), + ); + }); + + it('requires valid owner credentials', async () => { + await request(app.getHttpServer()) + .get(`/trips/${owner}/list`) + .expect(401); + await request(app.getHttpServer()) + .get(`/trips/${owner}/list`) + .set('Authorization', `${owner} ${'1'.repeat(20)}`) + .expect(401); + }); + + it('rejects an authenticated request for another owner', async () => { + await request(app.getHttpServer()) + .get('/trips/fedcba/list') + .set('Authorization', `${owner} ${'0'.repeat(20)}`) + .expect(403); + }); + }); + + describe('shared history windows', () => { + it.each([STATUS.RUNNING, STATUS.FINISHED])( + 'includes delayed telemetry ingested after expiry in a %s log', + async (status) => { + await logs.deleteMany({}); + const uploadedAt = new Date(end.getTime() + 1000); + freezeAt(uploadedAt); + const samples = [ + { timestamp: new Date(start.getTime() - 1).toISOString(), cec: 0 }, + { timestamp: start.toISOString(), cec: 100 }, + { timestamp: during.toISOString(), cec: 110 }, + { timestamp: end.toISOString(), cec: 999 }, + ]; + for (const sample of samples) { + await logsService.syncData(owner, { + ...sample, + charging: true, + dcBatteryPower: -20, + } as Parameters[1]); + } + const log = await logs.findOne({ akey: owner }); + expect(log.startDate).toEqual(uploadedAt); + if (status === STATUS.FINISHED) { + await logs.updateOne( + { _id: log._id }, + { status, endDate: uploadedAt }, + ); + } + const before = await logs.findById(log._id).lean(); + + const response = await request(app.getHttpServer()) + .get('/trips/retain/info') + .expect(200); + + expect(response.body.logs).toHaveLength(1); + expect(response.body.logs[0]).toMatchObject({ + id: log._id.toString(), + status: STATUS.FINISHED, + startDate: start.toISOString(), + endDate: end.toISOString(), + rechargedKWh: 10, + averageKW: -20, + history: samples.slice(1, 3).map((sample) => ({ + ...sample, + charging: true, + dcBatteryPower: -20, + })), + }); + expect(response.body.metadata.rechargedEnergy).toBe(10); + expect(await logs.findById(log._id).lean()).toEqual(before); + await request(app.getHttpServer()) + .get('/trips/expire/info') + .expect(404); + }, + ); + + it('uses sample dates instead of recorded session bounds and ignores malformed histories', async () => { + freezeAt(during); + await logs.deleteMany({}); + const log = await logs.create({ + akey: owner, + startDate: new Date(start.getTime() - 120000), + endDate: new Date(start.getTime() - 60000), + status: STATUS.FINISHED, + history: [ + { timestamp: '2030-01-01T13:00:00+01:00', socDisplay: 80 }, + { timestamp: during, socDisplay: 70 }, + { timestamp: 'invalid' }, + { timestamp: null }, + {}, + ], + }); + const unrelated = { + akey: owner, + startDate: start, + endDate: end, + status: STATUS.FINISHED, + }; + await logs.collection.insertMany([ + { ...unrelated, history: null }, + { ...unrelated }, + { ...unrelated, history: [] }, + { ...unrelated, history: [null, {}, { timestamp: 'invalid' }] }, + { + ...unrelated, + history: [ + { timestamp: new Date(start.getTime() - 1) }, + { timestamp: end }, + ], + }, + { ...unrelated, akey: 'fedcba', history: [batteryEntry] }, + ]); + const before = await logs.findById(log._id).lean(); + + const response = await request(app.getHttpServer()) + .get('/trips/expire/info') + .expect(200); + + expect(response.body.logs).toHaveLength(1); + expect(response.body.logs[0]).toMatchObject({ + id: log._id.toString(), + startDate: start.toISOString(), + endDate: during.toISOString(), + startSOC: 80, + endSOC: 70, + history: [ + { timestamp: '2030-01-01T13:00:00+01:00', socDisplay: 80 }, + { timestamp: during.toISOString(), socDisplay: 70 }, + ], + }); + expect(await logs.findById(log._id).lean()).toEqual(before); + }); + + it.each([ + { + name: 'starts before the share', + from: -60000, + to: 1800000, + status: STATUS.FINISHED, + }, + { + name: 'ends after the share', + from: 60000, + to: 3660000, + status: STATUS.FINISHED, + }, + { + name: 'spans the share', + from: -60000, + to: 3660000, + status: STATUS.FINISHED, + }, + { + name: 'is still running from before the share', + from: -60000, + to: null, + status: STATUS.RUNNING, + }, + ])( + 'includes a log that $name without exposing outside samples', + async ({ from, to, status }) => { + freezeAt(new Date(end.getTime() + 1000)); + const logStart = new Date(start.getTime() + from); + const logEnd = to == null ? null : new Date(start.getTime() + to); + const first = new Date( + Math.max(logStart.getTime(), start.getTime()) + 1000, + ); + const last = new Date( + Math.min(logEnd?.getTime() ?? end.getTime(), end.getTime()) - 1000, + ); + const samples = [first, last].map((timestamp) => ({ + timestamp, + socDisplay: 80, + })); + if (from < 0) samples.unshift({ timestamp: logStart, socDisplay: 90 }); + if (to == null || logEnd >= end) + samples.push({ timestamp: end, socDisplay: 70 }); + const log = await logs.create({ + akey: owner, + startDate: logStart, + endDate: logEnd, + status, + history: samples, + }); + const before = await logs.findById(log._id).lean(); + const response = await request(app.getHttpServer()) + .get('/trips/retain/info') + .expect(200); + const shared = response.body.logs.find( + (entry) => entry.id === log._id.toString(), + ); + expect(shared.history.map((entry) => entry.timestamp)).toEqual( + [first, last].map((time) => time.toISOString()), + ); + expect(shared.startDate).toBe( + new Date(Math.max(logStart.getTime(), start.getTime())).toISOString(), + ); + expect(shared.endDate).toBe( + new Date( + Math.min(logEnd?.getTime() ?? end.getTime(), end.getTime()), + ).toISOString(), + ); + expect(shared.status).toBe(STATUS.FINISHED); + expect(await logs.findById(log._id).lean()).toEqual(before); + }, + ); + + it('handles exact boundaries and excludes unrelated or empty histories', async () => { + freezeAt(during); + await logs.deleteMany({}); + const touching = await logs.create({ + akey: owner, + startDate: new Date(start.getTime() - 60000), + endDate: start, + status: STATUS.FINISHED, + history: [{ timestamp: start, socDisplay: 80 }], + }); + await logs.create([ + { + akey: owner, + startDate: end, + endDate: new Date(end.getTime() + 60000), + status: STATUS.FINISHED, + history: [{ timestamp: end, socDisplay: 80 }], + }, + { + akey: owner, + startDate: new Date(start.getTime() - 60000), + endDate: new Date(start.getTime() - 1), + status: STATUS.FINISHED, + history: [{ timestamp: new Date(start.getTime() - 1) }], + }, + { + akey: owner, + startDate: new Date(start.getTime() - 60000), + endDate: new Date(end.getTime() + 60000), + status: STATUS.FINISHED, + history: [{ timestamp: 'invalid' }, { timestamp: end }], + }, + { + akey: 'fedcba', + startDate: start, + endDate: end, + status: STATUS.FINISHED, + history: [batteryEntry], + }, + ]); + const response = await request(app.getHttpServer()) + .get('/trips/expire/info') + .expect(200); + expect(response.body.logs.map((entry) => entry.id)).toEqual([ + touching._id.toString(), + ]); + expect(response.body.logs[0].history[0].timestamp).toBe( + start.toISOString(), + ); + }); + + it('returns windowed totals rather than stored whole-session totals', async () => { + freezeAt(during); + await logs.deleteMany({}); + const log = await logs.create({ + akey: owner, + startDate: new Date(start.getTime() - 60000), + endDate: new Date(end.getTime() + 60000), + status: STATUS.FINISHED, + type: TYPE.CHARGE, + rechargedKWh: 999, + dischargedKWh: 999, + averageKW: 999, + history: [ + { timestamp: new Date(start.getTime() - 1), cec: 0, ced: 0 }, + { timestamp: start, cec: 110, ced: 50, dcBatteryPower: -10 }, + { timestamp: during, cec: 115, ced: 51, dcBatteryPower: -30 }, + { timestamp: end, cec: 999, ced: 999, dcBatteryPower: -999 }, + ], + }); + const response = await request(app.getHttpServer()) + .get('/trips/expire/info') + .expect(200); + expect(response.body.logs[0]).toMatchObject({ + rechargedKWh: 5, + dischargedKWh: 1, + averageKW: -20, + }); + expect(response.body.metadata).toMatchObject({ + rechargedEnergy: 5, + consumedEnergy: 1, + avgChargeSpeed: 20, + }); + expect((await logs.findById(log._id)).rechargedKWh).toBe(999); + + await logs.create({ + akey: owner, + startDate: new Date(start.getTime() - 60000), + endDate: end, + status: STATUS.FINISHED, + type: TYPE.CHARGE, + rechargedKWh: 999, + history: [batteryEntry], + }); + const incomplete = await request(app.getHttpServer()) + .get('/trips/expire/info') + .expect(200); + expect(incomplete.body.metadata).not.toHaveProperty('rechargedEnergy'); + expect(incomplete.body.metadata).not.toHaveProperty('avgChargeSpeed'); + }); + + it('strips spatial fields from mixed battery/location records', async () => { + freezeAt(during); + await trips.updateOne({ code: 'expire' }, { locationEnabled: false }); + await logs.updateMany( + {}, + { + history: [ + { + ...batteryEntry, + latitude: 52.5, + longitude: 13.4, + altitude: 20, + accuracy: 5, + speed: 30, + odo: 1000, + futureSensitiveField: 'private', + }, + ], + }, + ); + const response = await request(app.getHttpServer()) + .get('/trips/expire/info') + .expect(200); + expect(response.body.logs[0].history).toEqual([ + { ...batteryEntry, timestamp: during.toISOString() }, + ]); + }); + }); + + it('uses the captured time when a lookup crosses the trip start', async () => { + freezeAt(new Date(start.getTime() - 1)); + const findOne = trips.findOne.bind(trips); + const lookup = jest + .spyOn(trips, 'findOne') + .mockImplementationOnce((filter) => { + jest.setSystemTime(start); + return findOne(filter); + }); + + await expect(service.findAccessibleByCode('expire')).rejects.toBeInstanceOf( + TripNotStartedException, + ); + expect(lookup).toHaveBeenCalledWith( + expect.objectContaining({ + $and: expect.arrayContaining([ + { + $or: [ + { accessibleAfterEnd: true }, + { + accessibleAfterEnd: false, + endDate: { $gt: new Date(start.getTime() - 1) }, + }, + ], + }, + ]), + }), + ); + }); +}); diff --git a/src/tripnotify/tripnotify.controller.ts b/src/tripnotify/tripnotify.controller.ts index 1ddf6bc..3ea6980 100644 --- a/src/tripnotify/tripnotify.controller.ts +++ b/src/tripnotify/tripnotify.controller.ts @@ -1,21 +1,32 @@ -import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, InternalServerErrorException, NotFoundException, Param, Post, UseGuards } from "@nestjs/common"; -import { ApiSecurity, ApiTags } from "@nestjs/swagger"; -import { TripNotifyService } from "./tripnotify.service"; -import { TripDto } from "./dto/trip.dto"; -import { AuthGuard } from "src/account/account.guard"; -import { Guest } from "src/account/decorators/guest.decorator"; -import { CreateTripDto } from "./dto/create-trip.dto"; -import { TripNotExistsException } from "./exceptions/trip-not-exists.exception"; -import { TripCreationException } from "./exceptions/trip-creation.exception"; -import { LogsService } from "src/logs/logs.service"; -import { HISTORY_TYPE } from "src/logs/entities/history-type.entity"; -import { STATUS } from "src/logs/entities/status.entity"; -import { LogWithHistoryDto } from "./dto/log-with-history.dto"; -import { Premium } from "src/premium/decorators/premium.decorator"; -import { PremiumGuard } from "src/premium/premium.guard"; -import { TripNotStartedException } from "./exceptions/trip-not-started.exception"; -import { TripMissingPremiumException } from "./exceptions/trip-missing-premium.exception"; -import { TripInformationDto } from "./dto/trip-information.dto"; +import { + BadRequestException, + Body, + Controller, + Delete, + ForbiddenException, + Get, + InternalServerErrorException, + NotFoundException, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { TripNotifyService } from './tripnotify.service'; +import { TripDto } from './dto/trip.dto'; +import { AuthGuard } from 'src/account/account.guard'; +import { Guest } from 'src/account/decorators/guest.decorator'; +import { CreateTripDto } from './dto/create-trip.dto'; +import { TripNotExistsException } from './exceptions/trip-not-exists.exception'; +import { TripCreationException } from './exceptions/trip-creation.exception'; +import { LogsService } from 'src/logs/logs.service'; +import { HISTORY_TYPE } from 'src/logs/entities/history-type.entity'; +import { Premium } from 'src/premium/decorators/premium.decorator'; +import { PremiumGuard } from 'src/premium/premium.guard'; +import { TripNotStartedException } from './exceptions/trip-not-started.exception'; +import { TripMissingPremiumException } from './exceptions/trip-missing-premium.exception'; +import { TripInformationDto } from './dto/trip-information.dto'; +import { sharedLog } from './utils/shared-log'; @Controller('trips') @UseGuards(AuthGuard) @@ -28,28 +39,31 @@ export class TripNotifyController { private readonly tripNotifyService: TripNotifyService, ) {} - @Get(':akey/list') - async getActiveTripsByAKey( - @Param('akey') akey: string, - ): Promise { + @Get(':akey/list') + async getActiveTripsByAKey(@Param('akey') akey: string): Promise { try { - return (await this.tripNotifyService.findAccessibleByAkey(akey)).map((trip) => new TripDto(trip)); - } catch(_) { + return (await this.tripNotifyService.findAccessibleByAkey(akey)).map( + (trip) => new TripDto(trip), + ); + } catch { throw new InternalServerErrorException(); } } @Get(':code') @Guest() - async getActiveTripByCode( - @Param('code') code: string, - ): Promise { + async getActiveTripByCode(@Param('code') code: string): Promise { try { - return new TripDto(await this.tripNotifyService.findAccessibleByCode(code)); + return new TripDto( + await this.tripNotifyService.findAccessibleByCode(code), + ); } catch (error) { if (error instanceof TripNotExistsException) { throw new NotFoundException(error.message); - } else if (error instanceof TripNotStartedException || error instanceof TripMissingPremiumException) { + } else if ( + error instanceof TripNotStartedException || + error instanceof TripMissingPremiumException + ) { throw new ForbiddenException(error.message); } @@ -64,7 +78,9 @@ export class TripNotifyController { @Body() createTripDto: CreateTripDto, ): Promise { try { - return new TripDto(await this.tripNotifyService.create(akey, createTripDto)); + return new TripDto( + await this.tripNotifyService.create(akey, createTripDto), + ); } catch (error) { if (error instanceof TripCreationException) { throw new BadRequestException(error.message); @@ -82,21 +98,35 @@ export class TripNotifyController { try { const trip = await this.tripNotifyService.findAccessibleByCode(code); - const logs = await this.logsService.findLogsWithinDateRange(trip.akey, trip.startDate, trip.endDate); - - for(const log of logs) { - log.history = await this.logsService.findOneWithHistory(log.akey, log._id.toString(), trip.locationEnabled ? HISTORY_TYPE.ALL : HISTORY_TYPE.BATTERY_DATA); + const logs = await this.logsService.findLogsWithinDateRange( + trip.akey, + trip.startDate, + trip.endDate, + ); - if (log.startDate < trip.startDate || log.endDate > trip.endDate || log.status === STATUS.RUNNING) { - log.history = log.history.filter((entry) => new Date(entry.timestamp) <= trip.endDate); + const now = new Date(); + const sharedLogs = []; + for (const log of logs) { + // Calculate on the bounded raw samples, then strip private fields. + const history = await this.logsService.findOneWithHistory( + log.akey, + log._id.toString(), + HISTORY_TYPE.ALL, + ); + const view = sharedLog(log, history, trip, now); + if (view) { + sharedLogs.push(view); } } - return new TripInformationDto(logs.map((log) => new LogWithHistoryDto(log))); + return new TripInformationDto(sharedLogs); } catch (error) { if (error instanceof TripNotExistsException) { throw new NotFoundException(error.message); - } else if (error instanceof TripNotStartedException || error instanceof TripMissingPremiumException) { + } else if ( + error instanceof TripNotStartedException || + error instanceof TripMissingPremiumException + ) { throw new ForbiddenException(error.message); } @@ -105,13 +135,10 @@ export class TripNotifyController { } @Delete(':akey/:code') - async deleteTrip( - @Param('akey') akey: string, - @Param('code') code: string, - ) { + async deleteTrip(@Param('akey') akey: string, @Param('code') code: string) { try { return await this.tripNotifyService.deleteTrip(akey, code); - } catch(error) { + } catch (error) { if (error instanceof TripNotExistsException) { throw new NotFoundException(error.message); } @@ -119,4 +146,4 @@ export class TripNotifyController { throw new InternalServerErrorException(); } } -} \ No newline at end of file +} diff --git a/src/tripnotify/tripnotify.gateway.spec.ts b/src/tripnotify/tripnotify.gateway.spec.ts new file mode 100644 index 0000000..96877e8 --- /dev/null +++ b/src/tripnotify/tripnotify.gateway.spec.ts @@ -0,0 +1,541 @@ +import { INestApplication, Logger } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test } from '@nestjs/testing'; +import { randomBytes } from 'crypto'; +import { Connection, createConnection, Model, Types } from 'mongoose'; +import { AddressInfo } from 'net'; +import { Server } from 'socket.io'; +import { io, Socket } from 'socket.io-client'; +import { Account, AccountSchema } from '../account/schemas/account.schema'; +import { Log } from '../logs/schemas/log.schema'; +import { Sync } from '../logs/schemas/sync.schema'; +import { PremiumService } from '../premium/premium.service'; +import { Subscription } from '../premium/schemas/subscription.schema'; +import { Voucher } from '../premium/schemas/voucher.schema'; +import { TripType } from './entitites/trip-type.entity'; +import { TripNotifyHandler } from './handler/tripnotify.handler'; +import { Trip, TripSchema } from './schemas/trip.schema'; +import { TripNotifyGateway } from './tripnotify.gateway'; +import { TripNotifyService } from './tripnotify.service'; + +describe('TripNotify socket privacy', () => { + const start = new Date('2030-01-01T12:00:00.000Z'); + const during = new Date('2030-01-01T12:30:00.000Z'); + const end = new Date('2030-01-01T13:00:00.000Z'); + const owner = 'abcdef'; + const otherOwner = 'fedcba'; + const logId = new Types.ObjectId(); + const modes = [ + { code: 'expire', accessibleAfterEnd: false }, + { code: 'retain', accessibleAfterEnd: true }, + ]; + let app: INestApplication; + let connection: Connection; + let trips: Model; + let accounts: Model; + let service: TripNotifyService; + let handler: TripNotifyHandler; + let server: Server; + let url: string; + let clients: Socket[] = []; + let barrierId = 0; + + function freezeAt(time: Date) { + // Keep MongoDB, Socket.IO, and test timeout timers real. + jest.useFakeTimers({ + now: time, + doNotFake: [ + 'hrtime', + 'nextTick', + 'performance', + 'queueMicrotask', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + ], + }); + } + + async function connect(auth: Record = { code: 'expire' }) { + const client = io(url, { + auth, + autoConnect: false, + forceNew: true, + reconnection: false, + transports: ['websocket'], + timeout: 2000, + }); + clients.push(client); + await new Promise((resolve, reject) => { + client.once('connect', () => resolve()); + client.once('connect_error', reject); + client.connect(); + }); + return client; + } + + function watchUpdates(client: Socket) { + const updates: { + event: string; + payload: { logId: string; data: Record }; + }[] = []; + // Observe every event rather than trusting the normal client's event filter. + client.onAny((event, payload) => { + if (event.startsWith('route-update:')) { + updates.push({ event, payload }); + } + }); + return updates; + } + + async function flush(...sockets: Socket[]) { + // Ordered packets provide a barrier for negative assertions without sleeps. + const event = `test:barrier:${barrierId++}`; + const received = Promise.all( + sockets.map( + (socket) => new Promise((resolve) => socket.once(event, resolve)), + ), + ); + server.emit(event); + await received; + } + + function sync(): Sync { + return { + timestamp: new Date().toISOString(), + socDisplay: 80, + dcBatteryPower: 12, + charging: true, + latitude: 52.5, + longitude: 13.4, + altitude: 50, + accuracy: 5, + speed: 40, + odo: 10000, + } as Sync; + } + + async function publish(data = sync(), akey = owner) { + await handler.handleSyncEvent({ + log: { _id: logId, akey } as Log, + sync: data, + }); + } + + beforeAll(async () => { + await ConfigModule.forRoot(); + connection = await createConnection(process.env.DATABASE_URI).asPromise(); + const prefix = `tripnotify_socket_${randomBytes(6).toString('hex')}`; + trips = connection.model(Trip.name, TripSchema, `${prefix}_trips`); + accounts = connection.model( + Account.name, + AccountSchema, + `${prefix}_accounts`, + ); + const premium = new PremiumService( + accounts, + {} as Model, + {} as Model, + ); + const module = await Test.createTestingModule({ + providers: [ + TripNotifyGateway, + TripNotifyHandler, + TripNotifyService, + { provide: getModelToken(Trip.name), useValue: trips }, + { provide: PremiumService, useValue: premium }, + ], + }).compile(); + service = module.get(TripNotifyService); + handler = module.get(TripNotifyHandler); + const initialized = jest.spyOn(module.get(TripNotifyGateway), 'afterInit'); + app = module.createNestApplication(); + app.useLogger(false); + await app.listen(0, '127.0.0.1'); + server = initialized.mock.calls[0][0]; + initialized.mockRestore(); + url = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; + }); + + beforeEach(async () => { + freezeAt(during); + await accounts.create( + [owner, otherOwner].map((akey, index) => ({ + akey, + passwordHash: 'unused', + token: String(index).repeat(20), + premiumUntil: new Date(end.getTime() + 86400000), + })), + ); + await trips.create([ + ...modes.map((mode) => ({ + ...mode, + akey: owner, + startDate: start, + endDate: end, + locationEnabled: true, + type: TripType.CHARGE_AND_DRIVE, + })), + { + code: 'other1', + akey: otherOwner, + startDate: start, + endDate: end, + accessibleAfterEnd: false, + locationEnabled: true, + type: TripType.CHARGE_AND_DRIVE, + }, + ]); + }); + + afterEach(async () => { + clients.forEach((client) => client.disconnect()); + clients = []; + jest.useRealTimers(); + jest.restoreAllMocks(); + await Promise.all([trips.deleteMany({}), accounts.deleteMany({})]); + }); + + afterAll(async () => { + await app?.close(); + if (connection) { + try { + await Promise.all( + [trips, accounts] + .filter(Boolean) + .map((model) => model.collection.drop()), + ); + } finally { + await connection.close(); + } + } + }); + + it.each([ + {}, + { code: undefined }, + { code: null }, + { code: 123456 }, + { code: { $ne: null } }, + { code: ['expire'] }, + { code: 'toolong' }, + { code: '!!!!!!' }, + { code: 'absent' }, + ])('rejects missing, malformed, or unknown auth: %j', async (auth) => { + await expect(connect(auth)).rejects.toThrow('Unauthorized'); + expect(clients[0].connected).toBe(false); + }); + + describe.each(modes)( + '$code authorization', + ({ code, accessibleAfterEnd }) => { + it('rejects connections before the start', async () => { + freezeAt(new Date(start.getTime() - 1)); + await expect(connect({ code })).rejects.toThrow('Unauthorized'); + }); + + it.each([start, during])('accepts connections at %s', async (time) => { + freezeAt(time); + expect((await connect({ code })).connected).toBe(true); + }); + + it.each([end, new Date(end.getTime() + 1)])( + 'enforces historical-access settings at %s', + async (time) => { + freezeAt(time); + if (accessibleAfterEnd) { + expect((await connect({ code })).connected).toBe(true); + } else { + await expect(connect({ code })).rejects.toThrow('Unauthorized'); + } + }, + ); + + it.each([null, during, new Date(during.getTime() - 1)])( + 'rejects connections without active Premium: %s', + async (premiumUntil) => { + await accounts.updateOne({ akey: owner }, { premiumUntil }); + await expect(connect({ code })).rejects.toThrow('Unauthorized'); + }, + ); + + it.each([null, during, new Date(during.getTime() - 1)])( + 'stops existing connections when Premium becomes %s', + async (premiumUntil) => { + const client = await connect({ code }); + const updates = watchUpdates(client); + await accounts.updateOne({ akey: owner }, { premiumUntil }); + await publish(); + await flush(client); + expect(updates).toEqual([]); + }, + ); + + it('stops delivery after deletion', async () => { + const client = await connect({ code }); + const updates = watchUpdates(client); + await trips.deleteOne({ code }); + await publish(); + await flush(client); + expect(updates).toEqual([]); + }); + + it.each([end, new Date(end.getTime() + 1)])( + 'stops existing connections at %s', + async (time) => { + const client = await connect({ code }); + const updates = watchUpdates(client); + freezeAt(time); + await publish(); + await flush(client); + expect(updates).toEqual([]); + }, + ); + }, + ); + + it('isolates shares and accounts, including catch-all listeners', async () => { + const first = await connect(); + const second = await connect(); + const differentShare = await connect({ code: 'retain' }); + const differentAccount = await connect({ code: 'other1' }); + const observed = [first, second, differentShare, differentAccount].map( + watchUpdates, + ); + const data = sync(); + await trips.updateOne({ code: 'retain' }, { locationEnabled: false }); + await publish(data); + await flush(first, second, differentShare, differentAccount); + + const expected = { + event: 'route-update:expire', + payload: { logId: logId.toString(), data }, + }; + expect(observed[0]).toEqual([expected]); + expect(observed[1]).toEqual([expected]); + expect(observed[2]).toHaveLength(1); + expect(observed[2][0].event).toBe('route-update:retain'); + expect(observed[2][0].payload.data).not.toHaveProperty('latitude'); + expect(observed[3]).toEqual([]); + + await publish(data, otherOwner); + await flush(first, second, differentShare, differentAccount); + expect(observed[0]).toHaveLength(1); + expect(observed[1]).toHaveLength(1); + expect(observed[2]).toHaveLength(1); + expect(observed[3]).toEqual([ + { ...expected, event: 'route-update:other1' }, + ]); + }); + + it('filters mixed telemetry without mutating the input or another share', async () => { + const client = await connect(); + const updates = watchUpdates(client); + await trips.updateOne({ code: 'expire' }, { locationEnabled: false }); + const data = Object.freeze({ + ...sync(), + socBMS: 79, + soh: 98, + slowChargePort: false, + normalChargePort: true, + rapidChargePort: false, + auxBatteryVoltage: 12, + dcBatteryVoltage: 400, + dcBatteryCurrent: 30, + cec: 100, + ced: 90, + batteryMinTemperature: 20, + batteryMaxTemperature: 25, + batteryInletTemperature: 18, + outsideTemperature: 15, + futureSensitiveField: 'must not be forwarded', + }); + await publish(data); + await flush(client); + + const { + latitude, + longitude, + altitude, + accuracy, + speed, + odo, + futureSensitiveField, + ...batteryData + } = data; + expect(updates).toEqual([ + { + event: 'route-update:expire', + payload: { logId: logId.toString(), data: batteryData }, + }, + ]); + expect(data).toMatchObject({ + latitude, + longitude, + altitude, + accuracy, + speed, + odo, + futureSensitiveField, + }); + }); + + it('applies location permission changes to existing connections', async () => { + const client = await connect(); + const updates = watchUpdates(client); + const data = sync(); + await publish(data); + await flush(client); + expect(updates[0].payload.data.latitude).toBe(data.latitude); + + await trips.updateOne({ code: 'expire' }, { locationEnabled: false }); + await publish(data); + await flush(client); + expect(updates[1].payload.data).not.toHaveProperty('latitude'); + + await trips.updateOne({ code: 'expire' }, { locationEnabled: true }); + await publish(data); + await flush(client); + expect(updates[2].payload.data.latitude).toBe(data.latitude); + }); + + it('starts live delivery exactly at the start', async () => { + freezeAt(start); + const client = await connect(); + const updates = watchUpdates(client); + await publish(); + await flush(client); + expect(updates).toHaveLength(1); + }); + + it.each([ + { + name: 'before the share', + timestamp: new Date(start.getTime() - 1).toISOString(), + delivered: false, + }, + { name: 'at the start', timestamp: start.toISOString(), delivered: true }, + { + name: 'inside the share', + timestamp: during.toISOString(), + delivered: true, + }, + { + name: 'just before the end', + timestamp: new Date(end.getTime() - 1).toISOString(), + delivered: true, + }, + { name: 'at the end', timestamp: end.toISOString(), delivered: false }, + { + name: 'after the share', + timestamp: new Date(end.getTime() + 1).toISOString(), + delivered: false, + }, + { name: 'invalid', timestamp: 'invalid', delivered: false }, + { name: 'empty', timestamp: '', delivered: false }, + { name: 'missing', timestamp: undefined, delivered: false }, + { name: 'null', timestamp: null, delivered: false }, + ])( + 'checks sample time when the timestamp is $name', + async ({ timestamp, delivered }) => { + const client = await connect(); + const updates = watchUpdates(client); + const data = { ...sync(), timestamp }; + + await publish(data); + await flush(client); + + expect(updates).toEqual( + delivered + ? [ + { + event: 'route-update:expire', + payload: { logId: logId.toString(), data }, + }, + ] + : [], + ); + }, + ); + + it('rechecks deletion after active trips have been discovered', async () => { + const client = await connect(); + const updates = watchUpdates(client); + const findActives = service.findActives.bind(service); + jest.spyOn(service, 'findActives').mockImplementationOnce(async (akey) => { + const found = await findActives(akey); + await trips.deleteOne({ code: 'expire' }); + return found; + }); + await publish(); + await flush(client); + expect(updates).toEqual([]); + }); + + it('uses current location permissions rather than the discovered trip snapshot', async () => { + const client = await connect(); + const updates = watchUpdates(client); + const findActives = service.findActives.bind(service); + jest.spyOn(service, 'findActives').mockImplementationOnce(async (akey) => { + const found = await findActives(akey); + await trips.updateOne({ code: 'expire' }, { locationEnabled: false }); + return found; + }); + await publish(); + await flush(client); + expect(updates).toHaveLength(1); + expect(updates[0].payload.data).not.toHaveProperty('latitude'); + }); + + it('rechecks the time after the authorization query completes', async () => { + const client = await connect({ code: 'retain' }); + const updates = watchUpdates(client); + const findAccessible = service.findAccessibleByCode.bind(service); + jest + .spyOn(service, 'findAccessibleByCode') + .mockImplementation(async (code) => { + const trip = await findAccessible(code); + freezeAt(end); + return trip; + }); + await publish(); + await flush(client); + expect(updates).toEqual([]); + }); + + it('rejects connections when the authorization database is unavailable', async () => { + const warning = jest.spyOn(Logger.prototype, 'warn'); + jest + .spyOn(trips, 'findOne') + .mockRejectedValueOnce(new Error('private error')); + await expect(connect()).rejects.toThrow(/^Unauthorized$/); + expect(warning).toHaveBeenCalledWith( + 'TripNotify socket authorization failed', + ); + expect(JSON.stringify(warning.mock.calls)).not.toContain('private error'); + }); + + it('suppresses updates when the authorization database is unavailable', async () => { + const client = await connect(); + const updates = watchUpdates(client); + jest.spyOn(trips, 'findOne').mockRejectedValue(new Error('private error')); + await publish(); + await flush(client); + expect(updates).toEqual([]); + }); + + it('suppresses updates when the initial trip lookup fails', async () => { + const client = await connect(); + const updates = watchUpdates(client); + jest + .spyOn(service, 'findActives') + .mockRejectedValueOnce(new Error('private error')); + await publish(); + await flush(client); + expect(updates).toEqual([]); + }); +}); diff --git a/src/tripnotify/tripnotify.gateway.ts b/src/tripnotify/tripnotify.gateway.ts index bed700d..11c5535 100644 --- a/src/tripnotify/tripnotify.gateway.ts +++ b/src/tripnotify/tripnotify.gateway.ts @@ -1,31 +1,98 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway } from "@nestjs/websockets"; -import { Types } from "mongoose"; -import { Server, Socket } from "socket.io"; -import { Sync } from "src/logs/schemas/sync.schema"; +import { Injectable, Logger } from '@nestjs/common'; +import { + OnGatewayConnection, + OnGatewayDisconnect, + OnGatewayInit, + WebSocketGateway, +} from '@nestjs/websockets'; +import { Types } from 'mongoose'; +import { Server, Socket } from 'socket.io'; +import { Sync } from 'src/logs/schemas/sync.schema'; +import { TRIP_LENGTH } from './schemas/trip.schema'; +import { TripNotifyService } from './tripnotify.service'; +import { sharedSync } from './utils/shared-sync'; @WebSocketGateway({ cors: { origin: '*' }, }) @Injectable() -export class TripNotifyGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { +export class TripNotifyGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ private server: Server; - private logger = new Logger(TripNotifyGateway.name); + private readonly logger = new Logger(TripNotifyGateway.name); + + constructor(private readonly tripNotifyService: TripNotifyService) {} afterInit(server: Server) { - this.logger.log('Initialized TripNotify'); this.server = server; + server.use(async (client, next) => { + const code: unknown = client.handshake.auth?.code; + + try { + if ( + typeof code !== 'string' || + !new RegExp(`^[A-Za-z0-9]{${TRIP_LENGTH}}$`).test(code) + ) { + throw new Error('Unauthorized'); + } + + await this.tripNotifyService.findAccessibleByCode(code); + client.data.tripCode = code; + next(); + } catch { + this.logger.warn('TripNotify socket authorization failed'); + next(new Error('Unauthorized')); + } + }); + this.logger.log('Initialized TripNotify'); } - handleConnection(client: Socket) { - this.logger.debug(`Client connected: ${client.id}`); + async handleConnection(client: Socket) { + try { + await client.join(this.room(client.data.tripCode)); + } catch { + this.logger.warn('TripNotify socket room assignment failed'); + client.disconnect(true); + } } handleDisconnect(client: Socket) { this.logger.debug(`Client disconnected: ${client.id}`); } - handleSyncUpdate(code: string, logId: Types.ObjectId, data: Sync) { - this.server.emit(`route-update:${code}`, { logId, data }); + async handleSyncUpdate(code: string, logId: Types.ObjectId, data: Sync) { + try { + // Recheck mutable permissions; a connection does not grant permanent access. + const trip = await this.tripNotifyService.findAccessibleByCode(code); + const now = new Date(); + + // Historical access after expiry never extends the live sharing window. + if (trip.startDate > now || trip.endDate <= now) { + return; + } + + // Upload time does not authorize telemetry recorded outside the share. + const timestamp = + data.timestamp == null ? NaN : new Date(data.timestamp).getTime(); + if ( + !Number.isFinite(timestamp) || + timestamp < trip.startDate.getTime() || + timestamp >= trip.endDate.getTime() + ) { + return; + } + + this.server.to(this.room(code)).emit(`route-update:${code}`, { + logId, + data: sharedSync(data, trip.locationEnabled), + }); + } catch { + this.logger.warn('TripNotify update authorization failed'); + } + } + + private room(code: string): string { + return `tripnotify:${code}`; } -} \ No newline at end of file +} diff --git a/src/tripnotify/tripnotify.service.ts b/src/tripnotify/tripnotify.service.ts index a4e8aa9..70b2137 100644 --- a/src/tripnotify/tripnotify.service.ts +++ b/src/tripnotify/tripnotify.service.ts @@ -18,23 +18,12 @@ export class TripNotifyService { ) { } async findAccessibleByAkey(akey: string): Promise { - return await this.tripModel.find({ - $and: [ - { akey }, - { - $or: [ - { accessibleAfterEnd: true }, - { - accessibleAfterEnd: false, - endDate: { $lte: new Date() }, - }, - ], - }, - ], - }); + // Owners manage every share; guest access rules are enforced separately. + return await this.tripModel.find({ akey }); } async findAccessibleByCode(code: string): Promise { + const now = new Date(); const trip = await this.tripModel.findOne({ $and: [ { code }, @@ -43,7 +32,7 @@ export class TripNotifyService { { accessibleAfterEnd: true }, { accessibleAfterEnd: false, - endDate: { $lte: new Date() }, + endDate: { $gt: now }, }, ], }, @@ -54,7 +43,7 @@ export class TripNotifyService { throw new TripNotExistsException(); } - if (trip.startDate > new Date()) { + if (trip.startDate > now) { throw new TripNotStartedException(trip.startDate); } @@ -105,7 +94,7 @@ export class TripNotifyService { $lte: now, }, endDate: { - $gte: now, + $gt: now, } }).exec(); } @@ -119,4 +108,4 @@ export class TripNotifyService { await trip.deleteOne(); } -} \ No newline at end of file +} diff --git a/src/tripnotify/utils/shared-log.spec.ts b/src/tripnotify/utils/shared-log.spec.ts new file mode 100644 index 0000000..5ec2540 --- /dev/null +++ b/src/tripnotify/utils/shared-log.spec.ts @@ -0,0 +1,384 @@ +import { Types } from 'mongoose'; +import { STATUS } from '../../logs/entities/status.entity'; +import { TYPE } from '../../logs/entities/type.entity'; +import { Log } from '../../logs/schemas/log.schema'; +import { Sync } from '../../logs/schemas/sync.schema'; +import { TripInformationDto } from '../dto/trip-information.dto'; +import { Trip } from '../schemas/trip.schema'; +import { sharedLog } from './shared-log'; +import { LogWithHistoryDto } from '../dto/log-with-history.dto'; + +describe('shared log projection', () => { + const start = new Date('2030-01-01T12:00:00Z'); + const end = new Date('2030-01-01T13:00:00Z'); + const during = new Date('2030-01-01T12:30:00Z'); + const trip = { + startDate: start, + endDate: end, + locationEnabled: true, + } as Trip; + function source(overrides: Partial = {}): Log { + return { + _id: new Types.ObjectId(), + akey: 'abcdef', + startDate: new Date('2030-01-01T11:00:00Z'), + endDate: new Date('2030-01-01T14:00:00Z'), + updatedAt: new Date('2030-01-01T14:00:00Z'), + status: STATUS.FINISHED, + type: TYPE.DRIVE, + startSOC: 99, + currentSOC: 99, + endSOC: 99, + startODO: 999, + endODO: 999, + startCEC: 999, + endCEC: 999, + startCED: 999, + endCED: 999, + rechargedKWh: 999, + dischargedKWh: 999, + distance: 999, + averageKW: 999, + averageSpeed: 999, + history: [], + ...overrides, + } as Log; + } + function sample(timestamp: Date | string, fields: Partial = {}): Sync { + return { + timestamp: + typeof timestamp === 'string' ? timestamp : timestamp.toISOString(), + ...fields, + } as Sync; + } + + it('sorts and clips samples, recalculates all measurements, and leaves the input unchanged', () => { + const first = sample(start, { + socDisplay: 0, + socBMS: 99, + cec: 110, + ced: 510, + odo: 100, + dcBatteryPower: 0, + speed: 0, + latitude: 0, + longitude: 0, + }); + const middle = sample('2030-01-01T12:10:00Z', { + socDisplay: 80, + cec: 112, + ced: 515, + odo: 101, + dcBatteryPower: -10, + speed: 0, + latitude: 0, + longitude: 0, + }); + const last = sample('2030-01-01T12:20:00Z', { + socBMS: 75, + cec: 115, + ced: 520, + odo: 102, + dcBatteryPower: 10, + speed: 20, + latitude: 0, + longitude: 0.01, + }); + const history = [ + sample(end, { cec: 999 }), + last, + sample('invalid'), + first, + middle, + sample('2030-01-01T11:59:59Z', { ced: 0 }), + ]; + history.forEach(Object.freeze); + Object.freeze(history); + const log = Object.freeze(source({ history })); + const view = sharedLog(log, history, trip, end); + expect(view.history).toEqual([first, middle, last]); + expect(view).toMatchObject({ + startDate: start, + endDate: end, + updatedAt: new Date(last.timestamp), + startSOC: 0, + currentSOC: 75, + endSOC: 75, + startODO: 100, + endODO: 102, + startCEC: 110, + endCEC: 115, + startCED: 510, + endCED: 520, + rechargedKWh: 5, + dischargedKWh: 10, + averageKW: 0, + distance: 1.11, + }); + expect(view.averageSpeed).toBeCloseTo(20 / 3); + expect(log.rechargedKWh).toBe(999); + expect(log.startDate).toEqual(new Date('2030-01-01T11:00:00Z')); + expect(history[0].timestamp).toBe(end.toISOString()); + }); + + it('preserves stored measurements for a fully contained session', () => { + const history = [sample(during, { socDisplay: 80 })]; + const log = source({ + startDate: new Date(start.getTime() + 1000), + endDate: new Date(end.getTime() - 1000), + history, + }); + expect(sharedLog(log, history, trip, end)).toMatchObject({ + rechargedKWh: 999, + distance: 999, + averageKW: 999, + }); + }); + + it.each([ + { + startDate: new Date('2030-01-01T14:00:00Z'), + endDate: new Date('2030-01-01T15:00:00Z'), + }, + { + startDate: new Date('2030-01-01T10:00:00Z'), + endDate: new Date('2030-01-01T11:00:00Z'), + }, + ])( + 'keeps shared dates consistent with sample times despite recorded dates %j', + (dates) => { + const first = new Date('2030-01-01T12:10:00Z'); + const last = new Date('2030-01-01T12:20:00Z'); + const history = [sample(first, { cec: 10 }), sample(last, { cec: 12 })]; + const log = Object.freeze(source(dates)); + const view = sharedLog(log, history, trip, end); + + expect(view.startDate.getTime()).toBeLessThanOrEqual(first.getTime()); + expect(view.startDate.getTime()).toBeGreaterThanOrEqual(start.getTime()); + expect(view.endDate.getTime()).toBeGreaterThanOrEqual(last.getTime()); + expect(view.endDate.getTime()).toBeLessThanOrEqual(end.getTime()); + expect(view.rechargedKWh).toBe(2); + expect(log.startDate).toEqual(dates.startDate); + expect(log.endDate).toEqual(dates.endDate); + }, + ); + + it('does not turn insufficient data into zero or copy full-session values', () => { + const view = sharedLog( + source(), + [sample(during, { socDisplay: 0, cec: 0, ced: 0 })], + trip, + end, + ); + expect(view.startSOC).toBe(0); + for (const field of [ + 'averageKW', + 'averageSpeed', + 'distance', + 'rechargedKWh', + 'dischargedKWh', + 'startODO', + 'endODO', + ]) { + expect(view[field]).toBeUndefined(); + } + expect( + JSON.parse(JSON.stringify(new TripInformationDto([view]).metadata)), + ).toEqual({}); + }); + + it('keeps real zero measurements and zero deltas', () => { + const history = [start, during].map((time) => + sample(time, { cec: 0, ced: 0, dcBatteryPower: 0, speed: 0 }), + ); + const view = sharedLog(source(), history, trip, end); + expect(view).toMatchObject({ + rechargedKWh: 0, + dischargedKWh: 0, + averageKW: 0, + averageSpeed: 0, + }); + expect(new TripInformationDto([view]).metadata).toMatchObject({ + rechargedEnergy: 0, + consumedEnergy: 0, + avgDriveSpeed: 0, + }); + view.type = TYPE.CHARGE; + expect(new TripInformationDto([view]).metadata.avgChargeSpeed).toBe(0); + }); + + it.each([ + [10, 5, 12], + [-1, 5, 12], + ])( + 'does not report energy across invalid/reset counters: %j', + (...values: number[]) => { + const history = values.map((cec, index) => + sample(new Date(start.getTime() + index * 1000), { cec }), + ); + expect( + sharedLog(source(), history, trip, end).rechargedKWh, + ).toBeUndefined(); + }, + ); + + it('needs distinct timestamps for a counter delta', () => { + const history = [sample(start, { cec: 10 }), sample(start, { cec: 20 })]; + expect( + sharedLog(source(), history, trip, end).rechargedKWh, + ).toBeUndefined(); + }); + + it('ignores nonfinite readings and invalid coordinates', () => { + const history = [ + sample(start, { dcBatteryPower: NaN, latitude: 100, longitude: 0 }), + sample(during, { dcBatteryPower: Infinity, latitude: 0, longitude: 0 }), + ]; + const view = sharedLog(source(), history, trip, end); + expect(view.averageKW).toBeUndefined(); + expect(view.distance).toBeUndefined(); + }); + + it('omits legacy logs with missing or null histories and entries', () => { + for (const history of [undefined, null, [], [null]]) { + expect(sharedLog(source(), history, trip, end)).toBeNull(); + } + }); + + it('omits logs without any valid samples inside the window', () => { + expect( + sharedLog( + source(), + [sample('invalid'), sample(end), sample(new Date(start.getTime() - 1))], + trip, + end, + ), + ).toBeNull(); + }); + + it('shows a running session as finished once the share ends', () => { + const log = source({ endDate: null, status: STATUS.RUNNING }); + const history = [sample(during, { socDisplay: 80 })]; + expect(sharedLog(log, history, trip, during)).toMatchObject({ + status: STATUS.RUNNING, + endDate: null, + }); + expect(sharedLog(log, history, trip, end)).toMatchObject({ + status: STATUS.FINISHED, + endDate: end, + }); + expect(log.status).toBe(STATUS.RUNNING); + expect(log.endDate).toBeNull(); + }); + + it('sanitizes mixed telemetry and drops GPS-only history when location sharing is off', () => { + const history = [ + sample(start, { latitude: 1, longitude: 2 }), + sample(during, { + socDisplay: 0, + charging: false, + latitude: 1, + longitude: 2, + altitude: 10, + speed: 20, + odo: 123, + }), + ]; + const view = sharedLog( + source(), + history, + { ...trip, locationEnabled: false } as Trip, + end, + ); + expect(view.history).toEqual([ + sample(during, { socDisplay: 0, charging: false }), + ]); + expect(history[1].latitude).toBe(1); + }); + + it('does not silently exclude an unclassified session from averages', () => { + const history = [sample(start, { dcBatteryPower: 10, speed: 20 })]; + const charge = sharedLog(source({ type: TYPE.CHARGE }), history, trip, end); + const drive = sharedLog(source({ type: TYPE.DRIVE }), history, trip, end); + const unknown = sharedLog( + source({ type: TYPE.UNKNOWN }), + [sample(start, { socDisplay: 80 })], + trip, + end, + ); + const totals = new TripInformationDto([charge, drive, unknown]).metadata; + expect(totals.avgChargeSpeed).toBeUndefined(); + expect(totals.avgDriveSpeed).toBeUndefined(); + }); + + it.each([0, 60])( + 'classifies unknown sessions with zero power and speed %s as driving', + (averageSpeed) => { + const log = new LogWithHistoryDto( + source({ + type: TYPE.UNKNOWN, + averageKW: 0, + averageSpeed, + }), + ); + const totals = new TripInformationDto([log]).metadata; + expect(totals.avgDriveSpeed).toBe(averageSpeed); + expect(totals.avgChargeSpeed).toBeUndefined(); + }, + ); + + it.each([ + { + type: TYPE.CHARGE, + averageKW: 0, + averageSpeed: 60, + expected: { avgChargeSpeed: 0, avgDriveSpeed: undefined }, + }, + { + type: TYPE.DRIVE, + averageKW: 10, + averageSpeed: 0, + expected: { avgChargeSpeed: undefined, avgDriveSpeed: 0 }, + }, + { + type: TYPE.UNKNOWN, + averageKW: -10, + averageSpeed: 60, + expected: { avgChargeSpeed: 10, avgDriveSpeed: undefined }, + }, + { + type: TYPE.UNKNOWN, + averageKW: 0, + averageSpeed: undefined, + expected: { avgChargeSpeed: 0, avgDriveSpeed: undefined }, + }, + ])( + 'preserves classification of $type with power $averageKW and speed $averageSpeed', + ({ expected, ...fields }) => { + const log = new LogWithHistoryDto(source(fields)); + expect(new TripInformationDto([log]).metadata).toMatchObject(expected); + }, + ); + + it('does not present an incomplete sum or mean as a complete trip total', () => { + const known = sharedLog( + source({ type: TYPE.CHARGE }), + [ + sample(start, { cec: 10, ced: 20, dcBatteryPower: 10 }), + sample(during, { cec: 15, ced: 21, dcBatteryPower: 20 }), + ], + trip, + end, + ); + const unknown = sharedLog( + source({ type: TYPE.CHARGE }), + [sample(start, { ced: 30 }), sample(during, { ced: 31 })], + trip, + end, + ); + const totals = new TripInformationDto([known, unknown]).metadata; + expect(totals.consumedEnergy).toBe(2); + expect(totals.rechargedEnergy).toBeUndefined(); + expect(totals.avgChargeSpeed).toBeUndefined(); + }); +}); diff --git a/src/tripnotify/utils/shared-log.ts b/src/tripnotify/utils/shared-log.ts new file mode 100644 index 0000000..6c71f15 --- /dev/null +++ b/src/tripnotify/utils/shared-log.ts @@ -0,0 +1,147 @@ +import { distance } from '@turf/turf'; +import { STATUS } from '../../logs/entities/status.entity'; +import { Log } from '../../logs/schemas/log.schema'; +import { Sync } from '../../logs/schemas/sync.schema'; +import { LogWithHistoryDto } from '../dto/log-with-history.dto'; +import { Trip } from '../schemas/trip.schema'; +import { hasBatteryData, sharedSync } from './shared-sync'; + +function finite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function readings(history: Sync[], field: keyof Sync): number[] { + return history.map((entry) => entry[field]).filter(finite); +} + +function mean(values: number[]): number | undefined { + return values.length + ? values.reduce((sum, value) => sum + value, 0) / values.length + : undefined; +} + +function counterDelta( + history: Sync[], + field: 'cec' | 'ced', +): number | undefined { + const samples = history.filter((entry) => finite(entry[field])); + const values = readings(samples, field); + if ( + values.length < 2 || + new Date(samples[0].timestamp).getTime() >= + new Date(samples[samples.length - 1].timestamp).getTime() || + values.some( + (value, index) => value < 0 || (index > 0 && value < values[index - 1]), + ) + ) { + return undefined; + } + return Number((values[values.length - 1] - values[0]).toFixed(1)); +} + +function sharedDistance(history: Sync[]): number | undefined { + const coordinates = history.filter( + (entry) => + finite(entry.latitude) && + finite(entry.longitude) && + Math.abs(entry.latitude) <= 90 && + Math.abs(entry.longitude) <= 180, + ); + if (coordinates.length < 2) { + return undefined; + } + let total = 0; + for (let index = 1; index < coordinates.length; index++) { + const previous = coordinates[index - 1]; + const current = coordinates[index]; + total += distance( + [previous.longitude, previous.latitude], + [current.longitude, current.latitude], + ); + } + return Number(total.toFixed(2)); +} + +function recalculate(view: LogWithHistoryDto, history: Sync[]) { + const soc = history + .map((entry) => + finite(entry.socDisplay) ? entry.socDisplay : entry.socBMS, + ) + .filter(finite); + view.startSOC = soc[0]; + view.currentSOC = soc[soc.length - 1]; + view.endSOC = view.status === STATUS.RUNNING ? undefined : view.currentSOC; + + for (const [field, startField, endField] of [ + ['odo', 'startODO', 'endODO'], + ['cec', 'startCEC', 'endCEC'], + ['ced', 'startCED', 'endCED'], + ] as const) { + const values = readings(history, field); + view[startField] = values[0]; + view[endField] = values[values.length - 1]; + } + + view.averageKW = mean(readings(history, 'dcBatteryPower')); + view.averageSpeed = mean(readings(history, 'speed')); + view.rechargedKWh = counterDelta(history, 'cec'); + view.dischargedKWh = counterDelta(history, 'ced'); + view.distance = sharedDistance(history); + view.updatedAt = new Date(history[history.length - 1].timestamp); +} + +/** Build a bounded response without changing the source document or history. */ +export function sharedLog( + log: Log, + history: Sync[], + trip: Trip, + now: Date, +): LogWithHistoryDto | null { + const start = trip.startDate.getTime(); + const end = trip.endDate.getTime(); + const raw = history ?? []; + const samples = raw + .filter((entry) => entry != null) + .map((entry) => ({ + entry, + time: entry.timestamp == null ? NaN : new Date(entry.timestamp).getTime(), + })) + .filter(({ time }) => Number.isFinite(time) && time >= start && time < end) + .sort((left, right) => left.time - right.time) + .map(({ entry }) => entry); + if (!samples.length) { + return null; + } + + const view = new LogWithHistoryDto(log); + const partial = + log.startDate.getTime() < start || + !log.endDate || + log.endDate.getTime() >= end || + samples.length !== raw.length; + // Server-created session dates can disagree with delayed sample times. + const firstSample = new Date(samples[0].timestamp).getTime(); + const lastSample = new Date(samples[samples.length - 1].timestamp).getTime(); + view.startDate = new Date( + Math.max(Math.min(log.startDate.getTime(), firstSample), start), + ); + if (log.endDate) { + view.endDate = new Date( + Math.min(Math.max(log.endDate.getTime(), lastSample), end), + ); + } else if (now.getTime() >= end) { + view.endDate = new Date(end); + view.status = STATUS.FINISHED; + } + if (log.endDate?.getTime() >= end && now.getTime() >= end) { + view.status = STATUS.FINISHED; + } + + if (partial) { + recalculate(view, samples); + } + view.history = samples + .filter((entry) => trip.locationEnabled || hasBatteryData(entry)) + .map((entry) => sharedSync(entry, trip.locationEnabled)); + return view; +} diff --git a/src/tripnotify/utils/shared-sync.ts b/src/tripnotify/utils/shared-sync.ts new file mode 100644 index 0000000..49ee805 --- /dev/null +++ b/src/tripnotify/utils/shared-sync.ts @@ -0,0 +1,46 @@ +import { Sync } from '../../logs/schemas/sync.schema'; + +// Explicit fields keep future telemetry additions private by default. +const BATTERY_DATA_FIELDS: ReadonlyArray = [ + 'timestamp', + 'charging', + 'socDisplay', + 'socBMS', + 'soh', + 'slowChargePort', + 'normalChargePort', + 'rapidChargePort', + 'auxBatteryVoltage', + 'dcBatteryVoltage', + 'dcBatteryCurrent', + 'dcBatteryPower', + 'cec', + 'ced', + 'batteryMinTemperature', + 'batteryMaxTemperature', + 'batteryInletTemperature', + 'outsideTemperature', +]; + +export function hasBatteryData(data: Sync): boolean { + return BATTERY_DATA_FIELDS.some( + (field) => field !== 'timestamp' && data[field] != null, + ); +} + +export function sharedSync( + data: Sync, + locationEnabled: boolean, +): Partial { + if (locationEnabled) { + return { ...data }; + } + + const result: Partial = {}; + for (const field of BATTERY_DATA_FIELDS) { + if (data[field] !== undefined) { + Object.assign(result, { [field]: data[field] }); + } + } + return result; +}