From bca113f0d1324e22a1715fc8e5f3d93e9705d69b Mon Sep 17 00:00:00 2001 From: ibraheembello Date: Wed, 10 Jun 2026 23:04:30 +0100 Subject: [PATCH 1/2] feat(admin/logs): capture request user agent on log entries Add a nullable user_agent column to admin_logs and capture the request User-Agent header in LogService at write time (the request object is recycled before the deferred insert, so it must be read synchronously). Stored raw and capped at 512 chars; the admin logs read side parses it into a device label. Non-HTTP actions and absent headers store null. --- src/common/services/log.service.ts | 16 ++++++++++++ src/common/services/tests/log.service.spec.ts | 25 +++++++++++++++++++ .../1781308800000-AddUserAgentToAdminLogs.ts | 21 ++++++++++++++++ .../admin/logs/entities/admin-log.entity.ts | 4 +++ 4 files changed, 66 insertions(+) create mode 100644 src/database/migrations/1781308800000-AddUserAgentToAdminLogs.ts diff --git a/src/common/services/log.service.ts b/src/common/services/log.service.ts index f4f9b7a4..eb75b1c8 100644 --- a/src/common/services/log.service.ts +++ b/src/common/services/log.service.ts @@ -14,6 +14,8 @@ const MAX_STRING_LENGTH = 500; const MAX_SCRUB_DEPTH = 8; /** ip_address column is varchar(45) — a full IPv6 textual address. */ const MAX_IP_LENGTH = 45; +/** user_agent column is varchar(512); truncate anything longer before persisting. */ +const MAX_USER_AGENT_LENGTH = 512; /** * Shared audit-trail writer (BE-ADM-609). Persists admin_logs rows for the @@ -44,6 +46,7 @@ export class LogService { // Capture request-derived data synchronously: the request object may be // recycled by the framework before the deferred insert runs. const ipAddress = this.extractIpAddress(req); + const userAgent = this.extractUserAgent(req); const scrubbedMetadata = metadata ? this.scrubObject(metadata, 0) : {}; setImmediate(() => { @@ -55,6 +58,7 @@ export class LogService { action_type: actionType, description, ip_address: ipAddress, + user_agent: userAgent, status, metadata: scrubbedMetadata, }); @@ -84,6 +88,18 @@ export class LogService { return clientIp ? clientIp.slice(0, MAX_IP_LENGTH) : null; } + /** Captures the raw User-Agent header; the read side parses it into a device label. */ + private extractUserAgent(req: Request | null): string | null { + if (!req) { + return null; + } + + // The 'user-agent' header is a single-value header (string | undefined). + const userAgent = req.headers?.['user-agent']; + + return userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null; + } + /** FR-4: redact sensitive keys and truncate oversized strings, recursively. */ private scrubObject(value: Record, depth: number): Record { const scrubbed: Record = {}; diff --git a/src/common/services/tests/log.service.spec.ts b/src/common/services/tests/log.service.spec.ts index 252d75bf..3a871033 100644 --- a/src/common/services/tests/log.service.spec.ts +++ b/src/common/services/tests/log.service.spec.ts @@ -56,11 +56,36 @@ describe('LogService', () => { action_type: AdminLogActionType.LOGIN, description: 'User logged in', ip_address: '127.0.0.1', + user_agent: null, status: AdminLogStatus.SUCCESS, metadata: {}, }); }); + it('captures the User-Agent header when present', async () => { + const req = makeRequest({ + headers: { 'user-agent': 'Mozilla/5.0 (Macintosh) Chrome/134.0.0.0 Safari/537.36' }, + } as Partial); + + service.log('user-1', AdminLogActionType.LOGIN, 'x', req, AdminLogStatus.SUCCESS); + await flushImmediates(); + + expect(mockAdminLogRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + user_agent: 'Mozilla/5.0 (Macintosh) Chrome/134.0.0.0 Safari/537.36', + }), + ); + }); + + it('stores a null user_agent when the header is absent', async () => { + service.log('user-1', AdminLogActionType.LOGIN, 'x', makeRequest(), AdminLogStatus.SUCCESS); + await flushImmediates(); + + expect(mockAdminLogRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ user_agent: null }), + ); + }); + it('AC-04 / FR-2: returns before the database write starts', () => { service.log('user-1', AdminLogActionType.LOGIN, 'x', null, AdminLogStatus.SUCCESS); diff --git a/src/database/migrations/1781308800000-AddUserAgentToAdminLogs.ts b/src/database/migrations/1781308800000-AddUserAgentToAdminLogs.ts new file mode 100644 index 00000000..4dbe720b --- /dev/null +++ b/src/database/migrations/1781308800000-AddUserAgentToAdminLogs.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the raw User-Agent capture column to admin_logs. The value is parsed into + * a "Browser Major · OS Version" device label on read; storing it raw lets the + * parser improve later without a backfill. Nullable: non-HTTP actions and older + * rows have no user agent. varchar(512) comfortably fits real-world UA strings. + */ +export class AddUserAgentToAdminLogs1781308800000 implements MigrationInterface { + name = 'AddUserAgentToAdminLogs1781308800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "admin_logs" ADD COLUMN IF NOT EXISTS "user_agent" character varying(512)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "admin_logs" DROP COLUMN IF EXISTS "user_agent"`); + } +} diff --git a/src/modules/admin/logs/entities/admin-log.entity.ts b/src/modules/admin/logs/entities/admin-log.entity.ts index 8cc7b8c8..0eac4bc1 100644 --- a/src/modules/admin/logs/entities/admin-log.entity.ts +++ b/src/modules/admin/logs/entities/admin-log.entity.ts @@ -37,6 +37,10 @@ export class AdminLog { @Column({ type: 'varchar', length: 45, nullable: true }) ip_address: string | null; + /** Raw User-Agent header captured at write time; parsed into a device label on read. */ + @Column({ type: 'varchar', length: 512, nullable: true }) + user_agent: string | null; + @Index() @Column({ type: 'varchar', length: 10 }) status: AdminLogStatus; From bc3c4f446b6ec4b83b7e7aa5253d7fc98c24847b Mon Sep 17 00:00:00 2001 From: ibraheembello Date: Wed, 10 Jun 2026 23:06:31 +0100 Subject: [PATCH 2/2] feat(admin/logs): expose location and device on admin logs feed GET /admin/logs now returns two derived fields per entry: - device: the stored user agent parsed into a 'Browser Major . OS Version' label by a dependency-free parser (Chrome, Firefox, Safari, Edge, Opera across Windows, macOS, iOS, Android, Linux). - location: a 'Region, CC' label resolved from ip_address via freeipapi.com, a keyless HTTPS endpoint, over the built-in fetch. Cached per IP and degrades to null on any failure so the feed never breaks. The offline geo-IP database that would avoid the network call is a banned dependency. Both fields are null when they cannot be resolved (no IP, no user agent, private/loopback address, or provider unreachable). --- .../logs/actions/admin-logs-list.action.ts | 2 + src/modules/admin/logs/admin-logs.module.ts | 3 +- src/modules/admin/logs/admin-logs.service.ts | 16 ++- .../admin/logs/docs/admin-logs-swagger.doc.ts | 11 +- .../logs/interfaces/admin-logs.interfaces.ts | 4 + .../logs/services/geo-location.service.ts | 113 ++++++++++++++++++ .../logs/tests/admin-logs.controller.spec.ts | 2 + .../logs/tests/admin-logs.service.spec.ts | 28 ++++- .../logs/tests/geo-location.service.spec.ts | 91 ++++++++++++++ .../logs/tests/parse-user-agent.util.spec.ts | 53 ++++++++ .../admin/logs/utils/parse-user-agent.util.ts | 112 +++++++++++++++++ 11 files changed, 427 insertions(+), 8 deletions(-) create mode 100644 src/modules/admin/logs/services/geo-location.service.ts create mode 100644 src/modules/admin/logs/tests/geo-location.service.spec.ts create mode 100644 src/modules/admin/logs/tests/parse-user-agent.util.spec.ts create mode 100644 src/modules/admin/logs/utils/parse-user-agent.util.ts diff --git a/src/modules/admin/logs/actions/admin-logs-list.action.ts b/src/modules/admin/logs/actions/admin-logs-list.action.ts index 37f6e5e5..b58a1cb4 100644 --- a/src/modules/admin/logs/actions/admin-logs-list.action.ts +++ b/src/modules/admin/logs/actions/admin-logs-list.action.ts @@ -23,6 +23,7 @@ export interface RawAdminLogRow { log_action_type: AdminLogActionType; log_description: string; log_ip_address: string | null; + log_user_agent: string | null; log_status: AdminLogStatus; log_created_at: Date; } @@ -45,6 +46,7 @@ export class AdminLogsListAction { .addSelect('log.action_type', 'log_action_type') .addSelect('log.description', 'log_description') .addSelect('log.ip_address', 'log_ip_address') + .addSelect('log.user_agent', 'log_user_agent') .addSelect('log.status', 'log_status') .addSelect('log.created_at', 'log_created_at'); diff --git a/src/modules/admin/logs/admin-logs.module.ts b/src/modules/admin/logs/admin-logs.module.ts index 17167796..78e789d9 100644 --- a/src/modules/admin/logs/admin-logs.module.ts +++ b/src/modules/admin/logs/admin-logs.module.ts @@ -4,10 +4,11 @@ import { AdminAuthModule } from '../auth/admin-auth.module'; import { AdminLogsListAction } from './actions/admin-logs-list.action'; import { AdminLogsController } from './admin-logs.controller'; import { AdminLogsService } from './admin-logs.service'; +import { GeoLocationService } from './services/geo-location.service'; @Module({ imports: [RedisModule, AdminAuthModule], controllers: [AdminLogsController], - providers: [AdminLogsService, AdminLogsListAction], + providers: [AdminLogsService, AdminLogsListAction, GeoLocationService], }) export class AdminLogsModule {} diff --git a/src/modules/admin/logs/admin-logs.service.ts b/src/modules/admin/logs/admin-logs.service.ts index 95893b56..77c42402 100644 --- a/src/modules/admin/logs/admin-logs.service.ts +++ b/src/modules/admin/logs/admin-logs.service.ts @@ -3,6 +3,8 @@ import * as SYS_MSG from '../../../constants/system.messages'; import { AdminLogsListAction, RawAdminLogRow } from './actions/admin-logs-list.action'; import { GetAdminLogsQueryDto } from './dto/get-admin-logs-query.dto'; import { AdminLogItem, AdminLogsListMeta, AdminLogsListResponse } from './interfaces/admin-logs.interfaces'; +import { GeoLocationService } from './services/geo-location.service'; +import { formatDevice } from './utils/parse-user-agent.util'; const MAX_PER_PAGE = 50; const DEFAULT_PAGE = 1; @@ -13,7 +15,10 @@ const DATE_ONLY_LENGTH = 10; @Injectable() export class AdminLogsService { - constructor(private readonly adminLogsListAction: AdminLogsListAction) {} + constructor( + private readonly adminLogsListAction: AdminLogsListAction, + private readonly geoLocationService: GeoLocationService, + ) {} /** Returns the paginated, filtered audit-log feed, newest first. */ async listLogs(dto: GetAdminLogsQueryDto): Promise { @@ -49,11 +54,14 @@ export class AdminLogsService { ...(capped ? { capped: true } : {}), }; - return { data: rows.map((row) => this.toLogItem(row)), meta }; + // Resolve every row's location in one deduplicated pass before mapping. + const locations = await this.geoLocationService.resolveMany(rows.map((row) => row.log_ip_address)); + + return { data: rows.map((row, index) => this.toLogItem(row, locations[index])), meta }; } /** Maps a raw joined row to the FR-3 response shape (EC-01: never a null reference). */ - private toLogItem(row: RawAdminLogRow): AdminLogItem { + private toLogItem(row: RawAdminLogRow, location: string | null): AdminLogItem { return { id: row.log_id, user_id: row.log_user_id, @@ -62,6 +70,8 @@ export class AdminLogsService { action_type: row.log_action_type, description: row.log_description, ip_address: row.log_ip_address, + location, + device: formatDevice(row.log_user_agent), created_at: row.log_created_at, status: row.log_status, }; diff --git a/src/modules/admin/logs/docs/admin-logs-swagger.doc.ts b/src/modules/admin/logs/docs/admin-logs-swagger.doc.ts index 136a1054..e6849f1f 100644 --- a/src/modules/admin/logs/docs/admin-logs-swagger.doc.ts +++ b/src/modules/admin/logs/docs/admin-logs-swagger.doc.ts @@ -17,8 +17,11 @@ export function GetAdminLogsDocs(): ReturnType { 'Returns the paginated audit trail of user and system activity, newest first. ' + 'Filter by action_type, status and created_at date range; search matches the ' + 'acting user by full_name or email. Entries whose user was deleted display ' + - '`Deleted User` with a null email. per_page values above 50 are silently ' + - 'capped and flagged via `meta.capped`. Requires a valid admin JWT.', + '`Deleted User` with a null email. `location` is derived from `ip_address` ' + + '(format `Region, CC`) and `device` is parsed from the captured user agent ' + + '(format `Browser Major · OS Version`); either is null when it cannot be ' + + 'resolved. per_page values above 50 are silently capped and flagged via ' + + '`meta.capped`. Requires a valid admin JWT.', }), ApiOkResponse({ description: 'Logs retrieved successfully', @@ -37,6 +40,8 @@ export function GetAdminLogsDocs(): ReturnType { action_type: 'login', description: 'User logged in', ip_address: '102.89.33.21', + location: 'Lagos, NG', + device: 'Chrome 134 · macOS 10.15.7', created_at: '2026-06-06T09:15:00.000Z', status: 'success', }, @@ -48,6 +53,8 @@ export function GetAdminLogsDocs(): ReturnType { action_type: 'account_deleted', description: 'User deleted their account', ip_address: '102.89.33.22', + location: 'Abuja, NG', + device: 'Safari 17 · iOS 17.1', created_at: '2026-06-05T18:42:00.000Z', status: 'success', }, diff --git a/src/modules/admin/logs/interfaces/admin-logs.interfaces.ts b/src/modules/admin/logs/interfaces/admin-logs.interfaces.ts index 24449003..16943cd0 100644 --- a/src/modules/admin/logs/interfaces/admin-logs.interfaces.ts +++ b/src/modules/admin/logs/interfaces/admin-logs.interfaces.ts @@ -9,6 +9,10 @@ export interface AdminLogItem { action_type: AdminLogActionType; description: string; ip_address: string | null; + /** "Region, CC" derived from ip_address, or null when it cannot be resolved. */ + location: string | null; + /** "Browser Major · OS Version" parsed from the stored user agent, or null. */ + device: string | null; created_at: Date; status: AdminLogStatus; } diff --git a/src/modules/admin/logs/services/geo-location.service.ts b/src/modules/admin/logs/services/geo-location.service.ts new file mode 100644 index 00000000..0ee7c6e9 --- /dev/null +++ b/src/modules/admin/logs/services/geo-location.service.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger } from '@nestjs/common'; + +/** Shape of the fields we read from the keyless geo-IP endpoint (freeipapi.com). */ +interface GeoLookupResponse { + regionName?: string; + countryCode?: string; +} + +/** + * Resolves an IP address to a compact "Region, CC" label (for example "Lagos, NG") + * for the admin logs feed. + * + * Uses freeipapi.com, a keyless HTTPS geo-IP endpoint, over the built-in fetch. + * The offline geo-IP database that would have made this dependency-free of a + * network call is a new package, which the team blocks, so we look it up at read + * time instead. Results are cached per IP for the process lifetime, and every + * failure path degrades to null so the logs endpoint never fails when lookup is + * unavailable. + */ +@Injectable() +export class GeoLocationService { + private readonly logger = new Logger(GeoLocationService.name); + private readonly cache = new Map(); + + private static readonly ENDPOINT = 'https://freeipapi.com/api/json'; + private static readonly LOOKUP_TIMEOUT_MS = 2000; + + /** Resolves a single IP, using and populating the per-process cache. */ + async resolve(ip: string | null): Promise { + if (!ip || this.isNonRoutable(ip)) { + return null; + } + + const cached = this.cache.get(ip); + if (cached !== undefined) { + return cached; + } + + const location = await this.lookup(ip); + this.cache.set(ip, location); + return location; + } + + /** + * Resolves many IPs at once, deduplicating lookups, and returns labels aligned + * one-to-one with the input order (null entries stay null). + */ + async resolveMany(ips: Array): Promise> { + const unique = [...new Set(ips.filter((ip): ip is string => Boolean(ip)))]; + await Promise.all(unique.map((ip) => this.resolve(ip))); + return ips.map((ip) => (ip ? (this.cache.get(ip) ?? null) : null)); + } + + private async lookup(ip: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GeoLocationService.LOOKUP_TIMEOUT_MS); + + try { + const response = await fetch(`${GeoLocationService.ENDPOINT}/${ip}`, { + signal: controller.signal, + }); + + if (!response.ok) { + return null; + } + + const body = (await response.json()) as GeoLookupResponse; + return this.format(body); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + this.logger.warn(`Geo lookup failed for ${ip}: ${detail}`); + return null; + } finally { + clearTimeout(timer); + } + } + + private format(body: GeoLookupResponse): string | null { + const region = this.clean(body.regionName); + const country = this.clean(body.countryCode); + + if (!country) { + return null; + } + + return region ? `${region}, ${country}` : country; + } + + /** Normalises a field, treating blanks and the provider's "-"/"Unknown" as absent. */ + private clean(value: string | undefined): string | null { + const trimmed = value?.trim(); + + if (!trimmed || trimmed === '-' || trimmed.toLowerCase() === 'unknown') { + return null; + } + + return trimmed; + } + + /** Private, loopback and link-local addresses have no public geolocation. */ + private isNonRoutable(ip: string): boolean { + return ( + ip === '127.0.0.1' || + ip === '::1' || + ip.startsWith('10.') || + ip.startsWith('192.168.') || + ip.startsWith('169.254.') || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(ip) || + ip.startsWith('fc') || + ip.startsWith('fd') + ); + } +} diff --git a/src/modules/admin/logs/tests/admin-logs.controller.spec.ts b/src/modules/admin/logs/tests/admin-logs.controller.spec.ts index 9b5791b8..56bc16ed 100644 --- a/src/modules/admin/logs/tests/admin-logs.controller.spec.ts +++ b/src/modules/admin/logs/tests/admin-logs.controller.spec.ts @@ -17,6 +17,8 @@ const MOCK_LIST_RESPONSE = { action_type: AdminLogActionType.LOGIN, description: 'User logged in', ip_address: '102.89.33.21', + location: 'Lagos, NG', + device: 'Chrome 134 · macOS 10.15.7', created_at: new Date('2026-06-06T09:15:00.000Z'), status: AdminLogStatus.SUCCESS, }, diff --git a/src/modules/admin/logs/tests/admin-logs.service.spec.ts b/src/modules/admin/logs/tests/admin-logs.service.spec.ts index e2f2a439..f7452434 100644 --- a/src/modules/admin/logs/tests/admin-logs.service.spec.ts +++ b/src/modules/admin/logs/tests/admin-logs.service.spec.ts @@ -5,6 +5,11 @@ import { AdminLogsListAction, RawAdminLogRow } from '../actions/admin-logs-list. import { AdminLogsService } from '../admin-logs.service'; import { GetAdminLogsQueryDto } from '../dto/get-admin-logs-query.dto'; import { AdminLogActionType, AdminLogStatus } from '../enums/admin-log.enum'; +import { GeoLocationService } from '../services/geo-location.service'; + +const CHROME_MAC_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'; const makeRow = (overrides: Partial = {}): RawAdminLogRow => ({ log_id: 'log-uuid-1', @@ -14,12 +19,17 @@ const makeRow = (overrides: Partial = {}): RawAdminLogRow => ({ log_action_type: AdminLogActionType.LOGIN, log_description: 'User logged in', log_ip_address: '102.89.33.21', + log_user_agent: CHROME_MAC_UA, log_status: AdminLogStatus.SUCCESS, log_created_at: new Date('2026-06-06T09:15:00.000Z'), ...overrides, }); const mockAdminLogsListAction = { findLogsWithFilters: jest.fn() }; +// Echoes one label per input IP so location mapping is deterministic in tests. +const mockGeoLocationService = { + resolveMany: jest.fn((ips: Array) => Promise.resolve(ips.map((ip) => (ip ? 'Lagos, NG' : null)))), +}; describe('AdminLogsService', () => { let service: AdminLogsService; @@ -27,11 +37,15 @@ describe('AdminLogsService', () => { beforeEach(async () => { jest.clearAllMocks(); mockAdminLogsListAction.findLogsWithFilters.mockResolvedValue([[makeRow()], 1]); + mockGeoLocationService.resolveMany.mockImplementation((ips: Array) => + Promise.resolve(ips.map((ip) => (ip ? 'Lagos, NG' : null))), + ); const module: TestingModule = await Test.createTestingModule({ providers: [ AdminLogsService, { provide: AdminLogsListAction, useValue: mockAdminLogsListAction }, + { provide: GeoLocationService, useValue: mockGeoLocationService }, ], }).compile(); @@ -56,7 +70,7 @@ describe('AdminLogsService', () => { ); }); - it('FR-3: maps a row to exactly the nine allowed response fields', async () => { + it('FR-3: maps a row to exactly the allowed response fields, with location and device', async () => { const result = await service.listLogs({} as GetAdminLogsQueryDto); const item = result.data[0]; @@ -68,10 +82,20 @@ describe('AdminLogsService', () => { action_type: AdminLogActionType.LOGIN, description: 'User logged in', ip_address: '102.89.33.21', + location: 'Lagos, NG', + device: 'Chrome 134 · macOS 10.15.7', created_at: new Date('2026-06-06T09:15:00.000Z'), status: AdminLogStatus.SUCCESS, }); - expect(Object.keys(item)).toHaveLength(9); + expect(Object.keys(item)).toHaveLength(11); + }); + + it('maps a null user agent to a null device', async () => { + mockAdminLogsListAction.findLogsWithFilters.mockResolvedValue([[makeRow({ log_user_agent: null })], 1]); + + const result = await service.listLogs({} as GetAdminLogsQueryDto); + + expect(result.data[0].device).toBeNull(); }); it('computes has_next true when more rows exist beyond the current page', async () => { diff --git a/src/modules/admin/logs/tests/geo-location.service.spec.ts b/src/modules/admin/logs/tests/geo-location.service.spec.ts new file mode 100644 index 00000000..f5ac5d32 --- /dev/null +++ b/src/modules/admin/logs/tests/geo-location.service.spec.ts @@ -0,0 +1,91 @@ +import { Logger } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GeoLocationService } from '../services/geo-location.service'; + +const okResponse = (body: unknown): Response => + ({ ok: true, json: () => Promise.resolve(body) }) as unknown as Response; + +describe('GeoLocationService', () => { + let service: GeoLocationService; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + + const module: TestingModule = await Test.createTestingModule({ + providers: [GeoLocationService], + }).compile(); + + service = module.get(GeoLocationService); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('resolve', () => { + it('formats a successful lookup as "Region, CC"', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(okResponse({ regionName: 'Lagos', countryCode: 'NG' })); + + await expect(service.resolve('102.89.33.21')).resolves.toBe('Lagos, NG'); + }); + + it('falls back to the country code alone when the region is absent', async () => { + global.fetch = jest.fn().mockResolvedValueOnce(okResponse({ regionName: '-', countryCode: 'NG' })); + + await expect(service.resolve('102.89.33.21')).resolves.toBe('NG'); + }); + + it('returns null and never calls fetch for a null IP', async () => { + global.fetch = jest.fn(); + + await expect(service.resolve(null)).resolves.toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns null and never calls fetch for a private IP', async () => { + global.fetch = jest.fn(); + + await expect(service.resolve('192.168.0.10')).resolves.toBeNull(); + await expect(service.resolve('10.0.0.4')).resolves.toBeNull(); + await expect(service.resolve('127.0.0.1')).resolves.toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns null when the provider returns no country code', async () => { + global.fetch = jest.fn().mockResolvedValueOnce(okResponse({ regionName: '-', countryCode: '' })); + + await expect(service.resolve('8.8.8.8')).resolves.toBeNull(); + }); + + it('returns null and swallows a network error', async () => { + global.fetch = jest.fn().mockRejectedValueOnce(new Error('network down')); + + await expect(service.resolve('8.8.8.8')).resolves.toBeNull(); + }); + + it('caches a resolved IP and does not call fetch again', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(okResponse({ regionName: 'Abuja', countryCode: 'NG' })); + + await expect(service.resolve('41.58.1.1')).resolves.toBe('Abuja, NG'); + await expect(service.resolve('41.58.1.1')).resolves.toBe('Abuja, NG'); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('resolveMany', () => { + it('deduplicates lookups and aligns results with the input order', async () => { + global.fetch = jest + .fn() + .mockResolvedValue(okResponse({ regionName: 'Lagos', countryCode: 'NG' })); + + const result = await service.resolveMany(['9.9.9.9', null, '9.9.9.9']); + + expect(result).toEqual(['Lagos, NG', null, 'Lagos, NG']); + // One unique routable IP means exactly one network call. + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/modules/admin/logs/tests/parse-user-agent.util.spec.ts b/src/modules/admin/logs/tests/parse-user-agent.util.spec.ts new file mode 100644 index 00000000..7d924e1e --- /dev/null +++ b/src/modules/admin/logs/tests/parse-user-agent.util.spec.ts @@ -0,0 +1,53 @@ +import { formatDevice } from '../utils/parse-user-agent.util'; + +const SEP = '·'; + +describe('formatDevice', () => { + it('returns null for null, undefined or empty input', () => { + expect(formatDevice(null)).toBeNull(); + expect(formatDevice(undefined)).toBeNull(); + expect(formatDevice('')).toBeNull(); + }); + + it('parses Chrome on macOS', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'; + expect(formatDevice(ua)).toBe(`Chrome 134 ${SEP} macOS 10.15.7`); + }); + + it('parses Firefox on Windows 10', () => { + const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0'; + expect(formatDevice(ua)).toBe(`Firefox 132 ${SEP} Windows 10`); + }); + + it('parses Safari on iOS', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 ' + + '(KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1'; + expect(formatDevice(ua)).toBe(`Safari 17 ${SEP} iOS 17.1`); + }); + + it('detects Edge before Chrome (Edge embeds the Chrome token)', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0'; + expect(formatDevice(ua)).toBe(`Edge 134 ${SEP} Windows 10`); + }); + + it('parses Chrome on Android', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/134.0.0.0 Mobile Safari/537.36'; + expect(formatDevice(ua)).toBe(`Chrome 134 ${SEP} Android 14`); + }); + + it('returns the browser alone when the OS is unrecognised', () => { + const ua = 'Mozilla/5.0 (Unknown) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0'; + expect(formatDevice(ua)).toBe('Chrome 134'); + }); + + it('returns null when neither browser nor OS can be identified', () => { + expect(formatDevice('curl/8.4.0')).toBeNull(); + }); +}); diff --git a/src/modules/admin/logs/utils/parse-user-agent.util.ts b/src/modules/admin/logs/utils/parse-user-agent.util.ts new file mode 100644 index 00000000..3ccdb78e --- /dev/null +++ b/src/modules/admin/logs/utils/parse-user-agent.util.ts @@ -0,0 +1,112 @@ +/** + * Dependency-free User-Agent formatter for the admin logs feed. + * + * Produces a compact "Browser Major · OS Version" label (for example + * "Chrome 134 · macOS 10.15.7") to match the activity-log design. An offline + * geo/UA parsing library would have been a new dependency, which the team + * blocks, so this parses the common browsers and platforms by hand. + * + * Caveat: modern browsers freeze the high-entropy OS version in the legacy UA + * string (macOS is pinned at 10_15_7, Windows 11 still reports NT 10.0), so the + * OS portion is best-effort. Returns null when nothing recognisable is found. + */ + +/** Middle dot (U+00B7) used as the browser/OS separator in the design. */ +const SEPARATOR = '·'; + +interface ParsedAgent { + name: string; + version: string | null; +} + +export function formatDevice(userAgent: string | null | undefined): string | null { + if (!userAgent) { + return null; + } + + const browser = parseBrowser(userAgent); + const os = parseOs(userAgent); + const browserLabel = browser ? joinNameVersion(browser) : null; + const osLabel = os ? joinNameVersion(os) : null; + + if (browserLabel && osLabel) { + return `${browserLabel} ${SEPARATOR} ${osLabel}`; + } + + return browserLabel ?? osLabel; +} + +function joinNameVersion(parsed: ParsedAgent): string { + return parsed.version ? `${parsed.name} ${parsed.version}` : parsed.name; +} + +/** Order matters: Edge and Opera embed "Chrome", and Chrome embeds "Safari". */ +function parseBrowser(ua: string): ParsedAgent | null { + const edge = /Edg(?:e|A|iOS)?\/(\d+)/.exec(ua); + if (edge) { + return { name: 'Edge', version: edge[1] }; + } + + const opera = /(?:OPR|Opera)\/(\d+)/.exec(ua); + if (opera) { + return { name: 'Opera', version: opera[1] }; + } + + const firefox = /(?:Firefox|FxiOS)\/(\d+)/.exec(ua); + if (firefox) { + return { name: 'Firefox', version: firefox[1] }; + } + + const chrome = /(?:Chrome|CriOS)\/(\d+)/.exec(ua); + if (chrome) { + return { name: 'Chrome', version: chrome[1] }; + } + + if (/Safari\//.test(ua)) { + const version = /Version\/(\d+)/.exec(ua); + return { name: 'Safari', version: version ? version[1] : null }; + } + + return null; +} + +/** iOS is checked before macOS because iPad UAs can also mention "Mac OS X". */ +function parseOs(ua: string): ParsedAgent | null { + const windows = /Windows NT (\d+\.\d+)/.exec(ua); + if (windows) { + return { name: 'Windows', version: mapWindowsVersion(windows[1]) }; + } + + const ios = /(?:iPhone OS|CPU OS) (\d+(?:_\d+)*)/.exec(ua); + if (ios) { + return { name: 'iOS', version: ios[1].replace(/_/g, '.') }; + } + + const mac = /Mac OS X (\d+(?:_\d+)*)/.exec(ua); + if (mac) { + return { name: 'macOS', version: mac[1].replace(/_/g, '.') }; + } + + const android = /Android (\d+(?:\.\d+)?)/.exec(ua); + if (android) { + return { name: 'Android', version: android[1] }; + } + + if (/Linux/.test(ua)) { + return { name: 'Linux', version: null }; + } + + return null; +} + +/** Maps Windows NT kernel versions to their marketing names where well known. */ +function mapWindowsVersion(ntVersion: string): string { + const marketingNames: Record = { + '10.0': '10', + '6.3': '8.1', + '6.2': '8', + '6.1': '7', + }; + + return marketingNames[ntVersion] ?? ntVersion; +}