WP-30/31/32: contract and failing tests for backup management; OS scan skips container dirs
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 23:05:22 +02:00
parent 5026ce22de
commit 39af18b336
16 changed files with 1456 additions and 1 deletions

View File

@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test'
test('admin creates a target and a strategy, runs it and sees the backup', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('admin@example.com')
await page.getByLabel('Password').fill('admin-password-123')
await page.getByRole('button', { name: 'Sign in' }).click()
await page.getByRole('link', { name: 'Backups' }).click()
await page.getByRole('button', { name: 'New target' }).click()
const name = `NAS ${Date.now()}`
await page.getByLabel('Name').fill(name)
await page.getByLabel('Host').fill('nas.local')
await page.getByLabel('Share').fill('backups')
await page.getByLabel('Directory').fill('softvisor')
await page.getByLabel('Username').fill('backup')
await page.getByLabel('Password').fill('smb-secret')
await page.getByRole('button', { name: 'Save target' }).click()
const targetRow = page.getByRole('row', { name: new RegExp(name) })
await expect(targetRow).toBeVisible()
await targetRow.getByRole('button', { name: 'Test' }).click()
await expect(page.getByRole('status')).toContainText('Connection ok')
await page.getByRole('button', { name: 'New strategy' }).click()
await page.getByLabel('Strategy name').fill('Gitea database')
await page.getByLabel('Source type').selectOption('postgres_dump')
await page.getByLabel('Namespace').fill('gitea')
await page.getByLabel('Pod').fill('gitea-postgresql-0')
await page.getByLabel('Schedule').fill('0 0 2 * * *')
await page.getByLabel('Target').selectOption({ label: name })
await page.getByLabel('Keep last').fill('3')
await page.getByRole('button', { name: 'Save strategy' }).click()
const row = page.getByRole('row', { name: /Gitea database/ })
await expect(row).toBeVisible()
await row.getByRole('button', { name: 'Run now' }).click()
await expect(row).toContainText(/gitea-database_.*\.sql\.gz/, { timeout: 20_000 })
await row.getByRole('button', { name: 'History' }).click()
await expect(page.getByTestId('backup-records')).toContainText('sql.gz')
})

View File

@ -163,3 +163,71 @@ export interface VulnSummary {
last_scan: string | null
scanner: string
}
export type StorageKind = 'smb' | 'ftp'
export interface BackupTargetView {
id: string
name: string
kind: StorageKind
host: string
port: number | null
share: string
path: string
username: string
tls: boolean
password_set: boolean
}
export interface BackupTargetPayload {
name: string
kind: StorageKind
host: string
port: number | null
share: string
path: string
username: string
password: string
tls: boolean
}
export type BackupSource =
| { type: 'volume_claim'; namespace: string; pvc: string }
| { type: 'postgres_dump'; namespace: string; pod: string }
| { type: 'kubernetes_manifests'; namespace: string }
| { type: 'host_path'; path: string }
export interface BackupStrategyView {
id: string
name: string
source: BackupSource
schedule: string
target_id: string
retention: number
encrypted: boolean
enabled: boolean
}
export interface BackupStrategyPayload {
name: string
source: BackupSource
schedule: string
target_id: string
retention: number
passphrase: string | null
enabled: boolean
}
export interface BackupRecord {
id: string
strategy_id: string
filename: string
size_bytes: number
sha256: string
created_at: string
}
export interface StrategyStatus extends BackupStrategyView {
target_name: string
last_backup: BackupRecord | null
}

View File

