mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
fix(resources): harden Resource Explorer with auth, validation, design, and UX fixes (#527)
- Sanitize error messages in all delete/prune/create/inspect endpoints to prevent Docker internals from leaking to the frontend - Add CIDR, IPv4, and Docker resource ID input validation - Add requirePaid gate to network topology endpoint - Add invalidateNodeCaches after image/volume/network mutations - Fix design system violations: card borders, destructive button variant, visible DialogDescription, overflow-auto replaced with ScrollArea, hardcoded Tailwind colors replaced with tokens - Gate purge button behind isAdmin to prevent silent 403s - Fix shared inspect loading state to be per-network-row - Parse error response bodies for meaningful toast messages - Add clipboard API fallback for non-HTTPS contexts - Render Options section in network inspect sheet - Add operational and diagnostic logging for resource operations - Extend validation and DockerController test suites - Update docs with Options field in network inspect
This commit is contained in:
@@ -555,3 +555,52 @@ describe('DockerController - inspectNetwork edge cases', () => {
|
||||
await expect(dc.inspectNetwork('any-id')).rejects.toThrow('Cannot connect to Docker daemon');
|
||||
});
|
||||
});
|
||||
|
||||
// --- createNetwork validation --------------------------------------------------
|
||||
|
||||
describe('createNetwork', () => {
|
||||
it('rejects empty network name', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.createNetwork({ Name: '' })).rejects.toThrow('Invalid network name');
|
||||
});
|
||||
|
||||
it('rejects names with invalid characters', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.createNetwork({ Name: 'net work' })).rejects.toThrow('Invalid network name');
|
||||
await expect(dc.createNetwork({ Name: '../escape' })).rejects.toThrow('Invalid network name');
|
||||
await expect(dc.createNetwork({ Name: 'net;rm' })).rejects.toThrow('Invalid network name');
|
||||
});
|
||||
|
||||
it('accepts valid network names and passes through to Docker', async () => {
|
||||
mockDocker.createNetwork.mockResolvedValue({ id: 'new-net-id' });
|
||||
const dc = DockerController.getInstance(1);
|
||||
const result = await dc.createNetwork({ Name: 'my-network_v2' });
|
||||
expect(result.id).toBe('new-net-id');
|
||||
expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'my-network_v2' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- removeContainers mixed results -------------------------------------------
|
||||
|
||||
describe('removeContainers', () => {
|
||||
it('returns mixed results when some removals fail', async () => {
|
||||
mockDocker.getContainer.mockImplementation((id: string) => {
|
||||
if (id === 'fail-id') {
|
||||
return { remove: vi.fn().mockRejectedValue(new Error('no such container')) };
|
||||
}
|
||||
return { remove: vi.fn().mockResolvedValue(undefined) };
|
||||
});
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const results = await dc.removeContainers(['ok-id-000000', 'fail-id']);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ id: 'ok-id-000000', success: true });
|
||||
expect(results[1]).toMatchObject({ id: 'fail-id', success: false });
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
const results = await dc.removeContainers([]);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from '../utils/validation';
|
||||
import {
|
||||
isValidStackName, isValidRemoteUrl, isPathWithinBase,
|
||||
isValidCidr, isValidIPv4, isValidDockerResourceId,
|
||||
} from '../utils/validation';
|
||||
|
||||
// ─── isValidStackName ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,3 +125,103 @@ describe('isPathWithinBase', () => {
|
||||
expect(isPathWithinBase('/app/compose/other-stack/.env', '/app/compose/mystack')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- isValidCidr ---------------------------------------------------------------
|
||||
|
||||
describe('isValidCidr', () => {
|
||||
it('accepts valid CIDR notation', () => {
|
||||
expect(isValidCidr('10.0.0.0/24')).toBe(true);
|
||||
expect(isValidCidr('172.16.0.0/16')).toBe(true);
|
||||
expect(isValidCidr('192.168.1.0/28')).toBe(true);
|
||||
expect(isValidCidr('0.0.0.0/0')).toBe(true);
|
||||
expect(isValidCidr('255.255.255.255/32')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing prefix', () => {
|
||||
expect(isValidCidr('10.0.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects prefix out of range', () => {
|
||||
expect(isValidCidr('10.0.0.0/33')).toBe(false);
|
||||
expect(isValidCidr('10.0.0.0/99')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects octet out of range', () => {
|
||||
expect(isValidCidr('256.0.0.0/24')).toBe(false);
|
||||
expect(isValidCidr('10.999.0.0/16')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty and garbage input', () => {
|
||||
expect(isValidCidr('')).toBe(false);
|
||||
expect(isValidCidr('not-a-cidr')).toBe(false);
|
||||
expect(isValidCidr('10.0.0/24')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- isValidIPv4 ---------------------------------------------------------------
|
||||
|
||||
describe('isValidIPv4', () => {
|
||||
it('accepts valid IPv4 addresses', () => {
|
||||
expect(isValidIPv4('10.0.0.1')).toBe(true);
|
||||
expect(isValidIPv4('192.168.1.1')).toBe(true);
|
||||
expect(isValidIPv4('0.0.0.0')).toBe(true);
|
||||
expect(isValidIPv4('255.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects incomplete addresses', () => {
|
||||
expect(isValidIPv4('10.0.0')).toBe(false);
|
||||
expect(isValidIPv4('10')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects octet out of range', () => {
|
||||
expect(isValidIPv4('256.1.2.3')).toBe(false);
|
||||
expect(isValidIPv4('10.0.0.999')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects CIDR notation (use isValidCidr instead)', () => {
|
||||
expect(isValidIPv4('10.0.0.1/24')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty and garbage input', () => {
|
||||
expect(isValidIPv4('')).toBe(false);
|
||||
expect(isValidIPv4('not-an-ip')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- isValidDockerResourceId ---------------------------------------------------
|
||||
|
||||
describe('isValidDockerResourceId', () => {
|
||||
it('accepts 12-character hex IDs (short form)', () => {
|
||||
expect(isValidDockerResourceId('a1b2c3d4e5f6')).toBe(true);
|
||||
expect(isValidDockerResourceId('AABB00112233')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts 64-character hex IDs (full SHA256)', () => {
|
||||
expect(isValidDockerResourceId('a'.repeat(64))).toBe(true);
|
||||
expect(isValidDockerResourceId('abcdef0123456789'.repeat(4))).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts mixed-case hex of valid lengths', () => {
|
||||
expect(isValidDockerResourceId('aAbBcCdDeEfF')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects IDs shorter than 12 characters', () => {
|
||||
expect(isValidDockerResourceId('a1b2c3d4e5f')).toBe(false);
|
||||
expect(isValidDockerResourceId('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects IDs longer than 64 characters', () => {
|
||||
expect(isValidDockerResourceId('a'.repeat(65))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-hex characters', () => {
|
||||
expect(isValidDockerResourceId('g1b2c3d4e5f6')).toBe(false);
|
||||
expect(isValidDockerResourceId('hello-world!')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects IDs with slashes, dots, or spaces', () => {
|
||||
expect(isValidDockerResourceId('a1b2c3/d4e5f6')).toBe(false);
|
||||
expect(isValidDockerResourceId('a1b2c3.d4e5f6')).toBe(false);
|
||||
expect(isValidDockerResourceId('a1b2c3 d4e5f6')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user