diff --git a/backend/__tests__/unit/controllers/messageController.test.js b/backend/__tests__/unit/controllers/messageController.test.js index eaafe8393a..d17a8fd63c 100644 --- a/backend/__tests__/unit/controllers/messageController.test.js +++ b/backend/__tests__/unit/controllers/messageController.test.js @@ -70,6 +70,33 @@ describe('messageController', () => { expect(res.status).toHaveBeenCalledWith(400); }); + it.each(['2019-02-30', '+002026-01-01T00:00:00.000Z', '1900-02-29', '2026-04-31', '+078915-01-01T00:00:00.000Z', '0000-01-01T00:00:00Z', 'garbage', ['2026-01-01', '2026-01-02']])( + 'rejects invalid pagination timestamp %j without querying either message store', async (before) => { + PGMessage.findByPodId.mockClear(); + MongoMessage.find.mockClear(); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await messageController.getMessages({ params: { podId: 'p1' }, query: { before }, user: { id: 'u1' } }, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(PGMessage.findByPodId).not.toHaveBeenCalled(); + expect(MongoMessage.find).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['2024-02-29', '2024-02-29T00:00:00.000Z'], + ['2026-10-24T12:30:15.123Z', '2026-10-24T12:30:15.123Z'], + ['2026-10-24T12:30:15+02:00', '2026-10-24T10:30:15.000Z'], + ])('keeps a valid calendar cursor %s on the PG path', async (before, canonical) => { + Pod.findById.mockResolvedValue({ members: ['u1'] }); + PGMessage.findByPodId.mockResolvedValue([]); + MongoMessage.find.mockClear(); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await messageController.getMessages({ params: { podId: 'p1' }, query: { before }, user: { id: 'u1' } }, res); + expect(PGMessage.findByPodId).toHaveBeenLastCalledWith('p1', 50, canonical); + expect(MongoMessage.find).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith([]); + }); + it('returns 404 if pod not found', async () => { Pod.findById.mockResolvedValue(null); const req = { params: { podId: 'p1' }, query: {}, user: { id: 'u1' } }; diff --git a/backend/__tests__/unit/models/Message.test.js b/backend/__tests__/unit/models/Message.test.js index 0bc02ef248..c70cdc376b 100644 --- a/backend/__tests__/unit/models/Message.test.js +++ b/backend/__tests__/unit/models/Message.test.js @@ -71,6 +71,29 @@ describe('Message Model Tests', () => { expect(savedMessage.updatedAt).toBeDefined(); }); + it.each(['+078915-01-01T00:00:00.000Z', '0000-12-31T23:59:59.999Z'])( + 'rejects nonportable createdAt %s before persistence', async (createdAt) => { + await expect(Message.create({ podId: testPod._id, userId: testUser._id, + content: 'Must not poison pagination', createdAt })).rejects.toMatchObject({ + name: 'ValidationError', errors: { createdAt: expect.anything() }, + }); + expect(await Message.countDocuments({ podId: testPod._id })).toBe(0); + }, + ); + + it('accepts four-digit-year timestamp boundaries without changing them', async () => { + for (const createdAt of ['0001-01-01T00:00:00.000Z', '9999-12-31T23:59:59.999Z']) { + const message = new Message({ podId: testPod._id, userId: testUser._id, content: 'Boundary', createdAt }); + await message.save(); + const reloaded = await Message.findById(message._id).lean(); + expect(reloaded.createdAt.toISOString()).toBe(createdAt); + expect(String(reloaded.podId)).toBe(String(testPod._id)); + expect(String(reloaded.userId)).toBe(String(testUser._id)); + expect(reloaded.content).toBe('Boundary'); + expect(reloaded.messageType).toBe('text'); + } + }); + it('should not save a message without required fields', async () => { // Test missing podId const messageWithoutPodId = new Message({ diff --git a/backend/controllers/messageController.ts b/backend/controllers/messageController.ts index 56230608ef..397f174e0d 100644 --- a/backend/controllers/messageController.ts +++ b/backend/controllers/messageController.ts @@ -85,7 +85,31 @@ const normalizeMongo = (m: Record): NormalizedMessage => { exports.getMessages = async (req: AuthRequest, res: Response): Promise => { try { const { podId } = req.params; - const { before } = req.query as { before?: string }; + let { before } = req.query as { before?: string }; + // Legacy Mongo rows can still supply an extended-year cursor. Refuse it + // before PG errors and the whole request falls back to Mongo. + if (before !== undefined) { + // Date.parse normalizes February 30 and accepts six-digit signed + // years that PG rejects. Validate calendar fields before parsing. + const match = typeof before === 'string' + ? /^(\d{4})-(\d{2})-(\d{2})(?:T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d))?$/.exec(before) + : null; + const year = Number(match?.[1]); + const month = Number(match?.[2]); + const day = Number(match?.[3]); + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const cursor = match ? new Date(before) : null; + if (!cursor || !Number.isFinite(cursor.getTime()) || year < 1 + || month < 1 || month > 12 || day < 1 || day > days[month - 1] + || cursor.getUTCFullYear() < 1 || cursor.getUTCFullYear() > 9999) { + res.status(400).json({ msg: 'before must be a valid ISO date or timestamp with a year from 0001 to 9999' }); + return; + } + // Send one portable representation to PG, including for offset inputs. + before = cursor.toISOString(); + } + // Clamp the page size [1, 200]. Unbounded `limit` let a caller ask for // millions of rows → OOM (both the PG and Mongo read paths below). Mirrors // the postController.getPosts clamp pattern. diff --git a/backend/models/Message.ts b/backend/models/Message.ts index 9af8e046aa..2c66dda65d 100644 --- a/backend/models/Message.ts +++ b/backend/models/Message.ts @@ -53,7 +53,14 @@ const MessageSchema = new Schema({ authorKind: { type: String, enum: ['kernel', null], default: null }, payload: { type: Schema.Types.Mixed }, metadata: { type: Schema.Types.Mixed }, - createdAt: { type: Date, default: Date.now }, + // Client pagination and the PG timestamp parser require four-digit years. + // JS/Mongo accept extended ISO years (e.g. +078915), which PG rejects when + // a client sends the stored value back as its pagination cursor. + createdAt: { + type: Date, default: Date.now, required: true, + min: new Date('0001-01-01T00:00:00.000Z'), + max: new Date('9999-12-31T23:59:59.999Z'), + }, updatedAt: { type: Date, default: Date.now }, // New Mongo-only writes start pending. The post-save hook stamps a server // commit marker after the insert is acknowledged; a crash before that stamp