diff --git a/Framework/Application.wsc b/Framework/Application.wsc index 124c2ff..4b61bb6 100644 --- a/Framework/Application.wsc +++ b/Framework/Application.wsc @@ -11,6 +11,7 @@ + @@ -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") diff --git a/README.md b/README.md index 94fddac..4eaa684 100644 --- a/README.md +++ b/README.md @@ -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://:8091 # test-app site, plain curl+JSON, any CLI diff --git a/SPEC.md b/SPEC.md index c58cfe2..28a2c8f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 824f100..f758426 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 `` entries backed by ordinary `Sub`/`Function` procedures, never ``/`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 `` 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`. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a63f3ae..9c11419 100644 --- a/docs/DECISIONS.md +++ b/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. diff --git a/docs/TEST-RESULTS.md b/docs/TEST-RESULTS.md index 8c6d9ee..6d2ebc5 100644 --- a/docs/TEST-RESULTS.md +++ b/docs/TEST-RESULTS.md @@ -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**. diff --git a/public/Default.asp b/public/Default.asp index 83e57d9..4e54443 100644 --- a/public/Default.asp +++ b/public/Default.asp @@ -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 diff --git a/public/web.config b/public/web.config index ebeafa5..2020c73 100644 --- a/public/web.config +++ b/public/web.config @@ -22,12 +22,11 @@ diff --git a/Controllers/SelfTestController.wsc b/test-app/Controllers/SelfTestController.wsc similarity index 75% rename from Controllers/SelfTestController.wsc rename to test-app/Controllers/SelfTestController.wsc index 4965919..1d7217b 100644 --- a/Controllers/SelfTestController.wsc +++ b/test-app/Controllers/SelfTestController.wsc @@ -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 diff --git a/test-app/public/Default.asp b/test-app/public/Default.asp index 1a30075..f3ae05c 100644 --- a/test-app/public/Default.asp +++ b/test-app/public/Default.asp @@ -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 diff --git a/tests/Test-Components.vbs b/tests/Test-Components.vbs index 29de555..15edf18 100644 --- a/tests/Test-Components.vbs +++ b/tests/Test-Components.vbs @@ -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 diff --git a/tests/Test-Http.ps1 b/tests/Test-Http.ps1 index 10908df..12b1ce4 100644 --- a/tests/Test-Http.ps1 +++ b/tests/Test-Http.ps1 @@ -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. diff --git a/tests/run-self-test.sh b/tests/run-self-test.sh index b24febd..d9d4a10 100755 --- a/tests/run-self-test.sh +++ b/tests/run-self-test.sh @@ -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" diff --git a/tools/Register-Components.ps1 b/tools/Register-Components.ps1 index d130831..1344087 100644 --- a/tools/Register-Components.ps1 +++ b/tools/Register-Components.ps1 @@ -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) { diff --git a/tools/Unregister-Components.ps1 b/tools/Unregister-Components.ps1 index 8c25c17..0cc2d66 100644 --- a/tools/Unregister-Components.ps1 +++ b/tools/Unregister-Components.ps1 @@ -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}' }