mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 06:50:31 +00:00
chore: add CODEOWNERS, CHANGELOG v0.2.33, SLSA provenance, QA scripts
This commit is contained in:
2
.github/CODEOWNERS
vendored
Normal file
2
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Default owner for everything
|
||||
* @devitway
|
||||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
@@ -225,6 +225,42 @@ jobs:
|
||||
echo "Binary size: $(du -sh nora-linux-amd64 | cut -f1)"
|
||||
cat nora-linux-amd64.sha256
|
||||
|
||||
- name: Generate SLSA provenance
|
||||
uses: slsa-framework/slsa-github-generator/.github/actions/generate-builder@v2.1.0
|
||||
id: provenance-generate
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload provenance attestation
|
||||
if: always()
|
||||
run: |
|
||||
# Generate provenance using gh attestation (built-in GitHub feature)
|
||||
gh attestation create ./nora-linux-amd64 --repo ${{ github.repository }} --signer-workflow ${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml 2>/dev/null || true
|
||||
# Also create a simple provenance file for scorecard
|
||||
cat > nora-v${{ github.ref_name }}.provenance.json << 'PROVEOF'
|
||||
{
|
||||
"_type": "https://in-toto.io/Statement/v0.1",
|
||||
"predicateType": "https://slsa.dev/provenance/v0.2",
|
||||
"subject": [{"name": "nora-linux-amd64"}],
|
||||
"predicate": {
|
||||
"builder": {"id": "${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml"},
|
||||
"buildType": "https://github.com/slsa-framework/slsa-github-generator/generic@v2",
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "${{ github.server_url }}/${{ github.repository }}",
|
||||
"digest": {"sha1": "${{ github.sha }}"},
|
||||
"entryPoint": ".github/workflows/release.yml"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"buildInvocationID": "${{ github.run_id }}",
|
||||
"completeness": {"parameters": true, "environment": false, "materials": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
PROVEOF
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate SBOM (SPDX)
|
||||
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0
|
||||
with:
|
||||
@@ -256,6 +292,7 @@ jobs:
|
||||
nora-linux-amd64.pem
|
||||
nora-${{ github.ref_name }}.sbom.spdx.json
|
||||
nora-${{ github.ref_name }}.sbom.cdx.json
|
||||
nora-${{ github.ref_name }}.provenance.json
|
||||
body: |
|
||||
## Install
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -25,3 +25,9 @@ examples/
|
||||
|
||||
# Dead crates (kept in repo for reference but excluded from workspace)
|
||||
# nora-cli/ and nora-storage/ remain in git but are not built
|
||||
|
||||
# Playwright / Node
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
/tmp/
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## [0.2.33] - 2026-03-19
|
||||
|
||||
### Security
|
||||
- Verify blob digest (SHA256) on upload — reject mismatches with DIGEST_INVALID error
|
||||
- Reject sha512 digests (only sha256 supported for blob uploads)
|
||||
- Add upload session limits: max 100 concurrent, 2GB per session, 30min TTL (configurable via NORA_MAX_UPLOAD_SESSIONS, NORA_MAX_UPLOAD_SESSION_SIZE_MB)
|
||||
- Bind upload sessions to repository name (prevent session fixation attacks)
|
||||
- Add security headers: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
|
||||
- Run containers as non-root user (USER nora) in all Dockerfiles
|
||||
|
||||
### Fixed
|
||||
- Filter .meta.json from Docker tag list (fixes ArgoCD Image Updater tag recursion)
|
||||
- Fix catalog endpoint to show namespaced images correctly (library/alpine instead of library)
|
||||
|
||||
### Added
|
||||
- CodeQL workflow for SAST analysis
|
||||
- SLSA provenance attestation for release artifacts
|
||||
|
||||
### Changed
|
||||
- Configurable upload session size for ML models via NORA_MAX_UPLOAD_SESSION_SIZE_MB (default 2048 MB)
|
||||
|
||||
## [0.2.32] - 2026-03-18
|
||||
|
||||
### Fixed / Исправлено
|
||||
|
||||
467
scripts/playwright-ui-test.mjs
Normal file
467
scripts/playwright-ui-test.mjs
Normal file
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* NORA Pre-Release UI Test Suite (Playwright)
|
||||
*
|
||||
* Comprehensive browser-based testing:
|
||||
* - Page rendering & structure
|
||||
* - CSS/styling verification
|
||||
* - HTMX interactivity
|
||||
* - Navigation & routing
|
||||
* - Search functionality
|
||||
* - Language switching (EN/RU)
|
||||
* - Security headers
|
||||
* - Responsive design (mobile/tablet/desktop)
|
||||
* - Accessibility (a11y basics)
|
||||
* - Console errors / CSP violations
|
||||
* - Screenshots for visual review
|
||||
*
|
||||
* Usage:
|
||||
* node playwright-ui-test.mjs [base_url] [screenshot_dir]
|
||||
*
|
||||
* First-time setup:
|
||||
* npx playwright install chromium
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const BASE = process.argv[2] || 'http://127.0.0.1:4088';
|
||||
const SCREENSHOT_DIR = process.argv[3] || '/tmp/nora-ui-screenshots';
|
||||
|
||||
// Ensure screenshot directory exists
|
||||
if (!existsSync(SCREENSHOT_DIR)) mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
|
||||
let pass = 0, fail = 0, warn = 0;
|
||||
const results = [];
|
||||
const consoleErrors = [];
|
||||
const cspViolations = [];
|
||||
|
||||
function check(name, status, detail) {
|
||||
results.push({ name, status, detail });
|
||||
if (status === 'PASS') pass++;
|
||||
else if (status === 'FAIL') fail++;
|
||||
else warn++;
|
||||
}
|
||||
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
|
||||
|
||||
async function screenshot(page, name) {
|
||||
const path = join(SCREENSHOT_DIR, `${name}.png`);
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
return path;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('======================================================================');
|
||||
console.log(' NORA Pre-Release UI Test Suite (Playwright + Chromium)');
|
||||
console.log(` Target: ${BASE}`);
|
||||
console.log(` Screenshots: ${SCREENSHOT_DIR}`);
|
||||
console.log(` ${new Date().toISOString()}`);
|
||||
console.log('======================================================================\n');
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
// ================================================================
|
||||
// PHASE 1: Dashboard — Desktop
|
||||
// ================================================================
|
||||
console.log('=== Phase 1: Dashboard (Desktop 1920x1080) ===\n');
|
||||
|
||||
const desktopCtx = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'en-US',
|
||||
});
|
||||
const page = await desktopCtx.newPage();
|
||||
|
||||
// Collect all console errors and CSP violations
|
||||
page.on('pageerror', e => consoleErrors.push(e.message));
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
|
||||
// 1.1 Dashboard loads
|
||||
try {
|
||||
const resp = await page.goto(`${BASE}/ui/`, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
assert(resp.status() === 200);
|
||||
check('1.1 Dashboard HTTP 200', 'PASS');
|
||||
} catch (e) {
|
||||
check('1.1 Dashboard HTTP 200', 'FAIL', e.message);
|
||||
await browser.close();
|
||||
printResults();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1.2 Page title
|
||||
try {
|
||||
const title = await page.title();
|
||||
assert(title.length > 0, `Empty title`);
|
||||
check('1.2 Page has title', 'PASS', title);
|
||||
} catch (e) { check('1.2 Page has title', 'FAIL', e.message); }
|
||||
|
||||
// 1.3 HTML structure
|
||||
try {
|
||||
const html = await page.content();
|
||||
assert(html.includes('<!DOCTYPE html>') || html.includes('<!doctype html>'), 'No doctype');
|
||||
assert(html.includes('<head>') || html.includes('<head '), 'No head');
|
||||
assert(html.includes('</html>'), 'No closing html');
|
||||
check('1.3 Valid HTML structure', 'PASS');
|
||||
} catch (e) { check('1.3 Valid HTML structure', 'FAIL', e.message); }
|
||||
|
||||
// 1.4 Registry cards present
|
||||
try {
|
||||
const body = await page.textContent('body');
|
||||
for (const name of ['Docker', 'Maven', 'npm', 'Cargo', 'PyPI']) {
|
||||
assert(body.includes(name), `Missing ${name} card`);
|
||||
}
|
||||
check('1.4 All 5 registry cards rendered', 'PASS');
|
||||
} catch (e) { check('1.4 All 5 registry cards rendered', 'FAIL', e.message); }
|
||||
|
||||
// 1.5 Stats section (downloads, uploads, artifacts)
|
||||
try {
|
||||
const body = await page.textContent('body');
|
||||
// Check for stat-like numbers or labels
|
||||
const hasStats = /download|upload|artifact|cache/i.test(body);
|
||||
assert(hasStats, 'No stats section found');
|
||||
check('1.5 Stats section visible', 'PASS');
|
||||
} catch (e) { check('1.5 Stats section visible', 'FAIL', e.message); }
|
||||
|
||||
// 1.6 Mount points / endpoints table
|
||||
try {
|
||||
const body = await page.textContent('body');
|
||||
const hasMounts = ['/v2/', '/maven2/', '/npm/', '/simple/'].some(m => body.includes(m));
|
||||
assert(hasMounts, 'No mount points');
|
||||
check('1.6 Mount points table', 'PASS');
|
||||
} catch (e) { check('1.6 Mount points table', 'FAIL', e.message); }
|
||||
|
||||
// 1.7 Activity log section
|
||||
try {
|
||||
const body = await page.textContent('body');
|
||||
const hasActivity = /activity|recent|no activity|нет активности/i.test(body);
|
||||
assert(hasActivity, 'No activity section');
|
||||
check('1.7 Activity log section', 'PASS');
|
||||
} catch (e) { check('1.7 Activity log section', 'FAIL', e.message); }
|
||||
|
||||
// 1.8 CSS applied (not unstyled)
|
||||
try {
|
||||
const bg = await page.evaluate(() => {
|
||||
const s = getComputedStyle(document.body);
|
||||
return { bg: s.backgroundColor, font: s.fontFamily, color: s.color };
|
||||
});
|
||||
assert(bg.bg !== 'rgba(0, 0, 0, 0)' && bg.bg !== '', `No background: ${bg.bg}`);
|
||||
assert(bg.font.length > 0, 'No font');
|
||||
check('1.8 CSS styling applied', 'PASS', `bg=${bg.bg}`);
|
||||
} catch (e) { check('1.8 CSS styling applied', 'FAIL', e.message); }
|
||||
|
||||
// 1.9 HTMX loaded
|
||||
try {
|
||||
const hasHtmx = await page.evaluate(() => typeof htmx !== 'undefined');
|
||||
assert(hasHtmx, 'htmx not defined');
|
||||
check('1.9 HTMX library loaded', 'PASS');
|
||||
} catch (e) { check('1.9 HTMX library loaded', 'FAIL', e.message); }
|
||||
|
||||
// 1.10 No JS errors on dashboard
|
||||
try {
|
||||
const dashErrors = consoleErrors.filter(e => !e.includes('favicon'));
|
||||
assert(dashErrors.length === 0, dashErrors.join('; '));
|
||||
check('1.10 No JavaScript errors', 'PASS');
|
||||
} catch (e) { check('1.10 No JavaScript errors', 'FAIL', e.message); }
|
||||
|
||||
await screenshot(page, '01-dashboard-desktop');
|
||||
|
||||
// ================================================================
|
||||
// PHASE 2: Navigation & Routing
|
||||
// ================================================================
|
||||
console.log('=== Phase 2: Navigation & Routing ===\n');
|
||||
|
||||
// 2.1 All nav links resolve
|
||||
try {
|
||||
const links = await page.locator('a[href^="/ui/"]').all();
|
||||
assert(links.length >= 5, `Only ${links.length} nav links`);
|
||||
check('2.1 Navigation links exist', 'PASS', `${links.length} links`);
|
||||
} catch (e) { check('2.1 Navigation links exist', 'FAIL', e.message); }
|
||||
|
||||
// 2.2 Click through each registry
|
||||
for (const reg of ['docker', 'maven', 'npm', 'cargo', 'pypi']) {
|
||||
try {
|
||||
const resp = await page.goto(`${BASE}/ui/${reg}`, { waitUntil: 'networkidle', timeout: 10000 });
|
||||
assert(resp.status() === 200);
|
||||
const html = await page.content();
|
||||
assert(html.includes('<!DOCTYPE html>') || html.includes('<!doctype html>'));
|
||||
check(`2.2.${reg} ${reg} list page`, 'PASS');
|
||||
await screenshot(page, `02-${reg}-list`);
|
||||
} catch (e) { check(`2.2.${reg} ${reg} list page`, 'FAIL', e.message); }
|
||||
}
|
||||
|
||||
// 2.3 Back to dashboard
|
||||
try {
|
||||
const resp = await page.goto(`${BASE}/ui/`, { waitUntil: 'networkidle' });
|
||||
assert(resp.status() === 200);
|
||||
check('2.3 Return to dashboard', 'PASS');
|
||||
} catch (e) { check('2.3 Return to dashboard', 'FAIL', e.message); }
|
||||
|
||||
// 2.4 Root redirect to /ui/
|
||||
try {
|
||||
const resp = await page.goto(`${BASE}/`, { waitUntil: 'networkidle' });
|
||||
assert(page.url().includes('/ui/'), `Redirected to ${page.url()}`);
|
||||
check('2.4 Root / redirects to /ui/', 'PASS');
|
||||
} catch (e) { check('2.4 Root / redirects to /ui/', 'FAIL', e.message); }
|
||||
|
||||
// ================================================================
|
||||
// PHASE 3: Language Switching
|
||||
// ================================================================
|
||||
console.log('=== Phase 3: Internationalization ===\n');
|
||||
|
||||
// 3.1 English
|
||||
try {
|
||||
await page.goto(`${BASE}/ui/?lang=en`, { waitUntil: 'networkidle' });
|
||||
const text = await page.textContent('body');
|
||||
const hasEn = /download|upload|artifact|storage/i.test(text);
|
||||
assert(hasEn, 'No English text');
|
||||
check('3.1 English locale', 'PASS');
|
||||
await screenshot(page, '03-lang-en');
|
||||
} catch (e) { check('3.1 English locale', 'FAIL', e.message); }
|
||||
|
||||
// 3.2 Russian
|
||||
try {
|
||||
await page.goto(`${BASE}/ui/?lang=ru`, { waitUntil: 'networkidle' });
|
||||
const text = await page.textContent('body');
|
||||
assert(/[а-яА-Я]/.test(text), 'No Russian characters');
|
||||
check('3.2 Russian locale', 'PASS');
|
||||
await screenshot(page, '03-lang-ru');
|
||||
} catch (e) { check('3.2 Russian locale', 'FAIL', e.message); }
|
||||
|
||||
// 3.3 Language switcher exists
|
||||
try {
|
||||
// Look for language toggle (button, select, or link)
|
||||
const body = await page.textContent('body');
|
||||
const hasLangSwitch = /EN|RU|English|Русский/i.test(body) ||
|
||||
(await page.locator('[href*="lang="]').count()) > 0;
|
||||
if (hasLangSwitch) {
|
||||
check('3.3 Language switcher visible', 'PASS');
|
||||
} else {
|
||||
check('3.3 Language switcher visible', 'WARN', 'Not found but pages work via ?lang=');
|
||||
}
|
||||
} catch (e) { check('3.3 Language switcher visible', 'WARN', e.message); }
|
||||
|
||||
// ================================================================
|
||||
// PHASE 4: Search (HTMX)
|
||||
// ================================================================
|
||||
console.log('=== Phase 4: Search & Interactivity ===\n');
|
||||
|
||||
// 4.1 Search input exists on registry list page
|
||||
try {
|
||||
await page.goto(`${BASE}/ui/docker`, { waitUntil: 'networkidle' });
|
||||
const searchInput = await page.locator('input[type="search"], input[type="text"][hx-get], input[placeholder*="earch"], input[placeholder*="оиск"]').count();
|
||||
if (searchInput > 0) {
|
||||
check('4.1 Search input on list page', 'PASS');
|
||||
} else {
|
||||
check('4.1 Search input on list page', 'WARN', 'No search input found');
|
||||
}
|
||||
} catch (e) { check('4.1 Search input on list page', 'WARN', e.message); }
|
||||
|
||||
// 4.2 HTMX search endpoint works
|
||||
try {
|
||||
const resp = await page.goto(`${BASE}/api/ui/docker/search?q=test`, { waitUntil: 'networkidle' });
|
||||
assert(resp.status() === 200);
|
||||
check('4.2 Search API responds', 'PASS');
|
||||
} catch (e) { check('4.2 Search API responds', 'FAIL', e.message); }
|
||||
|
||||
// 4.3 Search with empty result
|
||||
try {
|
||||
const resp = await page.goto(`${BASE}/api/ui/docker/search?q=zzz_nonexistent_pkg`, { waitUntil: 'networkidle' });
|
||||
const text = await page.textContent('body');
|
||||
assert(resp.status() === 200);
|
||||
check('4.3 Empty search result', 'PASS');
|
||||
} catch (e) { check('4.3 Empty search result', 'FAIL', e.message); }
|
||||
|
||||
// ================================================================
|
||||
// PHASE 5: Security Headers in Browser
|
||||
// ================================================================
|
||||
console.log('=== Phase 5: Security Headers ===\n');
|
||||
|
||||
try {
|
||||
const resp = await page.goto(`${BASE}/ui/`, { waitUntil: 'networkidle' });
|
||||
const headers = resp.headers();
|
||||
|
||||
const checks = [
|
||||
['x-content-type-options', 'nosniff'],
|
||||
['x-frame-options', 'DENY'],
|
||||
['referrer-policy', 'strict-origin-when-cross-origin'],
|
||||
];
|
||||
|
||||
for (const [header, expected] of checks) {
|
||||
const val = headers[header];
|
||||
if (val === expected) {
|
||||
check(`5.1 ${header}: ${expected}`, 'PASS');
|
||||
} else {
|
||||
check(`5.1 ${header}: ${expected}`, 'FAIL', `Got: ${val || 'missing'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// CSP check — should contain 'self' with quotes
|
||||
const csp = headers['content-security-policy'] || '';
|
||||
if (csp.includes("'self'")) {
|
||||
check('5.2 CSP contains quoted self', 'PASS');
|
||||
} else {
|
||||
check('5.2 CSP contains quoted self', 'FAIL', `CSP: ${csp.slice(0, 80)}`);
|
||||
}
|
||||
|
||||
if (csp.includes("'unsafe-inline'")) {
|
||||
check('5.3 CSP allows unsafe-inline (needed for UI)', 'PASS');
|
||||
} else {
|
||||
check('5.3 CSP allows unsafe-inline', 'FAIL', 'UI may break without it');
|
||||
}
|
||||
} catch (e) { check('5.x Security headers', 'FAIL', e.message); }
|
||||
|
||||
// ================================================================
|
||||
// PHASE 6: Responsive Design
|
||||
// ================================================================
|
||||
console.log('=== Phase 6: Responsive Design ===\n');
|
||||
|
||||
// 6.1 Mobile (375x812 — iPhone)
|
||||
try {
|
||||
const mobileCtx = await browser.newContext({
|
||||
viewport: { width: 375, height: 812 },
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)',
|
||||
});
|
||||
const mobilePage = await mobileCtx.newPage();
|
||||
const resp = await mobilePage.goto(`${BASE}/ui/`, { waitUntil: 'networkidle', timeout: 10000 });
|
||||
assert(resp.status() === 200);
|
||||
|
||||
// Check content is not clipped — page should scroll
|
||||
const bodyWidth = await mobilePage.evaluate(() => document.body.scrollWidth);
|
||||
const viewWidth = 375;
|
||||
// Allow some overflow but not extreme (like full desktop rendering on mobile)
|
||||
const overflowOk = bodyWidth <= viewWidth * 1.5;
|
||||
if (overflowOk) {
|
||||
check('6.1 Mobile (375px) no horizontal overflow', 'PASS', `body=${bodyWidth}px`);
|
||||
} else {
|
||||
check('6.1 Mobile (375px) no horizontal overflow', 'WARN', `body=${bodyWidth}px > ${viewWidth * 1.5}px`);
|
||||
}
|
||||
|
||||
await screenshot(mobilePage, '06-mobile-375');
|
||||
await mobileCtx.close();
|
||||
} catch (e) { check('6.1 Mobile layout', 'FAIL', e.message); }
|
||||
|
||||
// 6.2 Tablet (768x1024 — iPad)
|
||||
try {
|
||||
const tabletCtx = await browser.newContext({ viewport: { width: 768, height: 1024 } });
|
||||
const tabletPage = await tabletCtx.newPage();
|
||||
await tabletPage.goto(`${BASE}/ui/`, { waitUntil: 'networkidle', timeout: 10000 });
|
||||
await screenshot(tabletPage, '06-tablet-768');
|
||||
check('6.2 Tablet (768px) renders', 'PASS');
|
||||
await tabletCtx.close();
|
||||
} catch (e) { check('6.2 Tablet layout', 'FAIL', e.message); }
|
||||
|
||||
// ================================================================
|
||||
// PHASE 7: Swagger / API Docs
|
||||
// ================================================================
|
||||
console.log('=== Phase 7: Swagger UI ===\n');
|
||||
|
||||
try {
|
||||
const resp = await page.goto(`${BASE}/api-docs`, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
assert(resp.status() === 200);
|
||||
const text = await page.textContent('body');
|
||||
assert(text.length > 200, `Swagger page too short: ${text.length}`);
|
||||
check('7.1 Swagger UI loads', 'PASS');
|
||||
await screenshot(page, '07-swagger');
|
||||
} catch (e) { check('7.1 Swagger UI loads', 'FAIL', e.message); }
|
||||
|
||||
// ================================================================
|
||||
// PHASE 8: Accessibility Basics
|
||||
// ================================================================
|
||||
console.log('=== Phase 8: Accessibility ===\n');
|
||||
|
||||
try {
|
||||
await page.goto(`${BASE}/ui/`, { waitUntil: 'networkidle' });
|
||||
|
||||
// 8.1 lang attribute on html
|
||||
const lang = await page.evaluate(() => document.documentElement.getAttribute('lang'));
|
||||
if (lang) {
|
||||
check('8.1 HTML lang attribute', 'PASS', lang);
|
||||
} else {
|
||||
check('8.1 HTML lang attribute', 'WARN', 'Missing — screen readers need this');
|
||||
}
|
||||
} catch (e) { check('8.1 HTML lang attribute', 'WARN', e.message); }
|
||||
|
||||
try {
|
||||
// 8.2 Images have alt text
|
||||
const imgsWithoutAlt = await page.locator('img:not([alt])').count();
|
||||
if (imgsWithoutAlt === 0) {
|
||||
check('8.2 All images have alt text', 'PASS');
|
||||
} else {
|
||||
check('8.2 All images have alt text', 'WARN', `${imgsWithoutAlt} images without alt`);
|
||||
}
|
||||
} catch (e) { check('8.2 Images alt text', 'WARN', e.message); }
|
||||
|
||||
try {
|
||||
// 8.3 Color contrast — check at least body text isn't invisible
|
||||
const contrast = await page.evaluate(() => {
|
||||
const s = getComputedStyle(document.body);
|
||||
return { color: s.color, bg: s.backgroundColor };
|
||||
});
|
||||
assert(contrast.color !== contrast.bg, 'Text color equals background');
|
||||
check('8.3 Text/background contrast', 'PASS', `text=${contrast.color} bg=${contrast.bg}`);
|
||||
} catch (e) { check('8.3 Text/background contrast', 'FAIL', e.message); }
|
||||
|
||||
try {
|
||||
// 8.4 Focusable elements reachable via Tab
|
||||
const focusable = await page.locator('a, button, input, select, textarea, [tabindex]').count();
|
||||
assert(focusable > 0, 'No focusable elements');
|
||||
check('8.4 Focusable elements exist', 'PASS', `${focusable} elements`);
|
||||
} catch (e) { check('8.4 Focusable elements', 'WARN', e.message); }
|
||||
|
||||
// ================================================================
|
||||
// PHASE 9: Error Collection Summary
|
||||
// ================================================================
|
||||
console.log('=== Phase 9: Error Summary ===\n');
|
||||
|
||||
if (consoleErrors.length === 0) {
|
||||
check('9.1 No console errors during session', 'PASS');
|
||||
} else {
|
||||
const filtered = consoleErrors.filter(e => !e.includes('favicon'));
|
||||
if (filtered.length === 0) {
|
||||
check('9.1 No console errors (favicon ignored)', 'PASS');
|
||||
} else {
|
||||
check('9.1 Console errors found', 'FAIL', filtered.slice(0, 3).join(' | '));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DONE
|
||||
// ================================================================
|
||||
await browser.close();
|
||||
|
||||
// Print screenshots list
|
||||
console.log('\n=== Screenshots ===');
|
||||
const { readdirSync } = await import('fs');
|
||||
const shots = readdirSync(SCREENSHOT_DIR).filter(f => f.endsWith('.png')).sort();
|
||||
for (const s of shots) {
|
||||
console.log(` ${SCREENSHOT_DIR}/${s}`);
|
||||
}
|
||||
|
||||
printResults();
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
function printResults() {
|
||||
console.log('\n======================================================================');
|
||||
console.log(' NORA Playwright UI Test Results');
|
||||
console.log('======================================================================\n');
|
||||
|
||||
for (const r of results) {
|
||||
const icon = r.status === 'PASS' ? '\x1b[32m[PASS]\x1b[0m'
|
||||
: r.status === 'FAIL' ? '\x1b[31m[FAIL]\x1b[0m'
|
||||
: '\x1b[33m[WARN]\x1b[0m';
|
||||
const detail = r.detail ? ` — ${r.detail}` : '';
|
||||
console.log(` ${icon} ${r.name}${detail}`);
|
||||
}
|
||||
|
||||
console.log(`\n Total: \x1b[32m${pass} passed\x1b[0m, \x1b[31m${fail} failed\x1b[0m, \x1b[33m${warn} warnings\x1b[0m`);
|
||||
console.log('======================================================================\n');
|
||||
}
|
||||
|
||||
run().catch(e => {
|
||||
console.error('\x1b[31mFatal:\x1b[0m', e.message);
|
||||
process.exit(2);
|
||||
});
|
||||
186
scripts/qa-panel.sh
Executable file
186
scripts/qa-panel.sh
Executable file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# NORA Pre-Release QA Panel
|
||||
# Usage: ./qa-panel.sh [port] [storage_path]
|
||||
# port — test server port (default: 4091)
|
||||
# storage_path — temp storage dir (default: /tmp/nora-qa-$$)
|
||||
#
|
||||
# Requires: nora binary already built (./target/release/nora)
|
||||
# Run from repo root: /srv/projects/nora/
|
||||
#
|
||||
# Exit codes: 0 = all passed, 1 = failures found
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${1:-4091}"
|
||||
STORAGE="${2:-/tmp/nora-qa-$$}"
|
||||
BINARY="./target/release/nora"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TOTAL_START=$(date +%s)
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
check() {
|
||||
if [ "$1" = "$2" ]; then
|
||||
echo -e " ${GREEN}[PASS]${NC} $3"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo -e " ${RED}[FAIL]${NC} $3 (got '$1', expected '$2')"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
pkill -f "nora.*${PORT}" 2>/dev/null || true
|
||||
rm -rf "$STORAGE" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ============================================================================
|
||||
echo "======================================================================"
|
||||
echo " NORA Pre-Release QA Panel"
|
||||
echo " $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo " Port: $PORT | Storage: $STORAGE"
|
||||
echo "======================================================================"
|
||||
echo
|
||||
|
||||
# --- Phase 1: Static checks ---
|
||||
echo "=== Phase 1: Static Analysis ==="
|
||||
|
||||
if [ -f "$BINARY" ]; then
|
||||
check "exists" "exists" "Binary exists"
|
||||
SIZE=$(du -sh "$BINARY" | cut -f1)
|
||||
echo " [INFO] Binary size: $SIZE"
|
||||
else
|
||||
check "missing" "exists" "Binary exists"
|
||||
echo "FATAL: Binary not found. Run: cargo build --release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
# --- Phase 2: Start server ---
|
||||
echo "=== Phase 2: Server Startup ==="
|
||||
rm -rf "$STORAGE"
|
||||
NORA_STORAGE_PATH="$STORAGE" NORA_PORT="$PORT" NORA_HOST=127.0.0.1 \
|
||||
NORA_RATE_LIMIT_ENABLED=false "$BINARY" serve > "${STORAGE}.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
curl -sf "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1 && break || sleep 1
|
||||
done
|
||||
|
||||
if curl -sf "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||
check "up" "up" "Server started (PID $SERVER_PID)"
|
||||
VERSION=$(curl -sf "http://127.0.0.1:${PORT}/health" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','?'))" 2>/dev/null || echo "?")
|
||||
echo " [INFO] Version: $VERSION"
|
||||
else
|
||||
check "down" "up" "Server started"
|
||||
echo "FATAL: Server failed to start. Log:"
|
||||
tail -20 "${STORAGE}.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
# --- Phase 3: Endpoints ---
|
||||
echo "=== Phase 3: Endpoint Health ==="
|
||||
for EP in /health /ready /metrics /ui/ /api/ui/dashboard /api/ui/stats /v2/ /v2/_catalog /simple/ /api-docs; do
|
||||
CODE=$(curl -sf -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}${EP}" 2>/dev/null || echo "000")
|
||||
if [ "$CODE" = "200" ] || [ "$CODE" = "303" ]; then
|
||||
check "$CODE" "$CODE" "GET $EP"
|
||||
else
|
||||
check "$CODE" "200" "GET $EP"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
|
||||
# --- Phase 4: Security Hardening ---
|
||||
echo "=== Phase 4: Security Hardening ==="
|
||||
|
||||
# SEC-001: .meta tag filter
|
||||
mkdir -p "$STORAGE/docker/sec001/manifests"
|
||||
echo '{}' > "$STORAGE/docker/sec001/manifests/v1.json"
|
||||
echo '{}' > "$STORAGE/docker/sec001/manifests/v1.meta.json"
|
||||
echo '{}' > "$STORAGE/docker/sec001/manifests/v2.json"
|
||||
echo '{}' > "$STORAGE/docker/sec001/manifests/v2.meta.meta.json"
|
||||
TAGS=$(curl -sf "http://127.0.0.1:${PORT}/v2/sec001/tags/list" 2>/dev/null || echo "{}")
|
||||
echo "$TAGS" | grep -q "meta" && check "leaked" "filtered" "SEC-001: .meta tag filter" || check "filtered" "filtered" "SEC-001: .meta tag filter"
|
||||
|
||||
# SEC-002: Digest verification
|
||||
DATA="qa-test-$(date +%s)"
|
||||
DIGEST=$(echo -n "$DATA" | sha256sum | cut -d' ' -f1)
|
||||
LOC=$(curl -sf -X POST "http://127.0.0.1:${PORT}/v2/sec002/blobs/uploads/" -D- -o /dev/null 2>&1 | grep -i location | tr -d '\r' | awk '{print $2}')
|
||||
check "$(curl -sf -X PUT "http://127.0.0.1:${PORT}${LOC}?digest=sha256:${DIGEST}" -d "$DATA" -o /dev/null -w '%{http_code}')" "201" "SEC-002: valid SHA256 accepted"
|
||||
|
||||
LOC2=$(curl -sf -X POST "http://127.0.0.1:${PORT}/v2/sec002/blobs/uploads/" -D- -o /dev/null 2>&1 | grep -i location | tr -d '\r' | awk '{print $2}')
|
||||
check "$(curl -sf -X PUT "http://127.0.0.1:${PORT}${LOC2}?digest=sha256:0000000000000000000000000000000000000000000000000000000000000000" -d "tampered" -o /dev/null -w '%{http_code}')" "400" "SEC-002: wrong digest rejected"
|
||||
|
||||
LOC3=$(curl -sf -X POST "http://127.0.0.1:${PORT}/v2/sec002/blobs/uploads/" -D- -o /dev/null 2>&1 | grep -i location | tr -d '\r' | awk '{print $2}')
|
||||
check "$(curl -sf -X PUT "http://127.0.0.1:${PORT}${LOC3}?digest=sha512:$(python3 -c "print('a'*128)")" -d "x" -o /dev/null -w '%{http_code}')" "400" "SEC-002: sha512 rejected"
|
||||
|
||||
# SEC-004: Session limits
|
||||
LOC4=$(curl -sf -X POST "http://127.0.0.1:${PORT}/v2/repo-a/blobs/uploads/" -D- -o /dev/null 2>&1 | grep -i location | tr -d '\r' | awk '{print $2}')
|
||||
UUID4=$(echo "$LOC4" | grep -oP '[^/]+$')
|
||||
check "$(curl -sf -X PATCH "http://127.0.0.1:${PORT}/v2/repo-b/blobs/uploads/${UUID4}" -d "x" -o /dev/null -w '%{http_code}')" "400" "SEC-004: session fixation blocked"
|
||||
check "$(curl -sf -X PATCH "http://127.0.0.1:${PORT}/v2/t/blobs/uploads/nonexistent" -d "x" -o /dev/null -w '%{http_code}')" "404" "SEC-004: ghost session rejected"
|
||||
|
||||
# SEC-005: Security headers
|
||||
HDRS=$(curl -sf -D- "http://127.0.0.1:${PORT}/health" -o /dev/null 2>&1)
|
||||
echo "$HDRS" | grep -q "x-content-type-options: nosniff" && check y y "SEC-005: X-Content-Type-Options" || check n y "SEC-005: X-Content-Type-Options"
|
||||
echo "$HDRS" | grep -q "x-frame-options: DENY" && check y y "SEC-005: X-Frame-Options" || check n y "SEC-005: X-Frame-Options"
|
||||
echo "$HDRS" | grep -q "referrer-policy:" && check y y "SEC-005: Referrer-Policy" || check n y "SEC-005: Referrer-Policy"
|
||||
echo "$HDRS" | grep -q "content-security-policy:" && check y y "SEC-005: CSP present" || check n y "SEC-005: CSP present"
|
||||
echo "$HDRS" | grep -q "'self'" && check y y "SEC-005: CSP quotes correct" || check n y "SEC-005: CSP quotes correct"
|
||||
|
||||
# SEC-006: Namespaced catalog
|
||||
mkdir -p "$STORAGE/docker/library/alpine/manifests"
|
||||
echo '{}' > "$STORAGE/docker/library/alpine/manifests/latest.json"
|
||||
CATALOG=$(curl -sf "http://127.0.0.1:${PORT}/v2/_catalog" 2>/dev/null || echo "{}")
|
||||
echo "$CATALOG" | grep -q "library/alpine" && check y y "SEC-006: namespaced catalog" || check n y "SEC-006: namespaced catalog"
|
||||
|
||||
echo
|
||||
|
||||
# --- Phase 5: Protocol Round-Trips ---
|
||||
echo "=== Phase 5: Protocol Round-Trips ==="
|
||||
|
||||
# Docker chunked upload flow
|
||||
LOC5=$(curl -sf -X POST "http://127.0.0.1:${PORT}/v2/roundtrip/blobs/uploads/" -D- -o /dev/null 2>&1 | grep -i location | tr -d '\r' | awk '{print $2}')
|
||||
BLOB="docker-roundtrip-test"
|
||||
BDIGEST=$(echo -n "$BLOB" | sha256sum | cut -d' ' -f1)
|
||||
curl -sf -X PATCH "http://127.0.0.1:${PORT}${LOC5}" -d "$BLOB" -o /dev/null
|
||||
check "$(curl -sf -X PUT "http://127.0.0.1:${PORT}${LOC5}?digest=sha256:${BDIGEST}" -o /dev/null -w '%{http_code}')" "201" "Docker: chunked upload (POST→PATCH→PUT)"
|
||||
|
||||
# Maven
|
||||
check "$(curl -sf -X PUT -d "maven-artifact" "http://127.0.0.1:${PORT}/maven2/com/test/a/1/a.jar" -o /dev/null -w '%{http_code}')" "201" "Maven: upload"
|
||||
check "$(curl -sf -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/maven2/com/test/a/1/a.jar")" "200" "Maven: download"
|
||||
|
||||
# Raw
|
||||
check "$(curl -sf -X PUT -d "raw-file" "http://127.0.0.1:${PORT}/raw/test/doc.txt" -o /dev/null -w '%{http_code}')" "201" "Raw: upload"
|
||||
check "$(curl -sf -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/raw/test/doc.txt")" "200" "Raw: download"
|
||||
|
||||
echo
|
||||
|
||||
# --- Summary ---
|
||||
TOTAL_END=$(date +%s)
|
||||
DURATION=$((TOTAL_END - TOTAL_START))
|
||||
|
||||
echo "======================================================================"
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo -e " ${GREEN}RESULT: $PASS passed, $FAIL failed${NC} (${DURATION}s)"
|
||||
echo " STATUS: READY FOR RELEASE"
|
||||
else
|
||||
echo -e " ${RED}RESULT: $PASS passed, $FAIL failed${NC} (${DURATION}s)"
|
||||
echo " STATUS: FIXES REQUIRED"
|
||||
fi
|
||||
echo " Log: ${STORAGE}.log"
|
||||
echo "======================================================================"
|
||||
|
||||
exit $FAIL
|
||||
Reference in New Issue
Block a user