feat: expand backup to all data and add automatic scheduled backups
Backup export/import now covers Role, User, RolePermission, Notification, AuditLog and Document (with actual file content), not just business data - matching "wirklich alle Daten". Role/User use natural keys (role.key, resource+action) so a bundle restores cleanly into a different database. Adds BackupSchedulerService (@nestjs/schedule) for cron-based automatic backups to a local path (BACKUP_STORAGE_PATH), with retention pruning and a file-mtime guard against duplicate runs across replicas. New GET /backup/schedule and POST /backup/schedule/run-now endpoints, plus a status card and manual trigger in the frontend Backup settings page.
This commit is contained in:
1 parent
d998f611e2
commit
7b23862a41
13 files changed
+853
-68
No files matched your search
@@ -3,7 +3,15 @@ import { BackupService } from './backup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
|
||||
const ENTITY_KEYS = [
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
const BUSINESS_ENTITY_KEYS = [
|
||||
'apiary',
|
||||
'queen',
|
||||
'colony',
|
||||
@@ -37,32 +45,69 @@ const ENTITY_KEYS = [
|
||||
'siteSettings',
|
||||
] as const;
|
||||
|
||||
const ALL_ENTITY_KEYS = [
|
||||
'role',
|
||||
'user',
|
||||
'rolePermission',
|
||||
...BUSINESS_ENTITY_KEYS,
|
||||
'document',
|
||||
'notification',
|
||||
'auditLog',
|
||||
] as const;
|
||||
|
||||
describe('BackupService', () => {
|
||||
let service: BackupService;
|
||||
let prisma: Record<string, { findMany: jest.Mock }> & {
|
||||
user: { findMany: jest.Mock };
|
||||
rolePermission: { findMany: jest.Mock };
|
||||
document: { findMany: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: Record<
|
||||
string,
|
||||
{ upsert: jest.Mock; update?: jest.Mock }
|
||||
>;
|
||||
let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & {
|
||||
permission: { findUnique: jest.Mock };
|
||||
user: { upsert: jest.Mock; findMany: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {};
|
||||
for (const key of ENTITY_KEYS) {
|
||||
tx[key] = { upsert: jest.fn().mockResolvedValue({}) };
|
||||
jest.clearAllMocks();
|
||||
|
||||
tx = {} as never;
|
||||
for (const key of [
|
||||
...BUSINESS_ENTITY_KEYS,
|
||||
'role',
|
||||
'user',
|
||||
'rolePermission',
|
||||
'document',
|
||||
'notification',
|
||||
'auditLog',
|
||||
]) {
|
||||
tx[key] = {
|
||||
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
|
||||
};
|
||||
}
|
||||
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||
tx.permission = {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
|
||||
};
|
||||
tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]);
|
||||
|
||||
prisma = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'user-1' }]) },
|
||||
$transaction: jest.fn(
|
||||
(callback: (txArg: typeof tx) => unknown) => callback(tx),
|
||||
user: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
rolePermission: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
document: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
} as never;
|
||||
for (const key of ENTITY_KEYS) {
|
||||
for (const key of [
|
||||
'role',
|
||||
...BUSINESS_ENTITY_KEYS,
|
||||
'notification',
|
||||
'auditLog',
|
||||
]) {
|
||||
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
|
||||
@@ -75,29 +120,169 @@ describe('BackupService', () => {
|
||||
});
|
||||
|
||||
describe('exportAll', () => {
|
||||
it('returns a versioned bundle with every entity key', async () => {
|
||||
it('returns a version 2 bundle with every entity key, including role/user/document', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
expect(bundle.version).toBe(1);
|
||||
expect(bundle.version).toBe(2);
|
||||
expect(typeof bundle.exportedAt).toBe('string');
|
||||
for (const key of ENTITY_KEYS) {
|
||||
for (const key of ALL_ENTITY_KEYS) {
|
||||
expect(bundle.data).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('denormalizes user.roleId into roleKey and strips roleId', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
email: '[email protected]',
|
||||
roleId: 'role-db-id',
|
||||
role: { key: 'ADMIN' },
|
||||
},
|
||||
]);
|
||||
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
expect(bundle.data.user).toEqual([
|
||||
{ id: 'user-1', email: '[email protected]', roleKey: 'ADMIN' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('denormalizes rolePermission into roleKey/resource/action', async () => {
|
||||
prisma.rolePermission.findMany.mockResolvedValue([
|
||||
{
|
||||
role: { key: 'ADMIN' },
|
||||
permission: { resource: 'users', action: 'read' },
|
||||
},
|
||||
]);
|
||||
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
expect(bundle.data.rolePermission).toEqual([
|
||||
{ roleKey: 'ADMIN', resource: 'users', action: 'read' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('embeds document content as base64 and falls back to null when the file is missing', async () => {
|
||||
prisma.document.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'doc-1',
|
||||
storagePath: '/uploads/ok.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
storagePath: '/uploads/missing.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
]);
|
||||
(fs.readFile as jest.Mock).mockImplementation((filePath: string) =>
|
||||
filePath === '/uploads/ok.pdf'
|
||||
? Promise.resolve(Buffer.from('hello'))
|
||||
: Promise.reject(new Error('ENOENT')),
|
||||
);
|
||||
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
const documents = bundle.data.document as {
|
||||
id: string;
|
||||
contentBase64: string | null;
|
||||
}[];
|
||||
expect(documents.find((d) => d.id === 'doc-1')?.contentBase64).toBe(
|
||||
Buffer.from('hello').toString('base64'),
|
||||
);
|
||||
expect(documents.find((d) => d.id === 'doc-2')?.contentBase64).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('importAll', () => {
|
||||
it('rejects a bundle with no data object', async () => {
|
||||
await expect(
|
||||
service.importAll({ version: 1 } as never, 'admin-1'),
|
||||
service.importAll({ version: 2 } as never, 'admin-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upserts a role by key and drops the source id on update', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] },
|
||||
},
|
||||
'admin-1',
|
||||
);
|
||||
|
||||
const [[call]] = tx.role.upsert.mock.calls as [
|
||||
[{ where: { key: string }; update: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.where).toEqual({ key: 'ADMIN' });
|
||||
expect(call.update).not.toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('resolves a user roleKey to the freshly upserted role id', async () => {
|
||||
tx.role.upsert = jest.fn().mockResolvedValue({ id: 'role-new-id' });
|
||||
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
role: [{ key: 'ADMIN', name: 'Admin' }],
|
||||
user: [{ id: 'user-1', email: '[email protected]', roleKey: 'ADMIN' }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
);
|
||||
|
||||
const [[call]] = tx.user.upsert.mock.calls as [
|
||||
[{ create: { roleId: string } }],
|
||||
];
|
||||
expect(call.create.roleId).toBe('role-new-id');
|
||||
});
|
||||
|
||||
it('throws when a user references a role key not present in the bundle', async () => {
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
user: [{ id: 'user-1', email: '[email protected]', roleKey: 'UNKNOWN' }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('resolves rolePermission via permission.findUnique by resource/action and skips unknown permissions', async () => {
|
||||
tx.permission.findUnique = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'perm-1' })
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
role: [{ key: 'ADMIN', name: 'Admin' }],
|
||||
rolePermission: [
|
||||
{ roleKey: 'ADMIN', resource: 'users', action: 'read' },
|
||||
{ roleKey: 'ADMIN', resource: 'ghost', action: 'read' },
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
);
|
||||
|
||||
expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('links a self-referencing motherQueenId only after every queen exists', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
queen: [
|
||||
@@ -109,16 +294,12 @@ describe('BackupService', () => {
|
||||
'admin-1',
|
||||
);
|
||||
|
||||
// First pass: both queens upserted with motherQueenId stripped, so a
|
||||
// daughter appearing before her mother in the array never violates
|
||||
// the foreign key.
|
||||
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
|
||||
for (const call of tx.queen.upsert.mock.calls as {
|
||||
create: { motherQueenId: string | null };
|
||||
}[][]) {
|
||||
expect(call[0].create.motherQueenId).toBeNull();
|
||||
}
|
||||
// Second pass: only the daughter's real motherQueenId is patched back.
|
||||
expect(tx.queen.update).toHaveBeenCalledTimes(1);
|
||||
expect(tx.queen.update).toHaveBeenCalledWith({
|
||||
where: { id: 'queen-daughter' },
|
||||
@@ -127,15 +308,19 @@ describe('BackupService', () => {
|
||||
});
|
||||
|
||||
it('remaps a required userId that does not exist in the target database to the importing admin', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
||||
tx.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
||||
|
||||
await service.importAll(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
inspection: [
|
||||
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'ghost-user' },
|
||||
{
|
||||
id: 'inspection-1',
|
||||
colonyId: 'colony-1',
|
||||
userId: 'ghost-user',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -149,15 +334,19 @@ describe('BackupService', () => {
|
||||
});
|
||||
|
||||
it('keeps a required userId that does exist in the target database', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
||||
tx.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
|
||||
|
||||
await service.importAll(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
inspection: [
|
||||
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'known-user' },
|
||||
{
|
||||
id: 'inspection-1',
|
||||
colonyId: 'colony-1',
|
||||
userId: 'known-user',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -173,7 +362,7 @@ describe('BackupService', () => {
|
||||
it('leaves an optional userId as null rather than substituting the admin', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
stockMovement: [
|
||||
@@ -193,7 +382,7 @@ describe('BackupService', () => {
|
||||
it('upserts automationRule by triggerType, not id, and drops the source id', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
automationRule: [
|
||||
@@ -220,10 +409,49 @@ describe('BackupService', () => {
|
||||
expect(call.create).not.toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('decodes document contentBase64, writes it to a fresh path, and skips rows with no content', async () => {
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
entityType: 'Colony',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: 'known-user',
|
||||
contentBase64: Buffer.from('hello').toString('base64'),
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
entityType: 'Colony',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: null,
|
||||
contentBase64: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(tx.document.upsert).toHaveBeenCalledTimes(1);
|
||||
const [[call]] = tx.document.upsert.mock.calls as [
|
||||
[{ create: { storagePath: string; contentBase64?: unknown } }],
|
||||
];
|
||||
expect(call.create.storagePath).toMatch(/colony/);
|
||||
expect(call.create).not.toHaveProperty('contentBase64');
|
||||
});
|
||||
|
||||
it('records an audit log entry with the per-entity import counts', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
||||
},
|
||||
@@ -235,7 +463,7 @@ describe('BackupService', () => {
|
||||
entityType: 'Backup',
|
||||
action: 'CREATE',
|
||||
userId: 'admin-1',
|
||||
afterData: expect.objectContaining({ apiary: 1 }),
|
||||
afterData: expect.objectContaining({ apiary: 1 }) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user