mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
ed553f1f19
Updates the backend listen port, Vite dev proxy target, Docker EXPOSE, compose port mapping, .env.example default, GitHub Actions smoke-test default, healthcheck URLs, and every doc/example reference. Test fixtures that include example URLs were updated for consistency, though their assertions are port-agnostic. The rate-limit value of 3000 in middleware/rateLimiters.ts and the 3000 entry in WEB_UI_PORTS (which detects user containers like Grafana) are intentionally untouched.
228 lines
8.2 KiB
TypeScript
228 lines
8.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
isValidStackName, isValidRemoteUrl, isPathWithinBase,
|
|
isValidCidr, isValidIPv4, isValidDockerResourceId,
|
|
} from '../utils/validation';
|
|
|
|
// ─── isValidStackName ────────────────────────────────────────────────────────
|
|
|
|
describe('isValidStackName', () => {
|
|
it('accepts alphanumeric names', () => {
|
|
expect(isValidStackName('mystack')).toBe(true);
|
|
expect(isValidStackName('MyStack123')).toBe(true);
|
|
});
|
|
|
|
it('accepts hyphens and underscores', () => {
|
|
expect(isValidStackName('my-stack')).toBe(true);
|
|
expect(isValidStackName('my_stack')).toBe(true);
|
|
});
|
|
|
|
it('rejects path separators', () => {
|
|
expect(isValidStackName('../etc')).toBe(false);
|
|
expect(isValidStackName('foo/bar')).toBe(false);
|
|
expect(isValidStackName('foo\\bar')).toBe(false);
|
|
});
|
|
|
|
it('rejects dots', () => {
|
|
expect(isValidStackName('.hidden')).toBe(false);
|
|
expect(isValidStackName('foo.bar')).toBe(false);
|
|
});
|
|
|
|
it('rejects spaces and special characters', () => {
|
|
expect(isValidStackName('my stack')).toBe(false);
|
|
expect(isValidStackName('foo;rm -rf /')).toBe(false);
|
|
expect(isValidStackName('')).toBe(false);
|
|
});
|
|
|
|
it('rejects URL-encoded traversal attempts', () => {
|
|
// These would already be decoded by decodeURIComponent before validation,
|
|
// but the raw encoded forms should also fail
|
|
expect(isValidStackName('%2e%2e%2f')).toBe(false);
|
|
expect(isValidStackName('foo%2fbar')).toBe(false);
|
|
expect(isValidStackName('..%2fetc')).toBe(false);
|
|
});
|
|
|
|
it('rejects unicode characters', () => {
|
|
expect(isValidStackName('stäck')).toBe(false);
|
|
expect(isValidStackName('stack\u0000')).toBe(false);
|
|
expect(isValidStackName('\u202Estack')).toBe(false);
|
|
});
|
|
|
|
it('handles very long names', () => {
|
|
// The regex itself has no length limit, but 255 chars should still pass
|
|
const longValid = 'a'.repeat(255);
|
|
expect(isValidStackName(longValid)).toBe(true);
|
|
|
|
// Names with only valid chars remain valid regardless of length
|
|
const veryLong = 'stack-' + 'x'.repeat(1000);
|
|
expect(isValidStackName(veryLong)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── isValidRemoteUrl ────────────────────────────────────────────────────────
|
|
|
|
describe('isValidRemoteUrl', () => {
|
|
it('accepts valid http URLs', () => {
|
|
const result = isValidRemoteUrl('http://192.168.1.10:1852');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('accepts valid https URLs', () => {
|
|
const result = isValidRemoteUrl('https://sencho.example.com');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('rejects malformed URLs', () => {
|
|
const result = isValidRemoteUrl('not-a-url');
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it('rejects non-http schemes', () => {
|
|
expect(isValidRemoteUrl('ftp://example.com').valid).toBe(false);
|
|
expect(isValidRemoteUrl('file:///etc/passwd').valid).toBe(false);
|
|
expect(isValidRemoteUrl('javascript:alert(1)').valid).toBe(false);
|
|
});
|
|
|
|
it('rejects localhost', () => {
|
|
expect(isValidRemoteUrl('http://localhost:1852').valid).toBe(false);
|
|
expect(isValidRemoteUrl('http://LOCALHOST:1852').valid).toBe(false);
|
|
});
|
|
|
|
it('rejects loopback IPs', () => {
|
|
expect(isValidRemoteUrl('http://127.0.0.1:1852').valid).toBe(false);
|
|
expect(isValidRemoteUrl('http://127.1.2.3').valid).toBe(false);
|
|
// Node.js URL.hostname preserves brackets: new URL('http://[::1]').hostname === '[::1]'
|
|
expect(isValidRemoteUrl('http://[::1]:1852').valid).toBe(false);
|
|
});
|
|
|
|
it('rejects 0.0.0.0', () => {
|
|
expect(isValidRemoteUrl('http://0.0.0.0:1852').valid).toBe(false);
|
|
});
|
|
|
|
it('allows LAN/private IPs (users need these for local network nodes)', () => {
|
|
// Users legitimately run Sencho nodes on their LAN
|
|
expect(isValidRemoteUrl('http://192.168.1.100:1852').valid).toBe(true);
|
|
expect(isValidRemoteUrl('http://10.0.0.5:1852').valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── isPathWithinBase ────────────────────────────────────────────────────────
|
|
|
|
describe('isPathWithinBase', () => {
|
|
it('accepts paths within the base directory', () => {
|
|
expect(isPathWithinBase('/app/compose/mystack/.env', '/app/compose/mystack')).toBe(true);
|
|
});
|
|
|
|
it('accepts the base directory itself', () => {
|
|
expect(isPathWithinBase('/app/compose/mystack', '/app/compose/mystack')).toBe(true);
|
|
});
|
|
|
|
it('rejects paths that escape via ..', () => {
|
|
expect(isPathWithinBase('/app/compose/mystack/../../../etc/passwd', '/app/compose/mystack')).toBe(false);
|
|
});
|
|
|
|
it('rejects sibling directories', () => {
|
|
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);
|
|
});
|
|
});
|