ソースを参照

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
master
Bottybotsterson 2週間前
親
コミット
b93d0e7a6d
10個のファイルの変更、292行の追加、1行の削除
  1. +160
    -0
      Controllers/SelfTestController.wsc
  2. +40
    -0
      Framework/Application.wsc
  3. +14
    -0
      docs/ARCHITECTURE.md
  4. +8
    -0
      docs/DECISIONS.md
  5. +24
    -0
      docs/TEST-RESULTS.md
  6. +16
    -0
      tests/Test-Http.ps1
  7. +23
    -0
      tests/run-self-test.sh
  8. +2
    -1
      tools/Register-Components.ps1
  9. +1
    -0
      tools/Unregister-Components.ps1
  10. +4
    -0
      web.config

+ 160
- 0
Controllers/SelfTestController.wsc ファイルの表示

@@ -0,0 +1,160 @@
<?xml version="1.0"?>
<?component error="true" debug="false"?>
<component>
<registration
description="WscMvc SelfTestController"
progid="WscMvc.SelfTestController"
version="1.00"
classid="{D2634944-4646-4C55-956E-4C05E7E10904}">
</registration>

<public>
<method name="RunSelfTest">
<parameter name="logDir"/>
<parameter name="body"/>
</method>
</public>

<script language="VBScript">
<![CDATA[
Option Explicit

' HTTP+JSON test harness, callable as GET /self-test from any CLI (curl,
' Invoke-WebRequest, etc.) - no cscript/PowerShell/WSH access to the VM
' required. Runs the same checks as tests/Test-Components.vbs, in-process,
' against the live registered components. Intentionally returns raw
' Err.Description in failure details: unlike Default.asp's own error
' handling (which must never leak internals to an arbitrary client), this
' route IS the diagnostics surface, so its whole job is to say what broke.
' This is a dev/test-milestone tool, not a production data endpoint -
' revisit whether it should be gated/removed during M6 hardening.
Sub RunSelfTest(logDir, body)
Dim checks, allPass
checks = ""
allPass = True

' --- RequestContext basic contract ---
Dim ctx1, ctx1Ok, ctx1Detail
ctx1Ok = False
ctx1Detail = ""
On Error Resume Next
Set ctx1 = CreateObject("WscMvc.RequestContext")
If Err.Number = 0 Then ctx1.Initialize "/self-test-a", "GET", logDir
If Err.Number <> 0 Then
ctx1Detail = Err.Description
Err.Clear
ElseIf ctx1.Path <> "/self-test-a" Then
ctx1Detail = "Path roundtrip mismatch: got [" & ctx1.Path & "]"
ElseIf ctx1.HttpMethod <> "GET" Then
ctx1Detail = "HttpMethod roundtrip mismatch: got [" & ctx1.HttpMethod & "]"
ElseIf Len(ctx1.CorrelationId) = 0 Then
ctx1Detail = "CorrelationId was empty"
ElseIf ctx1.ElapsedMs() < 0 Then
ctx1Detail = "ElapsedMs was negative"
Else
ctx1Ok = True
End If
On Error Goto 0
checks = AppendCheck(checks, "request_context_contract", ctx1Ok, ctx1Detail)
allPass = allPass And ctx1Ok

' --- Two contexts must not collide on correlation id ---
Dim ctx2, distinctOk, distinctDetail
distinctOk = False
distinctDetail = ""
On Error Resume Next
Set ctx2 = CreateObject("WscMvc.RequestContext")
ctx2.Initialize "/self-test-b", "GET", logDir
If Err.Number <> 0 Then
distinctDetail = Err.Description
Err.Clear
ElseIf ctx1.CorrelationId = ctx2.CorrelationId Then
distinctDetail = "Both contexts produced the same CorrelationId: " & ctx1.CorrelationId
Else
distinctOk = True
End If
On Error Goto 0
checks = AppendCheck(checks, "correlation_id_uniqueness", distinctOk, distinctDetail)
allPass = allPass And distinctOk

' --- Application.Run happy path ---
Dim app, ctxHello, helloStatus, helloType, helloBody, helloOk, helloDetail
helloOk = False
helloDetail = ""
On Error Resume Next
Set app = CreateObject("WscMvc.Application")
Set ctxHello = CreateObject("WscMvc.RequestContext")
ctxHello.Initialize "/hello", "GET", logDir
helloStatus = "" : helloType = "" : helloBody = ""
app.Run ctxHello, helloStatus, helloType, helloBody
If Err.Number <> 0 Then
helloDetail = Err.Description
Err.Clear
ElseIf helloStatus <> "200 OK" Then
helloDetail = "Expected 200 OK, got [" & helloStatus & "]"
ElseIf helloType <> "text/html; charset=utf-8" Then
helloDetail = "Unexpected content type [" & helloType & "]"
ElseIf helloBody <> "Hello from WSC-MVC!" Then
helloDetail = "Unexpected body [" & helloBody & "]"
Else
helloOk = True
End If
On Error Goto 0
checks = AppendCheck(checks, "application_run_hello", helloOk, helloDetail)
allPass = allPass And helloOk

' --- Application.Run unknown route (expected 404, not an error) ---
Dim ctxUnknown, unkStatus, unkType, unkBody, unkOk, unkDetail
unkOk = False
unkDetail = ""
On Error Resume Next
Set ctxUnknown = CreateObject("WscMvc.RequestContext")
ctxUnknown.Initialize "/self-test-does-not-exist", "GET", logDir
unkStatus = "" : unkType = "" : unkBody = ""
app.Run ctxUnknown, unkStatus, unkType, unkBody
If Err.Number <> 0 Then
unkDetail = Err.Description
Err.Clear
ElseIf unkStatus <> "404 Not Found" Then
unkDetail = "Expected 404 Not Found, got [" & unkStatus & "]"
Else
unkOk = True
End If
On Error Goto 0
checks = AppendCheck(checks, "application_run_unknown_route", unkOk, unkDetail)
allPass = allPass And unkOk

Set ctx1 = Nothing
Set ctx2 = Nothing
Set ctxHello = Nothing
Set ctxUnknown = Nothing
Set app = Nothing

body = "{""ok"":" & LCase(CStr(allPass)) & ",""checks"":[" & checks & "]}"
End Sub

Function AppendCheck(checksSoFar, name, pass, detail)
Dim entry
entry = "{""name"":""" & JsonEscape(name) & """,""pass"":" & LCase(CStr(pass)) & _
",""detail"":""" & JsonEscape(detail) & """}"
If Len(checksSoFar) = 0 Then
AppendCheck = entry
Else
AppendCheck = checksSoFar & "," & entry
End If
End Function

Function JsonEscape(s)
Dim result
result = s
result = Replace(result, "\", "\\")
result = Replace(result, """", "\""")
result = Replace(result, vbCrLf, "\n")
result = Replace(result, vbCr, "\n")
result = Replace(result, vbLf, "\n")
result = Replace(result, vbTab, "\t")
JsonEscape = result
End Function
]]>
</script>
</component>

+ 40
- 0
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"


+ 14
- 0
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.



+ 8
- 0
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.

+ 24
- 0
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.

+ 16
- 0
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)"


+ 23
- 0
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 <base-url>, 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

+ 2
- 1
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) {


+ 1
- 0
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}' }


+ 4
- 0
web.config ファイルの表示

@@ -25,6 +25,10 @@
<match url="^hello/?$" />
<action type="Rewrite" url="Default.asp?route=/hello" appendQueryString="false" />
</rule>
<rule name="WscMvc-SelfTest" stopProcessing="true">
<match url="^self-test/?$" />
<action type="Rewrite" url="Default.asp?route=/self-test" appendQueryString="false" />
</rule>
</rules>
</rewrite>
<defaultDocument>


読み込み中…
キャンセル
保存

Powered by TurnKey Linux.