Skip to content

Commit 874888f

Browse files
committed
Validate client timeline item createdAt handling
1 parent 033a7c7 commit 874888f

10 files changed

Lines changed: 842 additions & 66 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
CLIENT_TIMELINE_ITEM_CREATED_AT_MAX_FUTURE_SKEW_MS,
4+
ClientTimelineItemCreatedAtValidationError,
5+
normalizeClientTimelineItemCreatedAt,
6+
} from "./client-timeline-item-created-at";
7+
8+
describe("normalizeClientTimelineItemCreatedAt", () => {
9+
const now = new Date("2026-04-17T10:00:00.000Z");
10+
11+
it("returns undefined when createdAt is omitted", () => {
12+
expect(
13+
normalizeClientTimelineItemCreatedAt({
14+
field: "item.createdAt",
15+
now,
16+
})
17+
).toBeUndefined();
18+
});
19+
20+
it("accepts the current timestamp", () => {
21+
const result = normalizeClientTimelineItemCreatedAt({
22+
createdAt: "2026-04-17T10:00:00.000Z",
23+
field: "item.createdAt",
24+
now,
25+
});
26+
27+
expect(result?.toISOString()).toBe("2026-04-17T10:00:00.000Z");
28+
});
29+
30+
it("accepts historical timestamps", () => {
31+
const result = normalizeClientTimelineItemCreatedAt({
32+
createdAt: "2026-04-13T10:00:00.000Z",
33+
field: "item.createdAt",
34+
now,
35+
});
36+
37+
expect(result?.toISOString()).toBe("2026-04-13T10:00:00.000Z");
38+
});
39+
40+
it("accepts timestamps exactly at the future boundary", () => {
41+
const result = normalizeClientTimelineItemCreatedAt({
42+
createdAt: new Date(
43+
now.getTime() + CLIENT_TIMELINE_ITEM_CREATED_AT_MAX_FUTURE_SKEW_MS
44+
).toISOString(),
45+
field: "item.createdAt",
46+
now,
47+
});
48+
49+
expect(result?.toISOString()).toBe("2026-04-17T10:05:00.000Z");
50+
});
51+
52+
it("rejects timestamps beyond the future boundary", () => {
53+
expect(() =>
54+
normalizeClientTimelineItemCreatedAt({
55+
createdAt: new Date(
56+
now.getTime() + CLIENT_TIMELINE_ITEM_CREATED_AT_MAX_FUTURE_SKEW_MS + 1
57+
).toISOString(),
58+
field: "item.createdAt",
59+
now,
60+
})
61+
).toThrow(ClientTimelineItemCreatedAtValidationError);
62+
});
63+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export const CLIENT_TIMELINE_ITEM_CREATED_AT_MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
2+
3+
export class ClientTimelineItemCreatedAtValidationError extends Error {
4+
constructor(message: string) {
5+
super(message);
6+
this.name = "ClientTimelineItemCreatedAtValidationError";
7+
}
8+
}
9+
10+
export function normalizeClientTimelineItemCreatedAt(params: {
11+
createdAt?: string | Date;
12+
field: string;
13+
now?: Date;
14+
}): Date | undefined {
15+
const { createdAt, field, now = new Date() } = params;
16+
17+
if (createdAt === undefined) {
18+
return;
19+
}
20+
21+
const normalizedDate =
22+
createdAt instanceof Date ? createdAt : new Date(createdAt);
23+
24+
if (Number.isNaN(normalizedDate.getTime())) {
25+
throw new ClientTimelineItemCreatedAtValidationError(
26+
`${field} must be a valid RFC 3339 / ISO 8601 timestamp.`
27+
);
28+
}
29+
30+
if (
31+
normalizedDate.getTime() >
32+
now.getTime() + CLIENT_TIMELINE_ITEM_CREATED_AT_MAX_FUTURE_SKEW_MS
33+
) {
34+
throw new ClientTimelineItemCreatedAtValidationError(
35+
`${field} cannot be more than 5 minutes in the future.`
36+
);
37+
}
38+
39+
return normalizedDate;
40+
}

apps/api/src/rest/openapi-contract.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
createConversationRequestSchema,
77
getConversationResponseSchema,
88
} from "@cossistant/types/api/conversation";
9+
import { sendTimelineItemRequestSchema } from "@cossistant/types/api/timeline-item";
910
import { OpenAPIHono } from "@hono/zod-openapi";
1011
import {
1112
actorUserIdHeader,
@@ -50,10 +51,13 @@ type OpenAPIMetadataSchema = {
5051
};
5152

5253
type OpenAPISchemaWithProperties = {
54+
type?: string | string[];
55+
description?: string;
5356
properties?: Record<
5457
string,
5558
OpenAPIMetadataSchema | OpenAPISchemaWithProperties
5659
>;
60+
items?: OpenAPISchemaWithProperties;
5761
required?: string[];
5862
};
5963

@@ -288,6 +292,129 @@ describe("REST OpenAPI contract guards", () => {
288292
expect(responseValueTypes).toEqual(["boolean", "null", "number", "string"]);
289293
});
290294

295+
it("documents client timeline item inputs and createdAt rules for conversation bootstrap and message sends", () => {
296+
const app = new OpenAPIHono();
297+
298+
app.openapi(
299+
{
300+
method: "post",
301+
path: "/conversations",
302+
request: {
303+
body: {
304+
required: true,
305+
content: {
306+
"application/json": {
307+
schema: createConversationRequestSchema,
308+
},
309+
},
310+
},
311+
},
312+
responses: {
313+
200: {
314+
description: "Conversation created",
315+
},
316+
},
317+
},
318+
(() => new Response(null)) as never
319+
);
320+
321+
app.openapi(
322+
{
323+
method: "post",
324+
path: "/messages",
325+
request: {
326+
body: {
327+
required: true,
328+
content: {
329+
"application/json": {
330+
schema: sendTimelineItemRequestSchema,
331+
},
332+
},
333+
},
334+
},
335+
responses: {
336+
200: {
337+
description: "Timeline item created",
338+
},
339+
},
340+
},
341+
(() => new Response(null)) as never
342+
);
343+
344+
const doc = app.getOpenAPI31Document({
345+
openapi: "3.1.0",
346+
info: {
347+
title: "OpenAPI timeline input contract test",
348+
version: "1.0.0",
349+
},
350+
});
351+
352+
const createPath = doc.paths?.["/conversations"]?.post;
353+
const createRequestBody = createPath?.requestBody as
354+
| OpenAPIJsonContent
355+
| undefined;
356+
const createRequestSchema = createRequestBody?.content?.["application/json"]
357+
?.schema as OpenAPISchemaWithProperties | undefined;
358+
const defaultTimelineItemsSchema = createRequestSchema?.properties
359+
?.defaultTimelineItems as OpenAPISchemaWithProperties | undefined;
360+
const defaultTimelineItemInput = defaultTimelineItemsSchema?.items;
361+
const createCreatedAtSchema = defaultTimelineItemInput?.properties
362+
?.createdAt as OpenAPIMetadataSchema | undefined;
363+
364+
const messagesPath = doc.paths?.["/messages"]?.post;
365+
const messagesRequestBody = messagesPath?.requestBody as
366+
| OpenAPIJsonContent
367+
| undefined;
368+
const messagesRequestSchema = messagesRequestBody?.content?.[
369+
"application/json"
370+
]?.schema as OpenAPISchemaWithProperties | undefined;
371+
const messageItemInput = messagesRequestSchema?.properties?.item as
372+
| OpenAPISchemaWithProperties
373+
| undefined;
374+
const messageCreatedAtSchema = messageItemInput?.properties?.createdAt as
375+
| OpenAPIMetadataSchema
376+
| undefined;
377+
378+
expect(defaultTimelineItemInput?.properties).not.toHaveProperty(
379+
"conversationId"
380+
);
381+
expect(defaultTimelineItemInput?.properties).not.toHaveProperty(
382+
"organizationId"
383+
);
384+
expect(defaultTimelineItemInput?.properties).not.toHaveProperty(
385+
"deletedAt"
386+
);
387+
expect(defaultTimelineItemInput?.required ?? []).not.toContain(
388+
"conversationId"
389+
);
390+
expect(defaultTimelineItemInput?.required ?? []).not.toContain(
391+
"organizationId"
392+
);
393+
expect(defaultTimelineItemInput?.required ?? []).not.toContain("deletedAt");
394+
expect(createCreatedAtSchema?.description).toContain(
395+
"server assigns the timestamp"
396+
);
397+
expect(createCreatedAtSchema?.description).toContain(
398+
"Historical timestamps are allowed"
399+
);
400+
expect(createCreatedAtSchema?.description).toContain(
401+
"more than 5 minutes in the future are rejected"
402+
);
403+
404+
expect(messageItemInput?.properties).not.toHaveProperty("conversationId");
405+
expect(messageItemInput?.properties).not.toHaveProperty("organizationId");
406+
expect(messageItemInput?.properties).not.toHaveProperty("deletedAt");
407+
expect(messageCreatedAtSchema?.description).toContain(
408+
"server assigns the timestamp"
409+
);
410+
expect(messageCreatedAtSchema?.description).toContain(
411+
"Historical timestamps are allowed"
412+
);
413+
expect(messageCreatedAtSchema?.description).toContain(
414+
"more than 5 minutes in the future are rejected"
415+
);
416+
});
417+
291418
it("documents contact identify visitorId precedence between body and X-Visitor-Id", () => {
292419
const app = new OpenAPIHono();
293420

apps/api/src/rest/routers/conversation-create.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,96 @@ describe("POST /v1/conversations", () => {
634634
expect(payload.conversation.channel).toBe("widget");
635635
});
636636

637+
it("returns 400 when a default timeline item createdAt is more than 5 minutes in the future", async () => {
638+
const dbHarness = createDbHarness({});
639+
safelyExtractRequestDataMock.mockResolvedValue({
640+
db: dbHarness.db,
641+
website: baseWebsite,
642+
organization: baseOrganization,
643+
visitorIdHeader: "visitor-1",
644+
body: {
645+
conversationId: "conv-1",
646+
visitorId: "visitor-1",
647+
defaultTimelineItems: [
648+
{
649+
type: "message",
650+
text: "hello",
651+
parts: [{ type: "text", text: "hello" }],
652+
visibility: "public",
653+
userId: null,
654+
visitorId: "visitor-1",
655+
aiAgentId: null,
656+
createdAt: "3026-02-26T00:00:00.000Z",
657+
deletedAt: null,
658+
},
659+
],
660+
channel: "widget",
661+
},
662+
});
663+
664+
const { conversationRouter } = await conversationRouterModulePromise;
665+
const response = await conversationRouter.request(
666+
createValidConversationPostRequest()
667+
);
668+
const payload = (await response.json()) as {
669+
error: string;
670+
message: string;
671+
};
672+
673+
expect(response.status).toBe(400);
674+
expect(payload).toEqual({
675+
error: "BAD_REQUEST",
676+
message:
677+
"defaultTimelineItems[0].createdAt cannot be more than 5 minutes in the future.",
678+
});
679+
expect(getVisitorMock).not.toHaveBeenCalled();
680+
expect(upsertConversationMock).not.toHaveBeenCalled();
681+
expect(createMessageTimelineItemMock).not.toHaveBeenCalled();
682+
});
683+
684+
it("falls back to the server timestamp when a default timeline item omits createdAt", async () => {
685+
const dbHarness = createDbHarness({});
686+
safelyExtractRequestDataMock.mockResolvedValue({
687+
db: dbHarness.db,
688+
website: baseWebsite,
689+
organization: baseOrganization,
690+
visitorIdHeader: "visitor-1",
691+
body: {
692+
conversationId: "conv-1",
693+
visitorId: "visitor-1",
694+
defaultTimelineItems: [
695+
{
696+
type: "message",
697+
text: "hello",
698+
parts: [{ type: "text", text: "hello" }],
699+
visibility: "public",
700+
userId: null,
701+
visitorId: "visitor-1",
702+
aiAgentId: null,
703+
deletedAt: null,
704+
},
705+
],
706+
channel: "widget",
707+
},
708+
});
709+
upsertConversationMock.mockResolvedValue({
710+
status: "created",
711+
conversation: baseConversation,
712+
});
713+
714+
const { conversationRouter } = await conversationRouterModulePromise;
715+
const response = await conversationRouter.request(
716+
createValidConversationPostRequest()
717+
);
718+
719+
expect(response.status).toBe(200);
720+
expect(createMessageTimelineItemMock).toHaveBeenCalledWith(
721+
expect.objectContaining({
722+
createdAt: undefined,
723+
})
724+
);
725+
});
726+
637727
it("returns 409 when conversationId belongs to another owner tuple", async () => {
638728
const dbHarness = createDbHarness({});
639729
safelyExtractRequestDataMock.mockResolvedValue({

0 commit comments

Comments
 (0)