#!/usr/bin/env python3 """Pure starter generation tests; no npm, engine, live paths or network.""" import hashlib import importlib.util import json from pathlib import Path import tempfile import unittest spec = importlib.util.spec_from_file_location('starter', Path(__file__).with_name('create-app-starter.py')) starter = importlib.util.module_from_spec(spec) spec.loader.exec_module(starter) class StarterGeneration(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) self.output = self.root / 'fixture-notes' def test_real_template_generated_with_consistent_identity_and_hashes(self): receipt = starter.generate('fixture-notes', self.output) self.assertEqual(json.loads((self.output / 'package.json').read_text())['name'], 'fixture-notes') manifest = (self.output / 'manifest.yml').read_text() for value in ("id: 'fixture-notes'", 'localhost/fixture-notes:0.1.0'): self.assertIn(value, manifest) self.assertEqual(receipt, json.loads((self.output / 'GENERATION.json').read_text())) self.assertEqual({entry['path'] for entry in receipt['files']}, {str(path.relative_to(self.output)) for path in self.output.rglob('*') if path.is_file() and path.name != 'GENERATION.json'}) self.assertEqual(len(receipt['files']), len({entry['path'] for entry in receipt['files']})) original_lock = json.loads((starter.TEMPLATE / 'package-lock.json').read_text()) generated_lock = json.loads((self.output / 'package-lock.json').read_text()) original_lock['name'] = 'fixture-notes' original_lock['packages']['']['name'] = 'fixture-notes' self.assertEqual(original_lock, generated_lock) for entry in receipt['files']: self.assertEqual(hashlib.sha256((self.output / entry['path']).read_bytes()).hexdigest(), entry['generated_sha256']) self.assertEqual(hashlib.sha256((starter.TEMPLATE / entry['path']).read_bytes()).hexdigest(), entry['template_sha256']) self.assertTrue((self.output / 'src/App.vue').is_file()) self.assertIn('not built', receipt['status']) def test_generated_package_is_locked_nonroot_and_scratch_only(self): import re import yaml receipt = starter.generate('fixture-notes', self.output) paths = {entry['path'] for entry in receipt['files']} required = {'package.json', 'package-lock.json', 'Dockerfile', 'nginx.conf', 'manifest.yml', 'vite.config.ts', 'index.html', 'src/main.ts', 'src/App.vue', 'src/styles.css', 'src/items.mjs'} with self.subTest(contract='required inputs'): self.assertFalse(required - paths, required - paths) package = json.loads((self.output / 'package.json').read_text()) with self.subTest(contract='exact dependency versions'): for version in package['dependencies'].values(): self.assertRegex(version, r'^\d+\.\d+\.\d+$') with self.subTest(contract='lock binding'): self.assertTrue((self.output / 'package-lock.json').is_file()) lock = json.loads((self.output / 'package-lock.json').read_text()) self.assertEqual(lock['lockfileVersion'], 3) for obj in (package, lock, lock['packages']['']): self.assertEqual(obj['name'], 'fixture-notes') self.assertEqual(obj['version'], '0.1.0') self.assertEqual(lock['packages']['']['dependencies'], package['dependencies']) self.assertEqual(lock['packages']['']['devDependencies'], package['devDependencies']) for name, version in package['dependencies'].items(): self.assertEqual(lock['packages']['node_modules/' + name]['version'], version) for name, entry in lock['packages'].items(): if name: self.assertRegex(entry['resolved'], r'^https://registry\.npmjs\.org/') self.assertRegex(entry['integrity'], r'^sha512-') docker = (self.output / 'Dockerfile').read_text() with self.subTest(contract='immutable build recipe'): bases = re.findall(r'^FROM (\S+)', docker, re.M) self.assertEqual(len(bases), 2) for base in bases: self.assertRegex(base, r'^docker\.io/library/(node|nginx)@sha256:[0-9a-f]{64}$') self.assertIn('RUN npm ci --ignore-scripts && npm run build', docker) self.assertNotIn('npm install', docker) self.assertIn('COPY package.json package-lock.json vite.config.ts index.html ./', docker) self.assertIn('COPY src ./src', docker) self.assertIn('COPY --from=build /src/dist /usr/share/nginx/html', docker) self.assertIn('COPY nginx.conf /etc/nginx/nginx.conf', docker) self.assertIn('USER 101:101', docker) self.assertIn('EXPOSE 8080', docker) self.assertIn('ENTRYPOINT ["nginx", "-g", "daemon off;"]', docker) self.assertIn('CMD []', docker) app = yaml.safe_load((self.output / 'manifest.yml').read_text())['app'] with self.subTest(contract='scratch and resource alignment'): self.assertEqual(app['volumes'], [{'type': 'tmpfs', 'target': '/tmp', 'options': ['rw', 'nosuid', 'nodev', 'size=16m']}]) self.assertEqual(app['resources'], {'cpu_limit': 1, 'memory_limit': '128Mi', 'disk_limit': '256Mi'}) self.assertEqual(app['security'], {'capabilities': [], 'readonly_root': True, 'no_new_privileges': True, 'network_policy': 'isolated'}) self.assertEqual(app['ports'], [{'host': 8180, 'container': 8080, 'protocol': 'tcp', 'bind': '127.0.0.1', 'auth': 'gated'}]) self.assertEqual(app['interfaces']['main']['port'], 8180) self.assertEqual(app['health_check']['endpoint'], 'http://127.0.0.1:8080') self.assertEqual(app['health_check']['path'], '/') with self.subTest(contract='nonroot nginx writable paths'): self.assertTrue((self.output / 'nginx.conf').is_file()) nginx = (self.output / 'nginx.conf').read_text() self.assertIn('worker_processes 1;', nginx) self.assertIn('listen 8080;', nginx) self.assertIn('root /usr/share/nginx/html;', nginx) self.assertIn('try_files $uri $uri/ /index.html;', nginx) self.assertIn('error_log /dev/stderr warn;', nginx) self.assertIn('access_log /dev/stdout;', nginx) paths = re.findall(r'(?:pid|\w+_temp_path)\s+(\S+);', nginx) self.assertEqual(len(paths), 6) self.assertTrue(all(path.startswith('/tmp/') for path in paths)) def test_missing_required_package_inputs_refused_before_output(self): import shutil required = ('package.json', 'package-lock.json', 'Dockerfile', 'nginx.conf', 'manifest.yml', 'vite.config.ts', 'index.html', 'src/main.ts', 'src/App.vue', 'src/styles.css', 'src/items.mjs') for index, name in enumerate(required): with self.subTest(missing=name): template = self.root / ('template-' + str(index)) shutil.copytree(starter.TEMPLATE, template) (template / name).unlink() parent = self.root / ('output-' + str(index)) parent.mkdir() output = parent / 'fixture-notes' with self.assertRaisesRegex(ValueError, 'Incomplete template'): starter.generate('fixture-notes', output, template) self.assertFalse(output.exists()) def test_incoherent_package_lock_refused_before_output(self): import shutil mutations = ( ('package.json', ('name',), 'other-app'), ('package-lock.json', ('name',), 'other-app'), ('package-lock.json', ('packages', '', 'name'), 'other-app'), ('package-lock.json', ('version',), '9.9.9'), ('package-lock.json', ('packages', '', 'version'), '9.9.9'), ('package-lock.json', ('lockfileVersion',), 2), ('package-lock.json', ('packages', '', 'dependencies', 'vue'), '9.9.9'), ('package-lock.json', ('packages', '', 'devDependencies'), {'extra': '1.0.0'}), ('package-lock.json', ('packages', 'node_modules/vue', 'version'), '9.9.9'), ('package.json', ('dependencies', 'vue'), '^3.5.43'), ('package-lock.json', ('packages',), []), ) for index, (filename, keys, value) in enumerate(mutations): with self.subTest(filename=filename, keys=keys): template = self.root / ('template-' + str(index)) shutil.copytree(starter.TEMPLATE, template) path = template / filename data = json.loads(path.read_text()) target = data for key in keys[:-1]: target = target[key] target[keys[-1]] = value path.write_text(json.dumps(data)) parent = self.root / ('output-' + str(index)) parent.mkdir() output = parent / 'fixture-notes' with self.assertRaisesRegex(ValueError, 'Incoherent package'): starter.generate('fixture-notes', output, template) self.assertFalse(output.exists()) def test_invalid_ids_do_not_create_output(self): for value in ('', '../app', 'App', '-app', 'app-', 'app--x', 'a\nimage: evil', 'a' * 49, 'archipelago-app-starter'): with self.subTest(value=value), self.assertRaises(ValueError): starter.generate(value, self.output) self.assertFalse(self.output.exists()) def test_yaml_keyword_ids_remain_exact_strings(self): # The contributor preflight also uses PyYAML's safe loader. import yaml for app_id in ('null', 'true', 'false', 'yes', 'no', 'on', 'off'): with self.subTest(app_id=app_id): output = self.root / app_id starter.generate(app_id, output) parsed = yaml.safe_load((output / 'manifest.yml').read_text()) self.assertIsInstance(parsed['app']['id'], str) self.assertEqual(parsed['app']['id'], app_id) self.assertEqual(json.loads((output / 'package.json').read_text())['name'], app_id) lock = json.loads((output / 'package-lock.json').read_text()) self.assertEqual(lock['name'], app_id) self.assertEqual(lock['packages']['']['name'], app_id) def test_existing_output_and_symlink_never_overwritten(self): self.output.mkdir() (self.output / 'sentinel').write_bytes(b'preserve') with self.assertRaises(FileExistsError): starter.generate('fixture-notes', self.output) self.assertEqual((self.output / 'sentinel').read_bytes(), b'preserve') link = self.root / 'link' link.symlink_to(self.output) with self.assertRaises(FileExistsError): starter.generate('fixture-notes', link) self.assertEqual((self.output / 'sentinel').read_bytes(), b'preserve') def test_dirty_or_symlinked_template_refused_before_output(self): template = self.root / 'template' template.mkdir() (template / 'manifest.yml').write_text('app: {}') (template / 'link').symlink_to(template / 'manifest.yml') with self.assertRaises(ValueError): starter.generate('fixture-notes', self.output, template) self.assertFalse(self.output.exists()) def test_empty_cache_directories_are_refused_before_output(self): for name in ('.git', 'node_modules', 'dist'): with self.subTest(name=name), tempfile.TemporaryDirectory(dir=self.root) as temporary: template = Path(temporary) (template / 'manifest.yml').write_text('app: {}') (template / name).mkdir() output = self.root / ('output-' + name.replace('.', '')) / 'fixture-notes' output.parent.mkdir() with self.assertRaises(ValueError): starter.generate('fixture-notes', output, template) self.assertFalse(output.exists()) def test_reserved_receipt_input_refused_before_output(self): template = self.root / 'template' template.mkdir() (template / 'manifest.yml').write_text('app: {}') (template / 'GENERATION.json').write_text('preserve') with self.assertRaises(ValueError): starter.generate('fixture-notes', self.output, template) self.assertFalse(self.output.exists()) self.assertEqual((template / 'GENERATION.json').read_text(), 'preserve') def test_manifest_directory_identity_must_match(self): with self.assertRaises(ValueError): starter.generate('fixture-notes', self.root / 'wrong-name') self.assertFalse((self.root / 'wrong-name').exists()) def test_distinct_generation_receipts_are_deterministic(self): first = starter.generate('fixture-notes', self.output) (self.root / 'second').mkdir() second = starter.generate('fixture-notes', self.root / 'second/fixture-notes') self.assertEqual(first, second) def test_output_inside_template_refused_before_creation(self): with self.assertRaises(ValueError): starter.generate('fixture-notes', starter.TEMPLATE / 'fixture-notes') self.assertFalse((starter.TEMPLATE / 'fixture-notes').exists()) def test_nonregular_input_refused_before_output(self): import os template = self.root / 'template' template.mkdir() (template / 'manifest.yml').write_text('app: {}') os.mkfifo(template / 'pipe') with self.assertRaises(ValueError): starter.generate('fixture-notes', self.output, template) self.assertFalse(self.output.exists()) if __name__ == '__main__': unittest.main()