From b93d0e7a6db4daee139c55821174b61f36b13fe2 Mon Sep 17 00:00:00 2001 From: Bottybotsterson Date: Sat, 19 Sep 2026 11:12:15 -0400 Subject: [PATCH] Add frontend-agnostic HTTP+JSON test harness (GET /self-test) - Controllers/SelfTestController.wsc: runs the same checks as tests/Test-Components.vbs in-process, returns JSON ({ok, checks:[{name,pass,detail}]}) - no PowerShell/cscript/SSH needed - Application.wsc routes /self-test to it; always HTTP 200 (pass/fail lives in the JSON body, matching conventional health-check design) - tests/run-self-test.sh: pure curl + python3 wrapper, verified working directly from the Linux host with zero Windows tooling - tests/Test-Http.ps1: now also calls /self-test and folds each check into its own PASS/FAIL output - Verified both the happy path and a genuine failure path (deliberately broke HomeController's registration, confirmed /self-test correctly reported ok:false with the specific failing check pinpointed, then re-registered and confirmed full recovery) - docs updated: ARCHITECTURE.md, DECISIONS.md, TEST-RESULTS.md --- Controllers/SelfTestController.wsc | 160 +++++++++++++++++++++++++++++ Framework/Application.wsc | 40 ++++++++ docs/ARCHITECTURE.md | 14 +++ docs/DECISIONS.md | 8 ++ docs/TEST-RESULTS.md | 24 +++++ tests/Test-Http.ps1 | 16 +++ tests/run-self-test.sh | 23 +++++ tools/Register-Components.ps1 | 3 +- tools/Unregister-Components.ps1 | 1 + web.config | 4 + 10 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 Controllers/SelfTestController.wsc create mode 100755 tests/run-self-test.sh diff --git a/Controllers/SelfTestController.wsc b/Controllers/SelfTestController.wsc new file mode 100644 index 0000000..4965919 --- /dev/null +++ b/Controllers/SelfTestController.wsc @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + diff --git a/Framework/Application.wsc b/Framework/Application.wsc index ab1758b..124c2ff 100644 --- a/Framework/Application.wsc +++ b/Framework/Application.wsc @@ -64,6 +64,46 @@ Sub Run(ctx, statusLine, contentType, body) contentType = "text/html; charset=utf-8" body = helloBody Set ctrl = Nothing + ElseIf path = "/self-test" Then + Set ctrl = Nothing + On Error Resume Next + Set ctrl = CreateObject("WscMvc.SelfTestController") + If Err.Number <> 0 Then + Err.Clear + On Error Goto 0 + statusLine = "500 Internal Server Error" + contentType = "text/plain; charset=utf-8" + body = "Internal Server Error" + LogOutcome ctx, statusLine + Exit Sub + End If + On Error Goto 0 + + Dim selfTestBody + selfTestBody = "" + On Error Resume Next + ctrl.RunSelfTest ctx.LogDir, selfTestBody + If Err.Number <> 0 Then + Err.Clear + On Error Goto 0 + statusLine = "500 Internal Server Error" + contentType = "text/plain; charset=utf-8" + body = "Internal Server Error" + Set ctrl = Nothing + LogOutcome ctx, statusLine + Exit Sub + End If + On Error Goto 0 + + ' Always 200: the self-test HTTP call itself succeeded. Whether the + ' underlying checks passed is reported in the JSON body's "ok" field + ' and per-check "pass" fields, not the HTTP status - this matches + ' conventional health-check endpoint design (5xx is reserved for the + ' diagnostics mechanism itself being broken, handled above). + statusLine = "200 OK" + contentType = "application/json; charset=utf-8" + body = selfTestBody + Set ctrl = Nothing Else ' Expected outcome, not a failure: no matching route yet (M3 adds a real table). statusLine = "404 Not Found" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21f9e89..a35e66e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,6 +29,19 @@ GET /hello - `RequestContext.Initialize` generates a correlation id from `Fix(Timer)` plus `Scripting.FileSystemObject.GetTempName()` — not `Randomize`/`Rnd()`, which was tried first and shown experimentally to collide when two contexts are created within the same clock tick (see `docs/DECISIONS.md`). - `Application.LogOutcome` appends one line per request (timestamp, correlation id, method, path, status line, elapsed ms) to `logs/app.log`, guarded end-to-end by `On Error Resume Next` so a logging failure can never affect the HTTP response. Concurrent writers are serialized with a manual lock-file mutex (`logs/app.log.lock`, via `CreateTextFile(..., OverwriteExisting:=False)`) since no ASP intrinsic locking primitive (`Application.Lock`) is available to code that must not reference ASP intrinsics. The retry budget is deliberately short (see `docs/DECISIONS.md`): logging is explicitly best-effort and may drop lines under heavy concurrency without affecting correctness of the response. +## Test harness: GET /self-test (JSON, frontend-agnostic) + +`Controllers/SelfTestController.wsc` (`WscMvc.SelfTestController`) exposes the same checks as `tests/Test-Components.vbs`, but callable over plain HTTP and returning JSON — no PowerShell, cscript, or SSH access to the VM required. `Application.Run` routes `path = "/self-test"` to it. The HTTP status is always `200` if the self-test mechanism itself ran (a broken `SelfTestController`/`Application` still degrades to the normal `500` path); the JSON body's top-level `"ok"` field and each check's `"pass"` field carry the actual test results — this is conventional health-check design (5xx is reserved for the diagnostics mechanism being broken, not for a failed assertion inside it). + +``` +curl http://100.127.62.31:8090/self-test +{"ok":true,"checks":[{"name":"request_context_contract","pass":true,"detail":""}, ...]} +``` + +`tests/run-self-test.sh` wraps this in `curl` + `python3 -m json.tool`, runnable from any CLI (this was verified working directly from the Linux OpenClaw host, not just from the VM). `tests/Test-Http.ps1` also calls `/self-test` and folds each check into its own PASS/FAIL output, complementing (not duplicating) its own direct `/hello` and denied-`.wsc`-access checks, which `/self-test` cannot observe about itself since those are IIS-layer/`web.config` concerns, not component-layer ones. + +`SelfTestController`'s error details intentionally include raw `Err.Description` text — unlike every other route, which must never leak internals to an arbitrary caller, this route's entire purpose is diagnostics. It is a dev/test-milestone tool; reconsider whether it should be gated or removed before any production deployment during the M6 hardening pass. + ## COM identity | Component | ProgID | CLSID | @@ -36,6 +49,7 @@ GET /hello | `Framework/RequestContext.wsc` | `WscMvc.RequestContext` | `{1C36FA55-34DF-4974-94B9-D657389362B2}` | | `Framework/Application.wsc` | `WscMvc.Application` | `{851C7763-1638-42FE-A166-BF3DD3A96A88}` | | `Controllers/HomeController.wsc` | `WscMvc.HomeController` | `{87488446-60BE-4068-8368-0B709BB68F3F}` | +| `Controllers/SelfTestController.wsc` | `WscMvc.SelfTestController` | `{D2634944-4646-4C55-956E-4C05E7E10904}` | CLSIDs are fixed at creation and must never be recycled for a different component (AGENTS.md). `Application`'s public `Run` signature changed between M1 and M2 (added a leading `ctx` parameter) under the same CLSID; acceptable because this is active pre-release (v0.1) development with exactly one caller (`Default.asp`, updated in lockstep) — not a claim that live interface changes are safe for a published/external client. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2a6c11e..3e3ae93 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -63,3 +63,11 @@ Three real defects were found and fixed while building this, all confirmed exper ## Investigated and resolved: an inherited `IUSR:(F)` ACE appeared under `logs/` While diagnosing the concurrency finding above, `icacls C:\Projects\wsc-mvc\logs` showed an inherited `NT AUTHORITY\IUSR:(I)(F)` (Full Control) entry that wasn't present as an explicit ACE on `C:\Projects\wsc-mvc` or `C:\Projects` themselves. Initially flagged as a possible SPEC §10 "no broad filesystem write privileges" violation (anonymous web identity with Full Control beyond what it needs). Investigated properly rather than left as a guess: a recursive `icacls C:\Projects\wsc-mvc /T` scan shows the `IUSR:(F)` ACE present **only** on `logs/` and files/folders created underneath it during testing (`app.log`, diagnostic marker files, etc.) — confirmed absent on `Framework/`, `Controllers/`, `Default.asp`, `web.config`, `tools/`, `tests/`. Both `C:\Projects` and `C:\Projects\wsc-mvc` carry an inherited `CREATOR OWNER:(I)(OI)(CI)(IO)(F)` ACE (the `(IO)` = inherit-only flag), which is standard NTFS behavior: it doesn't grant CREATOR OWNER anything on the folder itself, but materializes into a concrete Full Control ACE for whichever security principal actually creates each new child file/folder underneath. Since IUSR (impersonated by classic ASP for anonymous requests, per this site's `anonymousAuthentication` config) was the one creating files under `logs/` during testing, it received Full Control on exactly those files it created there — nowhere else. **Resolved, not a defect**: this is expected NTFS ownership semantics, correctly scoped to only the dynamically-created log tree (which is already denied over HTTP via `hiddenSegments`), not a broad grant across the source tree. No remediation needed; no M6 follow-up required for this specific concern. + +## Test harness: HTTP+JSON self-test endpoint (2026-09-19) + +Daniel asked for the test harness to be "frontend agnostic" — runnable via a plain HTTP/API call from any CLI (not tied to PowerShell/cscript, which require either being on the VM or an SSH hop to it). Added `GET /self-test` (`Controllers/SelfTestController.wsc`), which runs the same checks as `tests/Test-Components.vbs` in-process against the live registered components and returns JSON (`{"ok":bool,"checks":[{"name","pass","detail"}, ...]}`). Verified directly from the Linux OpenClaw host with plain `curl` (no SSH, no PowerShell): a passing run, and a genuine failing run (deliberately removed `WscMvc.HomeController`'s registry entries, confirmed `/self-test` correctly reported `"ok":false` with `application_run_hello` pinpointed and a clear detail message, while every other check still correctly reported `true` — then re-registered and confirmed full recovery). + +Design choice: HTTP status is always `200` when the self-test mechanism itself executes; pass/fail lives in the JSON body (`"ok"` and per-check `"pass"`). A `5xx` is reserved for the diagnostics mechanism itself being broken (e.g. `SelfTestController` fails to instantiate), which still flows through `Application.Run`'s existing central error handling unchanged. This mirrors conventional health-check endpoint design and keeps a clean separation between "the test infrastructure is broken" and "a test found a real problem." + +`SelfTestController` is the first component whose failure details intentionally include raw `Err.Description` — everywhere else in this codebase that would violate SPEC §10 ("avoid... raw exception text to clients"), but a diagnostics-only endpoint's entire job is reporting what broke. Flagged in `docs/ARCHITECTURE.md` for reconsideration (gate or remove) before any production deployment, during the M6 hardening pass — not a concern for the current dev/test milestones. diff --git a/docs/TEST-RESULTS.md b/docs/TEST-RESULTS.md index d9386f6..b7f822f 100644 --- a/docs/TEST-RESULTS.md +++ b/docs/TEST-RESULTS.md @@ -149,6 +149,30 @@ Result: **PASS** — `/hello` returns `500` while unregistered and `200` after r - A logging mechanism with guaranteed no-loss-under-load delivery — explicitly deferred to M6 (see `docs/DECISIONS.md`). - Formal 400/405 method-not-allowed handling — still waiting on M3's route table. +## Test harness: GET /self-test (frontend-agnostic HTTP+JSON) + +Requested by Daniel: the harness should be callable "via an api call" with a "json response," runnable from the CLI over plain HTTP, not tied to any specific frontend/tooling. + +Command (from the Linux OpenClaw host, no SSH/PowerShell/cscript involved): +``` +curl http://100.127.62.31:8090/self-test +``` +Result on a healthy deployment: **PASS** +```json +{"ok":true,"checks":[ + {"name":"request_context_contract","pass":true,"detail":""}, + {"name":"correlation_id_uniqueness","pass":true,"detail":""}, + {"name":"application_run_hello","pass":true,"detail":""}, + {"name":"application_run_unknown_route","pass":true,"detail":""} +]} +``` + +Verified the failure-reporting path is real, not just the happy path: deliberately removed `WscMvc.HomeController`'s registry entries, re-ran `curl .../self-test`, got `"ok":false` with `application_run_hello` pinpointed (`"detail":"Expected 200 OK, got [500 Internal Server Error]"`) while the other three checks correctly still reported `true`; confirmed `GET /hello` itself also correctly degraded to `500` at the same time. Re-registered and confirmed both `/self-test` and `/hello` fully recovered. **PASS** + +Also verified `tests/run-self-test.sh` (`curl` + `python3 -m json.tool`, zero Windows tooling dependency) and the updated `tests/Test-Http.ps1` (now folds each `/self-test` check into its own PASS/FAIL output alongside its existing direct `/hello` and denied-`.wsc` checks) both work end to end. **PASS** + +`GET /Controllers/SelfTestController.wsc` (the component's own source file) still correctly denied — `404`, same `hiddenSegments` rule as every other component. **PASS** + ## M2 gate status: **PASS** The SPEC §13 M2 gate ("repeated/concurrent requests and failure cases behave predictably") was run on real Windows/IIS and holds unconditionally for HTTP response correctness. Four real defects were found and fixed while building this milestone (WSC property-syntax limitation, a locale-formatting bug, a `Randomize` collision, and a naive concurrent-logging approach that made things worse before a working lock-file mutex was verified) — see `docs/DECISIONS.md` for full detail on each. Proceeding to M3 (routing) is unblocked. diff --git a/tests/Test-Http.ps1 b/tests/Test-Http.ps1 index 53dc106..90a2dab 100644 --- a/tests/Test-Http.ps1 +++ b/tests/Test-Http.ps1 @@ -25,6 +25,22 @@ try { Report $false "GET /hello request" $_.Exception.Message } +# GET /self-test is the frontend-agnostic JSON harness (Controllers/SelfTestController.wsc) - +# callable from any CLI via plain HTTP, not just PowerShell/cscript on the VM. This exercises +# the same component-level checks as tests/Test-Components.vbs, in-process, over HTTP. +try { + $selfTestResp = Invoke-WebRequest -Uri "$BaseUrl/self-test" -UseBasicParsing + Report ($selfTestResp.StatusCode -eq 200) "GET /self-test status 200" "got $($selfTestResp.StatusCode)" + Report ($selfTestResp.Headers['Content-Type'] -eq 'application/json; charset=utf-8') "GET /self-test content-type" "got $($selfTestResp.Headers['Content-Type'])" + $selfTestJson = $selfTestResp.Content | ConvertFrom-Json + foreach ($check in $selfTestJson.checks) { + Report ([bool]$check.pass) "self-test: $($check.name)" $check.detail + } + Report ([bool]$selfTestJson.ok) "GET /self-test overall ok" "detail in individual checks above" +} catch { + Report $false "GET /self-test request" $_.Exception.Message +} + try { $wscResp = Invoke-WebRequest -Uri "$BaseUrl/Framework/Application.wsc" -UseBasicParsing Report $false "GET /Framework/Application.wsc denied" "got $($wscResp.StatusCode)" diff --git a/tests/run-self-test.sh b/tests/run-self-test.sh new file mode 100755 index 0000000..b24febd --- /dev/null +++ b/tests/run-self-test.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Frontend-agnostic test runner: plain curl + a JSON parser, no PowerShell, +# no cscript, no SSH access to the VM required. Runnable from any CLI that +# can reach the site over HTTP. +# +# Usage: ./run-self-test.sh http://100.127.62.31:8090 +set -euo pipefail + +BASE_URL="${1:?Usage: run-self-test.sh , e.g. http://100.127.62.31:8090}" + +response=$(curl -sf "$BASE_URL/self-test") + +echo "$response" | python3 -m json.tool + +ok=$(echo "$response" | python3 -c 'import json, sys; print("true" if json.load(sys.stdin)["ok"] else "false")') + +if [ "$ok" = "true" ]; then + echo "RESULT: ALL PASS" + exit 0 +else + echo "RESULT: FAILURE" + exit 1 +fi diff --git a/tools/Register-Components.ps1 b/tools/Register-Components.ps1 index a9409a8..d130831 100644 --- a/tools/Register-Components.ps1 +++ b/tools/Register-Components.ps1 @@ -10,7 +10,8 @@ if (-not $ProjectRoot) { $components = @( (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'), (Join-Path $ProjectRoot 'Framework\Application.wsc'), - (Join-Path $ProjectRoot 'Controllers\HomeController.wsc') + (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'), + (Join-Path $ProjectRoot 'Controllers\SelfTestController.wsc') ) foreach ($path in $components) { diff --git a/tools/Unregister-Components.ps1 b/tools/Unregister-Components.ps1 index 7f40056..8c25c17 100644 --- a/tools/Unregister-Components.ps1 +++ b/tools/Unregister-Components.ps1 @@ -15,6 +15,7 @@ if (-not $ProjectRoot) { # can never be touched even if it happened to reuse a ProgID string. $components = @( + @{ Path = (Join-Path $ProjectRoot 'Controllers\SelfTestController.wsc'); ProgId = 'WscMvc.SelfTestController'; ClassId = '{D2634944-4646-4C55-956E-4C05E7E10904}' }, @{ Path = (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'); ProgId = 'WscMvc.HomeController'; ClassId = '{87488446-60BE-4068-8368-0B709BB68F3F}' }, @{ Path = (Join-Path $ProjectRoot 'Framework\Application.wsc'); ProgId = 'WscMvc.Application'; ClassId = '{851C7763-1638-42FE-A166-BF3DD3A96A88}' }, @{ Path = (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'); ProgId = 'WscMvc.RequestContext'; ClassId = '{1C36FA55-34DF-4974-94B9-D657389362B2}' } diff --git a/web.config b/web.config index 21f2ca1..d34d4c8 100644 --- a/web.config +++ b/web.config @@ -25,6 +25,10 @@ + + + +