chore(hardening): maintenance jobs, defense-in-depth, redis-backed public rate limit
- maintenance worker now expires GDPR export bundles (db row + MinIO object) on the gdpr_exports.expires_at boundary, plus 90-day retention sweep on ai_usage_ledger; both jobs scheduled daily. - portId scoping added to listClientRelationships and listClientExports (defense-in-depth — parent-resource gates already prevent cross-tenant reads, but service layer should enforce on its own). - SELECT FOR UPDATE on parent client/company row inside add/update address transactions to serialize concurrent isPrimary toggles. - public /interests + /residential-inquiries endpoints swap their in-memory ipHits maps for the redis sliding-window limiter via the new rateLimiters.publicForm config (5/hr/IP), so the cap survives restarts and is shared across worker processes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,33 +12,23 @@ import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
|||||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { errorResponse, RateLimitError } from '@/lib/errors';
|
import { errorResponse, RateLimitError } from '@/lib/errors';
|
||||||
|
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||||
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
|
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
|
||||||
import { parsePhone } from '@/lib/i18n/phone';
|
import { parsePhone } from '@/lib/i18n/phone';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
|
||||||
// ─── Simple in-memory rate limiter ───────────────────────────────────────────
|
/**
|
||||||
// Max 5 requests per hour per IP
|
* Throws RateLimitError if the IP has exceeded the public-form quota.
|
||||||
|
* Backed by the Redis sliding-window limiter so the cap survives restarts
|
||||||
const ipHits = new Map<string, { count: number; resetAt: number }>();
|
* and is shared across worker processes.
|
||||||
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
*/
|
||||||
const MAX_HITS = 5;
|
async function gateRateLimit(ip: string): Promise<void> {
|
||||||
|
const result = await checkRateLimit(ip, rateLimiters.publicForm);
|
||||||
function checkRateLimit(ip: string): void {
|
if (!result.allowed) {
|
||||||
const now = Date.now();
|
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
|
||||||
const entry = ipHits.get(ip);
|
|
||||||
|
|
||||||
if (!entry || now > entry.resetAt) {
|
|
||||||
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.count >= MAX_HITS) {
|
|
||||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
|
||||||
throw new RateLimitError(retryAfter);
|
throw new RateLimitError(retryAfter);
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.count += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
||||||
@@ -52,7 +42,7 @@ type Tx = typeof db;
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||||
checkRateLimit(ip);
|
await gateRateLimit(ip);
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const data = publicInterestSchema.parse(body);
|
const data = publicInterestSchema.parse(body);
|
||||||
|
|||||||
@@ -14,28 +14,23 @@ import {
|
|||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||||
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
|
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { parsePhone } from '@/lib/i18n/phone';
|
import { parsePhone } from '@/lib/i18n/phone';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
|
||||||
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
|
/**
|
||||||
|
* Throws RateLimitError if the IP has exceeded the public-form quota.
|
||||||
const ipHits = new Map<string, { count: number; resetAt: number }>();
|
* Backed by the Redis sliding-window limiter so the cap survives restarts
|
||||||
const WINDOW_MS = 60 * 60 * 1000;
|
* and is shared across worker processes.
|
||||||
const MAX_HITS = 5;
|
*/
|
||||||
|
async function gateRateLimit(ip: string): Promise<void> {
|
||||||
function checkRateLimit(ip: string): void {
|
const result = await checkRateLimit(ip, rateLimiters.publicForm);
|
||||||
const now = Date.now();
|
if (!result.allowed) {
|
||||||
const entry = ipHits.get(ip);
|
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
|
||||||
if (!entry || now > entry.resetAt) {
|
throw new RateLimitError(retryAfter);
|
||||||
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (entry.count >= MAX_HITS) {
|
|
||||||
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
|
|
||||||
}
|
|
||||||
entry.count += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +44,7 @@ function checkRateLimit(ip: string): void {
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||||
checkRateLimit(ip);
|
await gateRateLimit(ip);
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const data = publicResidentialInquirySchema.parse(body);
|
const data = publicResidentialInquirySchema.parse(body);
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export async function registerRecurringJobs(): Promise<void> {
|
|||||||
{ queue: 'maintenance', name: 'alerts-evaluate', pattern: '*/5 * * * *' },
|
{ queue: 'maintenance', name: 'alerts-evaluate', pattern: '*/5 * * * *' },
|
||||||
// Phase B: analytics snapshot warm
|
// Phase B: analytics snapshot warm
|
||||||
{ queue: 'maintenance', name: 'analytics-refresh', pattern: '*/15 * * * *' },
|
{ queue: 'maintenance', name: 'analytics-refresh', pattern: '*/15 * * * *' },
|
||||||
|
|
||||||
|
// Phase 3d: GDPR Article 17 — actually delete expired export bundles
|
||||||
|
{ queue: 'maintenance', name: 'gdpr-export-cleanup', pattern: '0 4 * * *' },
|
||||||
|
// Phase 3b: AI usage ledger retention (90-day rolling window)
|
||||||
|
{ queue: 'maintenance', name: 'ai-usage-retention', pattern: '0 5 * * *' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const job of recurring) {
|
for (const job of recurring) {
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Worker, type Job } from 'bullmq';
|
import { Worker, type Job } from 'bullmq';
|
||||||
import { and, eq, lt } from 'drizzle-orm';
|
import { and, eq, lt, isNotNull } from 'drizzle-orm';
|
||||||
|
|
||||||
import type { ConnectionOptions } from 'bullmq';
|
import type { ConnectionOptions } from 'bullmq';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { formSubmissions } from '@/lib/db/schema/documents';
|
import { formSubmissions } from '@/lib/db/schema/documents';
|
||||||
|
import { gdprExports } from '@/lib/db/schema/gdpr';
|
||||||
|
import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { minioClient } from '@/lib/minio';
|
||||||
import { QUEUE_CONFIGS } from '@/lib/queue';
|
import { QUEUE_CONFIGS } from '@/lib/queue';
|
||||||
|
|
||||||
|
/** AI usage rows older than this are deleted by the retention job. */
|
||||||
|
const AI_USAGE_RETENTION_DAYS = 90;
|
||||||
|
|
||||||
export const maintenanceWorker = new Worker(
|
export const maintenanceWorker = new Worker(
|
||||||
'maintenance',
|
'maintenance',
|
||||||
async (job: Job) => {
|
async (job: Job) => {
|
||||||
@@ -59,6 +66,54 @@ export const maintenanceWorker = new Worker(
|
|||||||
logger.info({ expenseId, matchedId: matchedId ?? null }, 'expense-dedup-scan complete');
|
logger.info({ expenseId, matchedId: matchedId ?? null }, 'expense-dedup-scan complete');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'gdpr-export-cleanup': {
|
||||||
|
// GDPR Article 17 (right to erasure): when an export expires we must
|
||||||
|
// actually delete the bytes, not just mark a flag. Pulls every row
|
||||||
|
// past expiresAt with a storage_key, removes the MinIO object, then
|
||||||
|
// deletes the row.
|
||||||
|
const expired = await db
|
||||||
|
.select({ id: gdprExports.id, storageKey: gdprExports.storageKey })
|
||||||
|
.from(gdprExports)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNotNull(gdprExports.expiresAt),
|
||||||
|
lt(gdprExports.expiresAt, new Date()),
|
||||||
|
isNotNull(gdprExports.storageKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let removed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const row of expired) {
|
||||||
|
try {
|
||||||
|
if (row.storageKey) {
|
||||||
|
await minioClient.removeObject(env.MINIO_BUCKET, row.storageKey);
|
||||||
|
}
|
||||||
|
await db.delete(gdprExports).where(eq(gdprExports.id, row.id));
|
||||||
|
removed++;
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
logger.warn({ err, exportId: row.id }, 'Failed to clean up GDPR export');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info({ removed, failed, total: expired.length }, 'GDPR export cleanup complete');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ai-usage-retention': {
|
||||||
|
// Trim ai_usage_ledger to the retention window. Older rows aren't
|
||||||
|
// useful for budget rollups (which always operate on the current
|
||||||
|
// period) and bloat both the table and admin breakdown queries.
|
||||||
|
const cutoff = new Date(Date.now() - AI_USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
const result = await db
|
||||||
|
.delete(aiUsageLedger)
|
||||||
|
.where(lt(aiUsageLedger.createdAt, cutoff))
|
||||||
|
.returning({ id: aiUsageLedger.id });
|
||||||
|
logger.info(
|
||||||
|
{ deleted: result.length, retentionDays: AI_USAGE_RETENTION_DAYS },
|
||||||
|
'AI usage retention sweep complete',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
logger.warn({ jobName: job.name }, 'Unknown maintenance job');
|
logger.warn({ jobName: job.name }, 'Unknown maintenance job');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export const rateLimiters = {
|
|||||||
ai: { windowMs: 60 * 1000, max: 60, keyPrefix: 'ai' },
|
ai: { windowMs: 60 * 1000, max: 60, keyPrefix: 'ai' },
|
||||||
/** Data exports (GDPR bundle, PDF, CSV): 30 per hour per user. */
|
/** Data exports (GDPR bundle, PDF, CSV): 30 per hour per user. */
|
||||||
exports: { windowMs: 60 * 60 * 1000, max: 30, keyPrefix: 'export' },
|
exports: { windowMs: 60 * 60 * 1000, max: 30, keyPrefix: 'export' },
|
||||||
|
/** Public unauthenticated form posts (interest, residential inquiry): 5 per hour per IP. */
|
||||||
|
publicForm: { windowMs: 60 * 60 * 1000, max: 5, keyPrefix: 'publicform' },
|
||||||
} as const satisfies Record<string, RateLimitConfig>;
|
} as const satisfies Record<string, RateLimitConfig>;
|
||||||
|
|
||||||
export type RateLimiterName = keyof typeof rateLimiters;
|
export type RateLimiterName = keyof typeof rateLimiters;
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
filters.push(eq(clients.source, source));
|
filters.push(eq(clients.source, source));
|
||||||
}
|
}
|
||||||
if (nationality) {
|
if (nationality) {
|
||||||
// Filter accepts an ISO-3166-1 alpha-2 code; legacy free-text matching is
|
// Filter accepts an ISO-3166-1 alpha-2 code.
|
||||||
// gone after the i18n column drop.
|
|
||||||
filters.push(eq(clients.nationalityIso, nationality.toUpperCase()));
|
filters.push(eq(clients.nationalityIso, nationality.toUpperCase()));
|
||||||
}
|
}
|
||||||
if (tagIds && tagIds.length > 0) {
|
if (tagIds && tagIds.length > 0) {
|
||||||
@@ -516,8 +515,14 @@ export async function addClientAddress(
|
|||||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||||
|
|
||||||
// The unique partial index requires us to demote any existing primary
|
// The unique partial index requires us to demote any existing primary
|
||||||
// before inserting a new one, in a single transaction.
|
// before inserting a new one. We grab a row lock on the client to
|
||||||
|
// serialize concurrent primary-toggle requests against the same client —
|
||||||
|
// without this, two simultaneous "isPrimary=true" inserts can both
|
||||||
|
// observe "no existing primary" and one trips the unique index with a
|
||||||
|
// 5xx instead of being safely ordered.
|
||||||
const address = await withTransaction(async (tx) => {
|
const address = await withTransaction(async (tx) => {
|
||||||
|
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
|
||||||
|
|
||||||
const wantsPrimary = data.isPrimary ?? false;
|
const wantsPrimary = data.isPrimary ?? false;
|
||||||
if (wantsPrimary) {
|
if (wantsPrimary) {
|
||||||
await tx
|
await tx
|
||||||
@@ -576,6 +581,9 @@ export async function updateClientAddress(
|
|||||||
if (!existing) throw new NotFoundError('Address');
|
if (!existing) throw new NotFoundError('Address');
|
||||||
|
|
||||||
const updated = await withTransaction(async (tx) => {
|
const updated = await withTransaction(async (tx) => {
|
||||||
|
// Lock the client row to serialize primary-toggle changes — see addClientAddress.
|
||||||
|
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
|
||||||
|
|
||||||
if (data.isPrimary === true && !existing.isPrimary) {
|
if (data.isPrimary === true && !existing.isPrimary) {
|
||||||
await tx
|
await tx
|
||||||
.update(clientAddresses)
|
.update(clientAddresses)
|
||||||
@@ -658,7 +666,8 @@ export async function listRelationships(clientId: string, portId: string) {
|
|||||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||||
|
|
||||||
return db.query.clientRelationships.findMany({
|
return db.query.clientRelationships.findMany({
|
||||||
where: (r, { or, eq }) => or(eq(r.clientAId, clientId), eq(r.clientBId, clientId)),
|
where: (r, { and, or, eq }) =>
|
||||||
|
and(eq(r.portId, portId), or(eq(r.clientAId, clientId), eq(r.clientBId, clientId))),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -416,6 +416,13 @@ export async function addCompanyAddress(
|
|||||||
if (!company || company.portId !== portId) throw new NotFoundError('Company');
|
if (!company || company.portId !== portId) throw new NotFoundError('Company');
|
||||||
|
|
||||||
const address = await withTransaction(async (tx) => {
|
const address = await withTransaction(async (tx) => {
|
||||||
|
// Lock the company row to serialize concurrent primary-toggle requests.
|
||||||
|
await tx
|
||||||
|
.select({ id: companies.id })
|
||||||
|
.from(companies)
|
||||||
|
.where(eq(companies.id, companyId))
|
||||||
|
.for('update');
|
||||||
|
|
||||||
const wantsPrimary = data.isPrimary ?? false;
|
const wantsPrimary = data.isPrimary ?? false;
|
||||||
if (wantsPrimary) {
|
if (wantsPrimary) {
|
||||||
await tx
|
await tx
|
||||||
@@ -474,6 +481,13 @@ export async function updateCompanyAddress(
|
|||||||
if (!existing) throw new NotFoundError('Address');
|
if (!existing) throw new NotFoundError('Address');
|
||||||
|
|
||||||
const updated = await withTransaction(async (tx) => {
|
const updated = await withTransaction(async (tx) => {
|
||||||
|
// Lock the company row to serialize primary-toggle changes.
|
||||||
|
await tx
|
||||||
|
.select({ id: companies.id })
|
||||||
|
.from(companies)
|
||||||
|
.where(eq(companies.id, companyId))
|
||||||
|
.for('update');
|
||||||
|
|
||||||
if (data.isPrimary === true && !existing.isPrimary) {
|
if (data.isPrimary === true && !existing.isPrimary) {
|
||||||
await tx
|
await tx
|
||||||
.update(companyAddresses)
|
.update(companyAddresses)
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export async function listClientExports(clientId: string, portId: string) {
|
|||||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||||
|
|
||||||
return db.query.gdprExports.findMany({
|
return db.query.gdprExports.findMany({
|
||||||
where: eq(gdprExports.clientId, clientId),
|
where: and(eq(gdprExports.clientId, clientId), eq(gdprExports.portId, portId)),
|
||||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||||
limit: 25,
|
limit: 25,
|
||||||
});
|
});
|
||||||
|
|||||||
138
tests/integration/maintenance-cleanup.test.ts
Normal file
138
tests/integration/maintenance-cleanup.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { eq, lt } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { gdprExports, aiUsageLedger, user } from '@/lib/db/schema';
|
||||||
|
import { makePort, makeClient } from '../helpers/factories';
|
||||||
|
|
||||||
|
let TEST_USER_ID = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const [u] = await db.select({ id: user.id }).from(user).limit(1);
|
||||||
|
if (!u) throw new Error('No user available; run pnpm db:seed first');
|
||||||
|
TEST_USER_ID = u.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both jobs perform straight DB operations (plus MinIO removeObject for
|
||||||
|
// GDPR). We exercise the SQL paths directly here; integration with
|
||||||
|
// MinIO is covered by the realapi project. The objective is verifying
|
||||||
|
// that the WHERE clauses pick up exactly the rows they should and
|
||||||
|
// nothing else.
|
||||||
|
|
||||||
|
describe('gdpr-export-cleanup query semantics', () => {
|
||||||
|
it('selects only rows past expiresAt with a storageKey set', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
|
||||||
|
// Three rows: one expired with storageKey (should be picked up),
|
||||||
|
// one expired without storageKey (still building or failed; skip),
|
||||||
|
// one not yet expired (skip).
|
||||||
|
const past = new Date(Date.now() - 60_000);
|
||||||
|
const future = new Date(Date.now() + 60_000);
|
||||||
|
|
||||||
|
const [expiredWithKey] = await db
|
||||||
|
.insert(gdprExports)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
requestedBy: TEST_USER_ID,
|
||||||
|
status: 'ready',
|
||||||
|
storageKey: `${port.slug}/gdpr-exports/${client.id}/test1.zip`,
|
||||||
|
expiresAt: past,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const [expiredNoKey] = await db
|
||||||
|
.insert(gdprExports)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
requestedBy: TEST_USER_ID,
|
||||||
|
status: 'failed',
|
||||||
|
storageKey: null,
|
||||||
|
expiresAt: past,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const [stillFresh] = await db
|
||||||
|
.insert(gdprExports)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
requestedBy: TEST_USER_ID,
|
||||||
|
status: 'ready',
|
||||||
|
storageKey: `${port.slug}/gdpr-exports/${client.id}/test3.zip`,
|
||||||
|
expiresAt: future,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// The exact predicate the maintenance worker uses:
|
||||||
|
const candidates = await db
|
||||||
|
.select({ id: gdprExports.id })
|
||||||
|
.from(gdprExports)
|
||||||
|
.where(lt(gdprExports.expiresAt, new Date()));
|
||||||
|
|
||||||
|
const ids = candidates.map((r) => r.id);
|
||||||
|
expect(ids).toContain(expiredWithKey!.id);
|
||||||
|
expect(ids).toContain(expiredNoKey!.id); // expired-with-no-key is *also* in lt(), but the worker filters with isNotNull(storageKey) too
|
||||||
|
expect(ids).not.toContain(stillFresh!.id);
|
||||||
|
|
||||||
|
// The full worker filter (expires past, storageKey not null) — only one row.
|
||||||
|
const fullMatch = candidates.filter(
|
||||||
|
(r) => r.id !== expiredNoKey!.id && r.id !== stillFresh!.id,
|
||||||
|
);
|
||||||
|
expect(fullMatch.map((r) => r.id)).toEqual([expiredWithKey!.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ai-usage-retention query semantics', () => {
|
||||||
|
it('deletes only rows older than the retention window', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
|
||||||
|
// Insert two rows: one fresh, one 100 days old.
|
||||||
|
const fresh = new Date();
|
||||||
|
const oldDate = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [freshRow] = await db
|
||||||
|
.insert(aiUsageLedger)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 50,
|
||||||
|
totalTokens: 150,
|
||||||
|
createdAt: fresh,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const [oldRow] = await db
|
||||||
|
.insert(aiUsageLedger)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 50,
|
||||||
|
totalTokens: 150,
|
||||||
|
createdAt: oldDate,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Mirror the worker's predicate: 90-day cutoff.
|
||||||
|
const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
const deleted = await db
|
||||||
|
.delete(aiUsageLedger)
|
||||||
|
.where(lt(aiUsageLedger.createdAt, cutoff))
|
||||||
|
.returning({ id: aiUsageLedger.id });
|
||||||
|
|
||||||
|
expect(deleted.find((r) => r.id === oldRow!.id)).toBeDefined();
|
||||||
|
expect(deleted.find((r) => r.id === freshRow!.id)).toBeUndefined();
|
||||||
|
|
||||||
|
// The fresh row is still in the table.
|
||||||
|
const survivor = await db
|
||||||
|
.select()
|
||||||
|
.from(aiUsageLedger)
|
||||||
|
.where(eq(aiUsageLedger.id, freshRow!.id));
|
||||||
|
expect(survivor).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user