Files
dsh-web/web/src/Images.test.tsx
T
m1saka 9a71772771 Harden image admission, resource recovery and session renewal
Add exact-image compatibility policy, experimental admission, paginated build history and shared reauthentication checks. Preserve uncertain Docker creation records, bound frontend resource waits and renew runtime sessions proactively.

Lazy-load and split Monaco, add regression and opt-in live Docker fault tests, and record full race and real-browser acceptance results.
2026-10-14 13:41:54 +08:00

128 lines
8.3 KiB
TypeScript

import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
useRef(value: unknown) {const i = state.cursor++; return state.slots[i] ??= {current:value};},
useEffect() {},
}));
vi.mock('./ui', async original => ({
...await original<typeof import('./ui')>(),
useResource: (load: unknown) => ({data: load === api.compatibility ? policy : load === api.images ? [{id:'ok', version:'1.2.3', status:'available', selectable:true}, {id:'exp', version:'9.9.9', status:'experimental', selectable:false}] : [], loading:false, refresh:vi.fn(async () => {})}),
}));
import { api, type Compatibility } from './api';
import { Images } from './Images';
import { CreateInstance } from './CreateInstance';
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
import { Instances } from './Instances';
const policy: Compatibility = {recipe_rev:'7', platform:'linux/amd64', entries:[], unknown:{state:'existing_only', build_allowed:false, reason:'unknown'}, experimental_admin_only:true};
function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
function render(admin = false) {state.cursor = 0; return Images({admin});}
beforeEach(() => {state.slots = []; state.cursor = 0; vi.stubGlobal('window', {confirm:vi.fn(() => false)});});
afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();});
it('hides experimental and admission controls from ordinary users', () => {
const tree = render();
expect(nodes(tree, n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0);
expect(nodes(tree, n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(0);
expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(2);
});
it('canceled build confirmation sends zero requests', () => {
const request = vi.spyOn(api, 'startBuild');
const check = vi.spyOn(api, 'compatibility');
nodes(render(true), n => n.type === 'form')[0].props.onSubmit({preventDefault:vi.fn()});
expect(window.confirm).toHaveBeenCalledOnce();
expect(request).not.toHaveBeenCalled(); expect(check).not.toHaveBeenCalled();
});
it('requires evidence and confirmation; cancel sends no admission mutation', () => {
const request = vi.spyOn(api, 'admitImage');
nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick();
const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0];
state.slots = []; state.cursor = 0;
let form = child.type(child.props);
expect(nodes(form, n => n.type === 'button')[0].props.disabled).toBe(true);
nodes(form, n => n.type === 'textarea')[0].props.onChange({target:{value:'实际验收记录'}});
nodes(form, n => n.type === 'input')[0].props.onChange({target:{checked:true}});
state.cursor = 0; form = child.type(child.props);
form.props.onSubmit({preventDefault:vi.fn()});
expect(window.confirm).toHaveBeenCalledOnce(); expect(request).not.toHaveBeenCalled();
});
it('unknown latest never reserves an intent, including a second submit', async () => {
const request = vi.spyOn(api, 'request').mockResolvedValue({version:'9.9.9', build_allowed:false});
vi.spyOn(api, 'compatibility').mockResolvedValue(policy);
vi.stubGlobal('FormData', class {get() {return 'name';}});
const renderCreate = () => {state.cursor = 0; return CreateInstance({close:vi.fn(), refresh:vi.fn(async () => {})});};
let form = renderCreate();
expect(nodes(form, n => n.type === 'option' && n.props.value === 'exp')).toHaveLength(0);
nodes(form, n => n.type === 'select')[0].props.onChange({target:{value:'latest'}});
form = renderCreate(); await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
form = renderCreate(); await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
expect(request.mock.calls).toEqual([['/latest']]);
});
it('admission rejects blank/unconfirmed evidence, resets consent on edits, and submits once', async () => {
const request = vi.spyOn(api, 'admitImage').mockResolvedValue({});
nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick();
const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0];
state.slots = [];
const renderAdmission = () => {state.cursor = 0; return child.type(child.props);};
const submit = () => renderAdmission().props.onSubmit({preventDefault:vi.fn()});
submit(); expect(request).not.toHaveBeenCalled();
nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}});
submit(); expect(request).not.toHaveBeenCalled();
nodes(renderAdmission(), n => n.type === 'textarea')[0].props.onChange({target:{value:'验收记录 #42'}});
expect(nodes(renderAdmission(), n => n.type === 'input')[0].props.checked).toBe(false);
submit(); expect(request).not.toHaveBeenCalled();
nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}});
nodes(renderAdmission(), n => n.type === 'select')[0].props.onChange({target:{value:'recommended'}});
expect(nodes(renderAdmission(), n => n.type === 'input')[0].props.checked).toBe(false);
nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}});
vi.mocked(window.confirm).mockReturnValue(true);
submit(); submit();
await Promise.resolve();
expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'recommended', '验收记录 #42');
});
it.each([
['a'.repeat(4096), 4096, true],
['a'.repeat(4097), 4097, false],
['中'.repeat(1400), 4200, false],
['中'.repeat(1365), 4095, true],
['中'.repeat(1365) + 'a', 4096, true],
['中'.repeat(1365) + 'ab', 4097, false],
['😀'.repeat(1024), 4096, true],
['😀'.repeat(1024) + 'a', 4097, false],
[' ' + 'é'.repeat(2048) + '\n', 4096, true],
] as const)('checks UTF-8 evidence bytes (case %#)', async (evidence, bytes, valid) => {
const request = vi.spyOn(api, 'admitImage').mockResolvedValue({});
vi.mocked(window.confirm).mockReturnValue(true);
nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick();
const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0];
state.slots = [];
const renderAdmission = () => {state.cursor = 0; return child.type(child.props);};
nodes(renderAdmission(), n => n.type === 'textarea')[0].props.onChange({target:{value:evidence}});
nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}});
const form = renderAdmission();
expect(nodes(form, n => n.type === 'textarea')[0].props.maxLength).toBeUndefined();
expect(nodes(form, n => n.type === 'textarea')[0].props['aria-invalid']).toBe(!valid);
expect(nodes(form, n => n.type === 'p' && Array.isArray(n.props.children) && n.props.children.includes(bytes))).toHaveLength(1);
expect(nodes(form, n => n.type === 'button')[0].props.disabled).toBe(!valid);
form.props.onSubmit({preventDefault:vi.fn()});
await Promise.resolve();
if (valid) expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'compatible', evidence.trim());
else {expect(request).not.toHaveBeenCalled(); expect(window.confirm).not.toHaveBeenCalled();}
});
it('upgrade selector excludes experimental and unadmitted images', () => {
state.cursor = 0; Instances();
state.slots = [];
// Instances keeps the selected detail after its action state and creating flag.
state.slots[5] = {id:'instance', name:'test', version:'1', status:'stopped'};
state.cursor = 0;
const detail = nodes(Instances(), n => typeof n.type === 'function' && n.props.instance)[0];
state.slots = ['upgrade']; state.cursor = 0;
const tree = detail.type(detail.props);
expect(nodes(tree, n => n.type === 'option' && n.props.value === 'ok')).toHaveLength(1);
expect(nodes(tree, n => n.type === 'option' && n.props.value === 'exp')).toHaveLength(0);
});