Files
ESP32_Serial_Swiss_Army_Knife/tests/web_login_ui/run.py
T
Commander1024 4435a7fddd Add Standalone Login Page Renderer
Add a hash-bound, no-store login document with focused C and Node host
tests. Keep rendering inert until the 8D.3 authentication cutover.
2026-09-05 18:45:12 +02:00

122 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Compile the production renderer; test its C contract and rendered JavaScript."""
import base64
import ctypes as C
import hashlib
from html.parser import HTMLParser
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
STUB = r'''
#include "web_login_ui.h"
#include <string.h>
static int calls, sends, fail_at, send_error, count;
static const char *keys[8], *values[8], *html, *type;
void reset(int fail, int error) {
calls = sends = count = 0; fail_at = fail; send_error = error;
html = type = NULL;
}
int call_count(void) { return calls; }
int send_count(void) { return sends; }
int header_count(void) { return count; }
const char *header_key(int i) { return keys[i]; }
const char *header_value(int i) { return values[i]; }
const char *body(void) { return html; }
const char *content_type(void) { return type; }
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *v) {
(void)r; if (++calls == fail_at) return 73; type = v; return ESP_OK;
}
esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *k, const char *v) {
(void)r; if (++calls == fail_at) return 73;
if (count == 8) return 74;
keys[count] = k; values[count++] = v; return ESP_OK;
}
esp_err_t httpd_resp_send(httpd_req_t *r, const char *v, ssize_t n) {
(void)r; ++sends;
if (n < 0 || (size_t)n != strlen(v)) return 75;
html = v; return send_error;
}
'''
class Assets(HTMLParser):
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
assert 'src' not in attrs and 'srcset' not in attrs, (tag, attrs)
assert tag not in ('link', 'iframe', 'object', 'embed'), tag
assert not any(k.startswith('on') for k in attrs), attrs
def run():
with tempfile.TemporaryDirectory(prefix='web-login-ui-') as directory:
tmp = Path(directory)
(tmp / 'esp_err.h').write_text('#pragma once\ntypedef int esp_err_t;\n'
'#define ESP_OK 0\n#define ESP_ERR_INVALID_ARG 258\n')
(tmp / 'esp_http_server.h').write_text('''#pragma once
#include "esp_err.h"
#include <sys/types.h>
typedef struct { int unused; } httpd_req_t;
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
''')
(tmp / 'stub.c').write_text(STUB)
subprocess.run(shlex.split(os.environ.get('CC', 'cc')) + [
'-std=c11', '-Wall', '-Wextra', '-Werror', '-shared', '-fPIC',
'-I', str(tmp), '-I', str(ROOT / 'src'), str(tmp / 'stub.c'),
str(ROOT / 'src/web_login_ui.c'), '-o', str(tmp / 'renderer.so')], check=True)
lib = C.CDLL(str(tmp / 'renderer.so'))
lib.reset.argtypes = [C.c_int, C.c_int]
lib.web_login_ui_send_response.argtypes = [C.c_void_p]
for name in ('header_key', 'header_value', 'body', 'content_type'):
getattr(lib, name).restype = C.c_char_p
for name in ('header_key', 'header_value'):
getattr(lib, name).argtypes = [C.c_int]
request = C.c_int()
send = lambda: lib.web_login_ui_send_response(C.byref(request))
lib.reset(0, 0)
assert lib.web_login_ui_send_response(None) == 258
assert lib.call_count() == lib.send_count() == 0
assert send() == 0 and lib.send_count() == 1
count, calls = lib.header_count(), lib.call_count()
assert 0 < count <= 8
headers = {lib.header_key(i).decode(): lib.header_value(i).decode() for i in range(count)}
assert len(headers) == count
assert lib.content_type() == b'text/html; charset=utf-8'
html = lib.body().decode()
for key, value in {'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY'}.items():
assert headers[key] == value, (key, headers)
for failure in range(1, calls + 1):
lib.reset(failure, 0)
assert send() == 73 and lib.send_count() == 0
assert lib.call_count() == failure, 'header processing did not stop'
lib.reset(0, 91)
assert send() == 91 and lib.send_count() == 1
print('PASS C: NULL, headers, every setter failure, send error propagation', flush=True)
Assets().feed(html)
assert not re.search(r'url\s*\(|@import', html, re.I)
scripts = re.findall(r'<script>(.*?)</script>', html, re.S)
assert len(scripts) == 1
digest = base64.b64encode(hashlib.sha256(scripts[0].encode()).digest()).decode()
policy = dict(part.strip().split(' ', 1) for part in headers['Content-Security-Policy'].split(';') if part.strip())
expected = {"default-src": "'none'", "script-src": f"'sha256-{digest}'",
"connect-src": "'self'", "base-uri": "'none'", "form-action": "'none'",
"frame-ancestors": "'none'", "style-src": "'unsafe-inline'"}
# Report behavioral failures even when the script and CSP hash drift.
(tmp / 'rendered.json').write_text(json.dumps({'html': html, 'headers': headers, 'script': scripts[0]}))
result = subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], timeout=30)
assert policy == expected, f'CSP mismatch: expected {expected}, got {policy}'
assert result.returncode == 0, 'Node browser tests failed'
print(f'PASS HTML: standalone assets, {count}/8 header slots, exact CSP SHA256 {digest}')
if __name__ == '__main__':
run()