From 563cc9cddb8828e7e4a8096de0542b570d29cfd8 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 6 Oct 2026 22:25:55 -0400 Subject: [PATCH 1/2] feat(metrics): accept a time range on dashboard metrics and anomalies The two internal endpoints that fed the operator screens without a range now take from and to, so a console can offer one range control. Dashboard metrics keeps its 24 hour fields and adds window-neutral ones; anomalies pages and reports a real total. Closes #132. --- .changeset/ranged-internal-metrics.md | 10 ++ docs/admin-operations.md | 13 ++ openapi.json | 143 +++++++++++++++++++- src/controllers/internalDashboard.ts | 118 ++++++++-------- src/controllers/internalSecurity.ts | 27 ++-- src/generated/api.ts | 118 +++++++++++++++- src/routes/internal.routes.ts | 15 +- src/schemas/internal.query.ts | 2 + tests/integration/internal/internal.spec.ts | 131 +++++++++++++++++- tests/setup/mocks.ts | 1 + 10 files changed, 496 insertions(+), 82 deletions(-) create mode 100644 .changeset/ranged-internal-metrics.md diff --git a/.changeset/ranged-internal-metrics.md b/.changeset/ranged-internal-metrics.md new file mode 100644 index 0000000..572f136 --- /dev/null +++ b/.changeset/ranged-internal-metrics.md @@ -0,0 +1,10 @@ +--- +'seamless-auth-api': minor +--- + +`GET /internal/metrics/dashboard` and `GET /internal/security/anomalies` accept `from` and `to` (#132), with the same validation as the `/internal/auth-events/*` endpoints and a default of the last 24 hours. Both responses carry the `window` they covered. + +- Dashboard metrics adds `newUsers`, `loginSuccess`, `loginFailed`, `successRate`, `otpUsage` and `passkeyUsage` for the requested window. The `*24h` fields keep meaning the last 24 hours. +- Security anomalies takes `limit` (1 to 200, default 200) and `offset`. `total` now counts every match in the window. It used to report the number returned, which was capped at 200, so a caller could not tell there were more. + +Requires `@seamless-auth/types` 0.28.0. diff --git a/docs/admin-operations.md b/docs/admin-operations.md index d85ea74..133d0c7 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -149,6 +149,19 @@ endpoint, or the audit export, to find the individual events. The `/internal/auth-events/*` endpoints all accept the same query parameters: `from`, `to`, `userId`, and `interval` (`hour` or `day`, timeseries only). +`/internal/metrics/dashboard` and `/internal/security/anomalies` take `from` and `to` as well, +with the same validation, and default to the last 24 hours. Both answer with the `window` they +covered. + +- **Dashboard metrics.** The `*24h` fields (`loginSuccess24h`, `successRate24h`, and so on) + always cover the last 24 hours, whatever window is asked for, so a caller never gets a + different period under the same name. The window-neutral fields beside them (`newUsers`, + `loginSuccess`, `loginFailed`, `successRate`, `otpUsage`, `passkeyUsage`) cover the + requested window. `totalUsers`, `activeSessions` and `databaseSize` are current totals. +- **Security anomalies.** Failed and suspicious events in the window, newest first, paged with + `limit` (at most 200, the default) and `offset`. `total` counts every match in the window, + not just the page. + ### Date windows `from` and `to` are parsed as dates and rejected with `400` when unparseable or inverted. The diff --git a/openapi.json b/openapi.json index 90d01db..909a144 100644 --- a/openapi.json +++ b/openapi.json @@ -6100,14 +6100,37 @@ "/internal/security/anomalies": { "get": { "summary": "Detect suspicious activity", + "description": "Failed and suspicious auth events in [from, to), newest first. Defaults to the last 24 hours. `total` counts every match in the window, not just this page.", "tags": ["Internal"], "security": [{ "bearerAuth": [] }], + "parameters": [ + { "schema": { "type": "string" }, "required": false, "name": "from", "in": "query" }, + { "schema": { "type": "string" }, "required": false, "name": "to", "in": "query" }, + { + "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 200 }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { "type": "integer", "nullable": true, "minimum": 0, "default": 0 }, + "required": false, + "name": "offset", + "in": "query" + } + ], "responses": { "200": { "description": "HTTP 200", "content": { "application/json": { - "example": { "suspiciousEvents": [null], "total": 0 }, + "example": { + "suspiciousEvents": [null], + "total": 0, + "window": { "from": "string", "to": "string" }, + "limit": 0, + "offset": 0 + }, "schema": { "type": "object", "properties": { @@ -6146,13 +6169,61 @@ } } }, - "total": { "type": "integer", "minimum": 0 } + "total": { "type": "integer", "minimum": 0 }, + "window": { + "type": "object", + "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, + "required": ["from", "to"] + }, + "limit": { "type": "integer" }, + "offset": { "type": "integer" } }, "required": ["suspiciousEvents", "total"] } } } }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -6185,8 +6256,13 @@ "/internal/metrics/dashboard": { "get": { "summary": "Dashboard metrics", + "description": "Headline figures. The `*24h` fields always cover the last 24 hours. `newUsers`, `loginSuccess`, `loginFailed`, `successRate`, `otpUsage` and `passkeyUsage` cover [from, to), which defaults to the last 24 hours, and `window` says which period that was.", "tags": ["Internal"], "security": [{ "bearerAuth": [] }], + "parameters": [ + { "schema": { "type": "string" }, "required": false, "name": "from", "in": "query" }, + { "schema": { "type": "string" }, "required": false, "name": "to", "in": "query" } + ], "responses": { "200": { "description": "HTTP 200", @@ -6201,7 +6277,14 @@ "successRate24h": 0, "otpUsage24h": 0, "passkeyUsage24h": 0, - "databaseSize": 0 + "databaseSize": 0, + "window": { "from": "string", "to": "string" }, + "newUsers": 0, + "loginSuccess": 0, + "loginFailed": 0, + "successRate": 0, + "otpUsage": 0, + "passkeyUsage": 0 }, "schema": { "type": "object", @@ -6214,7 +6297,18 @@ "successRate24h": { "type": "number" }, "otpUsage24h": { "type": "number" }, "passkeyUsage24h": { "type": "number" }, - "databaseSize": { "type": "number" } + "databaseSize": { "type": "number" }, + "window": { + "type": "object", + "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, + "required": ["from", "to"] + }, + "newUsers": { "type": "number" }, + "loginSuccess": { "type": "number" }, + "loginFailed": { "type": "number" }, + "successRate": { "type": "number" }, + "otpUsage": { "type": "number" }, + "passkeyUsage": { "type": "number" } }, "required": [ "totalUsers", @@ -6231,6 +6325,47 @@ } } }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { diff --git a/src/controllers/internalDashboard.ts b/src/controllers/internalDashboard.ts index 94ff505..2523c05 100644 --- a/src/controllers/internalDashboard.ts +++ b/src/controllers/internalDashboard.ts @@ -13,21 +13,55 @@ import { User } from '../models/users.js'; import { SIGN_IN_FAILURE_TYPES, SIGN_IN_SUCCESS_TYPES } from '../schemas/authEvent.types.js'; import { getDatabaseSize } from './admin.js'; -export const getDashboardMetrics = async (_req: Request, res: Response) => { +const DAY_MS = 1000 * 60 * 60 * 24; + +/** + * `from`/`to` as a half-open window, defaulting to the 24 hours before now, which is the + * window every caller got before the endpoints took a range. + */ +export function resolveMetricsWindow(query: { from?: string; to?: string }, now = new Date()) { + const end = query.to ? new Date(query.to) : now; + const start = query.from ? new Date(query.from) : new Date(end.getTime() - DAY_MS); + return { start, end }; +} + +async function windowCounts(start: Date, end: Date) { + const between = { [Op.gte]: start, [Op.lt]: end }; + + const [newUsers, loginSuccess, loginFailed, otpUsage, passkeyUsage] = await Promise.all([ + User.count({ where: { createdAt: between } }), + + // Completed sign-ins. login_success is the pre-auth step resolving which methods + // an identifier may use, so a rate built from it measures identifier resolution. + AuthEvent.count({ + where: { type: { [Op.in]: [...SIGN_IN_SUCCESS_TYPES] }, created_at: between }, + }), + AuthEvent.count({ + where: { type: { [Op.in]: [...SIGN_IN_FAILURE_TYPES] }, created_at: between }, + }), + AuthEvent.count({ where: { type: 'otp_success', created_at: between } }), + AuthEvent.count({ where: { type: 'webauthn_login_success', created_at: between } }), + ]); + + const totalLogins = loginSuccess + loginFailed; + + return { + newUsers, + loginSuccess, + loginFailed, + successRate: totalLogins > 0 ? loginSuccess / totalLogins : 0, + otpUsage, + passkeyUsage, + }; +} + +export const getDashboardMetrics = async (req: Request, res: Response) => { const now = new Date(); - const last24h = new Date(now.getTime() - 1000 * 60 * 60 * 24); + const ranged = typeof req.query.from === 'string' || typeof req.query.to === 'string'; + const window = resolveMetricsWindow(req.query as { from?: string; to?: string }, now); try { - const [ - totalUsers, - activeSessions, - newUsers24h, - loginSuccess24h, - loginFailed24h, - otpUsage24h, - passkeyUsage24h, - dbSize, - ] = await Promise.all([ + const [totalUsers, activeSessions, last24h, requested, dbSize] = await Promise.all([ User.count(), // The same three conditions every other 'active session' query uses. Rotation // leaves the superseded row unrevoked, so filtering on revokedAt alone counted @@ -39,60 +73,26 @@ export const getDashboardMetrics = async (_req: Request, res: Response) => { expiresAt: { [Op.gt]: now }, }, }), - - User.count({ - where: { - createdAt: { [Op.gt]: last24h }, - }, - }), - - // Completed sign-ins. login_success is the pre-auth step resolving which methods - // an identifier may use, so a rate built from it measures identifier resolution. - AuthEvent.count({ - where: { - type: { [Op.in]: [...SIGN_IN_SUCCESS_TYPES] }, - created_at: { [Op.gt]: last24h }, - }, - }), - - AuthEvent.count({ - where: { - type: { [Op.in]: [...SIGN_IN_FAILURE_TYPES] }, - created_at: { [Op.gt]: last24h }, - }, - }), - - AuthEvent.count({ - where: { - type: 'otp_success', - created_at: { [Op.gt]: last24h }, - }, - }), - - AuthEvent.count({ - where: { - type: 'webauthn_login_success', - created_at: { [Op.gt]: last24h }, - }, - }), - + windowCounts(new Date(now.getTime() - DAY_MS), now), + // Without a range the requested window is the last 24 hours, already counted. + ranged ? windowCounts(window.start, window.end) : null, getDatabaseSize(), ]); - const totalLogins = loginSuccess24h + loginFailed24h; - + // The *24h fields keep meaning the last 24 hours whatever window is asked for, so a + // caller reading them is never handed a different period under the same name. return res.json({ totalUsers, activeSessions, - newUsers24h, - - loginSuccess24h, - loginFailed24h, - successRate24h: totalLogins > 0 ? loginSuccess24h / totalLogins : 0, - - otpUsage24h, - passkeyUsage24h, + newUsers24h: last24h.newUsers, + loginSuccess24h: last24h.loginSuccess, + loginFailed24h: last24h.loginFailed, + successRate24h: last24h.successRate, + otpUsage24h: last24h.otpUsage, + passkeyUsage24h: last24h.passkeyUsage, databaseSize: dbSize, + window: { from: window.start.toISOString(), to: window.end.toISOString() }, + ...(requested ?? last24h), }); } catch { return res.status(500).json({ error: 'Failed to fetch dashboard metrics' }); diff --git a/src/controllers/internalSecurity.ts b/src/controllers/internalSecurity.ts index 7b5c1cb..a22ab9f 100644 --- a/src/controllers/internalSecurity.ts +++ b/src/controllers/internalSecurity.ts @@ -11,6 +11,7 @@ import { AuthEvent } from '../models/authEvents.js'; import { FAILURE_EVENT_TYPES } from '../schemas/authEvent.types.js'; import { serializeAuthEvents } from '../services/authEventSerialization.js'; import getLogger from '../utils/logger.js'; +import { resolveMetricsWindow } from './internalDashboard.js'; const logger = getLogger('internalSecurity'); @@ -23,9 +24,12 @@ const logger = getLogger('internalSecurity'); */ const ANOMALY_LIMIT = 200; -export const getSecurityAnomalies = async (_req: Request, res: Response) => { - const now = new Date(); - const windowStart = new Date(now.getTime() - 60 * 60 * 1000 * 24); +export const getSecurityAnomalies = async (req: Request, res: Response) => { + const { limit = ANOMALY_LIMIT, offset = 0 } = req.query as unknown as { + limit?: number; + offset?: number; + }; + const { start, end } = resolveMetricsWindow(req.query as { from?: string; to?: string }); try { // Derived from AUTH_EVENT_TYPES. The hand-maintained list searched for five names @@ -34,11 +38,9 @@ export const getSecurityAnomalies = async (_req: Request, res: Response) => { // verify_otp_failed, totp_failed, magic_link_failed, and logout_failed. const FAILURE_TYPES = FAILURE_EVENT_TYPES; - const events = await AuthEvent.findAll({ + const { rows, count } = await AuthEvent.findAndCountAll({ where: { - created_at: { - [Op.gte]: windowStart, - }, + created_at: { [Op.gte]: start, [Op.lt]: end }, [Op.or]: [ { type: { @@ -54,12 +56,17 @@ export const getSecurityAnomalies = async (_req: Request, res: Response) => { }, attributes: ['user_id', 'type', 'ip_address', 'user_agent', 'metadata', 'created_at'], order: [['created_at', 'DESC']], - limit: ANOMALY_LIMIT, + limit, + offset, }); return res.json({ - suspiciousEvents: serializeAuthEvents(events), - total: events.length, + suspiciousEvents: serializeAuthEvents(rows), + // Every match in the window, not the page size, so a caller can tell there is more. + total: count, + window: { from: start.toISOString(), to: end.toISOString() }, + limit, + offset, }); } catch { logger.error(`Failed to get security events`); diff --git a/src/generated/api.ts b/src/generated/api.ts index c324aad..592d505 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -5974,10 +5974,18 @@ export interface paths { path?: never; cookie?: never; }; - /** Detect suspicious activity */ + /** + * Detect suspicious activity + * @description Failed and suspicious auth events in [from, to), newest first. Defaults to the last 24 hours. `total` counts every match in the window, not just this page. + */ get: { parameters: { - query?: never; + query?: { + from?: string; + to?: string; + limit?: number; + offset?: number | null; + }; header?: never; path?: never; cookie?: never; @@ -5995,7 +6003,13 @@ export interface paths { * "suspiciousEvents": [ * null * ], - * "total": 0 + * "total": 0, + * "window": { + * "from": "string", + * "to": "string" + * }, + * "limit": 0, + * "offset": 0 * } */ 'application/json': { @@ -6021,6 +6035,42 @@ export interface paths { updated_at?: string | null; }[]; total: number; + window?: { + from: string; + to: string; + }; + limit?: number; + offset?: number; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; }; }; }; @@ -6077,10 +6127,16 @@ export interface paths { path?: never; cookie?: never; }; - /** Dashboard metrics */ + /** + * Dashboard metrics + * @description Headline figures. The `*24h` fields always cover the last 24 hours. `newUsers`, `loginSuccess`, `loginFailed`, `successRate`, `otpUsage` and `passkeyUsage` cover [from, to), which defaults to the last 24 hours, and `window` says which period that was. + */ get: { parameters: { - query?: never; + query?: { + from?: string; + to?: string; + }; header?: never; path?: never; cookie?: never; @@ -6103,7 +6159,17 @@ export interface paths { * "successRate24h": 0, * "otpUsage24h": 0, * "passkeyUsage24h": 0, - * "databaseSize": 0 + * "databaseSize": 0, + * "window": { + * "from": "string", + * "to": "string" + * }, + * "newUsers": 0, + * "loginSuccess": 0, + * "loginFailed": 0, + * "successRate": 0, + * "otpUsage": 0, + * "passkeyUsage": 0 * } */ 'application/json': { @@ -6116,6 +6182,46 @@ export interface paths { otpUsage24h: number; passkeyUsage24h: number; databaseSize: number; + window?: { + from: string; + to: string; + }; + newUsers?: number; + loginSuccess?: number; + loginFailed?: number; + successRate?: number; + otpUsage?: number; + passkeyUsage?: number; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; }; }; }; diff --git a/src/routes/internal.routes.ts b/src/routes/internal.routes.ts index ca782ae..0e683ff 100644 --- a/src/routes/internal.routes.ts +++ b/src/routes/internal.routes.ts @@ -17,7 +17,12 @@ import { getSecurityAnomalies } from '../controllers/internalSecurity.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; import { ErrorSchema } from '../schemas/generic.responses.js'; -import { FunnelMetricsQuerySchema, MetricsQuerySchema } from '../schemas/internal.query.js'; +import { + DashboardMetricsQuerySchema, + FunnelMetricsQuerySchema, + MetricsQuerySchema, + SecurityAnomaliesQuerySchema, +} from '../schemas/internal.query.js'; import { AuthEventSummaryResponseSchema, AuthEventTimeseriesResponseSchema, @@ -91,10 +96,14 @@ internalRouter.get( auth: 'access', middleware: [requireAdmin('read')], summary: 'Detect suspicious activity', + description: + 'Failed and suspicious auth events in [from, to), newest first. Defaults to the last 24 hours. `total` counts every match in the window, not just this page.', tags: ['Internal'], schemas: { + query: SecurityAnomaliesQuerySchema, response: { 200: SecurityAnomaliesResponseSchema, + 400: ErrorSchema, 500: ErrorSchema, }, }, @@ -108,10 +117,14 @@ internalRouter.get( auth: 'access', middleware: [requireAdmin('read')], summary: 'Dashboard metrics', + description: + 'Headline figures. The `*24h` fields always cover the last 24 hours. `newUsers`, `loginSuccess`, `loginFailed`, `successRate`, `otpUsage` and `passkeyUsage` cover [from, to), which defaults to the last 24 hours, and `window` says which period that was.', tags: ['Internal'], schemas: { + query: DashboardMetricsQuerySchema, response: { 200: DashboardMetricsResponseSchema, + 400: ErrorSchema, 500: ErrorSchema, }, }, diff --git a/src/schemas/internal.query.ts b/src/schemas/internal.query.ts index deb698c..d9b3432 100644 --- a/src/schemas/internal.query.ts +++ b/src/schemas/internal.query.ts @@ -121,3 +121,5 @@ export const FunnelMetricsQuerySchema = z }); } }); + +export { DashboardMetricsQuerySchema, SecurityAnomaliesQuerySchema } from '@seamless-auth/types'; diff --git a/tests/integration/internal/internal.spec.ts b/tests/integration/internal/internal.spec.ts index 3bc50d4..73f5abe 100644 --- a/tests/integration/internal/internal.spec.ts +++ b/tests/integration/internal/internal.spec.ts @@ -278,7 +278,7 @@ describe('GET /internal/metrics/sign-ins', () => { describe('GET /internal/security/anomalies', () => { it('returns anomalies', async () => { - (AuthEvent.findAll as any).mockResolvedValue([ + const rows = [ { user_id: 'user_1', type: 'login_failed', @@ -335,12 +335,139 @@ describe('GET /internal/security/anomalies', () => { metadata: { challenge_failed: true }, created_at: new Date('2026-03-29T10:30:00Z'), }, - ]); + ]; + (AuthEvent.findAndCountAll as any).mockResolvedValue({ rows, count: rows.length }); const res = await request(app).get('/internal/security/anomalies'); expect(res.status).toBe(200); expect(res.body.total).toBe(7); + expect(res.body.suspiciousEvents).toHaveLength(7); + }); + + it('defaults to the last 24 hours, 200 at a time', async () => { + (AuthEvent.findAndCountAll as any).mockResolvedValue({ rows: [], count: 0 }); + + const res = await request(app).get('/internal/security/anomalies'); + + const { where, limit, offset } = (AuthEvent.findAndCountAll as any).mock.calls[0][0]; + const range = where.created_at; + const start = range[Object.getOwnPropertySymbols(range)[0]]; + const end = range[Object.getOwnPropertySymbols(range)[1]]; + expect(end.getTime() - start.getTime()).toBe(24 * 60 * 60 * 1000); + expect({ limit, offset }).toEqual({ limit: 200, offset: 0 }); + expect(res.body).toEqual( + expect.objectContaining({ + limit: 200, + offset: 0, + window: { from: start.toISOString(), to: end.toISOString() }, + }), + ); + }); + + it('pages through a requested window and reports every match in total', async () => { + (AuthEvent.findAndCountAll as any).mockResolvedValue({ rows: [], count: 950 }); + + const res = await request(app).get('/internal/security/anomalies').query({ + from: '2026-03-01T00:00:00.000Z', + to: '2026-03-08T00:00:00.000Z', + limit: '50', + offset: '100', + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + total: 950, + limit: 50, + offset: 100, + window: { from: '2026-03-01T00:00:00.000Z', to: '2026-03-08T00:00:00.000Z' }, + }), + ); + expect(AuthEvent.findAndCountAll).toHaveBeenCalledWith( + expect.objectContaining({ limit: 50, offset: 100 }), + ); + }); + + it('rejects a reversed range or an oversized page', async () => { + const reversed = await request(app).get('/internal/security/anomalies').query({ + from: '2026-03-08T00:00:00.000Z', + to: '2026-03-01T00:00:00.000Z', + }); + const oversized = await request(app) + .get('/internal/security/anomalies') + .query({ limit: '1000' }); + + expect(reversed.status).toBe(400); + expect(oversized.status).toBe(400); + expect(AuthEvent.findAndCountAll).not.toHaveBeenCalled(); + }); +}); + +describe('GET /internal/metrics/dashboard with a range', () => { + it('reports the requested window beside the unchanged 24 hour figures', async () => { + (User.count as any) + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(5) // newUsers, last 24h + .mockResolvedValueOnce(40); // newUsers, requested window + (Session.count as any).mockResolvedValue(20); + (AuthEvent.count as any) + .mockResolvedValueOnce(50) // last 24h: success + .mockResolvedValueOnce(25) // last 24h: failed + .mockResolvedValueOnce(10) // last 24h: otp + .mockResolvedValueOnce(15) // last 24h: passkey + .mockResolvedValueOnce(600) // window: success + .mockResolvedValueOnce(200) // window: failed + .mockResolvedValueOnce(90) // window: otp + .mockResolvedValueOnce(400); // window: passkey + const controller = await import('../../../src/controllers/admin.js'); + vi.spyOn(controller, 'getDatabaseSize').mockResolvedValue(1); + + const res = await request(app).get('/internal/metrics/dashboard').query({ + from: '2026-03-01T00:00:00.000Z', + to: '2026-03-08T00:00:00.000Z', + }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + newUsers24h: 5, + loginSuccess24h: 50, + loginFailed24h: 25, + successRate24h: 50 / 75, + window: { from: '2026-03-01T00:00:00.000Z', to: '2026-03-08T00:00:00.000Z' }, + newUsers: 40, + loginSuccess: 600, + loginFailed: 200, + successRate: 600 / 800, + otpUsage: 90, + passkeyUsage: 400, + }); + }); + + it('answers the ranged fields from the last 24 hours when no range is given', async () => { + (User.count as any).mockResolvedValueOnce(100).mockResolvedValueOnce(5); + (Session.count as any).mockResolvedValue(20); + (AuthEvent.count as any) + .mockResolvedValueOnce(50) + .mockResolvedValueOnce(25) + .mockResolvedValueOnce(10) + .mockResolvedValueOnce(15); + const controller = await import('../../../src/controllers/admin.js'); + vi.spyOn(controller, 'getDatabaseSize').mockResolvedValue(1); + + const res = await request(app).get('/internal/metrics/dashboard'); + + expect(res.body).toMatchObject({ newUsers: 5, loginSuccess: 50, passkeyUsage: 15 }); + expect(AuthEvent.count).toHaveBeenCalledTimes(4); + }); + + it('rejects a range wider than the maximum window', async () => { + const res = await request(app).get('/internal/metrics/dashboard').query({ + from: '2024-01-01T00:00:00.000Z', + to: '2026-01-01T00:00:00.000Z', + }); + + expect(res.status).toBe(400); }); }); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index a077a8f..b3e023f 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -20,6 +20,7 @@ vi.mock('../../src/models/authEvents.js', () => ({ AuthEvent: { create: vi.fn(), findAll: vi.fn(), + findAndCountAll: vi.fn(), count: vi.fn(), }, })); From 1bbcb4fe7c7d99413ec2853c4b664e468473b9f3 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 6 Oct 2026 22:52:46 -0400 Subject: [PATCH 2/2] chore(deps): depend on @seamless-auth/types ^0.28.0 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4082f96..55ff70c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.27.0", + "@seamless-auth/types": "^0.28.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -3082,9 +3082,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.27.0.tgz", - "integrity": "sha512-kIrPtdnSOxhDKdxI7NfoRO7pHT23jgpT3pgB8Aa2asGMnasU6pPeBceN1CngUgKpU8ZkOv10mRN44bfgVZGNSA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.28.0.tgz", + "integrity": "sha512-VKb83pSd0KyL608I4pqKxs0KYZNTHlc2CaPula9rtURcgj3eZDky39idSTvsRbHQTlxblvPeNLsgOBnyr+P7FQ==", "license": "Apache-2.0", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index cb58ab5..0920488 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.27.0", + "@seamless-auth/types": "^0.28.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2",