diff --git a/package.json b/package.json index 25b4791b..3800d089 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.14", + "version": "1.5.15", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 029d30f4..40bfb51e 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -24,56 +24,243 @@ const DAILY_EVENTS_GROUP_HASH_INDEX_NAME = 'groupHash'; const MAX_SEARCH_QUERY_LENGTH = 50; const FALLBACK_EVENT_TITLE = 'Unknown'; const { limitBacktraceForDailyEventsList } = require('../utils/eventPayloadLimits'); +const { + isUnsafeUnixTimestamp, + toSafeGraphQLInt, + toSafeUnixTimestampForGraphQLInt, + toSafeSortValueBoundary, + utcMidnightUnix, +} = require('../utils/graphqlIntSafe'); /** - * Temporary list-response sanitizer: - * - fallback for empty payload.title - * - cap backtrace frames/sourceCode size (heavy Rails stacks) + * TEMPORARY (remove after ~2026-11-15): clamps nextCursor Int fields. + * Factory still matches raw Mongo boundaries — converted cursors may skip + * leftover legacy rows on later pages (see sanitizeDailyEventsPortion note). * - * @param {object} dailyEventsPortion - portion returned by events factory - * @param {string|ObjectId} projectId - project id for logs - * @returns {object} + * @param {object} cursor - DailyEventsCursor from factory + * @param {string|null} projectIdStr - project id for logs + * @param {string|undefined} sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS + * @param {number} nowSec - current unix seconds + * @returns {object} original or converted cursor */ -function sanitizeDailyEventsPortion(dailyEventsPortion, projectId) { - if (!dailyEventsPortion || !Array.isArray(dailyEventsPortion.dailyEvents)) { - return dailyEventsPortion; +function sanitizeDailyEventsCursor(cursor, projectIdStr, sort, nowSec) { + if (!cursor) { + return cursor; } - dailyEventsPortion.dailyEvents = dailyEventsPortion.dailyEvents.map((dailyEvent) => { - const event = dailyEvent && dailyEvent.event ? dailyEvent.event : null; - const payload = event && event.payload ? event.payload : null; - const rawTitle = payload && typeof payload.title === 'string' ? payload.title : ''; - const hasValidTitle = rawTitle.trim().length > 0; - const title = hasValidTitle ? rawTitle : FALLBACK_EVENT_TITLE; - const backtrace = limitBacktraceForDailyEventsList(payload && payload.backtrace); - const titleChanged = !payload || payload.title !== title; - const backtraceChanged = !payload || payload.backtrace !== backtrace; - - if (!hasValidTitle) { - console.warn('🔴 [ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', { - projectId: projectId ? projectId.toString() : null, - dailyEventId: dailyEvent && dailyEvent.id ? dailyEvent.id.toString() : null, - dailyEventGroupHash: dailyEvent && dailyEvent.groupHash ? dailyEvent.groupHash.toString() : null, - eventOriginalId: event && event.originalEventId ? event.originalEventId.toString() : null, - eventId: event && event._id ? event._id.toString() : null, - }); - } + const safeGrouping = toSafeUnixTimestampForGraphQLInt( + cursor.groupingTimestampBoundary, + cursor.idBoundary, + nowSec + ); + const safeSort = toSafeSortValueBoundary( + cursor.sortValueBoundary, + cursor.idBoundary, + sort, + nowSec + ); + + if ( + safeGrouping === cursor.groupingTimestampBoundary && + safeSort === cursor.sortValueBoundary + ) { + return cursor; + } - if (!titleChanged && !backtraceChanged) { - return dailyEvent; - } + console.warn('🟡 [ProjectResolver.dailyEventsPortion] Converted nextCursor Int-unsafe values', { + projectId: projectIdStr, + sort, + before: { + groupingTimestampBoundary: cursor.groupingTimestampBoundary, + sortValueBoundary: cursor.sortValueBoundary, + }, + after: { + groupingTimestampBoundary: safeGrouping, + sortValueBoundary: safeSort, + }, + }); + + return { + ...cursor, + groupingTimestampBoundary: safeGrouping, + sortValueBoundary: safeSort, + }; +} - return { - ...dailyEvent, - event: { - ...(event || {}), +/** + * TEMPORARY (remove after ~2026-11-15): sanitizes one DailyEvent row for GraphQL + * list response — title fallback, backtrace limits, Int-safe timestamps/counts. + * Drop once collector clamp has aged out bad dailyEvents / repetitions. + * + * @param {object} dailyEvent - DailyEvent from factory + * @param {string|null} projectIdStr - project id for logs + * @param {number} nowSec - current unix seconds + * @returns {object} + */ +function sanitizeDailyEvent(dailyEvent, projectIdStr, nowSec) { + const event = dailyEvent && dailyEvent.event ? dailyEvent.event : null; + const payload = event && event.payload ? event.payload : null; + const rawTitle = payload && typeof payload.title === 'string' ? payload.title : ''; + const hasValidTitle = rawTitle.trim().length > 0; + const title = hasValidTitle ? rawTitle : FALLBACK_EVENT_TITLE; + const backtrace = limitBacktraceForDailyEventsList(payload && payload.backtrace); + const titleChanged = !payload || payload.title !== title; + const backtraceChanged = !payload || payload.backtrace !== backtrace; + + const fallbackId = (event && (event._id || event.id)) || + (dailyEvent && dailyEvent.id) || + null; + + const safeLastRepetitionTime = toSafeUnixTimestampForGraphQLInt( + dailyEvent && dailyEvent.lastRepetitionTime, + fallbackId, + nowSec + ); + const safeGroupingTimestamp = toSafeUnixTimestampForGraphQLInt( + dailyEvent && dailyEvent.groupingTimestamp, + fallbackId, + nowSec + ); + /** + * Prefer midnight of corrected lastRepetitionTime when grouping was also bad, + * so day buckets stay consistent with the event time we expose. + * Always use already-normalized safe* values — never raw ms. + */ + const groupingNeedsFix = isUnsafeUnixTimestamp(dailyEvent && dailyEvent.groupingTimestamp, nowSec); + const lastRepetitionNeedsFix = isUnsafeUnixTimestamp(dailyEvent && dailyEvent.lastRepetitionTime, nowSec); + const correctedGroupingTimestamp = groupingNeedsFix + ? utcMidnightUnix( + typeof safeLastRepetitionTime === 'number' + ? safeLastRepetitionTime + : safeGroupingTimestamp + ) + : safeGroupingTimestamp; + + const safeCount = typeof (dailyEvent && dailyEvent.count) === 'number' + ? toSafeGraphQLInt(dailyEvent.count, 0) + : dailyEvent.count; + const safeAffectedUsers = typeof (dailyEvent && dailyEvent.affectedUsers) === 'number' + ? toSafeGraphQLInt(dailyEvent.affectedUsers, 0) + : dailyEvent.affectedUsers; + + let nextEvent = event; + + if (event) { + const safeTotalCount = typeof event.totalCount === 'number' + ? toSafeGraphQLInt(event.totalCount, 0) + : event.totalCount; + const safeUsersAffected = typeof event.usersAffected === 'number' + ? toSafeGraphQLInt(event.usersAffected, 0) + : event.usersAffected; + const safeEventTimestamp = toSafeUnixTimestampForGraphQLInt( + event.timestamp, + fallbackId, + nowSec + ); + + const eventIntsChanged = safeTotalCount !== event.totalCount || + safeUsersAffected !== event.usersAffected || + safeEventTimestamp !== event.timestamp; + + if (eventIntsChanged || titleChanged || backtraceChanged) { + nextEvent = { + ...event, + totalCount: safeTotalCount, + usersAffected: safeUsersAffected, + timestamp: safeEventTimestamp, payload: { ...(payload || {}), title, backtrace, }, + }; + } + } else if (titleChanged || backtraceChanged) { + nextEvent = { + ...(event || {}), + payload: { + ...(payload || {}), + title, + backtrace, }, }; + } + + const dailyChanged = correctedGroupingTimestamp !== dailyEvent.groupingTimestamp || + safeLastRepetitionTime !== dailyEvent.lastRepetitionTime || + safeCount !== dailyEvent.count || + safeAffectedUsers !== dailyEvent.affectedUsers || + nextEvent !== event; + + if (dailyChanged && (groupingNeedsFix || lastRepetitionNeedsFix)) { + console.warn('🟡 [ProjectResolver.dailyEventsPortion] Converted Int-unsafe daily event timestamps', { + projectId: projectIdStr, + dailyEventId: dailyEvent && dailyEvent.id ? dailyEvent.id.toString() : null, + before: { + groupingTimestamp: dailyEvent.groupingTimestamp, + lastRepetitionTime: dailyEvent.lastRepetitionTime, + }, + after: { + groupingTimestamp: correctedGroupingTimestamp, + lastRepetitionTime: safeLastRepetitionTime, + }, + }); + } + + if (!hasValidTitle) { + console.warn('🔴 [ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', { + projectId: projectIdStr, + dailyEventId: dailyEvent && dailyEvent.id ? dailyEvent.id.toString() : null, + dailyEventGroupHash: dailyEvent && dailyEvent.groupHash ? dailyEvent.groupHash.toString() : null, + eventOriginalId: event && event.originalEventId ? event.originalEventId.toString() : null, + eventId: event && event._id ? event._id.toString() : null, + }); + } + + if (!dailyChanged) { + return dailyEvent; + } + + return { + ...dailyEvent, + count: safeCount, + affectedUsers: safeAffectedUsers, + groupingTimestamp: correctedGroupingTimestamp, + lastRepetitionTime: safeLastRepetitionTime, + event: nextEvent, + }; +} + +/** + * TEMPORARY (remove after ~2026-11-15): list-response sanitizer for + * dailyEventsPortion — title/backtrace hygiene plus Int overflow conversion + * for legacy far-future Sentry timestamps. Safe to delete once those docs age out. + * + * Note: converting nextCursor can skip remaining legacy rows on later pages + * (factory matches raw Mongo fields). Acceptable trade-off vs aggregation cost. + * + * @param {object} dailyEventsPortion - portion returned by events factory + * @param {string|ObjectId} projectId - project id for logs + * @param {string|undefined} sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS + * @returns {object} + */ +function sanitizeDailyEventsPortion(dailyEventsPortion, projectId, sort) { + if (!dailyEventsPortion || !Array.isArray(dailyEventsPortion.dailyEvents)) { + return dailyEventsPortion; + } + + const projectIdStr = projectId ? projectId.toString() : null; + const nowSec = Math.floor(Date.now() / 1000); + + dailyEventsPortion.nextCursor = sanitizeDailyEventsCursor( + dailyEventsPortion.nextCursor, + projectIdStr, + sort, + nowSec + ); + + dailyEventsPortion.dailyEvents = dailyEventsPortion.dailyEvents.map((dailyEvent) => { + return sanitizeDailyEvent(dailyEvent, projectIdStr, nowSec); }); return dailyEventsPortion; @@ -675,7 +862,7 @@ module.exports = { assignee ); - return sanitizeDailyEventsPortion(dailyEventsPortion, project._id); + return sanitizeDailyEventsPortion(dailyEventsPortion, project._id, sort); }, /** diff --git a/src/utils/graphqlIntSafe.js b/src/utils/graphqlIntSafe.js new file mode 100644 index 00000000..b1c4b5ca --- /dev/null +++ b/src/utils/graphqlIntSafe.js @@ -0,0 +1,228 @@ +/** + * TEMPORARY (remove after ~2026-11-15 together with sanitizeDailyEvent* in + * project.js): helpers to keep GraphQL Int fields within the signed 32-bit + * range while legacy/bad Sentry timestamps (e.g. year 2056) may still exist. + */ + +const GRAPHQL_INT_MIN = -2147483648; +const GRAPHQL_INT_MAX = 2147483647; + +/** + * Allow a small clock skew ahead of server time. + */ +const FUTURE_SLACK_SEC = 24 * 60 * 60; + +/** + * Reject timestamps older than this relative to now. + */ +const MAX_PAST_SEC = 10 * 365.25 * 24 * 60 * 60; + +/** + * Values above this are almost certainly unix milliseconds, not seconds. + */ +const UNIX_MS_THRESHOLD = 1e12; + +/** + * GraphQL / factory sort modes that use a unix timestamp as sortValueBoundary. + */ +const TIMESTAMP_SORT_MODES = new Set([ + 'BY_DATE', + 'lastRepetitionTime', + undefined, + null, + '', +]); + +/** + * @param {*} value + * @returns {boolean} + */ +function isOutOfGraphQLIntRange(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return false; + } + + if (!Number.isInteger(value)) { + return true; + } + + return value < GRAPHQL_INT_MIN || value > GRAPHQL_INT_MAX; +} + +/** + * @param {string|object|null|undefined} id - Mongo ObjectId or hex string + * @returns {number|null} unix seconds from ObjectId, or null + */ +function unixSecondsFromObjectId(id) { + if (!id) { + return null; + } + + const hex = id.toString().slice(0, 8); + + if (!/^[a-fA-F0-9]{8}$/.test(hex)) { + return null; + } + + const ts = parseInt(hex, 16); + + if (!Number.isFinite(ts)) { + return null; + } + + return ts; +} + +/** + * @param {number} unixSeconds + * @returns {number} UTC midnight unix seconds + */ +function utcMidnightUnix(unixSeconds) { + const date = new Date(unixSeconds * 1000); + + date.setUTCHours(0, 0, 0, 0); + + return Math.floor(date.getTime() / 1000); +} + +/** + * Normalize a stored timestamp to unix seconds (ms → sec). Does not range-check. + * + * @param {number} value + * @returns {number} + */ +function normalizeUnixSeconds(value) { + let ts = Math.trunc(value); + + if (ts > UNIX_MS_THRESHOLD) { + ts = Math.floor(ts / 1000); + } + + return ts; +} + +/** + * Clamp any number into GraphQL Int range. + * + * @param {*} value + * @param {number} [fallback=0] + * @returns {number} + */ +function toSafeGraphQLInt(value, fallback = 0) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return toSafeGraphQLInt(fallback, 0); + } + + const intValue = Math.trunc(value); + + if (intValue > GRAPHQL_INT_MAX) { + return GRAPHQL_INT_MAX; + } + + if (intValue < GRAPHQL_INT_MIN) { + return GRAPHQL_INT_MIN; + } + + return intValue; +} + +/** + * Convert a stored unix timestamp into a GraphQL-Int-safe value. + * Prefers ObjectId receive-time when the stored value is absurd / out of Int32. + * Non-numbers are returned unchanged. + * + * @param {*} value - stored timestamp (seconds or ms) + * @param {string|object|null|undefined} fallbackId - ObjectId for fallback seconds + * @param {number} [nowSec=Math.floor(Date.now()/1000)] + * @returns {*} + */ +function toSafeUnixTimestampForGraphQLInt(value, fallbackId, nowSec = Math.floor(Date.now() / 1000)) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return value; + } + + const fromOid = unixSecondsFromObjectId(fallbackId); + const fallback = fromOid != null ? fromOid : Math.min(nowSec, GRAPHQL_INT_MAX); + const ts = normalizeUnixSeconds(value); + + const maxFuture = nowSec + FUTURE_SLACK_SEC; + const minPast = nowSec - MAX_PAST_SEC; + + if ( + ts > GRAPHQL_INT_MAX || + ts < GRAPHQL_INT_MIN || + ts > maxFuture || + ts < minPast + ) { + return toSafeGraphQLInt(fallback, nowSec); + } + + return ts; +} + +/** + * @param {*} value + * @param {number} [nowSec=Math.floor(Date.now()/1000)] + * @returns {boolean} + */ +function isUnsafeUnixTimestamp(value, nowSec = Math.floor(Date.now() / 1000)) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return false; + } + + const ts = normalizeUnixSeconds(value); + const maxFuture = nowSec + FUTURE_SLACK_SEC; + const minPast = nowSec - MAX_PAST_SEC; + + /** + * Normalized seconds differ from the original → ms or non-integer input; + * must not be passed through as a GraphQL Int / into utcMidnightUnix raw. + */ + if (ts !== value) { + return true; + } + + return ( + ts > GRAPHQL_INT_MAX || + ts < GRAPHQL_INT_MIN || + ts > maxFuture || + ts < minPast + ); +} + +/** + * sortValueBoundary may be lastRepetitionTime (BY_DATE), count, or affectedUsers. + * + * @param {*} value + * @param {string|object|null|undefined} idBoundary + * @param {string|null|undefined} sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS (or factory field name) + * @param {number} [nowSec] + * @returns {*} + */ +function toSafeSortValueBoundary(value, idBoundary, sort, nowSec = Math.floor(Date.now() / 1000)) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return value; + } + + if (TIMESTAMP_SORT_MODES.has(sort)) { + return toSafeUnixTimestampForGraphQLInt(value, idBoundary, nowSec); + } + + return toSafeGraphQLInt(value, 0); +} + +module.exports = { + GRAPHQL_INT_MIN, + GRAPHQL_INT_MAX, + FUTURE_SLACK_SEC, + MAX_PAST_SEC, + UNIX_MS_THRESHOLD, + isOutOfGraphQLIntRange, + isUnsafeUnixTimestamp, + unixSecondsFromObjectId, + utcMidnightUnix, + normalizeUnixSeconds, + toSafeGraphQLInt, + toSafeUnixTimestampForGraphQLInt, + toSafeSortValueBoundary, +}; diff --git a/test/resolvers/project-daily-events-portion.test.ts b/test/resolvers/project-daily-events-portion.test.ts index e399f001..b4fd39c9 100644 --- a/test/resolvers/project-daily-events-portion.test.ts +++ b/test/resolvers/project-daily-events-portion.test.ts @@ -11,6 +11,8 @@ jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ import projectResolverModule from '../../src/resolvers/project'; import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; +const { GRAPHQL_INT_MAX } = require('../../src/utils/graphqlIntSafe'); + const projectResolver = projectResolverModule as { Project: { dailyEventsPortion: (...args: unknown[]) => Promise; @@ -221,6 +223,130 @@ describe('Project resolver dailyEventsPortion', () => { warnSpy.mockRestore(); }); + it('should convert far-future timestamps to ObjectId-based Int-safe values', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const eventObjectId = '6aa93c9b3a3878cb15936a41'; + const expectedTs = parseInt(eventObjectId.slice(0, 8), 16); + const expectedMidnight = Math.floor(new Date(expectedTs * 1000).setUTCHours(0, 0, 0, 0) / 1000); + + const findDailyEventsPortion = jest.fn().mockResolvedValue({ + nextCursor: { + groupingTimestampBoundary: 2736115200, + sortValueBoundary: 2736187957, + idBoundary: '6aa82a4f9f06968718806c76', + }, + dailyEvents: [ + { + id: '6aa93c9b9eb65b518e9f8cf0', + count: 1, + affectedUsers: 0, + groupingTimestamp: 2736201600, + lastRepetitionTime: 2736250836, + event: { + _id: eventObjectId, + originalEventId: '6a217a79db8fff3481881dd4', + totalCount: 13692, + usersAffected: 0, + timestamp: 2736250836, + payload: { + title: 'Future clock event', + }, + }, + }, + ], + }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + findDailyEventsPortion, + }); + + const project = { _id: 'project-1' }; + const result = await projectResolver.Project.dailyEventsPortion(project, { + limit: 10, + nextCursor: null, + sort: 'BY_DATE', + filters: {}, + search: '', + }, {}) as { + nextCursor: { + groupingTimestampBoundary: number; + sortValueBoundary: number; + }; + dailyEvents: Array<{ + groupingTimestamp: number; + lastRepetitionTime: number; + event: { timestamp: number; totalCount: number }; + }>; + }; + + expect(result.dailyEvents[0].groupingTimestamp).toBe(expectedMidnight); + expect(result.dailyEvents[0].lastRepetitionTime).toBe(expectedTs); + expect(result.dailyEvents[0].event.timestamp).toBe(expectedTs); + expect(result.dailyEvents[0].event.totalCount).toBe(13692); + /** + * Cursor is converted with the same helpers the factory uses for match/sort. + */ + expect(result.nextCursor.groupingTimestampBoundary).toBe(parseInt('6aa82a4f', 16)); + expect(result.nextCursor.sortValueBoundary).toBe(parseInt('6aa82a4f', 16)); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('should normalize millisecond lastRepetitionTime before utc midnight', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const eventObjectId = '6aa93c9b3a3878cb15936a41'; + const nowSec = Math.floor(Date.now() / 1000); + const lastRepetitionMs = (nowSec - 60) * 1000; + const expectedSeconds = nowSec - 60; + const expectedMidnight = Math.floor(new Date(expectedSeconds * 1000).setUTCHours(0, 0, 0, 0) / 1000); + + const findDailyEventsPortion = jest.fn().mockResolvedValue({ + nextCursor: null, + dailyEvents: [ + { + id: '6aa93c9b9eb65b518e9f8cf0', + count: 1, + affectedUsers: 0, + groupingTimestamp: 2736201600, + lastRepetitionTime: lastRepetitionMs, + event: { + _id: eventObjectId, + originalEventId: '6a217a79db8fff3481881dd4', + totalCount: 1, + timestamp: lastRepetitionMs, + payload: { + title: 'ms timestamp', + }, + }, + }, + ], + }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + findDailyEventsPortion, + }); + + const result = await projectResolver.Project.dailyEventsPortion({ _id: 'project-1' }, { + limit: 10, + nextCursor: null, + sort: 'BY_DATE', + filters: {}, + search: '', + }, {}) as { + dailyEvents: Array<{ + groupingTimestamp: number; + lastRepetitionTime: number; + event: { timestamp: number }; + }>; + }; + + expect(result.dailyEvents[0].lastRepetitionTime).toBe(expectedSeconds); + expect(result.dailyEvents[0].event.timestamp).toBe(expectedSeconds); + expect(result.dailyEvents[0].groupingTimestamp).toBe(expectedMidnight); + expect(result.dailyEvents[0].groupingTimestamp).toBeLessThanOrEqual(GRAPHQL_INT_MAX); + + warnSpy.mockRestore(); + }); + it('should cap backtrace frames and sourceCode size in list response', async () => { const longLine = 'x'.repeat(200); const frames = Array.from({ length: 80 }, (_, index) => { diff --git a/test/utils/graphqlIntSafe.test.ts b/test/utils/graphqlIntSafe.test.ts new file mode 100644 index 00000000..9e60668f --- /dev/null +++ b/test/utils/graphqlIntSafe.test.ts @@ -0,0 +1,69 @@ +import '../../src/env-test'; + +const { + GRAPHQL_INT_MAX, + isOutOfGraphQLIntRange, + isUnsafeUnixTimestamp, + unixSecondsFromObjectId, + utcMidnightUnix, + toSafeGraphQLInt, + toSafeUnixTimestampForGraphQLInt, + toSafeSortValueBoundary, +} = require('../../src/utils/graphqlIntSafe'); + +describe('graphqlIntSafe', () => { + const nowSec = Math.floor(new Date('2026-09-15T12:00:00Z').getTime() / 1000); + const objectId = '6aa93c9b3a3878cb15936a41'; // ~2026-09-15T12:39:55Z + const objectIdSec = unixSecondsFromObjectId(objectId); + + it('detects values outside GraphQL Int range', () => { + expect(isOutOfGraphQLIntRange(2736201600)).toBe(true); + expect(isOutOfGraphQLIntRange(GRAPHQL_INT_MAX)).toBe(false); + expect(isOutOfGraphQLIntRange(1.5)).toBe(true); + }); + + it('parses unix seconds from ObjectId', () => { + expect(objectIdSec).toBe(parseInt('6aa93c9b', 16)); + }); + + it('clamps oversized counts to Int max', () => { + expect(toSafeGraphQLInt(3000000000)).toBe(GRAPHQL_INT_MAX); + }); + + it('replaces far-future timestamps with ObjectId time', () => { + const farFuture = 2736250836; + const safe = toSafeUnixTimestampForGraphQLInt(farFuture, objectId, nowSec); + + expect(safe).toBe(objectIdSec); + expect(isOutOfGraphQLIntRange(safe)).toBe(false); + }); + + it('converts millisecond timestamps', () => { + const ms = (nowSec - 120) * 1000; + expect(toSafeUnixTimestampForGraphQLInt(ms, objectId, nowSec)).toBe(nowSec - 120); + }); + + it('treats integer millisecond timestamps as unsafe', () => { + const ms = (nowSec - 120) * 1000; + expect(isUnsafeUnixTimestamp(ms, nowSec)).toBe(true); + }); + + it('keeps reasonable timestamps', () => { + expect(toSafeUnixTimestampForGraphQLInt(nowSec - 3600, objectId, nowSec)).toBe(nowSec - 3600); + expect(isUnsafeUnixTimestamp(nowSec - 3600, nowSec)).toBe(false); + }); + + it('builds utc midnight from corrected time', () => { + const midnight = utcMidnightUnix(objectIdSec); + + expect(midnight).toBeLessThanOrEqual(objectIdSec); + expect(midnight % 86400).toBe(0); + }); + + it('uses timestamp conversion only for BY_DATE sort boundaries', () => { + expect(toSafeSortValueBoundary(2736187957, objectId, 'BY_DATE', nowSec)).toBe(objectIdSec); + expect(toSafeSortValueBoundary(3000000000, objectId, 'BY_COUNT', nowSec)).toBe(GRAPHQL_INT_MAX); + expect(toSafeSortValueBoundary(3000000000, objectId, 'BY_AFFECTED_USERS', nowSec)).toBe(GRAPHQL_INT_MAX); + expect(toSafeSortValueBoundary(42, objectId, 'BY_COUNT', nowSec)).toBe(42); + }); +});