@syncular/server-hono 0.0.2-138 → 0.0.2-140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1468 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { createPgliteDb } from '@syncular/dialect-pglite';
3
+ import { ensureSyncSchema, type SyncCoreDb } from '@syncular/server';
4
+ import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
5
+ import { Hono } from 'hono';
6
+ import type { Generated, Kysely } from 'kysely';
7
+ import {
8
+ type CreateConsoleRoutesOptions,
9
+ createConsoleRoutes,
10
+ } from '../console';
11
+
12
+ interface SyncRequestEventsTable {
13
+ event_id: Generated<number>;
14
+ partition_id: string;
15
+ request_id: string | null;
16
+ trace_id: string | null;
17
+ span_id: string | null;
18
+ event_type: 'push' | 'pull';
19
+ sync_path: 'http-combined' | 'ws-push';
20
+ actor_id: string;
21
+ client_id: string;
22
+ transport_path: 'direct' | 'relay';
23
+ status_code: number;
24
+ outcome: string;
25
+ response_status: string;
26
+ error_code: string | null;
27
+ duration_ms: number;
28
+ commit_seq: number | null;
29
+ operation_count: number | null;
30
+ row_count: number | null;
31
+ subscription_count: number | null;
32
+ scopes_summary: unknown | null;
33
+ tables: string[];
34
+ error_message: string | null;
35
+ payload_ref: string | null;
36
+ created_at: Generated<string>;
37
+ }
38
+
39
+ interface SyncRequestPayloadsTable {
40
+ payload_ref: string;
41
+ partition_id: string;
42
+ request_payload: unknown;
43
+ response_payload: unknown | null;
44
+ created_at: Generated<string>;
45
+ }
46
+
47
+ interface SyncOperationEventsTable {
48
+ operation_id: Generated<number>;
49
+ operation_type: string;
50
+ console_user_id: string | null;
51
+ partition_id: string | null;
52
+ target_client_id: string | null;
53
+ request_payload: unknown | null;
54
+ result_payload: unknown | null;
55
+ created_at: Generated<string>;
56
+ }
57
+
58
+ interface SyncApiKeysTable {
59
+ key_id: string;
60
+ key_hash: string;
61
+ key_prefix: string;
62
+ name: string;
63
+ key_type: 'relay' | 'proxy' | 'admin';
64
+ scope_keys: unknown | null;
65
+ actor_id: string | null;
66
+ created_at: Generated<string>;
67
+ expires_at: string | null;
68
+ last_used_at: string | null;
69
+ revoked_at: string | null;
70
+ }
71
+
72
+ interface TestDb extends SyncCoreDb {
73
+ sync_request_events: SyncRequestEventsTable;
74
+ sync_request_payloads: SyncRequestPayloadsTable;
75
+ sync_operation_events: SyncOperationEventsTable;
76
+ sync_api_keys: SyncApiKeysTable;
77
+ }
78
+
79
+ type TimelineResponse = {
80
+ items: Array<{
81
+ type: 'commit' | 'event';
82
+ timestamp: string;
83
+ commit: {
84
+ commitSeq: number;
85
+ actorId: string;
86
+ clientId: string;
87
+ affectedTables: string[];
88
+ } | null;
89
+ event: {
90
+ eventId: number;
91
+ actorId: string;
92
+ clientId: string;
93
+ eventType: 'push' | 'pull';
94
+ outcome: string;
95
+ tables: string[];
96
+ } | null;
97
+ }>;
98
+ total: number;
99
+ offset: number;
100
+ limit: number;
101
+ };
102
+
103
+ type OperationsResponse = {
104
+ items: Array<{
105
+ operationId: number;
106
+ operationType: 'prune' | 'compact' | 'notify_data_change' | 'evict_client';
107
+ consoleUserId: string | null;
108
+ partitionId: string | null;
109
+ targetClientId: string | null;
110
+ requestPayload: unknown;
111
+ resultPayload: unknown;
112
+ createdAt: string;
113
+ }>;
114
+ total: number;
115
+ offset: number;
116
+ limit: number;
117
+ };
118
+
119
+ type ApiKeysResponse = {
120
+ items: Array<{
121
+ keyId: string;
122
+ keyPrefix: string;
123
+ name: string;
124
+ keyType: 'relay' | 'proxy' | 'admin';
125
+ scopeKeys: string[];
126
+ actorId: string | null;
127
+ createdAt: string;
128
+ expiresAt: string | null;
129
+ lastUsedAt: string | null;
130
+ revokedAt: string | null;
131
+ }>;
132
+ total: number;
133
+ offset: number;
134
+ limit: number;
135
+ };
136
+
137
+ function timelineItemKey(item: TimelineResponse['items'][number]): string {
138
+ if (item.type === 'commit') {
139
+ return `C${item.commit?.commitSeq ?? 'unknown'}`;
140
+ }
141
+ return `E${item.event?.eventId ?? 'unknown'}`;
142
+ }
143
+
144
+ const CONSOLE_TOKEN = 'console-test-token';
145
+
146
+ describe('console timeline route filters', () => {
147
+ let db: Kysely<TestDb>;
148
+ let dialect: ReturnType<typeof createPostgresServerDialect>;
149
+ let app: Hono;
150
+
151
+ function atIso(minutes: number): string {
152
+ return new Date(
153
+ `2026-02-16T11:${String(minutes).padStart(2, '0')}:00.000Z`
154
+ ).toISOString();
155
+ }
156
+
157
+ async function requestTimeline(args: {
158
+ query?: Record<string, string | number | undefined>;
159
+ authenticated?: boolean;
160
+ }): Promise<Response> {
161
+ const params = new URLSearchParams({ limit: '50', offset: '0' });
162
+ for (const [key, value] of Object.entries(args.query ?? {})) {
163
+ if (value === undefined) continue;
164
+ params.set(key, String(value));
165
+ }
166
+
167
+ return app.request(
168
+ `http://localhost/console/timeline?${params.toString()}`,
169
+ {
170
+ headers:
171
+ args.authenticated === false
172
+ ? undefined
173
+ : { Authorization: `Bearer ${CONSOLE_TOKEN}` },
174
+ }
175
+ );
176
+ }
177
+
178
+ async function readTimeline(
179
+ query: Record<string, string | number | undefined> = {}
180
+ ): Promise<TimelineResponse> {
181
+ const response = await requestTimeline({ query });
182
+ expect(response.status).toBe(200);
183
+ return (await response.json()) as TimelineResponse;
184
+ }
185
+
186
+ async function requestEventPayload(
187
+ eventId: number,
188
+ query: Record<string, string | number | undefined> = {}
189
+ ): Promise<Response> {
190
+ const params = new URLSearchParams();
191
+ for (const [key, value] of Object.entries(query)) {
192
+ if (value === undefined) continue;
193
+ params.set(key, String(value));
194
+ }
195
+ const queryString = params.toString();
196
+
197
+ return app.request(
198
+ `http://localhost/console/events/${eventId}/payload${queryString ? `?${queryString}` : ''}`,
199
+ {
200
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
201
+ }
202
+ );
203
+ }
204
+
205
+ async function requestNotifyDataChange(body: {
206
+ tables: string[];
207
+ partitionId?: string;
208
+ }): Promise<Response> {
209
+ return app.request('http://localhost/console/notify-data-change', {
210
+ method: 'POST',
211
+ headers: {
212
+ Authorization: `Bearer ${CONSOLE_TOKEN}`,
213
+ 'Content-Type': 'application/json',
214
+ },
215
+ body: JSON.stringify(body),
216
+ });
217
+ }
218
+
219
+ async function readOperations(
220
+ query: Record<string, string | number | undefined> = {}
221
+ ): Promise<OperationsResponse> {
222
+ const params = new URLSearchParams({ limit: '50', offset: '0' });
223
+ for (const [key, value] of Object.entries(query)) {
224
+ if (value === undefined) continue;
225
+ params.set(key, String(value));
226
+ }
227
+
228
+ const response = await app.request(
229
+ `http://localhost/console/operations?${params.toString()}`,
230
+ {
231
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
232
+ }
233
+ );
234
+ expect(response.status).toBe(200);
235
+ return (await response.json()) as OperationsResponse;
236
+ }
237
+
238
+ async function requestCommits(
239
+ query: Record<string, string | number | undefined> = {}
240
+ ): Promise<Response> {
241
+ const params = new URLSearchParams({ limit: '50', offset: '0' });
242
+ for (const [key, value] of Object.entries(query)) {
243
+ if (value === undefined) continue;
244
+ params.set(key, String(value));
245
+ }
246
+
247
+ return app.request(
248
+ `http://localhost/console/commits?${params.toString()}`,
249
+ {
250
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
251
+ }
252
+ );
253
+ }
254
+
255
+ async function requestClients(
256
+ query: Record<string, string | number | undefined> = {}
257
+ ): Promise<Response> {
258
+ const params = new URLSearchParams({ limit: '50', offset: '0' });
259
+ for (const [key, value] of Object.entries(query)) {
260
+ if (value === undefined) continue;
261
+ params.set(key, String(value));
262
+ }
263
+
264
+ return app.request(
265
+ `http://localhost/console/clients?${params.toString()}`,
266
+ {
267
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
268
+ }
269
+ );
270
+ }
271
+
272
+ async function requestEvents(
273
+ query: Record<string, string | number | undefined> = {}
274
+ ): Promise<Response> {
275
+ const params = new URLSearchParams({ limit: '50', offset: '0' });
276
+ for (const [key, value] of Object.entries(query)) {
277
+ if (value === undefined) continue;
278
+ params.set(key, String(value));
279
+ }
280
+
281
+ return app.request(`http://localhost/console/events?${params.toString()}`, {
282
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
283
+ });
284
+ }
285
+
286
+ async function requestApiKeys(
287
+ query: Record<string, string | number | undefined> = {}
288
+ ): Promise<Response> {
289
+ const params = new URLSearchParams({ limit: '50', offset: '0' });
290
+ for (const [key, value] of Object.entries(query)) {
291
+ if (value === undefined) continue;
292
+ params.set(key, String(value));
293
+ }
294
+
295
+ return app.request(
296
+ `http://localhost/console/api-keys?${params.toString()}`,
297
+ {
298
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
299
+ }
300
+ );
301
+ }
302
+
303
+ async function readApiKeys(
304
+ query: Record<string, string | number | undefined> = {}
305
+ ): Promise<ApiKeysResponse> {
306
+ const response = await requestApiKeys(query);
307
+ expect(response.status).toBe(200);
308
+ return (await response.json()) as ApiKeysResponse;
309
+ }
310
+
311
+ async function requestBulkRevokeApiKeys(keyIds: string[]): Promise<Response> {
312
+ return app.request('http://localhost/console/api-keys/bulk-revoke', {
313
+ method: 'POST',
314
+ headers: {
315
+ Authorization: `Bearer ${CONSOLE_TOKEN}`,
316
+ 'Content-Type': 'application/json',
317
+ },
318
+ body: JSON.stringify({ keyIds }),
319
+ });
320
+ }
321
+
322
+ async function requestStageRotateApiKey(keyId: string): Promise<Response> {
323
+ return app.request(
324
+ `http://localhost/console/api-keys/${keyId}/rotate/stage`,
325
+ {
326
+ method: 'POST',
327
+ headers: {
328
+ Authorization: `Bearer ${CONSOLE_TOKEN}`,
329
+ },
330
+ }
331
+ );
332
+ }
333
+
334
+ async function requestCommitDetail(
335
+ seq: number,
336
+ query: Record<string, string | number | undefined> = {}
337
+ ): Promise<Response> {
338
+ const params = new URLSearchParams();
339
+ for (const [key, value] of Object.entries(query)) {
340
+ if (value === undefined) continue;
341
+ params.set(key, String(value));
342
+ }
343
+ const queryString = params.toString();
344
+
345
+ return app.request(
346
+ `http://localhost/console/commits/${seq}${queryString ? `?${queryString}` : ''}`,
347
+ {
348
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
349
+ }
350
+ );
351
+ }
352
+
353
+ async function requestEventDetail(
354
+ eventId: number,
355
+ query: Record<string, string | number | undefined> = {}
356
+ ): Promise<Response> {
357
+ const params = new URLSearchParams();
358
+ for (const [key, value] of Object.entries(query)) {
359
+ if (value === undefined) continue;
360
+ params.set(key, String(value));
361
+ }
362
+ const queryString = params.toString();
363
+
364
+ return app.request(
365
+ `http://localhost/console/events/${eventId}${queryString ? `?${queryString}` : ''}`,
366
+ {
367
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
368
+ }
369
+ );
370
+ }
371
+
372
+ async function requestEvictClient(
373
+ clientId: string,
374
+ query: Record<string, string | number | undefined> = {}
375
+ ): Promise<Response> {
376
+ const params = new URLSearchParams();
377
+ for (const [key, value] of Object.entries(query)) {
378
+ if (value === undefined) continue;
379
+ params.set(key, String(value));
380
+ }
381
+ const queryString = params.toString();
382
+
383
+ return app.request(
384
+ `http://localhost/console/clients/${clientId}${queryString ? `?${queryString}` : ''}`,
385
+ {
386
+ method: 'DELETE',
387
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
388
+ }
389
+ );
390
+ }
391
+
392
+ async function requestTimeseriesStats(args: {
393
+ query?: Record<string, string | number | undefined>;
394
+ targetApp?: Hono;
395
+ }): Promise<Response> {
396
+ const params = new URLSearchParams({ interval: 'hour', range: '24h' });
397
+ for (const [key, value] of Object.entries(args.query ?? {})) {
398
+ if (value === undefined) continue;
399
+ params.set(key, String(value));
400
+ }
401
+
402
+ return (args.targetApp ?? app).request(
403
+ `http://localhost/console/stats/timeseries?${params.toString()}`,
404
+ {
405
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
406
+ }
407
+ );
408
+ }
409
+
410
+ async function requestLatencyStats(args: {
411
+ query?: Record<string, string | number | undefined>;
412
+ targetApp?: Hono;
413
+ }): Promise<Response> {
414
+ const params = new URLSearchParams({ range: '24h' });
415
+ for (const [key, value] of Object.entries(args.query ?? {})) {
416
+ if (value === undefined) continue;
417
+ params.set(key, String(value));
418
+ }
419
+
420
+ return (args.targetApp ?? app).request(
421
+ `http://localhost/console/stats/latency?${params.toString()}`,
422
+ {
423
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
424
+ }
425
+ );
426
+ }
427
+
428
+ function createTestApp(
429
+ overrides: Pick<CreateConsoleRoutesOptions<TestDb>, 'metrics'> = {}
430
+ ): Hono {
431
+ const routes = createConsoleRoutes({
432
+ db,
433
+ dialect,
434
+ handlers: [],
435
+ authenticate: async (c) =>
436
+ c.req.header('Authorization') === `Bearer ${CONSOLE_TOKEN}`
437
+ ? { consoleUserId: 'console-test' }
438
+ : null,
439
+ corsOrigins: '*',
440
+ ...overrides,
441
+ });
442
+ const nextApp = new Hono();
443
+ nextApp.route('/console', routes);
444
+ return nextApp;
445
+ }
446
+
447
+ beforeEach(async () => {
448
+ dialect = createPostgresServerDialect();
449
+ db = createPgliteDb<TestDb>();
450
+ await ensureSyncSchema(db, dialect);
451
+ await dialect.ensureConsoleSchema?.(db);
452
+
453
+ await db
454
+ .insertInto('sync_commits')
455
+ .values([
456
+ {
457
+ partition_id: 'default',
458
+ actor_id: 'actor-a',
459
+ client_id: 'client-a',
460
+ client_commit_id: 'commit-a',
461
+ created_at: atIso(10),
462
+ meta: null,
463
+ result_json: null,
464
+ change_count: 2,
465
+ affected_tables: ['tasks'],
466
+ },
467
+ {
468
+ partition_id: 'default',
469
+ actor_id: 'actor-b',
470
+ client_id: 'client-b',
471
+ client_commit_id: 'commit-b',
472
+ created_at: atIso(20),
473
+ meta: null,
474
+ result_json: null,
475
+ change_count: 1,
476
+ affected_tables: ['notes'],
477
+ },
478
+ ])
479
+ .execute();
480
+
481
+ const commitRows = await db
482
+ .selectFrom('sync_commits')
483
+ .select(['commit_seq', 'client_commit_id'])
484
+ .execute();
485
+
486
+ const commitSeqByClientCommitId = new Map(
487
+ commitRows.map((row) => [row.client_commit_id, Number(row.commit_seq)])
488
+ );
489
+
490
+ await db
491
+ .insertInto('sync_request_events')
492
+ .values([
493
+ {
494
+ partition_id: 'default',
495
+ request_id: 'req-1',
496
+ trace_id: 'trace-1',
497
+ span_id: 'span-1',
498
+ event_type: 'push',
499
+ sync_path: 'http-combined',
500
+ actor_id: 'actor-a',
501
+ client_id: 'client-a',
502
+ transport_path: 'direct',
503
+ status_code: 200,
504
+ outcome: 'applied',
505
+ response_status: 'success',
506
+ error_code: null,
507
+ duration_ms: 18,
508
+ commit_seq: commitSeqByClientCommitId.get('commit-a') ?? null,
509
+ operation_count: 2,
510
+ row_count: 2,
511
+ subscription_count: null,
512
+ scopes_summary: null,
513
+ tables: ['tasks'],
514
+ error_message: null,
515
+ payload_ref: 'payload-1',
516
+ created_at: atIso(30),
517
+ },
518
+ {
519
+ partition_id: 'default',
520
+ request_id: 'req-2',
521
+ trace_id: 'trace-2',
522
+ span_id: 'span-2',
523
+ event_type: 'pull',
524
+ sync_path: 'http-combined',
525
+ actor_id: 'actor-c',
526
+ client_id: 'client-c',
527
+ transport_path: 'direct',
528
+ status_code: 500,
529
+ outcome: 'error',
530
+ response_status: 'server_error',
531
+ error_code: 'INTERNAL_SERVER_ERROR',
532
+ duration_ms: 44,
533
+ commit_seq: null,
534
+ operation_count: null,
535
+ row_count: null,
536
+ subscription_count: 2,
537
+ scopes_summary: JSON.stringify({ org_id: 'org-1' }),
538
+ tables: ['notes'],
539
+ error_message: 'pull failed',
540
+ payload_ref: null,
541
+ created_at: atIso(40),
542
+ },
543
+ {
544
+ partition_id: 'default',
545
+ request_id: 'req-3',
546
+ trace_id: null,
547
+ span_id: null,
548
+ event_type: 'pull',
549
+ sync_path: 'http-combined',
550
+ actor_id: 'actor-a',
551
+ client_id: 'client-d',
552
+ transport_path: 'relay',
553
+ status_code: 409,
554
+ outcome: 'rejected',
555
+ response_status: 'client_error',
556
+ error_code: 'CONFLICT',
557
+ duration_ms: 22,
558
+ commit_seq: null,
559
+ operation_count: 1,
560
+ row_count: 1,
561
+ subscription_count: 1,
562
+ scopes_summary: JSON.stringify({ org_id: ['org-1', 'org-2'] }),
563
+ tables: ['tasks', 'notes'],
564
+ error_message: null,
565
+ payload_ref: null,
566
+ created_at: atIso(50),
567
+ },
568
+ ])
569
+ .execute();
570
+
571
+ await db
572
+ .insertInto('sync_request_payloads')
573
+ .values({
574
+ payload_ref: 'payload-1',
575
+ partition_id: 'default',
576
+ request_payload: JSON.stringify({
577
+ clientCommitId: 'commit-a',
578
+ operations: [{ table: 'tasks', op: 'upsert' }],
579
+ }),
580
+ response_payload: JSON.stringify({
581
+ status: 'applied',
582
+ commitSeq: commitSeqByClientCommitId.get('commit-a') ?? null,
583
+ }),
584
+ created_at: atIso(31),
585
+ })
586
+ .execute();
587
+
588
+ await db
589
+ .insertInto('sync_operation_events')
590
+ .values([
591
+ {
592
+ operation_type: 'prune',
593
+ console_user_id: 'console-test',
594
+ partition_id: null,
595
+ target_client_id: null,
596
+ request_payload: JSON.stringify({ watermarkCommitSeq: 2 }),
597
+ result_payload: JSON.stringify({ deletedCommits: 1 }),
598
+ created_at: atIso(32),
599
+ },
600
+ {
601
+ operation_type: 'evict_client',
602
+ console_user_id: 'console-test',
603
+ partition_id: null,
604
+ target_client_id: 'client-d',
605
+ request_payload: JSON.stringify({ clientId: 'client-d' }),
606
+ result_payload: JSON.stringify({ evicted: true }),
607
+ created_at: atIso(52),
608
+ },
609
+ ])
610
+ .execute();
611
+
612
+ app = createTestApp();
613
+ });
614
+
615
+ afterEach(async () => {
616
+ await db.destroy();
617
+ });
618
+
619
+ it('filters by actor, client, and table across merged timeline rows', async () => {
620
+ const actorFiltered = await readTimeline({ actorId: 'actor-a' });
621
+ expect(actorFiltered.total).toBe(3);
622
+ expect(
623
+ actorFiltered.items.every(
624
+ (item) =>
625
+ item.commit?.actorId === 'actor-a' ||
626
+ item.event?.actorId === 'actor-a'
627
+ )
628
+ ).toBe(true);
629
+
630
+ const clientFiltered = await readTimeline({ clientId: 'client-d' });
631
+ expect(clientFiltered.total).toBe(1);
632
+ expect(clientFiltered.items[0]?.event?.clientId).toBe('client-d');
633
+
634
+ const tableFiltered = await readTimeline({ table: 'tasks' });
635
+ expect(tableFiltered.total).toBe(3);
636
+ expect(
637
+ tableFiltered.items.every((item) =>
638
+ item.type === 'commit'
639
+ ? (item.commit?.affectedTables ?? []).includes('tasks')
640
+ : (item.event?.tables ?? []).includes('tasks')
641
+ )
642
+ ).toBe(true);
643
+ });
644
+
645
+ it('applies outcome and event-type filters to event rows in all-view mode', async () => {
646
+ const outcomeFiltered = await readTimeline({ outcome: 'error' });
647
+ expect(outcomeFiltered.total).toBe(1);
648
+ expect(outcomeFiltered.items[0]?.type).toBe('event');
649
+ expect(outcomeFiltered.items[0]?.event?.outcome).toBe('error');
650
+
651
+ const eventTypeFiltered = await readTimeline({ eventType: 'push' });
652
+ expect(eventTypeFiltered.total).toBe(1);
653
+ expect(eventTypeFiltered.items[0]?.type).toBe('event');
654
+ expect(eventTypeFiltered.items[0]?.event?.eventType).toBe('push');
655
+ });
656
+
657
+ it('applies request-id and trace-id filters to event rows', async () => {
658
+ const requestIdFiltered = await readTimeline({ requestId: 'req-2' });
659
+ expect(requestIdFiltered.total).toBe(1);
660
+ expect(requestIdFiltered.items[0]?.type).toBe('event');
661
+ expect(requestIdFiltered.items[0]?.event?.eventId).toBeDefined();
662
+
663
+ const traceIdFiltered = await readTimeline({ traceId: 'trace-1' });
664
+ expect(traceIdFiltered.total).toBe(1);
665
+ expect(traceIdFiltered.items[0]?.type).toBe('event');
666
+ expect(traceIdFiltered.items[0]?.event?.eventType).toBe('push');
667
+ });
668
+
669
+ it('applies time-window and search filtering', async () => {
670
+ const fromFiltered = await readTimeline({ from: atIso(35) });
671
+ expect(fromFiltered.total).toBe(2);
672
+ expect(fromFiltered.items[0]?.timestamp >= atIso(35)).toBe(true);
673
+ expect(fromFiltered.items[1]?.timestamp >= atIso(35)).toBe(true);
674
+
675
+ const searchFiltered = await readTimeline({ search: 'client-d' });
676
+ expect(searchFiltered.total).toBe(1);
677
+ expect(searchFiltered.items[0]?.event?.clientId).toBe('client-d');
678
+ });
679
+
680
+ it('returns deterministic pagination slices for merged timeline rows', async () => {
681
+ const pageOne = await readTimeline({ limit: 2, offset: 0 });
682
+ const pageTwo = await readTimeline({ limit: 2, offset: 2 });
683
+
684
+ expect(pageOne.total).toBe(5);
685
+ expect(pageTwo.total).toBe(5);
686
+ expect(pageOne.items.length).toBe(2);
687
+ expect(pageTwo.items.length).toBe(2);
688
+
689
+ const pageOneKeys = pageOne.items.map(timelineItemKey);
690
+ const pageTwoKeys = pageTwo.items.map(timelineItemKey);
691
+
692
+ expect(pageOneKeys[0]).not.toBe(pageTwoKeys[0]);
693
+ expect(pageOneKeys.some((key) => pageTwoKeys.includes(key))).toBe(false);
694
+ });
695
+
696
+ it('lists operation audit events and filters by operation type', async () => {
697
+ const allOps = await readOperations();
698
+ expect(allOps.total).toBe(2);
699
+ expect(allOps.items[0]?.operationType).toBe('evict_client');
700
+ expect(allOps.items[1]?.operationType).toBe('prune');
701
+
702
+ const pruneOps = await readOperations({ operationType: 'prune' });
703
+ expect(pruneOps.total).toBe(1);
704
+ expect(pruneOps.items[0]?.operationType).toBe('prune');
705
+ expect(pruneOps.items[0]?.consoleUserId).toBe('console-test');
706
+ });
707
+
708
+ it('supports notify-data-change and records an operation audit event', async () => {
709
+ const response = await requestNotifyDataChange({
710
+ tables: ['tasks', 'notes'],
711
+ partitionId: 'default',
712
+ });
713
+
714
+ expect(response.status).toBe(200);
715
+ const payload = (await response.json()) as {
716
+ commitSeq: number;
717
+ tables: string[];
718
+ deletedChunks: number;
719
+ };
720
+
721
+ expect(payload.commitSeq).toBeGreaterThan(0);
722
+ expect(payload.tables).toEqual(['tasks', 'notes']);
723
+ expect(payload.deletedChunks).toBeGreaterThanOrEqual(0);
724
+
725
+ const notifyOps = await readOperations({
726
+ operationType: 'notify_data_change',
727
+ });
728
+ expect(notifyOps.total).toBe(1);
729
+ expect(notifyOps.items[0]?.consoleUserId).toBe('console-test');
730
+ expect(notifyOps.items[0]?.requestPayload).toEqual({
731
+ tables: ['tasks', 'notes'],
732
+ partitionId: 'default',
733
+ });
734
+ expect(notifyOps.items[0]?.resultPayload).toEqual(payload);
735
+ });
736
+
737
+ it('returns stats from aggregated metrics mode with partition filtering', async () => {
738
+ const aggregatedApp = createTestApp({
739
+ metrics: {
740
+ aggregationMode: 'aggregated',
741
+ },
742
+ });
743
+
744
+ const timeseriesResponse = await requestTimeseriesStats({
745
+ query: { partitionId: 'default' },
746
+ targetApp: aggregatedApp,
747
+ });
748
+ expect(timeseriesResponse.status).toBe(200);
749
+ const timeseriesPayload = (await timeseriesResponse.json()) as {
750
+ buckets: Array<{
751
+ pushCount: number;
752
+ pullCount: number;
753
+ errorCount: number;
754
+ }>;
755
+ };
756
+
757
+ const totals = timeseriesPayload.buckets.reduce(
758
+ (acc, bucket) => ({
759
+ pushCount: acc.pushCount + bucket.pushCount,
760
+ pullCount: acc.pullCount + bucket.pullCount,
761
+ errorCount: acc.errorCount + bucket.errorCount,
762
+ }),
763
+ { pushCount: 0, pullCount: 0, errorCount: 0 }
764
+ );
765
+ expect(totals.pushCount).toBe(1);
766
+ expect(totals.pullCount).toBe(2);
767
+ expect(totals.errorCount).toBe(1);
768
+
769
+ const latencyResponse = await requestLatencyStats({
770
+ query: { partitionId: 'default' },
771
+ targetApp: aggregatedApp,
772
+ });
773
+ expect(latencyResponse.status).toBe(200);
774
+ const latencyPayload = (await latencyResponse.json()) as {
775
+ push: { p50: number; p90: number; p99: number };
776
+ pull: { p50: number; p90: number; p99: number };
777
+ };
778
+
779
+ expect(latencyPayload.push.p50).toBe(18);
780
+ expect(latencyPayload.push.p90).toBe(18);
781
+ expect(latencyPayload.push.p99).toBe(18);
782
+ expect(latencyPayload.pull.p50).toBe(22);
783
+ expect(latencyPayload.pull.p90).toBe(44);
784
+ expect(latencyPayload.pull.p99).toBe(44);
785
+ });
786
+
787
+ it('applies partition filters across timeline, list, and operation endpoints', async () => {
788
+ await db
789
+ .insertInto('sync_commits')
790
+ .values({
791
+ partition_id: 'tenant-b',
792
+ actor_id: 'actor-z',
793
+ client_id: 'shared-client',
794
+ client_commit_id: 'commit-z',
795
+ created_at: atIso(55),
796
+ meta: null,
797
+ result_json: null,
798
+ change_count: 1,
799
+ affected_tables: ['tasks'],
800
+ })
801
+ .execute();
802
+
803
+ const tenantCommitRow = await db
804
+ .selectFrom('sync_commits')
805
+ .select(['commit_seq'])
806
+ .where('partition_id', '=', 'tenant-b')
807
+ .where('client_commit_id', '=', 'commit-z')
808
+ .executeTakeFirst();
809
+
810
+ const tenantCommitSeq = Number(tenantCommitRow?.commit_seq);
811
+ expect(Number.isFinite(tenantCommitSeq)).toBe(true);
812
+
813
+ await db
814
+ .insertInto('sync_changes')
815
+ .values({
816
+ partition_id: 'tenant-b',
817
+ commit_seq: tenantCommitSeq,
818
+ table: 'tasks',
819
+ row_id: 'row-z',
820
+ op: 'upsert',
821
+ row_json: JSON.stringify({ id: 'row-z', title: 'tenant row' }),
822
+ row_version: 1,
823
+ scopes: JSON.stringify({ org_id: 'tenant-b' }),
824
+ })
825
+ .execute();
826
+
827
+ await db
828
+ .insertInto('sync_request_events')
829
+ .values({
830
+ partition_id: 'tenant-b',
831
+ request_id: 'req-z',
832
+ trace_id: 'trace-z',
833
+ span_id: 'span-z',
834
+ event_type: 'push',
835
+ sync_path: 'http-combined',
836
+ actor_id: 'actor-z',
837
+ client_id: 'shared-client',
838
+ transport_path: 'direct',
839
+ status_code: 200,
840
+ outcome: 'applied',
841
+ response_status: 'success',
842
+ error_code: null,
843
+ duration_ms: 12,
844
+ commit_seq: tenantCommitSeq,
845
+ operation_count: 1,
846
+ row_count: 1,
847
+ subscription_count: null,
848
+ scopes_summary: null,
849
+ tables: ['tasks'],
850
+ error_message: null,
851
+ payload_ref: 'payload-z',
852
+ created_at: atIso(56),
853
+ })
854
+ .execute();
855
+
856
+ await db
857
+ .insertInto('sync_request_payloads')
858
+ .values({
859
+ payload_ref: 'payload-z',
860
+ partition_id: 'tenant-b',
861
+ request_payload: JSON.stringify({
862
+ clientCommitId: 'commit-z',
863
+ operations: [{ table: 'tasks', op: 'upsert' }],
864
+ }),
865
+ response_payload: JSON.stringify({
866
+ status: 'applied',
867
+ commitSeq: tenantCommitSeq,
868
+ }),
869
+ created_at: atIso(56),
870
+ })
871
+ .execute();
872
+
873
+ await db
874
+ .insertInto('sync_client_cursors')
875
+ .values([
876
+ {
877
+ partition_id: 'default',
878
+ client_id: 'shared-client',
879
+ actor_id: 'actor-a',
880
+ cursor: 1,
881
+ effective_scopes: JSON.stringify({ org_id: 'default' }),
882
+ updated_at: atIso(57),
883
+ },
884
+ {
885
+ partition_id: 'tenant-b',
886
+ client_id: 'shared-client',
887
+ actor_id: 'actor-z',
888
+ cursor: tenantCommitSeq,
889
+ effective_scopes: JSON.stringify({ org_id: 'tenant-b' }),
890
+ updated_at: atIso(58),
891
+ },
892
+ ])
893
+ .execute();
894
+
895
+ await db
896
+ .insertInto('sync_operation_events')
897
+ .values({
898
+ operation_type: 'notify_data_change',
899
+ console_user_id: 'console-test',
900
+ partition_id: 'tenant-b',
901
+ target_client_id: null,
902
+ request_payload: JSON.stringify({
903
+ tables: ['tasks'],
904
+ partitionId: 'tenant-b',
905
+ }),
906
+ result_payload: JSON.stringify({ commitSeq: tenantCommitSeq }),
907
+ created_at: atIso(59),
908
+ })
909
+ .execute();
910
+
911
+ const tenantTimeline = await readTimeline({ partitionId: 'tenant-b' });
912
+ expect(tenantTimeline.total).toBe(2);
913
+ expect(
914
+ tenantTimeline.items.every((item) =>
915
+ item.type === 'commit'
916
+ ? item.commit?.clientId === 'shared-client'
917
+ : item.event?.partitionId === 'tenant-b'
918
+ )
919
+ ).toBe(true);
920
+
921
+ const commitsResponse = await requestCommits({ partitionId: 'tenant-b' });
922
+ expect(commitsResponse.status).toBe(200);
923
+ const commitsPayload = (await commitsResponse.json()) as {
924
+ items: Array<{ clientId: string }>;
925
+ total: number;
926
+ };
927
+ expect(commitsPayload.total).toBe(1);
928
+ expect(commitsPayload.items[0]?.clientId).toBe('shared-client');
929
+
930
+ const clientsResponse = await requestClients({ partitionId: 'tenant-b' });
931
+ expect(clientsResponse.status).toBe(200);
932
+ const clientsPayload = (await clientsResponse.json()) as {
933
+ items: Array<{ actorId: string }>;
934
+ total: number;
935
+ };
936
+ expect(clientsPayload.total).toBe(1);
937
+ expect(clientsPayload.items[0]?.actorId).toBe('actor-z');
938
+
939
+ const eventsResponse = await requestEvents({ partitionId: 'tenant-b' });
940
+ expect(eventsResponse.status).toBe(200);
941
+ const eventsPayload = (await eventsResponse.json()) as {
942
+ items: Array<{ partitionId: string }>;
943
+ total: number;
944
+ };
945
+ expect(eventsPayload.total).toBe(1);
946
+ expect(eventsPayload.items[0]?.partitionId).toBe('tenant-b');
947
+
948
+ const tenantOps = await readOperations({ partitionId: 'tenant-b' });
949
+ expect(tenantOps.total).toBe(1);
950
+ expect(tenantOps.items[0]?.partitionId).toBe('tenant-b');
951
+ expect(tenantOps.items[0]?.operationType).toBe('notify_data_change');
952
+ });
953
+
954
+ it('guards detail endpoints and client eviction with partition filters', async () => {
955
+ await db
956
+ .insertInto('sync_commits')
957
+ .values({
958
+ partition_id: 'tenant-b',
959
+ actor_id: 'actor-z',
960
+ client_id: 'shared-client',
961
+ client_commit_id: 'commit-z-detail',
962
+ created_at: atIso(55),
963
+ meta: null,
964
+ result_json: null,
965
+ change_count: 1,
966
+ affected_tables: ['tasks'],
967
+ })
968
+ .execute();
969
+
970
+ const tenantCommitRow = await db
971
+ .selectFrom('sync_commits')
972
+ .select(['commit_seq'])
973
+ .where('partition_id', '=', 'tenant-b')
974
+ .where('client_commit_id', '=', 'commit-z-detail')
975
+ .executeTakeFirst();
976
+ const tenantCommitSeq = Number(tenantCommitRow?.commit_seq);
977
+ expect(Number.isFinite(tenantCommitSeq)).toBe(true);
978
+
979
+ await db
980
+ .insertInto('sync_changes')
981
+ .values({
982
+ partition_id: 'tenant-b',
983
+ commit_seq: tenantCommitSeq,
984
+ table: 'tasks',
985
+ row_id: 'row-z-detail',
986
+ op: 'upsert',
987
+ row_json: JSON.stringify({ id: 'row-z-detail' }),
988
+ row_version: 1,
989
+ scopes: JSON.stringify({ org_id: 'tenant-b' }),
990
+ })
991
+ .execute();
992
+
993
+ await db
994
+ .insertInto('sync_request_events')
995
+ .values({
996
+ partition_id: 'tenant-b',
997
+ request_id: 'req-z-detail',
998
+ trace_id: 'trace-z-detail',
999
+ span_id: 'span-z-detail',
1000
+ event_type: 'push',
1001
+ sync_path: 'http-combined',
1002
+ actor_id: 'actor-z',
1003
+ client_id: 'shared-client',
1004
+ transport_path: 'direct',
1005
+ status_code: 200,
1006
+ outcome: 'applied',
1007
+ response_status: 'success',
1008
+ error_code: null,
1009
+ duration_ms: 15,
1010
+ commit_seq: tenantCommitSeq,
1011
+ operation_count: 1,
1012
+ row_count: 1,
1013
+ subscription_count: null,
1014
+ scopes_summary: null,
1015
+ tables: ['tasks'],
1016
+ error_message: null,
1017
+ payload_ref: 'payload-z-detail',
1018
+ created_at: atIso(56),
1019
+ })
1020
+ .execute();
1021
+
1022
+ await db
1023
+ .insertInto('sync_request_payloads')
1024
+ .values({
1025
+ payload_ref: 'payload-z-detail',
1026
+ partition_id: 'tenant-b',
1027
+ request_payload: JSON.stringify({ clientCommitId: 'commit-z-detail' }),
1028
+ response_payload: JSON.stringify({ status: 'applied' }),
1029
+ created_at: atIso(56),
1030
+ })
1031
+ .execute();
1032
+
1033
+ await db
1034
+ .insertInto('sync_client_cursors')
1035
+ .values([
1036
+ {
1037
+ partition_id: 'default',
1038
+ client_id: 'shared-client',
1039
+ actor_id: 'actor-a',
1040
+ cursor: 1,
1041
+ effective_scopes: JSON.stringify({ org_id: 'default' }),
1042
+ updated_at: atIso(57),
1043
+ },
1044
+ {
1045
+ partition_id: 'tenant-b',
1046
+ client_id: 'shared-client',
1047
+ actor_id: 'actor-z',
1048
+ cursor: tenantCommitSeq,
1049
+ effective_scopes: JSON.stringify({ org_id: 'tenant-b' }),
1050
+ updated_at: atIso(58),
1051
+ },
1052
+ ])
1053
+ .execute();
1054
+
1055
+ const tenantEventRow = await db
1056
+ .selectFrom('sync_request_events')
1057
+ .select(['event_id'])
1058
+ .where('request_id', '=', 'req-z-detail')
1059
+ .executeTakeFirst();
1060
+ const tenantEventId = Number(tenantEventRow?.event_id);
1061
+ expect(Number.isFinite(tenantEventId)).toBe(true);
1062
+
1063
+ const commitDetailOk = await requestCommitDetail(tenantCommitSeq, {
1064
+ partitionId: 'tenant-b',
1065
+ });
1066
+ expect(commitDetailOk.status).toBe(200);
1067
+
1068
+ const commitDetailWrongPartition = await requestCommitDetail(
1069
+ tenantCommitSeq,
1070
+ {
1071
+ partitionId: 'default',
1072
+ }
1073
+ );
1074
+ expect(commitDetailWrongPartition.status).toBe(404);
1075
+
1076
+ const eventDetailOk = await requestEventDetail(tenantEventId, {
1077
+ partitionId: 'tenant-b',
1078
+ });
1079
+ expect(eventDetailOk.status).toBe(200);
1080
+
1081
+ const eventDetailWrongPartition = await requestEventDetail(tenantEventId, {
1082
+ partitionId: 'default',
1083
+ });
1084
+ expect(eventDetailWrongPartition.status).toBe(404);
1085
+
1086
+ const payloadOk = await requestEventPayload(tenantEventId, {
1087
+ partitionId: 'tenant-b',
1088
+ });
1089
+ expect(payloadOk.status).toBe(200);
1090
+
1091
+ const payloadWrongPartition = await requestEventPayload(tenantEventId, {
1092
+ partitionId: 'default',
1093
+ });
1094
+ expect(payloadWrongPartition.status).toBe(404);
1095
+
1096
+ const evictDefault = await requestEvictClient('shared-client', {
1097
+ partitionId: 'default',
1098
+ });
1099
+ expect(evictDefault.status).toBe(200);
1100
+ expect((await evictDefault.json()) as { evicted: boolean }).toEqual({
1101
+ evicted: true,
1102
+ });
1103
+
1104
+ const tenantCursorAfterDefaultEvict = await db
1105
+ .selectFrom('sync_client_cursors')
1106
+ .select(['client_id'])
1107
+ .where('partition_id', '=', 'tenant-b')
1108
+ .where('client_id', '=', 'shared-client')
1109
+ .executeTakeFirst();
1110
+ expect(tenantCursorAfterDefaultEvict).toBeDefined();
1111
+
1112
+ const evictTenant = await requestEvictClient('shared-client', {
1113
+ partitionId: 'tenant-b',
1114
+ });
1115
+ expect(evictTenant.status).toBe(200);
1116
+ expect((await evictTenant.json()) as { evicted: boolean }).toEqual({
1117
+ evicted: true,
1118
+ });
1119
+
1120
+ const tenantCursorAfterTenantEvict = await db
1121
+ .selectFrom('sync_client_cursors')
1122
+ .select(['client_id'])
1123
+ .where('partition_id', '=', 'tenant-b')
1124
+ .where('client_id', '=', 'shared-client')
1125
+ .executeTakeFirst();
1126
+ expect(tenantCursorAfterTenantEvict).toBeUndefined();
1127
+ });
1128
+
1129
+ it('filters API keys by type and lifecycle status', async () => {
1130
+ const dayMs = 24 * 60 * 60 * 1000;
1131
+ const now = Date.now();
1132
+ const isoAfter = (days: number): string =>
1133
+ new Date(now + days * dayMs).toISOString();
1134
+
1135
+ await db
1136
+ .insertInto('sync_api_keys')
1137
+ .values([
1138
+ {
1139
+ key_id: 'key-relay-active',
1140
+ key_hash: 'hash-1',
1141
+ key_prefix: 'srly_active_',
1142
+ name: 'relay-active',
1143
+ key_type: 'relay',
1144
+ scope_keys: ['scope-a'],
1145
+ actor_id: 'actor-a',
1146
+ created_at: atIso(11),
1147
+ expires_at: null,
1148
+ last_used_at: null,
1149
+ revoked_at: null,
1150
+ },
1151
+ {
1152
+ key_id: 'key-relay-expiring',
1153
+ key_hash: 'hash-2',
1154
+ key_prefix: 'srly_expire_',
1155
+ name: 'relay-expiring',
1156
+ key_type: 'relay',
1157
+ scope_keys: ['scope-a', 'scope-b'],
1158
+ actor_id: null,
1159
+ created_at: atIso(12),
1160
+ expires_at: isoAfter(3),
1161
+ last_used_at: null,
1162
+ revoked_at: null,
1163
+ },
1164
+ {
1165
+ key_id: 'key-relay-expired',
1166
+ key_hash: 'hash-3',
1167
+ key_prefix: 'srly_expired',
1168
+ name: 'relay-expired',
1169
+ key_type: 'relay',
1170
+ scope_keys: [],
1171
+ actor_id: null,
1172
+ created_at: atIso(13),
1173
+ expires_at: isoAfter(-1),
1174
+ last_used_at: null,
1175
+ revoked_at: null,
1176
+ },
1177
+ {
1178
+ key_id: 'key-relay-revoked',
1179
+ key_hash: 'hash-4',
1180
+ key_prefix: 'srly_revoked',
1181
+ name: 'relay-revoked',
1182
+ key_type: 'relay',
1183
+ scope_keys: [],
1184
+ actor_id: null,
1185
+ created_at: atIso(14),
1186
+ expires_at: isoAfter(10),
1187
+ last_used_at: null,
1188
+ revoked_at: isoAfter(0),
1189
+ },
1190
+ {
1191
+ key_id: 'key-admin-future',
1192
+ key_hash: 'hash-5',
1193
+ key_prefix: 'sadm_future_',
1194
+ name: 'admin-future',
1195
+ key_type: 'admin',
1196
+ scope_keys: ['org:1'],
1197
+ actor_id: 'actor-admin',
1198
+ created_at: atIso(15),
1199
+ expires_at: isoAfter(60),
1200
+ last_used_at: null,
1201
+ revoked_at: null,
1202
+ },
1203
+ ])
1204
+ .execute();
1205
+
1206
+ const relayOnly = await readApiKeys({ type: 'relay' });
1207
+ expect(relayOnly.total).toBe(4);
1208
+ expect(relayOnly.items.every((item) => item.keyType === 'relay')).toBe(
1209
+ true
1210
+ );
1211
+
1212
+ const revokedOnly = await readApiKeys({ status: 'revoked' });
1213
+ expect(revokedOnly.total).toBe(1);
1214
+ expect(revokedOnly.items[0]?.name).toBe('relay-revoked');
1215
+
1216
+ const activeOnly = await readApiKeys({ status: 'active' });
1217
+ expect(activeOnly.total).toBe(3);
1218
+ expect(activeOnly.items.some((item) => item.name === 'relay-active')).toBe(
1219
+ true
1220
+ );
1221
+ expect(
1222
+ activeOnly.items.some((item) => item.name === 'relay-expiring')
1223
+ ).toBe(true);
1224
+ expect(activeOnly.items.some((item) => item.name === 'admin-future')).toBe(
1225
+ true
1226
+ );
1227
+ expect(activeOnly.items.some((item) => item.name === 'relay-expired')).toBe(
1228
+ false
1229
+ );
1230
+ expect(activeOnly.items.some((item) => item.name === 'relay-revoked')).toBe(
1231
+ false
1232
+ );
1233
+
1234
+ const expiringDefault = await readApiKeys({ status: 'expiring' });
1235
+ expect(expiringDefault.total).toBe(1);
1236
+ expect(expiringDefault.items[0]?.name).toBe('relay-expiring');
1237
+ });
1238
+
1239
+ it('applies custom expiring-window filters for API keys', async () => {
1240
+ const dayMs = 24 * 60 * 60 * 1000;
1241
+ const now = Date.now();
1242
+ const isoAfter = (days: number): string =>
1243
+ new Date(now + days * dayMs).toISOString();
1244
+
1245
+ await db
1246
+ .insertInto('sync_api_keys')
1247
+ .values([
1248
+ {
1249
+ key_id: 'key-expiring-3',
1250
+ key_hash: 'hash-e3',
1251
+ key_prefix: 'srel_exp_03',
1252
+ name: 'expiring-3',
1253
+ key_type: 'relay',
1254
+ scope_keys: [],
1255
+ actor_id: null,
1256
+ created_at: atIso(16),
1257
+ expires_at: isoAfter(3),
1258
+ last_used_at: null,
1259
+ revoked_at: null,
1260
+ },
1261
+ {
1262
+ key_id: 'key-expiring-10',
1263
+ key_hash: 'hash-e10',
1264
+ key_prefix: 'srel_exp_10',
1265
+ name: 'expiring-10',
1266
+ key_type: 'relay',
1267
+ scope_keys: [],
1268
+ actor_id: null,
1269
+ created_at: atIso(17),
1270
+ expires_at: isoAfter(10),
1271
+ last_used_at: null,
1272
+ revoked_at: null,
1273
+ },
1274
+ ])
1275
+ .execute();
1276
+
1277
+ const expiringInSevenDays = await readApiKeys({
1278
+ status: 'expiring',
1279
+ expiresWithinDays: 7,
1280
+ });
1281
+ expect(expiringInSevenDays.total).toBe(1);
1282
+ expect(expiringInSevenDays.items[0]?.name).toBe('expiring-3');
1283
+
1284
+ const expiringInFourteenDays = await readApiKeys({
1285
+ status: 'expiring',
1286
+ expiresWithinDays: 14,
1287
+ });
1288
+ expect(expiringInFourteenDays.total).toBe(2);
1289
+ const expiringNames = expiringInFourteenDays.items
1290
+ .map((item) => item.name)
1291
+ .sort();
1292
+ expect(expiringNames).toEqual(['expiring-10', 'expiring-3']);
1293
+ });
1294
+
1295
+ it('bulk revokes active keys and reports already-revoked/not-found ids', async () => {
1296
+ const nowIso = atIso(18);
1297
+
1298
+ await db
1299
+ .insertInto('sync_api_keys')
1300
+ .values([
1301
+ {
1302
+ key_id: 'bulk-active-1',
1303
+ key_hash: 'bulk-hash-1',
1304
+ key_prefix: 'bulk_active1',
1305
+ name: 'bulk-active-1',
1306
+ key_type: 'relay',
1307
+ scope_keys: [],
1308
+ actor_id: null,
1309
+ created_at: nowIso,
1310
+ expires_at: null,
1311
+ last_used_at: null,
1312
+ revoked_at: null,
1313
+ },
1314
+ {
1315
+ key_id: 'bulk-active-2',
1316
+ key_hash: 'bulk-hash-2',
1317
+ key_prefix: 'bulk_active2',
1318
+ name: 'bulk-active-2',
1319
+ key_type: 'proxy',
1320
+ scope_keys: [],
1321
+ actor_id: null,
1322
+ created_at: nowIso,
1323
+ expires_at: null,
1324
+ last_used_at: null,
1325
+ revoked_at: null,
1326
+ },
1327
+ {
1328
+ key_id: 'bulk-revoked-1',
1329
+ key_hash: 'bulk-hash-3',
1330
+ key_prefix: 'bulk_revoked',
1331
+ name: 'bulk-revoked-1',
1332
+ key_type: 'admin',
1333
+ scope_keys: [],
1334
+ actor_id: null,
1335
+ created_at: nowIso,
1336
+ expires_at: null,
1337
+ last_used_at: null,
1338
+ revoked_at: nowIso,
1339
+ },
1340
+ ])
1341
+ .execute();
1342
+
1343
+ const response = await requestBulkRevokeApiKeys([
1344
+ 'bulk-active-1',
1345
+ 'bulk-active-2',
1346
+ 'bulk-revoked-1',
1347
+ 'bulk-missing-1',
1348
+ ]);
1349
+ expect(response.status).toBe(200);
1350
+
1351
+ const payload = (await response.json()) as {
1352
+ requestedCount: number;
1353
+ revokedCount: number;
1354
+ alreadyRevokedCount: number;
1355
+ notFoundCount: number;
1356
+ revokedKeyIds: string[];
1357
+ alreadyRevokedKeyIds: string[];
1358
+ notFoundKeyIds: string[];
1359
+ };
1360
+
1361
+ expect(payload.requestedCount).toBe(4);
1362
+ expect(payload.revokedCount).toBe(2);
1363
+ expect(payload.alreadyRevokedCount).toBe(1);
1364
+ expect(payload.notFoundCount).toBe(1);
1365
+ expect(payload.revokedKeyIds.sort()).toEqual([
1366
+ 'bulk-active-1',
1367
+ 'bulk-active-2',
1368
+ ]);
1369
+ expect(payload.alreadyRevokedKeyIds).toEqual(['bulk-revoked-1']);
1370
+ expect(payload.notFoundKeyIds).toEqual(['bulk-missing-1']);
1371
+
1372
+ const revokedRows = await db
1373
+ .selectFrom('sync_api_keys')
1374
+ .select(['key_id', 'revoked_at'])
1375
+ .where('key_id', 'in', ['bulk-active-1', 'bulk-active-2'])
1376
+ .execute();
1377
+ expect(revokedRows.every((row) => row.revoked_at !== null)).toBe(true);
1378
+ });
1379
+
1380
+ it('stages key rotation without revoking the original key', async () => {
1381
+ const nowIso = atIso(19);
1382
+
1383
+ await db
1384
+ .insertInto('sync_api_keys')
1385
+ .values({
1386
+ key_id: 'stage-old-key',
1387
+ key_hash: 'stage-hash-1',
1388
+ key_prefix: 'stage_old__',
1389
+ name: 'stage-old',
1390
+ key_type: 'relay',
1391
+ scope_keys: ['scope-x'],
1392
+ actor_id: 'actor-stage',
1393
+ created_at: nowIso,
1394
+ expires_at: null,
1395
+ last_used_at: null,
1396
+ revoked_at: null,
1397
+ })
1398
+ .execute();
1399
+
1400
+ const response = await requestStageRotateApiKey('stage-old-key');
1401
+ expect(response.status).toBe(200);
1402
+
1403
+ const payload = (await response.json()) as {
1404
+ key: {
1405
+ keyId: string;
1406
+ keyPrefix: string;
1407
+ name: string;
1408
+ keyType: 'relay' | 'proxy' | 'admin';
1409
+ scopeKeys: string[];
1410
+ actorId: string | null;
1411
+ };
1412
+ secretKey: string;
1413
+ };
1414
+
1415
+ expect(payload.key.keyId).not.toBe('stage-old-key');
1416
+ expect(payload.key.name).toBe('stage-old');
1417
+ expect(payload.key.keyType).toBe('relay');
1418
+ expect(payload.key.scopeKeys).toEqual(['scope-x']);
1419
+ expect(payload.key.actorId).toBe('actor-stage');
1420
+ expect(payload.secretKey.startsWith(payload.key.keyPrefix)).toBe(true);
1421
+
1422
+ const oldRow = await db
1423
+ .selectFrom('sync_api_keys')
1424
+ .select(['revoked_at'])
1425
+ .where('key_id', '=', 'stage-old-key')
1426
+ .executeTakeFirst();
1427
+ expect(oldRow?.revoked_at).toBeNull();
1428
+
1429
+ const newRow = await db
1430
+ .selectFrom('sync_api_keys')
1431
+ .select(['key_id', 'revoked_at'])
1432
+ .where('key_id', '=', payload.key.keyId)
1433
+ .executeTakeFirst();
1434
+ expect(newRow?.key_id).toBe(payload.key.keyId);
1435
+ expect(newRow?.revoked_at).toBeNull();
1436
+ });
1437
+
1438
+ it('rejects unauthenticated timeline requests', async () => {
1439
+ const response = await requestTimeline({ authenticated: false });
1440
+ expect(response.status).toBe(401);
1441
+ expect(await response.json()).toEqual({ error: 'UNAUTHENTICATED' });
1442
+ });
1443
+
1444
+ it('returns payload snapshots for events with payload refs', async () => {
1445
+ const row = await db
1446
+ .selectFrom('sync_request_events')
1447
+ .select(['event_id'])
1448
+ .where('request_id', '=', 'req-1')
1449
+ .executeTakeFirst();
1450
+
1451
+ expect(row).toBeDefined();
1452
+ const eventId = Number(row?.event_id);
1453
+ const response = await requestEventPayload(eventId);
1454
+
1455
+ expect(response.status).toBe(200);
1456
+ const payload = (await response.json()) as {
1457
+ payloadRef: string;
1458
+ partitionId: string;
1459
+ requestPayload: { clientCommitId: string };
1460
+ responsePayload: { status: string };
1461
+ };
1462
+
1463
+ expect(payload.payloadRef).toBe('payload-1');
1464
+ expect(payload.partitionId).toBe('default');
1465
+ expect(payload.requestPayload.clientCommitId).toBe('commit-a');
1466
+ expect(payload.responsePayload.status).toBe('applied');
1467
+ });
1468
+ });