Files
pic/webui/src/__tests__/Settings.IdentitySave.test.jsx
T
roof 649378b59b
Unit Tests / test (push) Successful in 7m17s
fix: resolve all Cell Identity banner and cert issues
Four bugs fixed:

1. Banner delay (up to 5 s): DraftConfigContext now exposes isDirty as
   reactive useState so App.jsx re-renders immediately when any section
   marks itself dirty, instead of waiting for the next checkPending() poll.

2. Banner re-triggers after Apply (race): For non-'*' container restarts
   (e.g., cell_name → DNS restart) the background thread took ~300 ms to
   clear _pending_restart. A concurrent checkPending() poll could see
   needs_restart=True and overwrite the frontend's optimistic clear.
   Fix: set needs_restart=False and applying=True synchronously before
   spawning the thread.

3. Apply showed banner during applyPending() when hasDirty()==false:
   setApplyStatus('saving') was skipped for the auto-save-then-apply
   path, leaving applyStatus=null while applyPending() ran and the
   banner stayed visible. Always set 'saving' before applyPending().

4. Cert status always 'unknown' in pic_ngo mode: _check_cert_via_ssl
   connected to cell-caddy:443 but sent SNI='cell-caddy'. Caddy finds no
   matching cert and returns nothing. Fix: pass the effective public
   domain (e.g. pic1.pic.ngo) as SNI so Caddy returns the right cert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 04:17:56 -04:00

285 lines
11 KiB
React

