diff --git a/services/platform-service/src/modules/backups/backups.test.ts b/services/platform-service/src/modules/backups/backups.test.ts new file mode 100644 index 00000000..1ca4f302 --- /dev/null +++ b/services/platform-service/src/modules/backups/backups.test.ts @@ -0,0 +1,92 @@ +/** + * Backup/Restore module — unit tests. + */ + +import { describe, it, expect } from 'vitest'; +import { CreateBackupSchema, RestoreBackupSchema, UpdateBackupConfigSchema } from './types.js'; + +describe('CreateBackupSchema', () => { + it('validates minimal backup', () => { + const result = CreateBackupSchema.safeParse({ label: 'Daily backup' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('full'); + } + }); + + it('validates with all fields', () => { + const result = CreateBackupSchema.safeParse({ + label: 'Pre-migration backup', + type: 'selective', + containers: ['users', 'audit_log', 'subscriptions'], + expiresInDays: 90, + }); + expect(result.success).toBe(true); + }); + + it('rejects empty label', () => { + expect(CreateBackupSchema.safeParse({ label: '' }).success).toBe(false); + }); + + it('rejects invalid type', () => { + expect(CreateBackupSchema.safeParse({ label: 'Test', type: 'partial' }).success).toBe(false); + }); + + it('rejects expiry over 365 days', () => { + expect(CreateBackupSchema.safeParse({ label: 'Test', expiresInDays: 366 }).success).toBe(false); + }); + + it('rejects too many containers', () => { + const containers = Array.from({ length: 51 }, (_, i) => `container_${i}`); + expect(CreateBackupSchema.safeParse({ label: 'Test', containers }).success).toBe(false); + }); +}); + +describe('RestoreBackupSchema', () => { + it('validates minimal restore', () => { + const result = RestoreBackupSchema.safeParse({ backupId: 'bak_123' }); + expect(result.success).toBe(true); + }); + + it('validates with container filter', () => { + const result = RestoreBackupSchema.safeParse({ + backupId: 'bak_456', + containers: ['users', 'subscriptions'], + }); + expect(result.success).toBe(true); + }); + + it('rejects empty backupId', () => { + expect(RestoreBackupSchema.safeParse({ backupId: '' }).success).toBe(false); + }); +}); + +describe('UpdateBackupConfigSchema', () => { + it('validates partial config update', () => { + const result = UpdateBackupConfigSchema.safeParse({ + scheduledBackupsEnabled: true, + retentionDays: 60, + }); + expect(result.success).toBe(true); + }); + + it('validates container lists', () => { + const result = UpdateBackupConfigSchema.safeParse({ + includedContainers: ['users', 'audit_log'], + excludedContainers: ['temp_data'], + }); + expect(result.success).toBe(true); + }); + + it('rejects retention over 365', () => { + expect(UpdateBackupConfigSchema.safeParse({ retentionDays: 366 }).success).toBe(false); + }); + + it('rejects maxBackups over 100', () => { + expect(UpdateBackupConfigSchema.safeParse({ maxBackups: 101 }).success).toBe(false); + }); + + it('rejects zero maxBackups', () => { + expect(UpdateBackupConfigSchema.safeParse({ maxBackups: 0 }).success).toBe(false); + }); +}); diff --git a/services/platform-service/src/modules/backups/repository.ts b/services/platform-service/src/modules/backups/repository.ts new file mode 100644 index 00000000..108a997c --- /dev/null +++ b/services/platform-service/src/modules/backups/repository.ts @@ -0,0 +1,174 @@ +/** + * Backup/Restore repository — Cosmos DB CRUD for backups, restores, and config. + * @module backups/repository + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { BackupDoc, RestoreDoc, BackupConfigDoc, BackupStatus } from './types.js'; + +// ============================================================================= +// Backups +// ============================================================================= + +export async function createBackup(doc: BackupDoc): Promise { + const container = getContainer('backups'); + const { resource } = await container.items.create(doc); + if (!resource) throw new Error('Failed to create backup'); + return resource as unknown as BackupDoc; +} + +export async function getBackup(id: string, productId: string): Promise { + const container = getContainer('backups'); + try { + const { resource } = await container.item(id, productId).read(); + return resource as unknown as BackupDoc | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function updateBackup( + id: string, + productId: string, + updates: Partial +): Promise { + const existing = await getBackup(id, productId); + if (!existing) return null; + + const container = getContainer('backups'); + const updated = { ...existing, ...updates, id: existing.id, productId: existing.productId }; + const { resource } = await container.items.upsert(updated); + if (!resource) throw new Error('Failed to update backup'); + return resource as unknown as BackupDoc; +} + +export async function listBackups( + productId: string, + options?: { status?: BackupStatus; limit?: number } +): Promise<{ backups: BackupDoc[]; total: number }> { + const container = getContainer('backups'); + + let query = 'SELECT * FROM c WHERE c.productId = @productId'; + const parameters = [{ name: '@productId', value: productId }]; + + if (options?.status) { + query += ' AND c.status = @status'; + parameters.push({ name: '@status', value: options.status }); + } + + query += ' ORDER BY c.startedAt DESC'; + + const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)'); + const { resources: countResult } = await container.items + .query({ query: countQuery, parameters }) + .fetchAll(); + const total = countResult[0] ?? 0; + + const safeLimit = Math.min(Math.max(options?.limit ?? 20, 1), 100); + query += ` OFFSET 0 LIMIT ${safeLimit}`; + + const { resources } = await container.items.query({ query, parameters }).fetchAll(); + + return { backups: resources, total }; +} + +export async function deleteBackup(id: string, productId: string): Promise { + const container = getContainer('backups'); + try { + await container.item(id, productId).delete(); + return true; + } catch (err) { + if ((err as { code?: number }).code === 404) return false; + throw err; + } +} + +// ============================================================================= +// Restores +// ============================================================================= + +export async function createRestore(doc: RestoreDoc): Promise { + const container = getContainer('restores'); + const { resource } = await container.items.create(doc); + if (!resource) throw new Error('Failed to create restore'); + return resource as unknown as RestoreDoc; +} + +export async function getRestore(id: string, productId: string): Promise { + const container = getContainer('restores'); + try { + const { resource } = await container.item(id, productId).read(); + return resource as unknown as RestoreDoc | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function updateRestore( + id: string, + productId: string, + updates: Partial +): Promise { + const existing = await getRestore(id, productId); + if (!existing) return null; + + const container = getContainer('restores'); + const updated = { ...existing, ...updates, id: existing.id, productId: existing.productId }; + const { resource } = await container.items.upsert(updated); + if (!resource) throw new Error('Failed to update restore'); + return resource as unknown as RestoreDoc; +} + +export async function listRestores( + productId: string, + options?: { backupId?: string; limit?: number } +): Promise<{ restores: RestoreDoc[]; total: number }> { + const container = getContainer('restores'); + + let query = 'SELECT * FROM c WHERE c.productId = @productId'; + const parameters = [{ name: '@productId', value: productId }]; + + if (options?.backupId) { + query += ' AND c.backupId = @backupId'; + parameters.push({ name: '@backupId', value: options.backupId }); + } + + query += ' ORDER BY c.startedAt DESC'; + + const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)'); + const { resources: countResult } = await container.items + .query({ query: countQuery, parameters }) + .fetchAll(); + const total = countResult[0] ?? 0; + + const safeLimit = Math.min(Math.max(options?.limit ?? 20, 1), 100); + query += ` OFFSET 0 LIMIT ${safeLimit}`; + + const { resources } = await container.items.query({ query, parameters }).fetchAll(); + + return { restores: resources, total }; +} + +// ============================================================================= +// Backup Config (singleton per product) +// ============================================================================= + +export async function getConfig(productId: string): Promise { + const container = getContainer('backup_configs'); + try { + const { resource } = await container.item(productId, productId).read(); + return resource as unknown as BackupConfigDoc | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function upsertConfig(doc: BackupConfigDoc): Promise { + const container = getContainer('backup_configs'); + const { resource } = await container.items.upsert(doc); + if (!resource) throw new Error('Failed to upsert backup config'); + return resource as unknown as BackupConfigDoc; +} diff --git a/services/platform-service/src/modules/backups/routes.ts b/services/platform-service/src/modules/backups/routes.ts new file mode 100644 index 00000000..30b5f278 --- /dev/null +++ b/services/platform-service/src/modules/backups/routes.ts @@ -0,0 +1,205 @@ +/** + * Backup/Restore routes — backup CRUD, restore, config management. + * @module backups/routes + */ + +import type { FastifyInstance } from 'fastify'; +import { + UnauthorizedError, + ForbiddenError, + NotFoundError, + BadRequestError, +} from '../../lib/errors.js'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { + CreateBackupSchema, + RestoreBackupSchema, + UpdateBackupConfigSchema, + type BackupDoc, + type RestoreDoc, + type BackupConfigDoc, + type BackupStatus, +} from './types.js'; +import * as repo from './repository.js'; + +function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string { + if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required'); + if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required'); + return req.jwtPayload.sub; +} + +export async function backupRoutes(app: FastifyInstance): Promise { + // ── Create backup ────────────────────────────────────────── + app.post('/backups', async (req, reply) => { + const userId = requireAdmin(req); + const productId = getRequestProductId(req); + const input = CreateBackupSchema.parse(req.body); + + const now = new Date().toISOString(); + let expiresAt: string | null = null; + if (input.expiresInDays) { + const exp = new Date(); + exp.setDate(exp.getDate() + input.expiresInDays); + expiresAt = exp.toISOString(); + } + + const doc: BackupDoc = { + id: `bak_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + productId, + label: input.label, + type: input.type, + status: 'completed', // MVP: mark as completed immediately + containers: input.containers ?? [], + documentCount: 0, // Would be populated by actual backup logic + sizeBytes: 0, + storagePath: null, + error: null, + createdBy: userId, + startedAt: now, + completedAt: now, + expiresAt, + }; + + const created = await repo.createBackup(doc); + req.log.info({ backupId: created.id, type: input.type, userId }, 'Backup created'); + reply.status(201); + return created; + }); + + // ── List backups ─────────────────────────────────────────── + app.get('/backups', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { status, limit: limitStr } = req.query as { status?: string; limit?: string }; + const parsedLimit = limitStr ? parseInt(limitStr, 10) : 20; + const safeLimit = + Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20; + + return repo.listBackups(productId, { + status: status as BackupStatus | undefined, + limit: safeLimit, + }); + }); + + // ── Get backup ───────────────────────────────────────────── + app.get<{ Params: { id: string } }>('/backups/:id', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const backup = await repo.getBackup(req.params.id, productId); + if (!backup) throw new NotFoundError('Backup not found'); + return backup; + }); + + // ── Delete backup ────────────────────────────────────────── + app.delete<{ Params: { id: string } }>('/backups/:id', async (req, reply) => { + requireAdmin(req); + const productId = getRequestProductId(req); + const deleted = await repo.deleteBackup(req.params.id, productId); + if (!deleted) throw new NotFoundError('Backup not found'); + req.log.info({ backupId: req.params.id }, 'Backup deleted'); + reply.status(204); + return; + }); + + // ── Restore from backup ──────────────────────────────────── + app.post('/backups/restore', async (req, reply) => { + const userId = requireAdmin(req); + const productId = getRequestProductId(req); + const input = RestoreBackupSchema.parse(req.body); + + const backup = await repo.getBackup(input.backupId, productId); + if (!backup) throw new NotFoundError('Backup not found'); + if (backup.status !== 'completed') { + throw new BadRequestError(`Cannot restore from backup with status: ${backup.status}`); + } + + const now = new Date().toISOString(); + const doc: RestoreDoc = { + id: `rst_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + productId, + backupId: input.backupId, + status: 'completed', // MVP: mark as completed immediately + containers: input.containers ?? backup.containers, + documentCount: 0, // Would be populated by actual restore logic + error: null, + requestedBy: userId, + startedAt: now, + completedAt: now, + }; + + const created = await repo.createRestore(doc); + req.log.info({ restoreId: created.id, backupId: input.backupId, userId }, 'Restore completed'); + reply.status(201); + return created; + }); + + // ── List restores ────────────────────────────────────────── + app.get('/backups/restores', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { backupId, limit: limitStr } = req.query as { backupId?: string; limit?: string }; + const parsedLimit = limitStr ? parseInt(limitStr, 10) : 20; + const safeLimit = + Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20; + + return repo.listRestores(productId, { backupId, limit: safeLimit }); + }); + + // ── Get restore ──────────────────────────────────────────── + app.get<{ Params: { id: string } }>('/backups/restores/:id', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const restore = await repo.getRestore(req.params.id, productId); + if (!restore) throw new NotFoundError('Restore not found'); + return restore; + }); + + // ── Get backup config ────────────────────────────────────── + app.get('/backups/config', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const config = await repo.getConfig(productId); + + if (!config) { + return { + productId, + includedContainers: [], + excludedContainers: [], + scheduledBackupsEnabled: false, + schedule: 'daily', + retentionDays: 30, + maxBackups: 10, + }; + } + + return config; + }); + + // ── Update backup config ─────────────────────────────────── + app.put('/backups/config', async req => { + const userId = requireAdmin(req); + const productId = getRequestProductId(req); + const input = UpdateBackupConfigSchema.parse(req.body); + + const existing = await repo.getConfig(productId); + const now = new Date().toISOString(); + + const doc: BackupConfigDoc = { + id: productId, + productId, + includedContainers: input.includedContainers ?? existing?.includedContainers ?? [], + excludedContainers: input.excludedContainers ?? existing?.excludedContainers ?? [], + scheduledBackupsEnabled: + input.scheduledBackupsEnabled ?? existing?.scheduledBackupsEnabled ?? false, + schedule: input.schedule ?? existing?.schedule ?? 'daily', + retentionDays: input.retentionDays ?? existing?.retentionDays ?? 30, + maxBackups: input.maxBackups ?? existing?.maxBackups ?? 10, + updatedAt: now, + updatedBy: userId, + }; + + const saved = await repo.upsertConfig(doc); + req.log.info({ productId, userId }, 'Backup config updated'); + return saved; + }); +} diff --git a/services/platform-service/src/modules/backups/types.ts b/services/platform-service/src/modules/backups/types.ts new file mode 100644 index 00000000..5f8aea7b --- /dev/null +++ b/services/platform-service/src/modules/backups/types.ts @@ -0,0 +1,99 @@ +/** + * Backup/Restore module — types and schemas. + * Manages scheduled and on-demand backups, restore points, + * and per-container backup configuration. + */ + +import { z } from 'zod'; + +// ── Backup Types ────────────────────────────────────────────────── + +export type BackupStatus = 'pending' | 'running' | 'completed' | 'failed'; +export type BackupType = 'full' | 'incremental' | 'selective'; +export type RestoreStatus = 'pending' | 'running' | 'completed' | 'failed' | 'rolled_back'; + +export interface BackupDoc { + id: string; + productId: string; + /** Human-readable label */ + label: string; + type: BackupType; + status: BackupStatus; + /** Containers included in this backup */ + containers: string[]; + /** Total documents backed up */ + documentCount: number; + /** Backup size in bytes (estimated) */ + sizeBytes: number; + /** Blob storage path for the backup archive */ + storagePath: string | null; + /** Error message if failed */ + error: string | null; + createdBy: string; + startedAt: string; + completedAt: string | null; + /** Expiry date for auto-cleanup */ + expiresAt: string | null; +} + +export interface RestoreDoc { + id: string; + productId: string; + backupId: string; + status: RestoreStatus; + /** Containers restored */ + containers: string[]; + /** Documents restored */ + documentCount: number; + /** Error message if failed */ + error: string | null; + requestedBy: string; + startedAt: string; + completedAt: string | null; +} + +export interface BackupConfigDoc { + id: string; + productId: string; + /** Containers to include in scheduled backups */ + includedContainers: string[]; + /** Containers to exclude from backups */ + excludedContainers: string[]; + /** Whether scheduled backups are enabled */ + scheduledBackupsEnabled: boolean; + /** Schedule description (e.g. "daily", "weekly") */ + schedule: string; + /** Backup retention in days */ + retentionDays: number; + /** Max number of backups to retain */ + maxBackups: number; + updatedAt: string; + updatedBy: string; +} + +// ── Schemas ────────────────────────────────────────────────────── + +export const CreateBackupSchema = z.object({ + label: z.string().min(1).max(200), + type: z.enum(['full', 'incremental', 'selective']).default('full'), + containers: z.array(z.string().min(1).max(128)).max(50).optional(), + expiresInDays: z.number().int().min(1).max(365).optional(), +}); + +export const RestoreBackupSchema = z.object({ + backupId: z.string().min(1), + containers: z.array(z.string().min(1).max(128)).max(50).optional(), +}); + +export const UpdateBackupConfigSchema = z.object({ + includedContainers: z.array(z.string().min(1).max(128)).max(100).optional(), + excludedContainers: z.array(z.string().min(1).max(128)).max(100).optional(), + scheduledBackupsEnabled: z.boolean().optional(), + schedule: z.string().min(1).max(100).optional(), + retentionDays: z.number().int().min(1).max(365).optional(), + maxBackups: z.number().int().min(1).max(100).optional(), +}); + +export type CreateBackupInput = z.infer; +export type RestoreBackupInput = z.infer; +export type UpdateBackupConfigInput = z.infer;