File size: 14,775 Bytes
1f5ea39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request, Response } from 'express';

vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
// The controller imports the tmp-dir + size cap at module load. The thin
// BackupService wrapper forwards every call straight into this module, so the
// mock also stubs the delegated functions for the wrapper tests below.
vi.mock('../../../src/services/backupService', () => ({
  getUploadTmpDir: () => '/tmp',
  MAX_BACKUP_UPLOAD_SIZE: 1024,
  BACKUP_RATE_WINDOW: 3600000,
  listBackups: vi.fn().mockReturnValue([{ filename: 'svc.zip' }]),
  createBackup: vi.fn().mockResolvedValue({ filename: 'svc.zip', size: 5 }),
  restoreFromZip: vi.fn().mockResolvedValue({ success: true }),
  getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: false }, timezone: 'UTC' }),
  updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }),
  deleteBackup: vi.fn(),
  isValidBackupFilename: vi.fn().mockReturnValue(true),
  backupFilePath: vi.fn().mockReturnValue('/data/backups/svc.zip'),
  backupFileExists: vi.fn().mockReturnValue(true),
  checkRateLimit: vi.fn().mockReturnValue(true),
}));

import { BackupController } from '../../../src/nest/backup/backup.controller';
import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service';
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
import type { BackupService } from '../../../src/nest/backup/backup.service';
import { writeAudit } from '../../../src/services/auditLog';
import * as backupSvc from '../../../src/services/backupService';
import type { User } from '../../../src/types';

const user = { id: 1, role: 'admin', email: 'a@example.test' } as User;
const req = { ip: '1.2.3.4', headers: {} } as Request;

function svc(o: Partial<BackupService> = {}): BackupService {
  return {
    listBackups: vi.fn().mockReturnValue([]),
    createBackup: vi.fn(),
    restoreFromZip: vi.fn(),
    getAutoSettings: vi.fn(),
    updateAutoSettings: vi.fn(),
    deleteBackup: vi.fn(),
    isValidBackupFilename: vi.fn().mockReturnValue(true),
    backupFilePath: vi.fn().mockReturnValue('/b/x.zip'),
    backupFileExists: vi.fn().mockReturnValue(true),
    checkRateLimit: vi.fn().mockReturnValue(true),
    rateWindow: 3600000,
    ...o,
  } as unknown as BackupService;
}

function thrown(fn: () => unknown): { status: number; body: unknown } {
  try { fn(); } catch (err) {
    expect(err).toBeInstanceOf(HttpException);
    const e = err as HttpException;
    return { status: e.getStatus(), body: e.getResponse() };
  }
  throw new Error('expected throw');
}
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
  try { await fn(); } catch (err) {
    expect(err).toBeInstanceOf(HttpException);
    const e = err as HttpException;
    return { status: e.getStatus(), body: e.getResponse() };
  }
  throw new Error('expected throw');
}

beforeEach(() => vi.clearAllMocks());
afterEach(() => { delete process.env.NODE_ENV; });

describe('AdminGuard (used by BackupController)', () => {
  function ctx(role?: string) {
    return { switchToHttp: () => ({ getRequest: () => ({ user: role ? { role } : undefined }) }) } as never;
  }
  it('403 for a non-admin, passes for an admin', () => {
    expect(thrown(() => new AdminGuard().canActivate(ctx('user')))).toEqual({ status: 403, body: { error: 'Admin access required' } });
    expect(new AdminGuard().canActivate(ctx('admin'))).toBe(true);
  });
});

