Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions backend/__tests__/unit/controllers/messageController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } };
Expand Down
23 changes: 23 additions & 0 deletions backend/__tests__/unit/models/Message.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
26 changes: 25 additions & 1 deletion backend/controllers/messageController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,31 @@ const normalizeMongo = (m: Record<string, unknown>): NormalizedMessage => {
exports.getMessages = async (req: AuthRequest, res: Response): Promise<void> => {
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.
Expand Down
9 changes: 8 additions & 1 deletion backend/models/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ const MessageSchema = new Schema<IMessage>({
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
Expand Down
Loading