Add QA test outputs, build scripts, and Dockerfile validation

- Created `qa-test-output-after-fix.txt` and `qa-test-output.txt` to log results of certificate page authentication tests.
- Added `build.sh` for deterministic backend builds in CI, utilizing `go list` for efficiency.
- Introduced `codeql_scan.sh` for CodeQL database creation and analysis for Go and JavaScript/TypeScript.
- Implemented `dockerfile_check.sh` to validate Dockerfiles for base image and package manager mismatches.
- Added `sourcery_precommit_wrapper.sh` to facilitate Sourcery CLI usage in pre-commit hooks.
This commit is contained in:
GitHub Actions
2025-12-11 18:26:24 +00:00
parent 65d837a13f
commit 8294d6ee49
609 changed files with 111623 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import compareHosts from '../compareHosts'
import type { ProxyHost } from '../../api/proxyHosts'
const hostA: ProxyHost = {
uuid: 'a',
name: 'Alpha',
domain_names: 'alpha.com',
forward_host: '127.0.0.1',
forward_port: 80,
forward_scheme: 'http',
enabled: true,
ssl_forced: false,
websocket_support: false,
certificate: null,
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: false,
application: 'none',
locations: [],
created_at: '2025-01-01',
updated_at: '2025-01-01',
}
const hostB: ProxyHost = {
uuid: 'b',
name: 'Beta',
domain_names: 'beta.com',
forward_host: '127.0.0.2',
forward_port: 8080,
forward_scheme: 'http',
enabled: true,
ssl_forced: false,
websocket_support: false,
certificate: null,
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: false,
application: 'none',
locations: [],
created_at: '2025-01-01',
updated_at: '2025-01-01',
}
describe('compareHosts', () => {
it('returns 0 for unknown sort column (default case)', () => {
const compareAny = compareHosts as unknown as (a: ProxyHost, b: ProxyHost, sortColumn: string, sortDirection: 'asc' | 'desc') => number
const res = compareAny(hostA, hostB, 'unknown', 'asc')
expect(res).toBe(0)
})
it('sorts by name', () => {
expect(compareHosts(hostA, hostB, 'name', 'asc')).toBeLessThan(0)
expect(compareHosts(hostB, hostA, 'name', 'asc')).toBeGreaterThan(0)
})
it('sorts by domain', () => {
expect(compareHosts(hostA, hostB, 'domain', 'asc')).toBeLessThan(0)
})
it('sorts by forward', () => {
expect(compareHosts(hostA, hostB, 'forward', 'asc')).toBeLessThan(0)
})
})

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { calculatePasswordStrength } from '../passwordStrength'
describe('calculatePasswordStrength', () => {
it('returns score 0 for empty password', () => {
const result = calculatePasswordStrength('')
expect(result.score).toBe(0)
expect(result.label).toBe('Empty')
})
it('returns low score for short password', () => {
const result = calculatePasswordStrength('short')
expect(result.score).toBeLessThan(2)
})
it('returns higher score for longer password', () => {
const result = calculatePasswordStrength('longerpassword')
expect(result.score).toBeGreaterThanOrEqual(2)
})
it('rewards complexity (numbers, symbols, uppercase)', () => {
const simple = calculatePasswordStrength('password123')
const complex = calculatePasswordStrength('Password123!')
expect(complex.score).toBeGreaterThan(simple.score)
})
it('returns max score for strong password', () => {
const result = calculatePasswordStrength('CorrectHorseBatteryStaple1!')
expect(result.score).toBe(4)
expect(result.label).toBe('Strong')
})
it('provides feedback for weak passwords', () => {
const result = calculatePasswordStrength('123456')
expect(result.feedback).toBeDefined()
// The feedback is an array of strings
expect(result.feedback.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast, toastCallbacks } from '../toast'
describe('toast util', () => {
beforeEach(() => {
// Ensure callbacks set is empty before each test
toastCallbacks.clear()
})
afterEach(() => {
toastCallbacks.clear()
})
it('calls registered callbacks for each toast type', () => {
const mock = vi.fn()
toastCallbacks.add(mock)
toast.success('ok')
toast.error('bad')
toast.info('info')
toast.warning('warn')
expect(mock).toHaveBeenCalledTimes(4)
expect(mock.mock.calls[0][0]).toMatchObject({ message: 'ok', type: 'success' })
expect(mock.mock.calls[1][0]).toMatchObject({ message: 'bad', type: 'error' })
expect(mock.mock.calls[2][0]).toMatchObject({ message: 'info', type: 'info' })
expect(mock.mock.calls[3][0]).toMatchObject({ message: 'warn', type: 'warning' })
})
it('provides incrementing ids', () => {
const mock = vi.fn()
toastCallbacks.add(mock)
// send multiple messages
toast.success('one')
toast.success('two')
const firstId = mock.mock.calls[0][0].id
const secondId = mock.mock.calls[1][0].id
expect(secondId).toBeGreaterThan(firstId)
})
})

6
frontend/src/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,32 @@
import type { ProxyHost } from '../api/proxyHosts'
type SortColumn = 'name' | 'domain' | 'forward'
type SortDirection = 'asc' | 'desc'
export function compareHosts(a: ProxyHost, b: ProxyHost, sortColumn: SortColumn, sortDirection: SortDirection) {
let aVal: string
let bVal: string
switch (sortColumn) {
case 'name':
aVal = (a.name || a.domain_names.split(',')[0] || '').toLowerCase()
bVal = (b.name || b.domain_names.split(',')[0] || '').toLowerCase()
break
case 'domain':
aVal = (a.domain_names.split(',')[0] || '').toLowerCase()
bVal = (b.domain_names.split(',')[0] || '').toLowerCase()
break
case 'forward':
aVal = `${a.forward_host}:${a.forward_port}`.toLowerCase()
bVal = `${b.forward_host}:${b.forward_port}`.toLowerCase()
break
default:
return 0
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1
return 0
}
export default compareHosts

View File

@@ -0,0 +1,24 @@
export const buildCrowdsecExportFilename = (): string => {
const timestamp = new Date().toISOString().replace(/:/g, '-')
return `crowdsec-export-${timestamp}.tar.gz`
}
export const promptCrowdsecFilename = (defaultName = buildCrowdsecExportFilename()): string | null => {
const input = window.prompt('Name your CrowdSec export archive', defaultName)
if (input === null || typeof input === 'undefined') return null
const trimmed = typeof input === 'string' ? input.trim() : ''
const candidate = trimmed || defaultName
const sanitized = candidate.replace(/[\\/]+/g, '-').replace(/\s+/g, '-')
return sanitized.toLowerCase().endsWith('.tar.gz') ? sanitized : `${sanitized}.tar.gz`
}
export const downloadCrowdsecExport = (blob: Blob, filename: string) => {
const url = window.URL.createObjectURL(new Blob([blob]))
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
}

View File

@@ -0,0 +1,80 @@
export interface PasswordStrength {
score: number; // 0-4
label: string;
color: string; // Tailwind color class prefix (e.g., 'red', 'yellow', 'green')
feedback: string[];
}
export function calculatePasswordStrength(password: string): PasswordStrength {
let score = 0;
const feedback: string[] = [];
if (!password) {
return {
score: 0,
label: 'Empty',
color: 'gray',
feedback: [],
};
}
// Length check
if (password.length < 8) {
feedback.push('Too short (min 8 chars)');
} else {
score += 1;
}
if (password.length >= 12) {
score += 1;
}
// Complexity checks
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[^A-Za-z0-9]/.test(password);
const varietyCount = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean).length;
if (varietyCount >= 3) {
score += 1;
}
if (varietyCount === 4) {
score += 1;
}
// Penalties
if (varietyCount < 2 && password.length >= 8) {
feedback.push('Add more variety (uppercase, numbers, symbols)');
}
// Cap score at 4
score = Math.min(score, 4);
// Determine label and color
let label = 'Very Weak';
let color = 'red';
switch (score) {
case 0:
case 1:
label = 'Weak';
color = 'red';
break;
case 2:
label = 'Fair';
color = 'yellow';
break;
case 3:
label = 'Good';
color = 'green';
break;
case 4:
label = 'Strong';
color = 'green';
break;
}
return { score, label, color, feedback };
}

View File

@@ -0,0 +1,103 @@
import type { ProxyHost } from '../api/proxyHosts'
export function formatSettingLabel(key: string) {
switch (key) {
case 'ssl_forced':
return 'Force SSL'
case 'http2_support':
return 'HTTP/2 Support'
case 'hsts_enabled':
return 'HSTS Enabled'
case 'hsts_subdomains':
return 'HSTS Subdomains'
case 'block_exploits':
return 'Block Exploits'
case 'websocket_support':
return 'Websockets Support'
default:
return key
}
}
export function settingHelpText(key: string) {
switch (key) {
case 'ssl_forced':
return 'Redirect all HTTP traffic to HTTPS.'
case 'http2_support':
return 'Enable HTTP/2 for improved performance.'
case 'hsts_enabled':
return 'Send HSTS header to enforce HTTPS.'
case 'hsts_subdomains':
return 'Include subdomains in HSTS policy.'
case 'block_exploits':
return 'Add common exploit-mitigation headers and rules.'
case 'websocket_support':
return 'Enable websocket proxying support.'
default:
return ''
}
}
export function settingKeyToField(key: string) {
switch (key) {
case 'ssl_forced':
return 'ssl_forced'
case 'http2_support':
return 'http2_support'
case 'hsts_enabled':
return 'hsts_enabled'
case 'hsts_subdomains':
return 'hsts_subdomains'
case 'block_exploits':
return 'block_exploits'
case 'websocket_support':
return 'websocket_support'
default:
return key
}
}
export async function applyBulkSettingsToHosts(options: {
hosts: ProxyHost[]
hostUUIDs: string[]
keysToApply: string[]
bulkApplySettings: Record<string, { apply: boolean; value: boolean }>
updateHost: (uuid: string, data: Partial<ProxyHost>) => Promise<ProxyHost>
setApplyProgress?: (p: { current: number; total: number } | null) => void
}) {
const { hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress } = options
let completed = 0
let errors = 0
setApplyProgress?.({ current: 0, total: hostUUIDs.length })
for (const uuid of hostUUIDs) {
const patch: Partial<ProxyHost> = {}
for (const key of keysToApply) {
const field = settingKeyToField(key) as keyof ProxyHost
;(patch as unknown as Record<string, unknown>)[field as string] = bulkApplySettings[key].value
}
const host = hosts.find(h => h.uuid === uuid)
if (!host) {
errors++
completed++
setApplyProgress?.({ current: completed, total: hostUUIDs.length })
continue
}
const merged: Partial<ProxyHost> = { ...host, ...patch }
try {
await updateHost(uuid, merged)
} catch {
errors++
}
completed++
setApplyProgress?.({ current: completed, total: hostUUIDs.length })
}
setApplyProgress?.(null)
return { errors, completed }
}
export default {}

View File

@@ -0,0 +1,29 @@
type ToastType = 'success' | 'error' | 'info' | 'warning'
export interface Toast {
id: number
message: string
type: ToastType
}
let toastId = 0
export const toastCallbacks = new Set<(toast: Toast) => void>()
export const toast = {
success: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'success' }))
},
error: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'error' }))
},
info: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'info' }))
},
warning: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'warning' }))
},
}

View File

@@ -0,0 +1,4 @@
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}