/**
* Regression tests for Cell Identity save behaviour in Settings.jsx.
*
* The Settings page uses a global Accept/Discard flow (DraftConfig).
* Changes accumulate in React state; nothing is sent to the API until
* the user presses Accept (which calls flushAll → identity flusher →
* saveIdentity). Auto-save only runs for non-DDNS-registration changes.
*
* Covers:
* - Changing cell_name marks identity dirty (Accept/Discard banner appears)
* - Auto-save does NOT fire for pic_ngo cell_name changes (requires Accept)
* - Auto-save DOES fire for ip_range-only changes in pic_ngo mode
* - Availability check is NOT sent on page load when name is unchanged
* - Availability check IS sent after the user types a new cell name
* - saveIdentity (called by the Accept flusher) saves the new cell name
*/
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
// ── API mocks ─────────────────────────────────────────────────────────────────
const mockGetConfig = vi.fn();
const mockUpdateConfig = vi.fn();
const mockListBackups = vi.fn();
const mockGetStatus = vi.fn();
const mockCheckName = vi.fn();
const mockGetCertStatus = vi.fn();
const mockSetDirty = vi.fn();
vi.mock('../services/api', () => ({
cellAPI: {
getConfig: (...a) => mockGetConfig(...a),
updateConfig: (...a) => mockUpdateConfig(...a),
listBackups: (...a) => mockListBackups(...a),
},
ddnsAPI: {
getStatus: (...a) => mockGetStatus(...a),
checkName: (...a) => mockCheckName(...a),
},
caddyAPI: {
getCertStatus: (...a) => mockGetCertStatus(...a),
},
}));
vi.mock('../contexts/ConfigContext', () => ({
useConfig: () => ({ domain: 'cell', cell_name: 'pic1', refresh: vi.fn() }),
}));
vi.mock('../contexts/DraftConfigContext', () => ({
useDraftConfig: () => ({
registerFlusher: vi.fn(() => vi.fn()),
setDirty: (...a) => mockSetDirty(...a),
hasDirty: vi.fn(() => false),
isDirty: false,
flushAll: vi.fn(),
clearAllDirty: vi.fn(),
}),
}));
vi.mock('../utils/serviceConfig', () => ({
PORT_CONFLICT_FIELDS: {},
detectPortConflicts: vi.fn(() => ({})),
validateServiceConfig: vi.fn(() => ({})),
SERVICE_DEFS: [],
}));
// ── helpers ───────────────────────────────────────────────────────────────────
function makeCfg(overrides = {}) {
return {
cell_name: 'pic1',
domain: 'cell',
ip_range: '172.20.0.0/16',
domain_mode: 'pic_ngo',
domain_name: 'pic1.pic.ngo',
effective_domain: 'pic1.pic.ngo',
ddns: { has_token: true },
service_configs: {},
installed_services: [],
...overrides,
};
}
function defaultMocks() {
mockGetConfig.mockResolvedValue({ data: makeCfg() });
mockUpdateConfig.mockResolvedValue({ data: { warnings: [] } });
mockListBackups.mockResolvedValue({ data: [] });
mockGetStatus.mockResolvedValue({ data: { registered: true, public_ip: '1.2.3.4', last_ip: '1.2.3.4' } });
mockGetCertStatus.mockResolvedValue({ data: null });
mockCheckName.mockResolvedValue({ data: { available: true } });
}
async function renderSettings() {
const { default: Settings } = await import('../pages/Settings.jsx');
let result;
await act(async () => {
result = render(<Settings />);
await Promise.resolve();
await Promise.resolve();
});
await waitFor(() => screen.getByDisplayValue('pic1'));
return result;
}
// ── tests ─────────────────────────────────────────────────────────────────────
describe('Cell Identity — dirty state triggers Accept/Discard banner', () => {
beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
it('changing cell_name calls draftConfig.setDirty("identity", true)', async () => {
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
});
it('changing ip_range calls draftConfig.setDirty("identity", true)', async () => {
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });
expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
});
});
describe('Cell Identity — auto-save behaviour', () => {
beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
it('auto-save does NOT fire for pic_ngo cell_name changes — Accept is required', async () => {
mockCheckName.mockResolvedValue({ data: { available: true } });
await renderSettings();
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
// Advance well past both debounces (availability 900 ms + auto-save 800 ms)
await act(async () => { vi.advanceTimersByTime(2500); });
await act(async () => { await Promise.resolve(); });
expect(mockUpdateConfig).not.toHaveBeenCalled();
});
it('auto-save fires after 800 ms when only ip_range changes (name unchanged)', async () => {
mockGetConfig
.mockResolvedValueOnce({ data: makeCfg() })
.mockResolvedValue({ data: makeCfg({ ip_range: '10.0.0.0/8' }) });
await renderSettings();
fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });
await act(async () => { vi.advanceTimersByTime(500); });
expect(mockUpdateConfig).not.toHaveBeenCalled(); // not yet
await act(async () => { vi.advanceTimersByTime(400); });
await act(async () => { await Promise.resolve(); });
expect(mockUpdateConfig).toHaveBeenCalledOnce();
expect(mockUpdateConfig.mock.calls[0][0]).toMatchObject({ ip_range: '10.0.0.0/8' });
});
});
describe('Cell Identity — availability check', () => {
beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
it('does NOT check availability on page load (name unchanged)', async () => {
await renderSettings();
// Advance past the 900 ms debounce
await act(async () => { vi.advanceTimersByTime(1200); });
expect(mockCheckName).not.toHaveBeenCalled();
});
it('checks availability after user types a new cell name', async () => {
await renderSettings();
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
await act(async () => { vi.advanceTimersByTime(950); });
await act(async () => { await Promise.resolve(); });
expect(mockCheckName).toHaveBeenCalledWith('pic2');
});
});
describe('Cell Identity — Accept path (saveIdentity called by flusher)', () => {
beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
it('saveIdentity saves new cell_name when called directly (simulates Accept press)', async () => {
mockCheckName.mockResolvedValue({ data: { available: true } });
mockGetConfig
.mockResolvedValueOnce({ data: makeCfg() })
.mockResolvedValue({ data: makeCfg({ cell_name: 'pic2', domain_name: 'pic2.pic.ngo' }) });
const { default: Settings } = await import('../pages/Settings.jsx');
// Capture the registered flusher so we can call it like Accept would
let identityFlusher;
const mockRegisterFlusher = vi.fn((key, fn) => {
if (key === 'identity') identityFlusher = fn;
return vi.fn();
});
vi.mocked(await import('../contexts/DraftConfigContext')).useDraftConfig = () => ({
registerFlusher: mockRegisterFlusher,
setDirty: mockSetDirty,
hasDirty: vi.fn(() => true),
flushAll: vi.fn(),
});
await act(async () => {
render(<Settings />);
await Promise.resolve();
await Promise.resolve();
});
await waitFor(() => screen.getByDisplayValue('pic1'));
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
// Confirm availability
await act(async () => { vi.advanceTimersByTime(950); });
await act(async () => { await Promise.resolve(); });
// Simulate Accept press (calls the flusher directly)
if (identityFlusher) {
await act(async () => { await identityFlusher(); });
}
expect(mockUpdateConfig).toHaveBeenCalledOnce();
expect(mockUpdateConfig.mock.calls[0][0]).toMatchObject({ cell_name: 'pic2' });
});
});
describe('Cell Identity — DraftConfig dirty state is set synchronously on change', () => {
/**
* Verifies that draftConfig.setDirty is called in the same synchronous
* event handler as the identity state change, not in a deferred effect.
* This is what allows the banner to appear immediately (the isDirty reactive
* state in DraftConfigContext re-renders App.jsx without waiting for a poll).
*/
beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
it('setDirty("identity", true) is called before any timer fires after cell_name change', async () => {
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
// setDirty must be called synchronously within the event, not in a timer
expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
expect(mockSetDirty).toHaveBeenCalledTimes(1);
});
it('setDirty("identity", true) is called before any timer fires after ip_range change', async () => {
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });
expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
expect(mockSetDirty).toHaveBeenCalledTimes(1);
});
it('setDirty("identity", false) is called after saveIdentity completes', async () => {
mockGetConfig
.mockResolvedValueOnce({ data: makeCfg() })
.mockResolvedValue({ data: makeCfg({ ip_range: '10.0.0.0/8' }) });
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });
// auto-save fires after 800 ms
await act(async () => { vi.advanceTimersByTime(900); });
await act(async () => { await Promise.resolve(); });
const calls = mockSetDirty.mock.calls;
expect(calls.some(([k, v]) => k === 'identity' && v === true)).toBe(true);
expect(calls.some(([k, v]) => k === 'identity' && v === false)).toBe(true);
});
});