/** * Tests for /api/cloud-backup routes — tier gating (community vs paid), * admin gating, config CRUD round-trip with secret encryption, audit logging. * The S3 SDK is mocked at the module level so no network calls happen. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import request from 'supertest'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; const sentSpy = vi.fn(); vi.mock('@aws-sdk/client-s3', () => { class S3Client { async send(cmd: { name: string; input: Record }) { return sentSpy(cmd); } } class PutObjectCommand { name = 'PutObjectCommand'; constructor(public input: Record) {} } class GetObjectCommand { name = 'GetObjectCommand'; constructor(public input: Record) {} } class ListObjectsV2Command { name = 'ListObjectsV2Command'; constructor(public input: Record) {} } class DeleteObjectCommand { name = 'DeleteObjectCommand'; constructor(public input: Record) {} } class HeadBucketCommand { name = 'HeadBucketCommand'; constructor(public input: Record) {} } return { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, HeadBucketCommand }; }); let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let LicenseService: typeof import('../services/LicenseService').LicenseService; let authCookie: string; beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); ({ LicenseService } = await import('../services/LicenseService')); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); ({ app } = await import('../index')); authCookie = await loginAsTestAdmin(app); }); afterAll(() => { cleanupTestDb(tmpDir); }); beforeEach(() => { sentSpy.mockReset(); const db = DatabaseService.getInstance(); for (const k of [ 'cloud_backup_provider', 'cloud_backup_endpoint', 'cloud_backup_region', 'cloud_backup_bucket', 'cloud_backup_access_key', 'cloud_backup_secret_key', 'cloud_backup_path_prefix', 'cloud_backup_auto_upload', ]) { db.updateGlobalSetting(k, ''); } }); // Sticky mocks (mockReturnValue, not mockReturnValueOnce) so a test that does // not actually hit a tier-gated codepath doesn't leak its persona into later // tests. The afterEach hook resets back to the paid baseline. function mockCommunity() { vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); } afterEach(() => { vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); }); const customConfigBody = { provider: 'custom', custom: { endpoint: 'https://s3.example.com', region: 'us-east-1', bucket: 'b', access_key: 'a', secret_key: 's', path_prefix: 'p/', auto_upload: false, }, }; describe('Cloud backup tier gating', () => { // GET /config is ungated — every tier can read the stored configuration. it('GET /config is readable on Community', async () => { mockCommunity(); const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); expect(res.status).toBe(200); }); it('GET /config is readable on the paid tier', async () => { const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); expect(res.status).toBe(200); expect(res.body).toHaveProperty('provider', 'disabled'); }); // PUT /config: 'custom' is available on every tier, 'sencho' is paid-only. it('PUT /config with provider=custom succeeds on Community', async () => { mockCommunity(); const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send(customConfigBody); expect(res.status).toBe(204); }); it('PUT /config with provider=sencho is rejected on Community with PAID_REQUIRED', async () => { mockCommunity(); const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' }); expect(res.status).toBe(403); expect(res.body.code).toBe('PAID_REQUIRED'); }); // POST /provision is paid-only by definition (Sencho Cloud Backup activation). it('POST /provision is rejected on Community', async () => { mockCommunity(); const res = await request(app).post('/api/cloud-backup/provision').set('Cookie', authCookie); expect(res.status).toBe(403); expect(res.body.code).toBe('PAID_REQUIRED'); }); // GET /usage is paid-only (sencho-specific endpoint). it('GET /usage is rejected on Community', async () => { mockCommunity(); const res = await request(app).get('/api/cloud-backup/usage').set('Cookie', authCookie); expect(res.status).toBe(403); }); // POST /test, GET /snapshots, POST /upload, GET /status, GET /object/.../download, // DELETE /object are gated by the *currently saved* provider. it('POST /test reaches handler on Community when saved provider is custom', async () => { DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'custom'); mockCommunity(); const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie); expect(res.status).not.toBe(403); }); it('POST /test is rejected on Community when saved provider is sencho', async () => { DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'sencho'); mockCommunity(); const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie); expect(res.status).toBe(403); expect(res.body.code).toBe('PAID_REQUIRED'); }); it('GET /snapshots reaches handler on Community when saved provider is custom', async () => { DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'custom'); mockCommunity(); const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', authCookie); expect(res.status).not.toBe(403); }); it('GET /snapshots is rejected on Community when saved provider is sencho', async () => { DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'sencho'); mockCommunity(); const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', authCookie); expect(res.status).toBe(403); }); // The paid tier retains access to every endpoint. it('a paid admin can configure provider=sencho', async () => { const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' }); expect(res.status).toBe(204); }); }); describe('Cloud backup read routes are admin-only', () => { // The archive download returns plaintext compose/.env, so listing and // downloading cloud snapshots must be admin-gated like the local snapshot // reads, regardless of provider/tier. let viewerCookie: string; const keyB64 = Buffer.from('sencho/instances/x/snapshots/1.tar.gz').toString('base64url'); beforeAll(async () => { const db = DatabaseService.getInstance(); const bcrypt = (await import('bcrypt')).default; const hash = await bcrypt.hash('cloud-viewer-pass', 1); try { db.addUser({ username: 'cloud-viewer', password_hash: hash, role: 'viewer' }); } catch { /* may already exist */ } const login = await request(app).post('/api/auth/login').send({ username: 'cloud-viewer', password: 'cloud-viewer-pass' }); const cookies = login.headers['set-cookie'] as string | string[]; viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; }); it('GET /snapshots returns 403 for a non-admin', async () => { const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', viewerCookie); expect(res.status).toBe(403); expect(res.body.code).toBe('ADMIN_REQUIRED'); }); it('GET /status/:id returns 403 for a non-admin', async () => { const res = await request(app).get('/api/cloud-backup/status/1').set('Cookie', viewerCookie); expect(res.status).toBe(403); expect(res.body.code).toBe('ADMIN_REQUIRED'); }); it('GET /object/:keyB64/download returns 403 for a non-admin', async () => { const res = await request(app).get(`/api/cloud-backup/object/${keyB64}/download`).set('Cookie', viewerCookie); expect(res.status).toBe(403); expect(res.body.code).toBe('ADMIN_REQUIRED'); }); it('does not block an admin on GET /snapshots', async () => { const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', authCookie); expect(res.status).not.toBe(403); }); }); describe('Cloud backup config CRUD', () => { it('redacts secret_key on read; persists encrypted ciphertext', async () => { const putRes = await request(app) .put('/api/cloud-backup/config') .set('Cookie', authCookie) .send({ provider: 'custom', custom: { endpoint: 'https://s3.example.com', region: 'us-east-1', bucket: 'b', access_key: 'AKIA1234', secret_key: 'super-secret', path_prefix: 'sencho/', auto_upload: true, }, }); expect(putRes.status).toBe(204); const stored = DatabaseService.getInstance().getGlobalSettings().cloud_backup_secret_key; expect(stored.startsWith('enc:')).toBe(true); expect(stored.includes('super-secret')).toBe(false); const getRes = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); expect(getRes.status).toBe(200); expect(getRes.body.provider).toBe('custom'); expect(getRes.body.custom.secret_key).toBe('***'); expect(getRes.body.custom.bucket).toBe('b'); }); it('preserves saved secret when client sends "***"', async () => { const db = DatabaseService.getInstance(); await request(app) .put('/api/cloud-backup/config') .set('Cookie', authCookie) .send({ provider: 'custom', custom: { endpoint: 'https://e', region: 'r', bucket: 'b', access_key: 'a', secret_key: 'first-secret', path_prefix: 's/', auto_upload: false }, }); const firstStored = db.getGlobalSettings().cloud_backup_secret_key; await request(app) .put('/api/cloud-backup/config') .set('Cookie', authCookie) .send({ provider: 'custom', custom: { endpoint: 'https://e', region: 'r2', bucket: 'b', access_key: 'a', secret_key: '***', path_prefix: 's/', auto_upload: true }, }); const secondStored = db.getGlobalSettings().cloud_backup_secret_key; expect(secondStored).toBe(firstStored); expect(db.getGlobalSettings().cloud_backup_region).toBe('r2'); expect(db.getGlobalSettings().cloud_backup_auto_upload).toBe('1'); }); it('rejects invalid provider value', async () => { const res = await request(app) .put('/api/cloud-backup/config') .set('Cookie', authCookie) .send({ provider: 'bogus' }); expect(res.status).toBe(400); }); it('rejects custom config missing required fields', async () => { const res = await request(app) .put('/api/cloud-backup/config') .set('Cookie', authCookie) .send({ provider: 'custom', custom: { endpoint: '', bucket: '', access_key: '' } }); expect(res.status).toBe(400); }); }); describe('Cloud backup audit log', () => { it('writes audit row with the cloud-backup summary on PUT /config', async () => { await request(app) .put('/api/cloud-backup/config') .set('Cookie', authCookie) .send({ provider: 'custom', custom: { endpoint: 'https://s3.example.com', region: 'r', bucket: 'b', access_key: 'a', secret_key: 's', path_prefix: 'p/', auto_upload: false }, }); const { entries } = DatabaseService.getInstance().getAuditLogs({ limit: 50 }); const cloudEntry = entries.find(e => e.path.includes('/cloud-backup/config') && e.method === 'PUT'); expect(cloudEntry).toBeDefined(); expect(cloudEntry!.summary).toBe('Updated cloud backup config'); }); }); describe('Cloud backup test endpoint', () => { it('reports failure when no provider is configured', async () => { const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie).send({}); expect(res.status).toBe(200); expect(res.body.success).toBe(false); }); it('reports success when HeadBucketCommand resolves', async () => { const db = DatabaseService.getInstance(); const { CryptoService } = await import('../services/CryptoService'); db.updateGlobalSetting('cloud_backup_provider', 'custom'); db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com'); db.updateGlobalSetting('cloud_backup_region', 'us-east-1'); db.updateGlobalSetting('cloud_backup_bucket', 'b'); db.updateGlobalSetting('cloud_backup_access_key', 'a'); db.updateGlobalSetting('cloud_backup_secret_key', CryptoService.getInstance().encrypt('s')); sentSpy.mockResolvedValueOnce({}); const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie).send({}); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); });