| @@ -11,6 +11,7 @@ | |||
| <public> | |||
| <method name="Run"> | |||
| <parameter name="ctx"/> | |||
| <parameter name="applicationName"/> | |||
| <parameter name="statusLine"/> | |||
| <parameter name="contentType"/> | |||
| <parameter name="body"/> | |||
| @@ -25,12 +26,12 @@ Option Explicit | |||
| ' (COM/method failure -> 500) outcomes, and one place logs them. ctx is our | |||
| ' own WscMvc.RequestContext object (not an ASP intrinsic), carrying only | |||
| ' primitive request data. No ASP intrinsics are referenced here. | |||
| Sub Run(ctx, statusLine, contentType, body) | |||
| Sub Run(ctx, applicationName, statusLine, contentType, body) | |||
| Dim ctrl, helloBody, path | |||
| path = ctx.Path | |||
| If path = "/hello" Then | |||
| If applicationName = "production" And path = "/hello" Then | |||
| Set ctrl = Nothing | |||
| On Error Resume Next | |||
| Set ctrl = CreateObject("WscMvc.HomeController") | |||
| @@ -64,7 +65,7 @@ Sub Run(ctx, statusLine, contentType, body) | |||
| contentType = "text/html; charset=utf-8" | |||
| body = helloBody | |||
| Set ctrl = Nothing | |||
| ElseIf path = "/self-test" Then | |||
| ElseIf applicationName = "tests" And path = "/self-test" Then | |||
| Set ctrl = Nothing | |||
| On Error Resume Next | |||
| Set ctrl = CreateObject("WscMvc.SelfTestController") | |||
| @@ -4,11 +4,12 @@ A small, WSC-first MVC framework for Classic ASP on IIS: VBScript Windows Script | |||
| ## Layout | |||
| Two separate IIS sites, sharing one set of framework/controller COM components: | |||
| Two separate IIS sites sharing only the framework components: | |||
| - `public/` — the production site's IIS physical path. Contains only `Default.asp` and `web.config`. | |||
| - `test-app/public/` — a second, separate site exposing `GET /self-test` (JSON test harness), so diagnostics aren't reachable on the production site/port. See `docs/ARCHITECTURE.md`. | |||
| - `Framework/`, `Controllers/` — the actual WSC components (`.wsc`), registered once via COM and used by both sites. Never served over HTTP by either site. | |||
| - `Framework/` — shared WSC components, registered once via COM and used by both sites. | |||
| - `Controllers/` — production-owned controllers; `test-app/Controllers/` — test-app-owned controllers. Neither app can activate the other's routes, including through direct `Default.asp?route=...` requests. None of these source folders is served over HTTP. | |||
| - `logs/`, `test-app/logs/` — each site's own runtime log, written by the app, never served over HTTP. | |||
| - `tests/`, `tools/`, `docs/` — test scripts, deployment/registration tooling, and documentation. Never served over HTTP. | |||
| @@ -26,7 +27,7 @@ Both `Setup-Site.ps1` invocations are idempotent — safe to re-run after any de | |||
| ```powershell | |||
| cscript //nologo tests\Test-Components.vbs # WSH smoke test, no IIS needed | |||
| powershell -File tests\Test-Http.ps1 -BaseUrl http://localhost:8090 # production site | |||
| powershell -File tests\Test-Http.ps1 -BaseUrl http://localhost:8090 -TestBaseUrl http://localhost:8091 | |||
| ``` | |||
| ```bash | |||
| ./tests/run-self-test.sh http://<host>:8091 # test-app site, plain curl+JSON, any CLI | |||
| @@ -28,28 +28,29 @@ Prefer a bootstrap with `Option Explicit`, no business logic, no includes, no em | |||
| Revised 2026-09-19 (explicit user direction, superseding the original v0.1 tree): IIS's site physical path is a `public/` folder **only** — never the project root. Every other project directory is a sibling of the folder actually served, entirely outside the served tree — not reachable by IIS regardless of `web.config`, which is a stronger guarantee than request-filtering rules over a shared directory. `Default.asp` reaches sibling directories (e.g. `logs/`) via a parent-relative `Server.MapPath`, which requires `enableParentPaths=true` for the site. See `docs/DECISIONS.md` for the full rationale and `docs/ARCHITECTURE.md` for how it's wired up. | |||
| Further revised 2026-09-19 (same day, same direction): the diagnostics/test harness (`GET /self-test`) is its own separate IIS site/app (`test-app/`), not a route on the production site. Both sites share the exact same `Framework/`/`Controllers/` COM components (registered once, globally) — nothing about the framework or business logic is duplicated, only the thin per-site `Default.asp`+`web.config` wiring exists twice (and `Default.asp` is intentionally byte-identical in both places; see `docs/DECISIONS.md`). | |||
| Further revised 2026-09-19 (same day, same direction): the diagnostics/test harness (`GET /self-test`) is its own separate IIS site/app (`test-app/`), not a route on the production site. **Only `Framework/` is shared** between the two sites (registered once, globally via COM) — `Controllers/` is *not* shared: it holds application-specific business logic, so production's `Controllers/HomeController.wsc` and the test-app's own `test-app/Controllers/SelfTestController.wsc` are kept apart. Each site also owns its thin `Default.asp` bootstrap and passes its fixed application name into the shared framework, so a direct `Default.asp?route=...` request cannot activate the other app's routes; see `docs/DECISIONS.md`. | |||
| ``` | |||
| WSC-MVC/ | |||
| public/ # production site's IIS physical path | |||
| Default.asp | |||
| Default.asp # selects only production routes | |||
| web.config | |||
| test-app/ | |||
| public/ # test-app site's IIS physical path (separate site/port) | |||
| Default.asp # intentionally identical to public/Default.asp | |||
| Default.asp # selects only test routes | |||
| web.config # only routes /self-test | |||
| Controllers/ | |||
| SelfTestController.wsc # test-app-specific; NOT in the shared Controllers/ below | |||
| logs/ | |||
| app.log # this site's own log, separate from the production one | |||
| Framework/ | |||
| Framework/ # the ONLY folder shared between both sites | |||
| Application.wsc | |||
| Router.wsc # phase 3 | |||
| RequestContext.wsc | |||
| ResponseResult.wsc # phase 2; optional if simpler contract works | |||
| ViewRenderer.wsc # phase 4 | |||
| Controllers/ | |||
| Controllers/ # production's own controllers, not shared with test-app/ | |||
| HomeController.wsc | |||
| SelfTestController.wsc | |||
| Views/ | |||
| Home.html # phase 4 | |||
| Layout.html # phase 4 | |||
| @@ -8,7 +8,7 @@ GET /hello | |||
| -> /Default.asp?route=/hello | |||
| -> Server.CreateObject("WscMvc.RequestContext"); ctx.Initialize path, httpMethod, logDir | |||
| -> Server.CreateObject("WscMvc.Application") | |||
| -> Application.Run(ctx, statusLine, contentType, body) [Framework/Application.wsc] | |||
| -> Application.Run(ctx, "production", statusLine, contentType, body) [Framework/Application.wsc] | |||
| -> CreateObject("WscMvc.HomeController") | |||
| -> HomeController.Hello(body) [Controllers/HomeController.wsc] | |||
| -> LogOutcome ctx, statusLine (best-effort append to logs/app.log) | |||
| @@ -18,9 +18,9 @@ GET /hello | |||
| ## Component boundary contract | |||
| - `Default.asp` is the only file that touches ASP intrinsic objects (`Request`, `Response`, `Server`). It contains no business logic — only reading the `route` query parameter and `REQUEST_METHOD`, resolving `logs/`'s physical path via `Server.MapPath`, invoking `WscMvc.RequestContext` and `WscMvc.Application`, and writing the response. | |||
| - `Framework/RequestContext.wsc`, `Framework/Application.wsc`, and `Controllers/HomeController.wsc` never reference `Request`/`Response`/`Server`/`Session`. All data crosses the ASP-to-WSC and WSC-to-WSC boundaries as either VBScript scalars (strings) or our own `RequestContext` COM object (not an ASP intrinsic) — never an ASP host object. See `docs/DECISIONS.md` for why the original SPEC §15 open question about passing ASP intrinsics into a WSC never needed a direct experiment: the architecture never crosses that boundary by design. | |||
| - `Framework/RequestContext.wsc`, `Framework/Application.wsc`, `Controllers/HomeController.wsc`, and `test-app/Controllers/SelfTestController.wsc` never reference `Request`/`Response`/`Server`/`Session`. All data crosses the ASP-to-WSC and WSC-to-WSC boundaries as either VBScript scalars (strings) or our own `RequestContext` COM object (not an ASP intrinsic) — never an ASP host object. See `docs/DECISIONS.md` for why the original SPEC §15 open question about passing ASP intrinsics into a WSC never needed a direct experiment: the architecture never crosses that boundary by design. | |||
| - WSC public members are exposed as plain `<method>` entries backed by ordinary `Sub`/`Function` procedures, never `<property>`/`Property Get`. VBScript's `Property Get/Let/Set` requires a `Class...End Class` block and cannot appear at a WSC's top-level script scope — confirmed experimentally (see `docs/DECISIONS.md`), not assumed from general WSC documentation. | |||
| - Routing in M1/M2 is still a single hardcoded `If path = "/hello"` check inside `Application.Run`. This is intentionally minimal and will be replaced by the explicit allowlisted route table in M3 (`Router.wsc`); do not extend it ad hoc before that milestone. | |||
| - Routing in M1/M2 is still a minimal hardcoded allowlist inside `Application.Run`. Every call includes a fixed application name (`production` or `tests`) supplied by that app's own bootstrap; a route must match both the application and path. This prevents direct `Default.asp?route=...` requests from crossing between apps. M3 will replace these checks with the explicit route table in `Router.wsc`. | |||
| - `Application.Run` is the single central point that decides expected (404, ordinary control flow) vs. unexpected (COM/method failure, 500) outcomes, and the single point that logs every outcome. `Default.asp` still independently guards its own three sequential calls (`RequestContext` creation, `Initialize`, `Application` creation, `Run`) since a WSC failing to even instantiate happens outside `Application.Run`'s reach. | |||
| ## Per-request lifetime and diagnostics | |||
| @@ -29,13 +29,15 @@ 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 — its own app, same shared framework | |||
| ## Test harness: GET /self-test — its own app, only Framework/ is shared | |||
| `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. | |||
| `test-app/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. | |||
| **This lives on its own IIS site (`test-app/`), not the production site.** Changed 2026-09-19 at Daniel's explicit direction: a diagnostics endpoint permanently reachable on the same site/port as real traffic was the wrong shape. `test-app/public/` is a second, separate IIS site's physical path (own site name, own app pool, own port, own `logs/`), whose `web.config` routes `/self-test` and nothing else. The production site's `web.config` no longer has a `/self-test` rewrite rule at all — `GET /self-test` against the production site now returns a plain `404` (nothing rewrites that path to `Default.asp`). | |||
| **"Own app, same framework" is achieved without duplicating any business logic**: `Application.wsc`'s `Run` method still has (and needs) its `path = "/self-test"` branch — that's shared framework code, registered once globally via COM, used by whichever site's `web.config` chooses to route a URL to it. The only genuinely duplicated file is `Default.asp` itself (also present at `test-app/public/Default.asp`), and only because IIS requires each site to have its own physical files — the bootstrap logic in that file is fully generic (doesn't hardcode routes or reference which site is calling it), so the two copies are intentionally byte-identical. If `Default.asp`'s bootstrap logic ever needs to change, change both copies the same way (see the comment at the top of each file). | |||
| **Only `Framework/` is genuinely shared between the two sites — `Controllers/` is not.** `Application.wsc`/`RequestContext.wsc` in `Framework/` are the reusable dispatch/context engine, registered once globally via COM and used by both sites. `Controllers/` holds *application-specific* business logic: production owns `Controllers/HomeController.wsc`, while the test app owns `test-app/Controllers/SelfTestController.wsc`. COM registration for a `.wsc` records its exact file path, so registration tooling points each controller at its app-owned directory. | |||
| Each site also owns its thin `Default.asp`. The files differ only in the fixed application name passed to the shared `Application.Run`: production passes `"production"`; the test app passes `"tests"`. That selector is part of the route match, so neither ordinary URL Rewrite nor a direct `Default.asp?route=...` request can activate a controller belonging to the other app. `SelfTestController` no longer invokes `/hello` or `HomeController`; its framework checks use the `tests` route set and explicitly verify that `/hello` is rejected. | |||
| The HTTP status is always `200` if the self-test mechanism itself ran (a broken `SelfTestController`/`Application` still degrades to the normal `500` path via the same central error handling); the JSON body's top-level `"ok"` field and each check's `"pass"` field carry the actual test results — conventional health-check design (5xx is reserved for the diagnostics mechanism being broken, not for a failed assertion inside it). | |||
| @@ -44,7 +46,7 @@ curl http://100.127.62.31:8091/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 (verified working directly from the Linux OpenClaw host, not just from the VM) — point it at the test-app site's URL. `tests/Test-Http.ps1` tests the production site only (`/hello`, and confirms `/self-test` is genuinely unreachable there). | |||
| `tests/run-self-test.sh` wraps this in `curl` + `python3 -m json.tool`, runnable from any CLI — point it at the test-app site's URL. It also probes direct `Default.asp?route=/hello` and requires a 404. `tests/Test-Http.ps1` tests production and, when `-TestBaseUrl` is supplied, verifies direct-query isolation in both directions. | |||
| `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 the test-app site should be gated or removed before any production deployment during the M6 hardening pass. | |||
| @@ -55,7 +57,7 @@ curl http://100.127.62.31:8091/self-test | |||
| | `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}` | | |||
| | `test-app/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. | |||
| @@ -72,7 +74,7 @@ Two separate IIS sites now, both set up/reconciled by the same `tools/Setup-Site | |||
| | Routes | `/hello` | `/self-test` | | |||
| | Logs | `C:\Projects\wsc-mvc\logs\app.log` | `C:\Projects\wsc-mvc\test-app\logs\app.log` | | |||
| Both are 64-bit, no managed code, anonymous auth identity `IUSR`. Each site's `public/` contains only `Default.asp` and `web.config`; every other directory (`Framework/`, `Controllers/`, `tests/`, `tools/`, `docs/`, both `logs/` folders) is a sibling of whichever `public/` is actually served, outside both sites' physical paths entirely. Changed 2026-09-19 at Daniel's explicit direction (see `docs/DECISIONS.md`); previously there was one site with the whole project directory as its root and those folders hidden via `hiddenSegments`, and `/self-test` was a route on that same site. | |||
| Both are 64-bit, no managed code, anonymous auth identity `IUSR`. Each site's `public/` contains only `Default.asp` and `web.config`; every other directory (`Framework/`, `Controllers/`, `test-app/Controllers/`, `tests/`, `tools/`, `docs/`, both `logs/` folders) is a sibling of whichever `public/` is actually served, outside both sites' physical paths entirely — see the "only Framework/ is shared" note above for which of those directories are truly cross-site versus app-specific. Changed 2026-09-19 at Daniel's explicit direction (see `docs/DECISIONS.md`); previously there was one site with the whole project directory as its root and those folders hidden via `hiddenSegments`, and `/self-test` was a route on that same site. | |||
| - Each site's `web.config` keeps only a `.wsc/.vbs/.ps1/.md` extension denylist as defense-in-depth (in case a stray file ever lands directly in its `public/`); no `hiddenSegments` are needed since the directories they used to hide no longer exist under either served root at all. | |||
| - `enableParentPaths=true` is set for **both** sites (scoped via `appcmd ... /commit:apphost`, a separate `<location>` block per site in `applicationHost.config` — not a machine-wide unlock of the locked `system.webServer/asp` section) so each site's `Default.asp` can `Server.MapPath("../logs")` to reach its own `logs/`. This setting affects only server-side script `MapPath`/`#include` resolution, not client-supplied URL paths — IIS's own URL normalization independently rejects `..`-traversal in an incoming request regardless of this setting; verified with a direct request attempt, see `docs/DECISIONS.md`. | |||
| @@ -99,3 +99,11 @@ Daniel: "the tests need to be its own app in its own folder but still uses the s | |||
| **Real bug found and fixed while doing this, in my own tooling**: my first attempt to invoke `Setup-Site.ps1 -PhysicalPath "C:\Projects\wsc-mvc\test-app\public"` over a chained SSH/cmd/PowerShell command failed with `New-Website : Parameter 'PhysicalPath' should point to existing path` — nested shell-quoting layers had passed the literal string `'C:\Projects\wsc-mvc\test-app\public'` (with the single quotes as literal characters) as the parameter value. The script's own `Write-Output "Created site..."` line printed unconditionally regardless of whether `New-Website` actually succeeded (it hadn't - `$ErrorActionPreference` was the default `Continue`, so the error was non-fatal and the script kept going, prompting a misleading "success" message followed by a real `IIS:\Sites\WscMvcTests` not-found error on the next line). Fixed two ways: (1) stopped fighting nested quoting and instead wrote the actual invocation into a small script file copied to the VM and run with `-File`, which sidesteps the quoting problem entirely; (2) added `$ErrorActionPreference = 'Stop'` and an explicit `Test-Path $PhysicalPath` pre-check to `Setup-Site.ps1` itself, so a bad path now fails loudly and immediately instead of printing a false-success message and failing confusingly two lines later. Re-verified both sites set up correctly and idempotently after the fix. | |||
| **Verified after the split**: production `GET /hello` → `200`; production `GET /self-test` → `404` (genuinely unreachable now); test-app `GET /self-test` → `200` with correct JSON; test-app `GET /hello` → `404` (not routed there, expected); both sites' `Framework/Application.wsc` → `404`; both sites write to their own separate `logs/app.log` (confirmed distinct file paths, distinct content); re-running `Setup-Site.ps1` for both sites a second time is a true no-op. Full `tests/Test-Components.vbs`, `tests/Test-Http.ps1` (now production-only), and `tests/run-self-test.sh` (now pointed at the test-app site) all pass. | |||
| ## Correction: test-app should only share Framework/, not Controllers/ (2026-09-19) | |||
| Follow-up direction from Daniel right after the previous entry: "Test app should only share /framework folder." The just-shipped version had `SelfTestController.wsc` sitting in the shared root `Controllers/` folder alongside `HomeController.wsc` — meaning both apps' registration tooling pointed into the same `Controllers/` directory, blurring the intended boundary. `Framework/` (`Application.wsc`, `RequestContext.wsc`) is the genuinely reusable dispatch/context engine, meant to be shared; `Controllers/` is *application-specific business logic*, and `SelfTestController` is test-app-specific, not production business logic, so it doesn't belong there. | |||
| **Fix**: moved `Controllers/SelfTestController.wsc` -> `test-app/Controllers/SelfTestController.wsc` and updated registration tooling to reference the app-owned path. Then closed a subtler boundary bypass: removing rewrite rules alone was insufficient because callers can address `Default.asp?route=...` directly. Each app's bootstrap now passes a fixed application name into shared `Application.Run`; the route allowlist matches both application and path. Production therefore cannot activate `/self-test`, and the test app cannot activate `/hello`, even through direct `Default.asp` requests. `SelfTestController` no longer invokes production's `/hello` route or `HomeController`; it verifies that the `tests` route set rejects `/hello`. | |||
| COM registration for a `.wsc` records the exact file path in the registry (`HKLM:\SOFTWARE\Classes\CLSID\{guid}\ScriptletURL`), so moving the file requires re-registration. `tools/Register-Components.ps1` and `tools/Unregister-Components.ps1` now point to `test-app/Controllers/SelfTestController.wsc`; verification includes checking that registry value and exercising both HTTP boundaries. | |||
| @@ -223,3 +223,44 @@ All: **PASS** | |||
| Confirmed the two sites write to genuinely separate log files (`C:\Projects\wsc-mvc\logs\app.log` vs `C:\Projects\wsc-mvc\test-app\logs\app.log`), read back over SSH with distinct content. **PASS** | |||
| Full regression: `tests/Test-Components.vbs` (WSH, unaffected by the site split — doesn't go through IIS), `tests/Test-Http.ps1` (updated to test the production site only: `/hello` plus confirming `/self-test` is genuinely unreachable there), and `tests/run-self-test.sh` (now pointed at the `WscMvcTests` site, `http://100.127.62.31:8091`). All: **PASS**. | |||
| ## Correction: SelfTestController moved to test-app/Controllers/ (2026-09-19) | |||
| Commands run after moving `Controllers/SelfTestController.wsc` -> `test-app/Controllers/SelfTestController.wsc` and updating `tools/Register-Components.ps1`/`Unregister-Components.ps1`: | |||
| ``` | |||
| powershell -File tools\Register-Components.ps1 | |||
| ``` | |||
| Result: **PASS** — registered all four components (`Framework\RequestContext.wsc`, `Framework\Application.wsc`, `Controllers\HomeController.wsc`, `test-app\Controllers\SelfTestController.wsc`), no errors. | |||
| Verified the registry actually updated, not just assumed: read `HKLM:\SOFTWARE\Classes\CLSID\{D2634944-4646-4C55-956E-4C05E7E10904}\ScriptletURL` directly — value is `file:///C:/Projects/wsc-mvc/test-app/Controllers/SelfTestController.wsc`, confirming the re-registration genuinely repointed the CLSID to the new file location. **PASS** | |||
| ``` | |||
| curl http://100.127.62.31:8091/self-test -> 200, correct JSON (unchanged) | |||
| curl http://100.127.62.31:8090/hello -> 200 (unaffected) | |||
| curl http://100.127.62.31:8090/Controllers/SelfTestController.wsc -> 404 (old location, production site) | |||
| curl http://100.127.62.31:8091/Controllers/SelfTestController.wsc -> 404 (new location, test-app site) | |||
| ``` | |||
| All: **PASS** | |||
| The move exposed a direct-entry boundary gap: URL Rewrite isolation alone did not cover `Default.asp?route=...`. Added a fixed application selector to `Application.Run`, made each app-owned bootstrap pass its own selector, removed the self-test's dependency on production `/hello`, and added direct-query regressions. | |||
| Windows Server 2025 / IIS, 64-bit app pools: | |||
| ``` | |||
| cscript //nologo tests\Test-Components.vbs | |||
| powershell -File tests\Test-Http.ps1 -BaseUrl http://localhost:8090 -TestBaseUrl http://localhost:8091 | |||
| ``` | |||
| Both: **PASS**. HTTP boundary probes: | |||
| ``` | |||
| production /hello -> 200 | |||
| production /self-test -> 404 | |||
| production /Default.asp?route=/self-test -> 404 | |||
| test app /self-test -> 200, JSON ok=true | |||
| test app /hello -> 404 | |||
| test app /Default.asp?route=/hello -> 404 | |||
| ``` | |||
| `tests/run-self-test.sh http://100.127.62.31:8091`: **PASS**, including JSON checks and the direct production-route rejection. Both `Setup-Site.ps1` invocations remained idempotent and reported the correct physical paths. Registry inspection showed the SelfTestController `ScriptletURL` at `file:///C:/Projects/wsc-mvc/test-app/Controllers/SelfTestController.wsc`; the old production controller file was absent. Full regression: **PASS**. | |||
| @@ -1,10 +1,11 @@ | |||
| <%@ Language="VBScript" %> | |||
| <% Option Explicit %> | |||
| <% | |||
| Dim route, httpMethod, logDir, ctx, app, statusLine, contentType, body | |||
| Dim route, httpMethod, logDir, ctx, app, applicationName, statusLine, contentType, body | |||
| route = Request.QueryString("route") | |||
| httpMethod = Request.ServerVariables("REQUEST_METHOD") | |||
| applicationName = "production" | |||
| ' logs/ deliberately lives outside the served "public" webroot (IIS site | |||
| ' physical path = public/), so a parent-relative MapPath is required here - | |||
| ' requires enableParentPaths=true for this site. See docs/DECISIONS.md. | |||
| @@ -55,7 +56,7 @@ contentType = "" | |||
| body = "" | |||
| On Error Resume Next | |||
| app.Run ctx, statusLine, contentType, body | |||
| app.Run ctx, applicationName, statusLine, contentType, body | |||
| If Err.Number <> 0 Then | |||
| Err.Clear | |||
| On Error Goto 0 | |||
| @@ -22,12 +22,11 @@ | |||
| </requestFiltering> | |||
| </security> | |||
| <!-- | |||
| No /self-test rewrite rule on this (production) site on purpose: the | |||
| diagnostics endpoint is only exposed on the separate test-app/ site | |||
| (see docs/ARCHITECTURE.md). Application.wsc's Run method still | |||
| technically has a "/self-test" case - that's shared framework code, | |||
| used by whichever site's web.config chooses to route to it - but | |||
| nothing on this site's rewrite rules can ever reach it. | |||
| No /self-test rewrite rule on this production site: diagnostics are | |||
| exposed only by the separate test app. Defense in depth lives in the | |||
| bootstrap too: Default.asp always passes applicationName="production", | |||
| so even a direct Default.asp?route=/self-test request is rejected by | |||
| the shared framework route allowlist. | |||
| --> | |||
| <rewrite> | |||
| <rules> | |||
| @@ -77,41 +77,17 @@ Sub RunSelfTest(logDir, body) | |||
| 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 | |||
| ' --- Shared Application must isolate route sets. The test app must not | |||
| ' activate or test production's HomeController. --- | |||
| Dim app, ctxUnknown, unkStatus, unkType, unkBody, unkOk, unkDetail | |||
| unkOk = False | |||
| unkDetail = "" | |||
| On Error Resume Next | |||
| Set app = CreateObject("WscMvc.Application") | |||
| Set ctxUnknown = CreateObject("WscMvc.RequestContext") | |||
| ctxUnknown.Initialize "/self-test-does-not-exist", "GET", logDir | |||
| ctxUnknown.Initialize "/hello", "GET", logDir | |||
| unkStatus = "" : unkType = "" : unkBody = "" | |||
| app.Run ctxUnknown, unkStatus, unkType, unkBody | |||
| app.Run ctxUnknown, "tests", unkStatus, unkType, unkBody | |||
| If Err.Number <> 0 Then | |||
| unkDetail = Err.Description | |||
| Err.Clear | |||
| @@ -121,12 +97,11 @@ Sub RunSelfTest(logDir, body) | |||
| unkOk = True | |||
| End If | |||
| On Error Goto 0 | |||
| checks = AppendCheck(checks, "application_run_unknown_route", unkOk, unkDetail) | |||
| checks = AppendCheck(checks, "test_app_rejects_production_route", unkOk, unkDetail) | |||
| allPass = allPass And unkOk | |||
| Set ctx1 = Nothing | |||
| Set ctx2 = Nothing | |||
| Set ctxHello = Nothing | |||
| Set ctxUnknown = Nothing | |||
| Set app = Nothing | |||
| @@ -1,17 +1,15 @@ | |||
| <%@ Language="VBScript" %> | |||
| <% Option Explicit %> | |||
| <% | |||
| ' Intentionally byte-identical to /public/Default.asp. This is the test | |||
| ' app's own bootstrap (separate IIS site/physical path from production), | |||
| ' but the bootstrap logic itself is fully generic - it doesn't hardcode | |||
| ' which routes exist (that lives in Framework/Application.wsc, shared by | |||
| ' both sites via COM registration) or which site is calling it. If you | |||
| ' change this file's logic, change public/Default.asp the same way, and | |||
| ' vice versa. See docs/ARCHITECTURE.md for why there are two IIS sites. | |||
| Dim route, httpMethod, logDir, ctx, app, statusLine, contentType, body | |||
| ' This test app owns its bootstrap and explicitly selects only the test route | |||
| ' set in the shared framework. Production's bootstrap selects "production". | |||
| ' Keeping this value inside each app prevents direct Default.asp?route=... | |||
| ' requests from crossing the application boundary. | |||
| Dim route, httpMethod, logDir, ctx, app, applicationName, statusLine, contentType, body | |||
| route = Request.QueryString("route") | |||
| httpMethod = Request.ServerVariables("REQUEST_METHOD") | |||
| applicationName = "tests" | |||
| ' logs/ deliberately lives outside the served "public" webroot (IIS site | |||
| ' physical path = public/), so a parent-relative MapPath is required here - | |||
| ' requires enableParentPaths=true for this site. See docs/DECISIONS.md. | |||
| @@ -62,7 +60,7 @@ contentType = "" | |||
| body = "" | |||
| On Error Resume Next | |||
| app.Run ctx, statusLine, contentType, body | |||
| app.Run ctx, applicationName, statusLine, contentType, body | |||
| If Err.Number <> 0 Then | |||
| Err.Clear | |||
| On Error Goto 0 | |||
| @@ -81,7 +81,7 @@ End If | |||
| If pass Then | |||
| statusLine = "" : contentType = "" : body = "" | |||
| On Error Resume Next | |||
| app.Run ctx1, statusLine, contentType, body | |||
| app.Run ctx1, "production", statusLine, contentType, body | |||
| If Err.Number <> 0 Then | |||
| WScript.Echo "FAIL: Application.Run raised error on /hello - " & Err.Description | |||
| pass = False | |||
| @@ -100,7 +100,7 @@ End If | |||
| If pass Then | |||
| statusLine = "" : contentType = "" : body = "" | |||
| On Error Resume Next | |||
| app.Run ctx2, statusLine, contentType, body | |||
| app.Run ctx2, "production", statusLine, contentType, body | |||
| If Err.Number <> 0 Then | |||
| WScript.Echo "FAIL: Application.Run raised error on unknown route - " & Err.Description | |||
| pass = False | |||
| @@ -1,7 +1,8 @@ | |||
| [CmdletBinding()] | |||
| param( | |||
| [Parameter(Mandatory = $true)] | |||
| [string]$BaseUrl | |||
| [string]$BaseUrl, | |||
| [string]$TestBaseUrl | |||
| ) | |||
| $script:failures = 0 | |||
| @@ -44,6 +45,41 @@ try { | |||
| Report $false "GET /self-test not exposed on production" $_.Exception.Message | |||
| } | |||
| # A caller can request Default.asp directly and supply the internal route | |||
| # query string, bypassing URL Rewrite. The per-app route-set argument must | |||
| # still prevent production from activating the test controller. | |||
| try { | |||
| $directSelfTestResp = Invoke-WebRequest -Uri "$BaseUrl/Default.asp?route=/self-test" -UseBasicParsing | |||
| Report $false "direct Default.asp cannot cross into test routes" "got $($directSelfTestResp.StatusCode)" | |||
| } catch [System.Net.WebException] { | |||
| $webResp = $_.Exception.Response | |||
| if ($webResp) { | |||
| $code = [int]$webResp.StatusCode | |||
| Report ($code -eq 404) "direct Default.asp cannot cross into test routes" "got $code" | |||
| } else { | |||
| Report $false "direct Default.asp cannot cross into test routes" $_.Exception.Message | |||
| } | |||
| } catch { | |||
| Report $false "direct Default.asp cannot cross into test routes" $_.Exception.Message | |||
| } | |||
| if ($TestBaseUrl) { | |||
| try { | |||
| $directHelloResp = Invoke-WebRequest -Uri "$TestBaseUrl/Default.asp?route=/hello" -UseBasicParsing | |||
| Report $false "test app cannot cross into production routes" "got $($directHelloResp.StatusCode)" | |||
| } catch [System.Net.WebException] { | |||
| $webResp = $_.Exception.Response | |||
| if ($webResp) { | |||
| $code = [int]$webResp.StatusCode | |||
| Report ($code -eq 404) "test app cannot cross into production routes" "got $code" | |||
| } else { | |||
| Report $false "test app cannot cross into production routes" $_.Exception.Message | |||
| } | |||
| } catch { | |||
| Report $false "test app cannot cross into production routes" $_.Exception.Message | |||
| } | |||
| } | |||
| # Framework/ is a sibling of the "public" webroot, not a rule-denied | |||
| # subfolder within it - this checks it's genuinely unreachable, not just | |||
| # filtered. | |||
| @@ -14,10 +14,19 @@ 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 | |||
| if [ "$ok" != "true" ]; then | |||
| echo "RESULT: FAILURE" | |||
| exit 1 | |||
| fi | |||
| # URL Rewrite is not the only entry path: Default.asp is directly addressable. | |||
| # Prove this test app still cannot activate a production route through its | |||
| # internal route query string. | |||
| hello_status=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE_URL/Default.asp?route=/hello") | |||
| if [ "$hello_status" != "404" ]; then | |||
| echo "FAIL: test app direct Default.asp activated production route (HTTP $hello_status)" >&2 | |||
| exit 1 | |||
| fi | |||
| echo "PASS: test app rejects production route through direct Default.asp" | |||
| echo "RESULT: ALL PASS" | |||
| @@ -8,10 +8,14 @@ if (-not $ProjectRoot) { | |||
| } | |||
| $components = @( | |||
| # Framework/ is the only thing genuinely shared between the production | |||
| # and test-app sites. Controllers/ is production's own; SelfTestController | |||
| # is test-app-specific and lives under test-app/ instead. See | |||
| # docs/ARCHITECTURE.md. | |||
| (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'), | |||
| (Join-Path $ProjectRoot 'Framework\Application.wsc'), | |||
| (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'), | |||
| (Join-Path $ProjectRoot 'Controllers\SelfTestController.wsc') | |||
| (Join-Path $ProjectRoot 'test-app\Controllers\SelfTestController.wsc') | |||
| ) | |||
| foreach ($path in $components) { | |||
| @@ -15,7 +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 'test-app\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}' } | |||
Powered by TurnKey Linux.