From b7cb458edfa37b76ea0b50e8afa964618b085d06 Mon Sep 17 00:00:00 2001 From: devitway Date: Wed, 18 Mar 2026 11:04:19 +0000 Subject: [PATCH] test: E2E smoke tests + Playwright browser tests (23 tests) smoke.sh: - Full E2E smoke test: health, npm proxy/publish/security, Maven, PyPI, Docker, Raw, UI, mirror CLI - Self-contained: starts NORA, runs tests, cleans up Playwright (tests/e2e/): - Dashboard: page load, registry sections visible, npm count > 0, Docker stats - npm: URL rewriting, scoped packages, tarball download, publish, immutability, security - Docker: v2 check, catalog, manifest push/pull, tags list - Maven: proxy download, upload - PyPI: simple index, package page - Raw: upload and download - Health, metrics, OpenAPI endpoints All 23 tests pass in 4.7s against live NORA instance. --- tests/e2e/.gitignore | 3 + tests/e2e/package-lock.json | 76 ++++++++ tests/e2e/package.json | 11 ++ tests/e2e/playwright.config.ts | 18 ++ tests/e2e/tests/dashboard.spec.ts | 82 +++++++++ tests/e2e/tests/docker-proxy.spec.ts | 74 ++++++++ tests/e2e/tests/npm-proxy.spec.ts | 132 ++++++++++++++ tests/e2e/tests/other-registries.spec.ts | 51 ++++++ tests/smoke.sh | 210 +++++++++++++++++++++++ 9 files changed, 657 insertions(+) create mode 100644 tests/e2e/.gitignore create mode 100644 tests/e2e/package-lock.json create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/tests/dashboard.spec.ts create mode 100644 tests/e2e/tests/docker-proxy.spec.ts create mode 100644 tests/e2e/tests/npm-proxy.spec.ts create mode 100644 tests/e2e/tests/other-registries.spec.ts create mode 100755 tests/smoke.sh diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000..dbd64df --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..e17f06f --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "nora-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nora-e2e", + "devDependencies": { + "@playwright/test": "^1.50.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..fc8db65 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "nora-e2e", + "private": true, + "scripts": { + "test": "npx playwright test", + "test:ui": "npx playwright test --ui" + }, + "devDependencies": { + "@playwright/test": "^1.50.0" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..0eb2f9a --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 30000, + retries: 1, + use: { + baseURL: process.env.NORA_URL || 'http://localhost:4000', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +}); diff --git a/tests/e2e/tests/dashboard.spec.ts b/tests/e2e/tests/dashboard.spec.ts new file mode 100644 index 0000000..27cb2d6 --- /dev/null +++ b/tests/e2e/tests/dashboard.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; + +test.describe('NORA Dashboard', () => { + + test('dashboard page loads and shows title', async ({ page }) => { + await page.goto('/ui/'); + await expect(page).toHaveTitle(/NORA|nora/i); + }); + + test('dashboard shows registry sections', async ({ page }) => { + await page.goto('/ui/'); + + // All registry types should be visible + await expect(page.getByText(/Docker/i).first()).toBeVisible(); + await expect(page.getByText(/npm/i).first()).toBeVisible(); + await expect(page.getByText(/Maven/i).first()).toBeVisible(); + await expect(page.getByText(/PyPI/i).first()).toBeVisible(); + await expect(page.getByText(/Cargo/i).first()).toBeVisible(); + }); + + test('dashboard shows non-zero npm count after proxy fetch', async ({ page, request }) => { + // Trigger npm proxy cache by fetching a package + await request.get('/npm/chalk'); + await request.get('/npm/chalk/-/chalk-5.4.1.tgz'); + + // Wait a moment for index rebuild + await page.waitForTimeout(1000); + + await page.goto('/ui/'); + + // npm section should show at least 1 package + // Look for a number > 0 near npm section + const statsResponse = await request.get('/api/ui/stats'); + const stats = await statsResponse.json(); + expect(stats.npm).toBeGreaterThan(0); + + // Verify it's actually rendered on the page + await page.goto('/ui/'); + await page.waitForTimeout(500); + + // The page should contain the package count somewhere + const content = await page.textContent('body'); + expect(content).not.toBeNull(); + // Should not show all zeros for npm + expect(content).toContain('npm'); + }); + + test('dashboard shows Docker images after proxy fetch', async ({ page, request }) => { + // Check stats API + const statsResponse = await request.get('/api/ui/stats'); + const stats = await statsResponse.json(); + + // Docker count should be accessible (may be 0 if no images pulled yet) + expect(stats).toHaveProperty('docker'); + }); + + test('health endpoint returns healthy', async ({ request }) => { + const response = await request.get('/health'); + expect(response.ok()).toBeTruthy(); + + const health = await response.json(); + expect(health.status).toBe('healthy'); + expect(health.registries.npm).toBe('ok'); + expect(health.registries.docker).toBe('ok'); + expect(health.registries.maven).toBe('ok'); + expect(health.registries.pypi).toBe('ok'); + expect(health.registries.cargo).toBe('ok'); + }); + + test('OpenAPI docs endpoint accessible', async ({ request }) => { + const response = await request.get('/api-docs', { maxRedirects: 0 }); + // api-docs redirects to swagger UI + expect([200, 303]).toContain(response.status()); + }); + + test('metrics endpoint returns prometheus format', async ({ request }) => { + const response = await request.get('/metrics'); + expect(response.ok()).toBeTruthy(); + const text = await response.text(); + expect(text).toContain('nora_http_request_duration_seconds'); + }); +}); diff --git a/tests/e2e/tests/docker-proxy.spec.ts b/tests/e2e/tests/docker-proxy.spec.ts new file mode 100644 index 0000000..d8e2d06 --- /dev/null +++ b/tests/e2e/tests/docker-proxy.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Docker Registry', () => { + + test('v2 check returns empty JSON', async ({ request }) => { + const response = await request.get('/v2/'); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body).toEqual({}); + }); + + test('catalog endpoint returns 200', async ({ request }) => { + const response = await request.get('/v2/_catalog'); + expect(response.ok()).toBeTruthy(); + }); + + test('put and get manifest works', async ({ request }) => { + // Push a simple blob + const blobData = 'test-blob-content'; + const crypto = require('crypto'); + const blobDigest = 'sha256:' + crypto.createHash('sha256').update(blobData).digest('hex'); + + await request.post(`/v2/e2e-test/blobs/uploads/?digest=${blobDigest}`, { + data: blobData, + headers: { 'Content-Type': 'application/octet-stream' }, + }); + + // Push config blob + const configData = '{}'; + const configDigest = 'sha256:' + crypto.createHash('sha256').update(configData).digest('hex'); + + await request.post(`/v2/e2e-test/blobs/uploads/?digest=${configDigest}`, { + data: configData, + headers: { 'Content-Type': 'application/octet-stream' }, + }); + + // Push manifest + const manifest = { + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + config: { + mediaType: 'application/vnd.oci.image.config.v1+json', + digest: configDigest, + size: configData.length, + }, + layers: [ + { + mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip', + digest: blobDigest, + size: blobData.length, + }, + ], + }; + + const putResponse = await request.put('/v2/e2e-test/manifests/1.0.0', { + data: manifest, + headers: { 'Content-Type': 'application/vnd.oci.image.manifest.v1+json' }, + }); + expect(putResponse.status()).toBe(201); + + // Pull manifest back + const getResponse = await request.get('/v2/e2e-test/manifests/1.0.0'); + expect(getResponse.ok()).toBeTruthy(); + const pulled = await getResponse.json(); + expect(pulled.schemaVersion).toBe(2); + expect(pulled.layers).toHaveLength(1); + }); + + test('tags list returns pushed tags', async ({ request }) => { + const response = await request.get('/v2/e2e-test/tags/list'); + // May or may not have tags depending on test order + expect([200, 404]).toContain(response.status()); + }); +}); diff --git a/tests/e2e/tests/npm-proxy.spec.ts b/tests/e2e/tests/npm-proxy.spec.ts new file mode 100644 index 0000000..ebb51ab --- /dev/null +++ b/tests/e2e/tests/npm-proxy.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from '@playwright/test'; + +test.describe('npm Proxy', () => { + + test('metadata proxy returns rewritten tarball URLs', async ({ request }) => { + const response = await request.get('/npm/chalk'); + expect(response.ok()).toBeTruthy(); + + const metadata = await response.json(); + expect(metadata.name).toBe('chalk'); + expect(metadata.versions).toBeDefined(); + + // Tarball URL must point to NORA, not npmjs.org + const version = metadata.versions['5.4.1']; + expect(version).toBeDefined(); + expect(version.dist.tarball).not.toContain('registry.npmjs.org'); + expect(version.dist.tarball).toContain('/npm/chalk/-/chalk-5.4.1.tgz'); + }); + + test('scoped package @babel/parser works', async ({ request }) => { + const response = await request.get('/npm/@babel/parser'); + expect(response.ok()).toBeTruthy(); + + const metadata = await response.json(); + expect(metadata.name).toBe('@babel/parser'); + + // Check tarball URL rewriting for scoped package + const versions = Object.keys(metadata.versions); + expect(versions.length).toBeGreaterThan(0); + + const firstVersion = metadata.versions[versions[0]]; + if (firstVersion?.dist?.tarball) { + expect(firstVersion.dist.tarball).toContain('/npm/@babel/parser/-/'); + expect(firstVersion.dist.tarball).not.toContain('registry.npmjs.org'); + } + }); + + test('tarball download returns gzip data', async ({ request }) => { + // Ensure metadata is cached first + await request.get('/npm/chalk'); + + const response = await request.get('/npm/chalk/-/chalk-5.4.1.tgz'); + expect(response.ok()).toBeTruthy(); + expect(response.headers()['content-type']).toBe('application/octet-stream'); + + const body = await response.body(); + expect(body.length).toBeGreaterThan(100); + // gzip magic bytes + expect(body[0]).toBe(0x1f); + expect(body[1]).toBe(0x8b); + }); + + test('npm publish creates package', async ({ request }) => { + const pkgName = `e2e-pub-${Date.now()}`; + const publishBody = { + name: pkgName, + versions: { + '1.0.0': { + name: pkgName, + version: '1.0.0', + dist: {}, + }, + }, + 'dist-tags': { latest: '1.0.0' }, + _attachments: { + [`${pkgName}-1.0.0.tgz`]: { + data: 'dGVzdA==', + content_type: 'application/octet-stream', + }, + }, + }; + + const response = await request.put(`/npm/${pkgName}`, { + data: publishBody, + headers: { 'Content-Type': 'application/json' }, + }); + expect(response.status()).toBe(201); + + // Verify published package is accessible + const getResponse = await request.get(`/npm/${pkgName}`); + expect(getResponse.ok()).toBeTruthy(); + const metadata = await getResponse.json(); + expect(metadata.name).toBe(pkgName); + expect(metadata.versions['1.0.0']).toBeDefined(); + }); + + test('npm publish rejects duplicate version (409)', async ({ request }) => { + const pkgName = `e2e-dupe-${Date.now()}`; + const body = { + name: pkgName, + versions: { '1.0.0': { name: pkgName, version: '1.0.0', dist: {} } }, + 'dist-tags': { latest: '1.0.0' }, + _attachments: { [`${pkgName}-1.0.0.tgz`]: { data: 'dGVzdA==' } }, + }; + + await request.put(`/npm/${pkgName}`, { + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + + // Publish same version again + const response = await request.put(`/npm/${pkgName}`, { + data: body, + headers: { 'Content-Type': 'application/json' }, + }); + expect(response.status()).toBe(409); + }); + + test('npm publish rejects name mismatch (400)', async ({ request }) => { + const response = await request.put('/npm/legitimate-pkg', { + data: { + name: 'evil-pkg', + versions: { '1.0.0': {} }, + _attachments: { 'a.tgz': { data: 'dGVzdA==' } }, + }, + headers: { 'Content-Type': 'application/json' }, + }); + expect(response.status()).toBe(400); + }); + + test('npm publish rejects path traversal filename (400)', async ({ request }) => { + const response = await request.put('/npm/safe-pkg', { + data: { + name: 'safe-pkg', + versions: { '1.0.0': {} }, + _attachments: { '../../etc/passwd': { data: 'dGVzdA==' } }, + }, + headers: { 'Content-Type': 'application/json' }, + }); + expect(response.status()).toBe(400); + }); +}); diff --git a/tests/e2e/tests/other-registries.spec.ts b/tests/e2e/tests/other-registries.spec.ts new file mode 100644 index 0000000..9d00f1f --- /dev/null +++ b/tests/e2e/tests/other-registries.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Maven Proxy', () => { + test('download Maven artifact', async ({ request }) => { + const response = await request.get( + '/maven2/org/apache/commons/commons-lang3/3.17.0/commons-lang3-3.17.0.pom' + ); + expect(response.ok()).toBeTruthy(); + const text = await response.text(); + expect(text).toContain('commons-lang3'); + }); + + test('Maven upload works', async ({ request }) => { + const response = await request.put('/maven2/com/test/smoke/1.0/smoke-1.0.jar', { + data: 'test-jar-content', + }); + expect(response.status()).toBe(201); + }); +}); + +test.describe('PyPI Proxy', () => { + test('simple index returns HTML', async ({ request }) => { + const response = await request.get('/simple/'); + expect(response.ok()).toBeTruthy(); + const text = await response.text(); + expect(text).toContain('Simple Index'); + }); + + test('package page returns links', async ({ request }) => { + const response = await request.get('/simple/requests/'); + expect(response.ok()).toBeTruthy(); + const text = await response.text(); + expect(text).toContain('requests'); + }); +}); + +test.describe('Raw Storage', () => { + test('upload and download file', async ({ request }) => { + const data = 'raw-e2e-test-content-' + Date.now(); + + const putResponse = await request.put('/raw/e2e/test.txt', { + data: data, + }); + expect(putResponse.status()).toBe(201); + + const getResponse = await request.get('/raw/e2e/test.txt'); + expect(getResponse.ok()).toBeTruthy(); + const body = await getResponse.text(); + expect(body).toBe(data); + }); +}); diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..6a93290 --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +set -euo pipefail + +# NORA E2E Smoke Test +# Starts NORA, runs real-world scenarios, verifies results. +# Exit code 0 = all passed, non-zero = failures. + +NORA_BIN="${NORA_BIN:-./target/release/nora}" +PORT="${NORA_TEST_PORT:-14000}" +BASE="http://localhost:${PORT}" +STORAGE_DIR=$(mktemp -d) +PASSED=0 +FAILED=0 +NORA_PID="" + +cleanup() { + [ -n "$NORA_PID" ] && kill "$NORA_PID" 2>/dev/null || true + rm -rf "$STORAGE_DIR" +} +trap cleanup EXIT + +fail() { + echo " FAIL: $1" + FAILED=$((FAILED + 1)) +} + +pass() { + echo " PASS: $1" + PASSED=$((PASSED + 1)) +} + +check() { + local desc="$1" + shift + if "$@" >/dev/null 2>&1; then + pass "$desc" + else + fail "$desc" + fi +} + +echo "=== NORA Smoke Test ===" +echo "Binary: $NORA_BIN" +echo "Port: $PORT" +echo "Storage: $STORAGE_DIR" +echo "" + +# Start NORA +NORA_HOST=127.0.0.1 \ +NORA_PORT=$PORT \ +NORA_STORAGE_PATH="$STORAGE_DIR" \ +NORA_RATE_LIMIT_ENABLED=false \ +NORA_PUBLIC_URL="$BASE" \ +"$NORA_BIN" serve & +NORA_PID=$! + +# Wait for startup +for i in $(seq 1 20); do + curl -sf "$BASE/health" >/dev/null 2>&1 && break + sleep 0.5 +done + +echo "--- Health & Monitoring ---" +check "GET /health returns healthy" \ + curl -sf "$BASE/health" + +check "GET /ready returns 200" \ + curl -sf "$BASE/ready" + +check "GET /metrics returns prometheus" \ + curl -sf "$BASE/metrics" + +echo "" +echo "--- npm Proxy ---" + +# Fetch metadata — triggers proxy cache +METADATA=$(curl -sf "$BASE/npm/chalk" 2>/dev/null || echo "{}") + +check "npm metadata returns 200" \ + curl -sf "$BASE/npm/chalk" + +# URL rewriting check +TARBALL_URL=$(echo "$METADATA" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('versions',{}).get('5.4.1',{}).get('dist',{}).get('tarball',''))" 2>/dev/null || echo "") +if echo "$TARBALL_URL" | grep -q "localhost:${PORT}/npm"; then + pass "npm tarball URL rewritten to NORA" +else + fail "npm tarball URL not rewritten: $TARBALL_URL" +fi + +# Fetch tarball +check "npm tarball download" \ + curl -sf "$BASE/npm/chalk/-/chalk-5.4.1.tgz" -o /dev/null + +# Scoped package +check "npm scoped package @babel/parser" \ + curl -sf "$BASE/npm/@babel/parser" + +# Publish +PUBLISH_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: application/json" \ + -d '{"name":"smoke-test-pkg","versions":{"1.0.0":{"name":"smoke-test-pkg","version":"1.0.0","dist":{}}},"dist-tags":{"latest":"1.0.0"},"_attachments":{"smoke-test-pkg-1.0.0.tgz":{"data":"dGVzdA==","content_type":"application/octet-stream"}}}' \ + "$BASE/npm/smoke-test-pkg") +if [ "$PUBLISH_RESULT" = "201" ]; then + pass "npm publish returns 201" +else + fail "npm publish returned $PUBLISH_RESULT" +fi + +# Version immutability +DUPE_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: application/json" \ + -d '{"name":"smoke-test-pkg","versions":{"1.0.0":{"name":"smoke-test-pkg","version":"1.0.0","dist":{}}},"dist-tags":{"latest":"1.0.0"},"_attachments":{"smoke-test-pkg-1.0.0.tgz":{"data":"dGVzdA==","content_type":"application/octet-stream"}}}' \ + "$BASE/npm/smoke-test-pkg") +if [ "$DUPE_RESULT" = "409" ]; then + pass "npm version immutability (409 on duplicate)" +else + fail "npm duplicate publish returned $DUPE_RESULT, expected 409" +fi + +# Security: name mismatch +MISMATCH_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: application/json" \ + -d '{"name":"evil-pkg","versions":{"1.0.0":{}},"_attachments":{"a.tgz":{"data":"dGVzdA=="}}}' \ + "$BASE/npm/lodash") +if [ "$MISMATCH_RESULT" = "400" ]; then + pass "npm name mismatch rejected (400)" +else + fail "npm name mismatch returned $MISMATCH_RESULT, expected 400" +fi + +# Security: path traversal +TRAVERSAL_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: application/json" \ + -d '{"name":"test-pkg","versions":{"1.0.0":{}},"_attachments":{"../../etc/passwd":{"data":"dGVzdA=="}}}' \ + "$BASE/npm/test-pkg") +if [ "$TRAVERSAL_RESULT" = "400" ]; then + pass "npm path traversal rejected (400)" +else + fail "npm path traversal returned $TRAVERSAL_RESULT, expected 400" +fi + +echo "" +echo "--- Maven ---" +check "Maven proxy download" \ + curl -sf "$BASE/maven2/org/apache/commons/commons-lang3/3.17.0/commons-lang3-3.17.0.pom" -o /dev/null + +echo "" +echo "--- PyPI ---" +check "PyPI simple index" \ + curl -sf "$BASE/simple/" + +check "PyPI package page" \ + curl -sf "$BASE/simple/requests/" + +echo "" +echo "--- Docker ---" +check "Docker v2 check" \ + curl -sf "$BASE/v2/" + +echo "" +echo "--- Raw ---" +echo "raw-test-data" | curl -sf -X PUT --data-binary @- "$BASE/raw/smoke/test.txt" >/dev/null 2>&1 +check "Raw upload" \ + curl -sf "$BASE/raw/smoke/test.txt" -o /dev/null + +echo "" +echo "--- UI & API ---" +check "UI dashboard loads" \ + curl -sf "$BASE/ui/" + +check "OpenAPI docs" \ + curl -sf "$BASE/api-docs" -o /dev/null + +# Dashboard stats — check npm count > 0 after proxy fetches +sleep 1 +STATS=$(curl -sf "$BASE/ui/api/stats" 2>/dev/null || echo "{}") +NPM_COUNT=$(echo "$STATS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('npm',0))" 2>/dev/null || echo "0") +if [ "$NPM_COUNT" -gt 0 ] 2>/dev/null; then + pass "Dashboard npm count > 0 (got $NPM_COUNT)" +else + fail "Dashboard npm count is $NPM_COUNT, expected > 0" +fi + +echo "" +echo "--- Mirror CLI ---" +# Create a minimal lockfile +LOCKFILE=$(mktemp) +cat > "$LOCKFILE" << 'EOF' +{ + "lockfileVersion": 3, + "packages": { + "": { "name": "test" }, + "node_modules/chalk": { "version": "5.4.1" } + } +} +EOF +MIRROR_RESULT=$("$NORA_BIN" mirror --registry "$BASE" npm --lockfile "$LOCKFILE" 2>&1) +if echo "$MIRROR_RESULT" | grep -q "Failed: 0"; then + pass "nora mirror npm --lockfile (0 failures)" +else + fail "nora mirror: $MIRROR_RESULT" +fi +rm -f "$LOCKFILE" + +echo "" +echo "================================" +echo "Results: $PASSED passed, $FAILED failed" +echo "================================" + +[ "$FAILED" -eq 0 ] && exit 0 || exit 1