import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { load } from 'js-yaml'; import { describe, expect, it } from 'vitest'; interface ComposeBuild { context?: string; args?: Record; } interface ComposeService { build?: string | ComposeBuild; environment?: Record | string[]; profiles?: string[]; } interface ComposeConfig { services: Record; } const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8'); const composeSource = readFileSync(resolve(process.cwd(), 'docker-compose.yml'), 'utf8'); const compose = load(composeSource) as ComposeConfig; function appService(role: 'learner' | 'ops' | 'server') { const service = compose.services[role]; expect(service, `Compose must define the ${role} service`).toBeDefined(); expect(typeof service.build).toBe('object'); expect(Array.isArray(service.environment)).toBe(false); return service as ComposeService & { build: ComposeBuild; environment: Record; }; } describe('Docker deployment-role contract', () => { it('makes the public deployment role available while Next.js builds the browser bundle', () => { const builder = dockerfile.match( /FROM base AS builder([\s\S]*?)FROM node:22-alpine AS runner/, )?.[1]; expect(builder).toBeDefined(); expect(builder).toContain('ARG NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=learner'); expect(builder).toContain( 'ENV NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=$NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE', ); expect(builder!.indexOf('ENV NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE')).toBeLessThan( builder!.indexOf('RUN pnpm build'), ); }); it('uses the build role as the fail-closed runtime default in the image', () => { const runner = dockerfile.match(/FROM node:22-alpine AS runner([\s\S]*)/)?.[1]; expect(runner).toBeDefined(); expect(runner).toContain('ARG NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=learner'); expect(runner).toContain('ENV OPENMAIC_DEPLOYMENT_ROLE=$NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE'); }); it.each(['learner', 'ops', 'server'] as const)( 'keeps the %s image build and runtime roles identical', (role) => { const service = appService(role); expect(service.build.context).toBe('.'); expect(service.build.args?.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE).toBe(role); expect(service.environment.OPENMAIC_DEPLOYMENT_ROLE).toBe(role); expect(service.environment.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE).toBe(role); }, ); it('does not compile the server-only publishing credential into any role image', () => { expect(composeSource).not.toContain('NEXT_PUBLIC_COURSEWARE_PUBLISH_TOKEN'); }); });