Files
archy/scripts/test-create-app-starter.py
T

256 lines
14 KiB
Python

#!/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()