feat: expand backup to all data and add automatic scheduled backups
Backend Image / build-and-push (push) Successful in 2m16s
Frontend Image / build-and-push (push) Successful in 1m56s

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:
Nick Szaunig committed 2026-09-03 11:33:48 +02:00
1 parent d998f611e2
commit 7b23862a41
13 files changed
+853 -68

No files matched your search

+259 -31
View File
@@ -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,
}),
);
});