/** * Native Docker CLI Testing * Tests the OCI registry implementation using the actual Docker CLI */ import { expect, tap } from '@git.zone/tstest/tapbundle'; import { tapNodeTools } from '@git.zone/tstest/tapbundle_serverside'; import { SmartRegistry } from '../ts/index.js'; import type { IRequestContext, IResponse, IRegistryConfig } from '../ts/core/interfaces.core.js'; import * as qenv from '@push.rocks/qenv'; import * as http from 'http'; import * as url from 'url'; import * as fs from 'fs'; import * as path from 'path'; const testQenv = new qenv.Qenv('./', './.nogit'); /** * Create a test registry with local token endpoint realm */ async function createDockerTestRegistry(port: number): Promise { const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY'); const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY'); const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT'); const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT'); const config: IRegistryConfig = { storage: { accessKey: s3AccessKey || 'minioadmin', accessSecret: s3SecretKey || 'minioadmin', endpoint: s3Endpoint || 'localhost', port: parseInt(s3Port || '9000', 10), useSsl: false, region: 'us-east-1', bucketName: 'test-registry', }, auth: { jwtSecret: 'test-secret-key', tokenStore: 'memory', npmTokens: { enabled: true, }, ociTokens: { enabled: true, realm: `http://localhost:${port}/v2/token`, service: 'test-registry', }, }, oci: { enabled: true, basePath: '/oci', }, }; const reg = new SmartRegistry(config); await reg.init(); return reg; } /** * Create test tokens for the registry */ async function createDockerTestTokens(reg: SmartRegistry) { const authManager = reg.getAuthManager(); const userId = await authManager.authenticate({ username: 'testuser', password: 'testpass', }); if (!userId) { throw new Error('Failed to authenticate test user'); } // Create OCI token with full access const ociToken = await authManager.createOciToken( userId, ['oci:repository:*:*'], 3600 ); return { ociToken, userId }; } // Test context let registry: SmartRegistry; let server: http.Server; let registryUrl: string; let registryPort: number; let ociToken: string; let testDir: string; let testImageName: string; /** * Create HTTP server wrapper around SmartRegistry * CRITICAL: Always passes rawBody for content-addressable operations (OCI manifests/blobs) * * Docker expects registry at /v2/ but SmartRegistry serves at /oci/v2/ * This wrapper rewrites paths for Docker compatibility * * Also implements a simple /v2/token endpoint for Docker Bearer auth flow */ async function createHttpServer( registryInstance: SmartRegistry, port: number, tokenForAuth: string ): Promise<{ server: http.Server; url: string }> { return new Promise((resolve, reject) => { const httpServer = http.createServer(async (req, res) => { try { // Parse request const parsedUrl = url.parse(req.url || '', true); let pathname = parsedUrl.pathname || '/'; const query = parsedUrl.query; // Handle token endpoint for Docker Bearer auth if (pathname === '/v2/token' || pathname === '/token') { console.log(`[Token Request] ${req.method} ${req.url}`); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ token: tokenForAuth, access_token: tokenForAuth, expires_in: 3600, issued_at: new Date().toISOString(), })); return; } // Log all requests for debugging console.log(`[Registry] ${req.method} ${pathname}`); // Docker expects /v2/ but SmartRegistry serves at /oci/v2/ if (pathname.startsWith('/v2')) { pathname = '/oci' + pathname; } // Read raw body - ALWAYS preserve exact bytes for OCI const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(chunk); } const bodyBuffer = Buffer.concat(chunks); // Parse body based on content type (for non-OCI protocols that need it) let parsedBody: any; if (bodyBuffer.length > 0) { const contentType = req.headers['content-type'] || ''; if (contentType.includes('application/json')) { try { parsedBody = JSON.parse(bodyBuffer.toString('utf-8')); } catch (error) { parsedBody = bodyBuffer; } } else { parsedBody = bodyBuffer; } } // Convert to IRequestContext const context: IRequestContext = { method: req.method || 'GET', path: pathname, headers: req.headers as Record, query: query as Record, body: parsedBody, rawBody: bodyBuffer, }; // Handle request const response: IResponse = await registryInstance.handleRequest(context); console.log(`[Registry] Response: ${response.status} for ${pathname}`); // Convert IResponse to HTTP response res.statusCode = response.status; // Set headers for (const [key, value] of Object.entries(response.headers || {})) { res.setHeader(key, value); } // Send body if (response.body) { if (Buffer.isBuffer(response.body)) { res.end(response.body); } else if (typeof response.body === 'string') { res.end(response.body); } else { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(response.body)); } } else { res.end(); } } catch (error) { console.error('Server error:', error); res.statusCode = 500; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message: String(error) })); } }); httpServer.listen(port, '0.0.0.0', () => { const serverUrl = `http://localhost:${port}`; resolve({ server: httpServer, url: serverUrl }); }); httpServer.on('error', reject); }); } /** * Create a test Dockerfile */ function createTestDockerfile(targetDir: string, content?: string): string { const dockerfilePath = path.join(targetDir, 'Dockerfile'); const dockerfileContent = content || `FROM alpine:latest RUN echo "Hello from SmartRegistry test" > /hello.txt CMD ["cat", "/hello.txt"] `; fs.writeFileSync(dockerfilePath, dockerfileContent, 'utf-8'); return dockerfilePath; } /** * Run Docker command using the main Docker daemon (not rootless) * Rootless Docker runs in its own network namespace and can't access host localhost * * IMPORTANT: DOCKER_HOST env var overrides --context flag, so we must unset it * and explicitly set the socket path to use the main Docker daemon. */ async function runDockerCommand( command: string, cwd?: string ): Promise<{ stdout: string; stderr: string; exitCode: number }> { // First unset DOCKER_HOST then set it to main Docker daemon socket // Using both unset and export ensures we override any inherited env var const dockerCommand = `unset DOCKER_HOST && export DOCKER_HOST=unix:///var/run/docker.sock && ${command}`; const fullCommand = cwd ? `cd "${cwd}" && ${dockerCommand}` : dockerCommand; try { const result = await tapNodeTools.runCommand(fullCommand); return { stdout: result.stdout || '', stderr: result.stderr || '', exitCode: result.exitCode || 0, }; } catch (error: any) { return { stdout: error.stdout || '', stderr: error.stderr || String(error), exitCode: error.exitCode || 1, }; } } /** * Cleanup test directory */ function cleanupTestDir(dir: string): void { if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); } } /** * Cleanup Docker resources */ async function cleanupDocker(imageName: string): Promise { await runDockerCommand(`docker rmi ${imageName} 2>/dev/null || true`); await runDockerCommand(`docker rmi ${imageName}:v1 2>/dev/null || true`); await runDockerCommand(`docker rmi ${imageName}:v2 2>/dev/null || true`); } // ======================================================================== // TESTS // ======================================================================== tap.test('Docker CLI: should verify Docker is installed', async () => { const result = await runDockerCommand('docker version'); console.log('Docker version output:', result.stdout.substring(0, 200)); expect(result.exitCode).toEqual(0); }); tap.test('Docker CLI: should setup registry and HTTP server', async () => { // Use localhost - Docker allows HTTP for localhost without any special config registryPort = 15000 + Math.floor(Math.random() * 1000); console.log(`Using port: ${registryPort}`); registry = await createDockerTestRegistry(registryPort); const tokens = await createDockerTestTokens(registry); ociToken = tokens.ociToken; expect(registry).toBeInstanceOf(SmartRegistry); expect(ociToken).toBeTypeOf('string'); const serverSetup = await createHttpServer(registry, registryPort, ociToken); server = serverSetup.server; registryUrl = serverSetup.url; expect(server).toBeDefined(); console.log(`Registry server started at ${registryUrl}`); // Setup test directory testDir = path.join(process.cwd(), '.nogit', 'test-docker-cli'); cleanupTestDir(testDir); fs.mkdirSync(testDir, { recursive: true }); testImageName = `localhost:${registryPort}/test-image`; }); tap.test('Docker CLI: should verify server is responding', async () => { // Give the server a moment to fully initialize await new Promise(resolve => setTimeout(resolve, 500)); const response = await fetch(`${registryUrl}/oci/v2/`); expect(response.status).toEqual(200); console.log('OCI v2 response:', await response.json()); }); tap.test('Docker CLI: should login to registry', async () => { const result = await runDockerCommand( `echo "${ociToken}" | docker login localhost:${registryPort} -u testuser --password-stdin` ); console.log('docker login output:', result.stdout); console.log('docker login stderr:', result.stderr); const combinedOutput = result.stdout + result.stderr; expect(combinedOutput).toContain('Login Succeeded'); }); tap.test('Docker CLI: should build test image', async () => { createTestDockerfile(testDir); const result = await runDockerCommand( `docker build -t ${testImageName}:v1 .`, testDir ); console.log('docker build output:', result.stdout.substring(0, 500)); expect(result.exitCode).toEqual(0); }); tap.test('Docker CLI: should push image to registry', async () => { // This is the critical test - if the digest mismatch bug is fixed, // this should succeed. The manifest bytes must be preserved exactly. const result = await runDockerCommand(`docker push ${testImageName}:v1`); console.log('docker push output:', result.stdout); console.log('docker push stderr:', result.stderr); expect(result.exitCode).toEqual(0); }); tap.test('Docker CLI: should verify manifest in registry via API', async () => { const response = await fetch(`${registryUrl}/oci/v2/test-image/tags/list`, { headers: { Authorization: `Bearer ${ociToken}` }, }); expect(response.status).toEqual(200); const tagList = await response.json(); console.log('Tags list:', tagList); expect(tagList.name).toEqual('test-image'); expect(tagList.tags).toContain('v1'); }); tap.test('Docker CLI: should pull pushed image', async () => { // First remove the local image await runDockerCommand(`docker rmi ${testImageName}:v1 || true`); const result = await runDockerCommand(`docker pull ${testImageName}:v1`); console.log('docker pull output:', result.stdout); expect(result.exitCode).toEqual(0); }); tap.test('Docker CLI: should run pulled image', async () => { const result = await runDockerCommand(`docker run --rm ${testImageName}:v1`); console.log('docker run output:', result.stdout); expect(result.exitCode).toEqual(0); expect(result.stdout).toContain('Hello from SmartRegistry test'); }); tap.postTask('cleanup docker cli tests', async () => { if (testImageName) { await cleanupDocker(testImageName); } if (server) { await new Promise((resolve) => { server.close(() => resolve()); }); } if (testDir) { cleanupTestDir(testDir); } if (registry) { registry.destroy(); } }); export default tap.start();