@ -0,0 +1,71 @@
import { mount } from '@vue/test-utils'
import StrategyForm from './StrategyForm.vue'
const targets = [
{ id: 't1', name: 'NAS' },
{ id: 't2', name: 'FTP' },
]
describe('StrategyForm', () => {
it('builds a postgres dump strategy', async () => {
const w = mount(StrategyForm, { props: { targets } })
await w.find('input[name=name]').setValue('Gitea DB')
await w.find('select[name=source_type]').setValue('postgres_dump')
await w.find('input[name=namespace]').setValue('gitea')
await w.find('input[name=pod]').setValue('gitea-postgresql-0')
await w.find('input[name=schedule]').setValue('0 0 2 * * *')
await w.find('select[name=target_id]').setValue('t2')
await w.find('input[name=retention]').setValue('7')
await w.find('input[name=passphrase]').setValue('a-long-passphrase!')
await w.find('form').trigger('submit')
expect(w.emitted('submit')![0][0]).toEqual({
name: 'Gitea DB',
source: { type: 'postgres_dump', namespace: 'gitea', pod: 'gitea-postgresql-0' },
schedule: '0 0 2 * * *',
target_id: 't2',
retention: 7,
passphrase: 'a-long-passphrase!',
enabled: true,
})
})
it('switches source fields by type', async () => {
const w = mount(StrategyForm, { props: { targets } })
await w.find('select[name=source_type]').setValue('host_path')
expect(w.find('input[name=path]').exists()).toBe(true)
expect(w.find('input[name=namespace]').exists()).toBe(false)
await w.find('select[name=source_type]').setValue('volume_claim')
expect(w.find('input[name=pvc]').exists()).toBe(true)
})
it('sends null passphrase when empty on create and keeps it on edit', async () => {
const w = mount(StrategyForm, { props: { targets } })
await w.find('input[name=name]').setValue('x')
await w.find('input[name=path]').exists()
await w.find('select[name=source_type]').setValue('kubernetes_manifests')
await w.find('input[name=namespace]').setValue('gitea')
await w.find('form').trigger('submit')
expect((w.emitted('submit')![0][0] as { passphrase: unknown }).passphrase).toBeNull()
const e = mount(StrategyForm, {
props: {
targets,
current: {
id: 's1',
name: 'Old',
source: { type: 'host_path', path: '/srv' },
schedule: '0 0 1 * * *',
target_id: 't1',
retention: 2,
encrypted: true,
enabled: false,
},
},
})
expect(e.text()).toContain('leave empty to keep')
await e.find('form').trigger('submit')
const p = e.emitted('submit')![0][0] as { passphrase: unknown; enabled: boolean }
expect(p.passphrase).toBe('')
expect(p.enabled).toBe(false)
})
})

View File

@ -0,0 +1,57 @@
import { mount } from '@vue/test-utils'
import TargetForm from './TargetForm.vue'
describe('TargetForm', () => {
it('emits an smb target payload', async () => {
const w = mount(TargetForm, { props: {} })
await w.find('input[name=name]').setValue('NAS')
await w.find('select[name=kind]').setValue('smb')
await w.find('input[name=host]').setValue('nas.local')
await w.find('input[name=share]').setValue('backups')
await w.find('input[name=path]').setValue('softvisor')
await w.find('input[name=username]').setValue('backup')
await w.find('input[name=password]').setValue('secret')
await w.find('form').trigger('submit')
expect(w.emitted('submit')![0][0]).toEqual({
name: 'NAS',
kind: 'smb',
host: 'nas.local',
port: null,
share: 'backups',
path: 'softvisor',
username: 'backup',
password: 'secret',
tls: false,
})
})
it('shows ftp-only fields and hides share for ftp', async () => {
const w = mount(TargetForm, { props: {} })
await w.find('select[name=kind]').setValue('ftp')
expect(w.find('input[name=share]').exists()).toBe(false)
expect(w.find('input[name=tls]').exists()).toBe(true)
expect(w.find('input[name=port]').exists()).toBe(true)
})
it('prefills an existing target and keeps the password empty', () => {
const w = mount(TargetForm, {
props: {
current: {
id: '1',
name: 'NAS',
kind: 'smb',
host: 'nas.local',
port: null,
share: 'backups',
path: 'x',
username: 'u',
tls: false,
password_set: true,
},
},
})
expect((w.find('input[name=host]').element as HTMLInputElement).value).toBe('nas.local')
expect((w.find('input[name=password]').element as HTMLInputElement).value).toBe('')
expect(w.text()).toContain('leave empty to keep')
})
})