/**
* The backup card must say when it cannot write to the output directory (#2544).
*
* The reporter's NAS share was read-only *to the service* (our systemd unit ships
* ProtectSystem=strict), his shell wrote to it fine, and the UI only ever said
* "Failed". A week of nightly backups went nowhere. The banner below is what
* turns that into something actionable.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
import { api } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
getGitHubBackupConfig: vi.fn().mockResolvedValue(null),
getGitHubBackupStatus: vi.fn().mockResolvedValue({ is_running: false, configured: false, enabled: false }),
getGitHubBackupLogs: vi.fn().mockResolvedValue([]),
getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
getPrinters: vi.fn().mockResolvedValue([]),
getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
getLocalBackups: vi.fn().mockResolvedValue([]),
getLocalBackupStatus: vi.fn().mockResolvedValue({
enabled: true,
schedule: 'daily',
time: '07:00',
retention: 30,
path: '/mnt/nasbackup',
default_path: '/app/data/backups',
is_running: false,
last_backup_at: null,
last_status: null,
last_message: null,
next_run: null,
timezone: 'America/New_York',
}),
checkLocalBackupPath: vi.fn(),
},
}));
const SANDBOXED = {
writable: false,
path: '/mnt/nasbackup',
code: 'sandboxed',
detail: "[Errno 30] Read-only file system: '/mnt/nasbackup/.bambuddy-write-test-x'",
remedy: 'sudo systemctl edit bambuddy.service\n\n[Service]\nReadWritePaths=/mnt/nasbackup',
message: '/mnt/nasbackup is read-only for the Bambuddy service.',
warning: null,
};
describe('GitHubBackupSettings — backup path check', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('explains an unwritable path instead of leaving the user with an errno', async () => {
vi.mocked(api.checkLocalBackupPath).mockResolvedValue(SANDBOXED);
render();
expect(await screen.findByText(/cannot write to this directory/i)).toBeInTheDocument();
// The cause, not just the symptom.
expect(screen.getByText(/ProtectSystem=strict/)).toBeInTheDocument();
// And the raw OS error is still there for anyone who wants it.
expect(screen.getByText(/Errno 30/)).toBeInTheDocument();
});
it('shows the drop-in that fixes it, with the operator\'s own path in it', async () => {
vi.mocked(api.checkLocalBackupPath).mockResolvedValue(SANDBOXED);
render();
const remedy = await screen.findByText(/ReadWritePaths=\/mnt\/nasbackup/);
expect(remedy).toBeInTheDocument();
expect(remedy.textContent).toContain('systemctl edit bambuddy.service');
});
it('warns when the path is writable but lives inside the container', async () => {
vi.mocked(api.checkLocalBackupPath).mockResolvedValue({
writable: true,
path: '/backups',
code: 'ok',
detail: null,
remedy: 'services:\n bambuddy:\n volumes:\n - /backups:/backups',
message: '/backups is writable.',
warning: 'container_ephemeral',
});
render();
expect(await screen.findByText(/will not survive a container restart/i)).toBeInTheDocument();
});
it('stays quiet when the directory is fine', async () => {
vi.mocked(api.checkLocalBackupPath).mockResolvedValue({
writable: true,
path: '/mnt/nasbackup',
code: 'ok',
detail: null,
remedy: null,
message: '/mnt/nasbackup is writable.',
warning: null,
});
render();
await waitFor(() => expect(api.checkLocalBackupPath).toHaveBeenCalled());
expect(screen.queryByText(/cannot write to this directory/i)).not.toBeInTheDocument();
expect(screen.queryByText(/will not survive a container restart/i)).not.toBeInTheDocument();
});
});