describe('BackupController', () => {
  it('GET /list returns backups, 500 on error', () => {
    expect(new BackupController(svc({ listBackups: vi.fn().mockReturnValue([{ filename: 'a.zip' }]) } as Partial<BackupService>)).list()).toEqual({ backups: [{ filename: 'a.zip' }] });
    expect(thrown(() => new BackupController(svc({ listBackups: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).list())).toEqual({ status: 500, body: { error: 'Error loading backups' } });
  });

  it('POST /create 429 when rate-limited, else creates + audits', async () => {
    expect(await thrownAsync(() => new BackupController(svc({ checkRateLimit: vi.fn().mockReturnValue(false) })).create(user, req))).toEqual({ status: 429, body: { error: 'Too many backup requests. Please try again later.' } });
    const createBackup = vi.fn().mockResolvedValue({ filename: 'b.zip', size: 10 });
    const res = await new BackupController(svc({ createBackup } as Partial<BackupService>)).create(user, req);
    expect(res).toEqual({ success: true, backup: { filename: 'b.zip', size: 10 } });
    expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.create', resource: 'b.zip' }));
  });

  it('GET /download 400 invalid / 404 missing, else res.download', () => {
    const res = { download: vi.fn() } as unknown as Response;
    expect(thrown(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).download('x', res))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
    expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).download('x.zip', res))).toEqual({ status: 404, body: { error: 'Backup not found' } });
    new BackupController(svc()).download('x.zip', res);
    expect(res.download).toHaveBeenCalledWith('/b/x.zip', 'x.zip');
  });

  it('POST /restore maps the service status, else audits', async () => {
    expect(await thrownAsync(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
    expect(await thrownAsync(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).restore(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
    expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad zip' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 422, body: { error: 'bad zip' } });
    const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).restore(user, 'x.zip', req);
    expect(res).toEqual({ success: true });
    expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.restore', resource: 'x.zip' }));
  });

  it('POST /restore falls back to status 400 when the service omits one', async () => {
    expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, error: 'nope' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 400, body: { error: 'nope' } });
  });

  it('POST /upload-restore 400 without a file, cleans up the tmp file', async () => {
    expect(await thrownAsync(() => new BackupController(svc()).uploadRestore(user, undefined, req))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
  });

  it('POST /upload-restore success audits + reports', async () => {
    const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File;
    const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).uploadRestore(user, file, req);
    expect(res).toEqual({ success: true });
    expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'up.zip' }));
  });

  it('POST /upload-restore maps a failed restore status', async () => {
    const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File;
    expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 422, body: { error: 'bad' } });
  });

  it('POST /upload-restore falls back to a default name and maps unexpected errors to 500', async () => {
    const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
    expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } });
    const ok = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
    await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).uploadRestore(user, ok, req);
    expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'upload.zip' }));
  });

  it('maps unexpected service errors to 500 (create, restore, auto-settings)', async () => {
    vi.spyOn(console, 'error').mockImplementation(() => {});
    expect(await thrownAsync(() => new BackupController(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial<BackupService>)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } });
    expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } });
    expect(thrown(() => new BackupController(svc({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } });
  });

  it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', () => {
    vi.spyOn(console, 'error').mockImplementation(() => {});
    process.env.NODE_ENV = 'development';
    const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) } as Partial<BackupService>)).updateAutoSettings(user, {}, req));
    expect(r.status).toBe(500);
    expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' });
  });

  it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => {
    vi.spyOn(console, 'error').mockImplementation(() => {});
    process.env.NODE_ENV = 'production';
    const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial<BackupService>)).updateAutoSettings(user, {}, req));
    expect(r.status).toBe(500);
    expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined });
  });

  it('PUT /auto-settings tolerates a missing body', () => {
    const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 });
    new BackupController(svc({ updateAutoSettings } as Partial<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
    expect(updateAutoSettings).toHaveBeenCalledWith({});
  });

  it('GET/PUT /auto-settings', () => {
    expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
    const res = new BackupController(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial<BackupService>)).updateAutoSettings(user, { enabled: true }, req);
    expect(res).toEqual({ settings: { enabled: true, interval: 'daily', keep_days: 7 } });
    expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.auto_settings' }));
  });

  it('DELETE /:filename 400/404, else deletes + audits', () => {
    expect(thrown(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).remove(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
    expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).remove(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
    const deleteBackup = vi.fn();
    expect(new BackupController(svc({ deleteBackup } as Partial<BackupService>)).remove(user, 'x.zip', req)).toEqual({ success: true });
    expect(deleteBackup).toHaveBeenCalledWith('x.zip');
  });
});

describe('BackupService (wrapper)', () => {
  const wrapper = new RealBackupService();

  it('forwards every call straight to the legacy backup service', async () => {
    expect(wrapper.listBackups()).toEqual([{ filename: 'svc.zip' }]);
    expect(backupSvc.listBackups).toHaveBeenCalled();

    await expect(wrapper.createBackup()).resolves.toEqual({ filename: 'svc.zip', size: 5 });
    expect(backupSvc.createBackup).toHaveBeenCalled();

    await expect(wrapper.restoreFromZip('/tmp/a.zip')).resolves.toEqual({ success: true });
    expect(backupSvc.restoreFromZip).toHaveBeenCalledWith('/tmp/a.zip');

    expect(wrapper.getAutoSettings()).toEqual({ settings: { enabled: false }, timezone: 'UTC' });
    expect(backupSvc.getAutoSettings).toHaveBeenCalled();

    expect(wrapper.updateAutoSettings({ enabled: true })).toEqual({ enabled: true, interval: 'daily', keep_days: 7 });
    expect(backupSvc.updateAutoSettings).toHaveBeenCalledWith({ enabled: true });

    wrapper.deleteBackup('svc.zip');
    expect(backupSvc.deleteBackup).toHaveBeenCalledWith('svc.zip');

    expect(wrapper.isValidBackupFilename('svc.zip')).toBe(true);
    expect(backupSvc.isValidBackupFilename).toHaveBeenCalledWith('svc.zip');

    expect(wrapper.backupFilePath('svc.zip')).toBe('/data/backups/svc.zip');
    expect(backupSvc.backupFilePath).toHaveBeenCalledWith('svc.zip');

    expect(wrapper.backupFileExists('svc.zip')).toBe(true);
    expect(backupSvc.backupFileExists).toHaveBeenCalledWith('svc.zip');

    expect(wrapper.checkRateLimit('ip', 3, 1000)).toBe(true);
    expect(backupSvc.checkRateLimit).toHaveBeenCalledWith('ip', 3, 1000);
  });

  it('exposes the legacy rate window', () => {
    expect(wrapper.rateWindow).toBe(backupSvc.BACKUP_RATE_WINDOW);
  });
});

describe('BackupModule', () => {
  it('wires the controller and service together', async () => {
    const { BackupModule } = await import('../../../src/nest/backup/backup.module');
    expect(new BackupModule()).toBeInstanceOf(BackupModule);
  });
});