packages feed

slackblocks-3.1.0: bin/http-check

#!/usr/bin/env python3
"""Exercise the actual Haskell HTTP client against a loopback server only."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

ROOT = Path(__file__).resolve().parents[1]


def check(args, project, build):
    cabal = ['cabal', f'--project-dir={project}', f'--builddir={build}']
    subprocess.run([*cabal, 'build', 'transport-check', '--jobs=2'], cwd=args.project, check=True)
    executable = subprocess.check_output([*cabal, 'list-bin', 'transport-check'], cwd=args.project, text=True).strip()
    plan = json.loads((build / 'cache/plan.json').read_text(encoding='utf-8'))
    versions = {entry['pkg-name']: entry['pkg-version'] for entry in plan['install-plan']}
    for name, version in args.pins.items():
        if versions.get(name) != version:
            raise SystemExit(f'HTTP dependency did not resolve {name} =={version}')
    subprocess.run([*cabal, 'freeze'], cwd=args.project, check=True)
    (build / 'resolution.project').write_bytes((project / 'cabal.project').read_bytes())
    (build / 'resolution.freeze').write_bytes((project / 'cabal.project.freeze').read_bytes())
    exercise(executable, args.project)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--project', type=Path, default=ROOT / 'integrations')
    parser.add_argument('--profile', choices=['locked', 'lower', 'latest'], default='latest')
    args = parser.parse_args()
    compiler = subprocess.check_output(['ghc', '--numeric-version'], text=True).strip()
    policy = {}
    if args.profile != 'latest':
        filename = f'http-locked-{compiler}.json' if args.profile == 'locked' else 'http-lower.json'
        policy = json.loads((ROOT / 'dependencies' / filename).read_text(encoding='utf-8'))
        if policy['compiler'] != compiler:
            raise SystemExit('HTTP lower dependency profile requires its minimum compiler')
    args.pins = policy.get('constraints', {})
    scratch = os.environ.get('RUNNER_TEMP')
    base = Path(os.environ.get('SLACKBLOCKS_HASKELL_HTTP_BUILD', ROOT / 'dist-http')).resolve()
    if Path('/Volumes/Repos').is_dir():
        for volume in ('Repos', 'Cache', 'Scratch'):
            if not Path('/Volumes', volume).is_mount():
                raise SystemExit(f'Required external volume unavailable: {volume}')
        scratch = '/Volumes/Scratch'
        if 'SLACKBLOCKS_HASKELL_HTTP_BUILD' not in os.environ:
            base = Path('/Volumes/Cache/slackblocks-haskell/http-checks')
    build = base / compiler / args.profile
    inputs = (args.project / 'cabal.project').read_text(encoding='utf-8').strip()
    if not inputs.startswith('packages: ') or '\n' in inputs:
        raise SystemExit('HTTP consumer must declare only its two explicit package locations')
    packages = [(args.project / path).resolve() for path in inputs.removeprefix('packages: ').split()]
    if len(packages) != 2:
        raise SystemExit('HTTP consumer must resolve exactly its application and slackblocks')
    with tempfile.TemporaryDirectory(prefix='haskell-http-', dir=scratch) as directory:
        project = Path(directory)
        text = 'packages: ' + ' '.join(path.as_posix() for path in packages) + '\n'
        if 'index_state' in policy:
            text += f'index-state: {policy["index_state"]}\n'
        if args.pins:
            text += 'constraints: ' + ', '.join(f'any.{name} =={version}' for name, version in args.pins.items()) + '\n'
        (project / 'cabal.project').write_text(text, encoding='utf-8')
        check(args, project, build)


def exercise(executable, project):
    heading = {'type': 'header', 'text': {'type': 'plain_text', 'text': 'Hello 😀'}}
    metadata = {'opaque': {'type': 'unmodeled', 'integer': 9007199254740993, 'flag': False, 'empty': {}}}
    message = {'channel': 'C_TEST', 'blocks': [heading], 'metadata': metadata,
               'text': '', 'mrkdwn': True,
               'attachments': [{'blocks': [heading], 'color': '#8800ff'}]}
    webhook = {'blocks': [heading], 'metadata': metadata}
    expected = {
        '/chat.postMessage': (message, True, 200, b'{"ok":true}'),
        '/webhook': (webhook, False, 200, b'ok'),
        '/response': ({'blocks': [heading], 'text': '', 'mrkdwn': True, 'replace_original': False, 'response_type': 'in_channel'}, False, 200, b'ok'),
        '/views.open': ({'trigger_id': 'trigger-test', 'view': {'type': 'modal', 'title': heading['text'], 'blocks': [heading]}}, True, 200, b'{"ok":true}'),
        '/views.publish': ({'user_id': 'U_TEST', 'view': {'type': 'home', 'blocks': [heading]}}, True, 200, b'{"ok":true}'),
        '/api-error': (message, True, 200, b'{"ok":false,"error":"invalid_blocks"}'),
        '/malformed': (message, True, 200, b'not json'),
        '/rate-limited': (message, True, 429, b'{"ok":false}'),
        '/server-error': (message, True, 500, b'{"ok":false}'),
        '/redirect': (message, True, 302, b''),
        '/webhook-error': (webhook, False, 200, b'invalid_payload'),
    }
    seen, failures = [], []

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, *args):
            pass

        def do_POST(self):
            try:
                payload, authenticated, status, response = expected[self.path]
                body = self.rfile.read(int(self.headers['Content-Length']))
                assert json.loads(body.decode('utf-8')) == payload, f'{self.path}: payload altered: {body!r}'
                assert self.headers['Content-Type'] == 'application/json; charset=utf-8'
                assert self.headers.get('Authorization') == ('Bearer test-token' if authenticated else None)
                seen.append(self.path)
            except Exception as error:
                failures.append(str(error))
                status, response = 400, b'bad request'
            self.send_response(status)
            self.send_header('Content-Length', str(len(response)))
            if status == 302:
                self.send_header('Location', '/must-not-follow')
            self.end_headers()
            self.wfile.write(response)

    with ThreadingHTTPServer(('127.0.0.1', 0), Handler) as server:
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        try:
            result = subprocess.run([executable, f'http://127.0.0.1:{server.server_port}'], cwd=project, timeout=90)
        finally:
            server.shutdown()
            thread.join()
    if failures or sorted(seen) != sorted(expected):
        raise SystemExit(f'HTTP wire parity failed: {failures}; observed paths: {seen}')
    result.check_returncode()
    print('Actual HTTP bytes: five payload surfaces, exact metadata/false/Unicode, authorization boundaries, six errors, no retries or redirects.')


if __name__ == '__main__':
    main()