Переглянути джерело

Test harness as its own app (WscMvcTests site), sharing the same framework

At Daniel's explicit direction: /self-test moves off the production site
entirely, onto a second, separate IIS site/app.

- test-app/public/: own site (WscMvcTests, *:8091), own web.config (routes
  only /self-test), own logs/ - genuinely isolated from production traffic
- Default.asp duplicated into test-app/public/ (intentionally byte-
  identical - the bootstrap is fully generic, IIS just requires each site
  to have its own physical files; documented 'keep in sync' in both copies)
- No framework/business-logic duplication: both sites share the exact same
  registered Framework/Controllers COM components. Application.wsc's
  /self-test route case is unchanged; only removed the production site's
  web.config rewrite rule that used to expose it there
- production public/web.config: dropped /self-test rewrite rule; GET
  /self-test on production now plain 404 (nothing routes there)
- tools/Setup-Site.ps1: now automates the logs/ IIS_IUSRS ACL grant
  (previously a manual one-off command), scoped per-site; also hardened
  with ='Stop' + a PhysicalPath pre-check after
  finding it silently continued past a real New-Website failure
- tests/Test-Http.ps1: production-only now (checks /hello + confirms
  /self-test is genuinely unreachable there); tests/run-self-test.sh
  points at the new WscMvcTests site
- Verified both sites end-to-end: correct routes, correct 404s for
  cross-site/Framework access, separate log files, idempotent Setup-Site.ps1
  reruns for both sites
- SPEC.md, docs/ARCHITECTURE.md, docs/DECISIONS.md, docs/TEST-RESULTS.md,
  README.md updated to match; README also brought current after being
  stale since M1
master
Bottybotsterson 2 тижднів тому
джерело
коміт
6196b788fd
10 змінених файлів з 272 додано та 42 видалено
  1. +32
    -11
      README.md
  2. +11
    -3
      SPEC.md
  3. +29
    -12
      docs/ARCHITECTURE.md
  4. +15
    -1
      docs/DECISIONS.md
  5. +26
    -1
      docs/TEST-RESULTS.md
  6. +8
    -4
      public/web.config
  7. +84
    -0
      test-app/public/Default.asp
  8. +36
    -0
      test-app/public/web.config
  9. +13
    -10
      tests/Test-Http.ps1
  10. +18
    -0
      tools/Setup-Site.ps1

+ 32
- 11
README.md Переглянути файл

@@ -1,16 +1,37 @@
# WSC-MVC Agent Starter Pack
# WSC-MVC

This is a *planning and agent-control package*, not yet an executable framework.
A small, WSC-first MVC framework for Classic ASP on IIS: VBScript Windows Script Components registered as COM, dispatched by a single thin `Default.asp` bootstrap. See `SPEC.md` for the full contract, `IMPLEMENTATION_PLAN.md` for milestone status, and `docs/` for architecture, decisions, and test evidence — those are the authoritative, kept-current docs; this file is just an orientation map.

## Files
- `SPEC.md` — authoritative product, architectural, security, milestone and acceptance specification.
- `AGENTS.md` — shared instructions for coding agents.
- `CLAUDE.md` — Claude Code entry point; defers to AGENTS.md.
- `IMPLEMENTATION_PLAN.md` — gated execution checklist.
## Layout

## Start a coding agent
Copy these files to the **root of the new WSC-MVC repository**, then issue:
Two separate IIS sites, sharing one set of framework/controller COM components:

> Read AGENTS.md, SPEC.md, IMPLEMENTATION_PLAN.md. Execute M0 and then the smallest testable piece of M1. Record actual commands/results and do not claim IIS tests passed unless you ran them on Windows IIS.
- `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.
- `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.

Choose the Windows/IIS machine as the execution host for M0/M1 wherever possible. A Linux-only agent can prepare text/config but cannot establish Windows COM/IIS behavior.
## Deploy and register (on the target Windows/IIS host)

```powershell
powershell -File tools\Register-Components.ps1
powershell -File tools\Setup-Site.ps1
powershell -File tools\Setup-Site.ps1 -SiteName WscMvcTests -PoolName WscMvcTests -PhysicalPath <repo>\test-app\public -Port 8091
```

Both `Setup-Site.ps1` invocations are idempotent — safe to re-run after any deploy.

## Test

```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
```
```bash
./tests/run-self-test.sh http://<host>:8091 # test-app site, plain curl+JSON, any CLI
```

## Status

M0–M2 gated PASS with real test evidence on Windows/IIS; see `docs/TEST-RESULTS.md`. Next milestone: M3 (explicit route table) per `IMPLEMENTATION_PLAN.md`.

+ 11
- 3
SPEC.md Переглянути файл

@@ -26,13 +26,21 @@ Prefer a bootstrap with `Option Explicit`, no business logic, no includes, no em

## 5. Target project structure

Revised 2026-09-19 (explicit user direction, superseding the original v0.1 tree): IIS's site physical path is the `public/` folder **only**. Every other project directory is a sibling of `public/`, 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.
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`).

```
WSC-MVC/
public/
public/ # production site's IIS physical path
Default.asp
web.config
test-app/
public/ # test-app site's IIS physical path (separate site/port)
Default.asp # intentionally identical to public/Default.asp
web.config # only routes /self-test
logs/
app.log # this site's own log, separate from the production one
Framework/
Application.wsc
Router.wsc # phase 3
@@ -46,7 +54,7 @@ WSC-MVC/
Home.html # phase 4
Layout.html # phase 4
logs/
app.log
app.log # production site's log
tests/
Test-Components.vbs
Test-Http.ps1


+ 29
- 12
docs/ARCHITECTURE.md Переглянути файл

@@ -29,18 +29,24 @@ 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)
## Test harness: GET /self-test — its own app, same shared framework

`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).
`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).

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).

```
curl http://100.127.62.31:8090/self-test
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 (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.
`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).

`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.
`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.

## COM identity

@@ -53,14 +59,25 @@ curl http://100.127.62.31:8090/self-test

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.

## IIS site (test host: win2025test, 100.127.62.31)
## IIS sites (test host: win2025test, 100.127.62.31)

- Site: `WscMvc`, binding `*:8090`, physical path `C:\Projects\wsc-mvc\public` — **not** the project root. `public/` contains only `Default.asp` and `web.config`; every other directory (`Framework/`, `Controllers/`, `tests/`, `tools/`, `docs/`, `logs/`) is a sibling of `public/`, outside IIS's site physical path entirely. Changed 2026-09-19 at Daniel's explicit direction (see `docs/DECISIONS.md`); previously the site root was the whole project directory with those folders hidden via `hiddenSegments`.
- App pool: `WscMvc`, 64-bit, no managed code. Anonymous authentication identity: `IUSR`.
- `public/web.config` keeps only a `.wsc/.vbs/.ps1/.md` extension denylist as defense-in-depth (in case a stray file ever lands directly in `public/`); no `hiddenSegments` are needed since the directories they used to hide no longer exist under the served root at all.
- `enableParentPaths=true` is set for this site (scoped via `appcmd ... /commit:apphost`, a `<location path="WscMvc">` block in `applicationHost.config` — not a machine-wide unlock of the locked `system.webServer/asp` section) so `Default.asp` can `Server.MapPath("../logs")` to reach `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`.
- `logs/` (`C:\Projects\wsc-mvc\logs`, unchanged physical location from before this restructure) has an explicit, scoped `icacls ... /grant "IIS_IUSRS:(OI)(CI)M"` so classic ASP (impersonating `IUSR` for anonymous requests) can write `app.log`. No other project directory grants `IUSR`/`IIS_IUSRS` write access — verified with a recursive `icacls /T` scan, see `docs/DECISIONS.md`.
- Default document is `Default.asp`; `/hello` and `/self-test` are served via rewrite rules, not the default document.
Two separate IIS sites now, both set up/reconciled by the same `tools/Setup-Site.ps1` (parameterized by name/pool/path/port; idempotent, safe to re-run):

| | Production | Test app |
|---|---|---|
| Site name | `WscMvc` | `WscMvcTests` |
| Port | `*:8090` | `*:8091` |
| Physical path | `C:\Projects\wsc-mvc\public` | `C:\Projects\wsc-mvc\test-app\public` |
| App pool | `WscMvc` | `WscMvcTests` |
| 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.

- 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`.
- Each site's `logs/` has an explicit, scoped `icacls ... /grant "IIS_IUSRS:(OI)(CI)M"` (now automated by `tools/Setup-Site.ps1`, previously a manual one-off command) so classic ASP (impersonating `IUSR` for anonymous requests) can write `app.log`. No other project directory grants `IUSR`/`IIS_IUSRS` write access — verified with a recursive `icacls /T` scan, see `docs/DECISIONS.md`.
- Default document is `Default.asp` on both sites; each site's one route is served via a rewrite rule, not the default document.

## Deferred to later milestones (do not implement early)



+ 15
- 1
docs/DECISIONS.md Переглянути файл

@@ -46,7 +46,7 @@ M1's original approach: IIS `requestFiltering/hiddenSegments` (blocks any URL pa

## Site/port allocation

New dedicated site `WscMvc`, app pool `WscMvc` (64-bit, no managed code), binding `*:8090`, physical path `C:\Projects\wsc-mvc`. Chosen to avoid the existing `*:80` and `*:8080` bindings already in use on this VM.
New dedicated site `WscMvc`, app pool `WscMvc` (64-bit, no managed code), binding `*:8090`. Chosen to avoid the existing `*:80` and `*:8080` bindings already in use on this VM. (Physical path later changed to `C:\Projects\wsc-mvc\public` — see the `public/`-only webroot entry. A second site, `WscMvcTests` on `*:8091`, was added later still — see the "test harness as its own app" entry.)

## M2 — Lifecycle, central error handling, correlation-safe diagnostics (2026-09-19)

@@ -85,3 +85,17 @@ Daniel asked for `Default.asp` to be served out of a `public/` folder with `publ
**Verified, not assumed, that this doesn't open a traversal hole**: `enableParentPaths` only affects server-side `MapPath`/`#include` resolution of literal strings already in the script (here, the hardcoded `"../logs"` - never user input). It has no effect on how IIS resolves an incoming client URL. Confirmed directly: `GET /../Framework/Application.wsc`, `GET /..%2fFramework/Application.wsc`, `GET /..%252fFramework/Application.wsc`, and `GET /../logs/app.log` against the live site all returned `404`/`403` — IIS's own URL normalization and request filtering reject these independently of the ASP-level setting.

**Full regression re-run after the restructure**: `tests/Test-Components.vbs` (WSH), `tests/Test-Http.ps1` (HTTP), and `tests/run-self-test.sh` (curl+JSON) all pass; `/hello` and `/self-test` work correctly; `logs/app.log` confirmed actually being written via the new parent-relative path (not just assumed - read back the full file content over SSH); `GET /Framework/Application.wsc` returns `404` (now because the path genuinely doesn't exist under the site root, not because of a `hiddenSegments` rule).

## Test harness as its own app, sharing the same framework (2026-09-19)

Daniel: "the tests need to be its own app in its own folder but still uses the same framework as the real app." `/self-test` had been a route on the production `WscMvc` site — a permanent diagnostics endpoint reachable on the same port as real traffic, which is the wrong shape once you think about it as "would this be here in production."

**Design**: a second IIS site, `WscMvcTests` (`*:8091`, physical path `test-app/public/`), sharing every `Framework/`/`Controllers/` COM component with production (registered once, globally — COM resolution doesn't care which IIS site's worker process calls `Server.CreateObject`). The only file that exists twice is `Default.asp` (`public/Default.asp` and `test-app/public/Default.asp`), and only because IIS requires each site to have its own physical files, not because the bootstrap logic differs between them — it's fully generic (doesn't hardcode which routes exist or which site is calling it), so the two copies are deliberately byte-identical, each carrying a comment pointing at the other with a "keep in sync" note. Considered an NTFS hardlink to guarantee they can never drift, but that adds real complexity (git doesn't preserve hardlinks across a checkout; a deployment step would need to re-establish it) for a ~40-line file that's designed to almost never change — plain, documented duplication is simpler and matches AGENTS.md's "prefer the simplest viable contracts" guidance.

**What moved where**: `public/web.config` (production) no longer has a `/self-test` rewrite rule — `Application.wsc`'s `Run` method still technically has that route case (shared framework code), but nothing on the production site's `web.config` can ever reach it, so `GET /self-test` against production now just 404s like any other undefined path. `test-app/public/web.config` has only the `/self-test` rule (no `/hello`) since testing `/hello`'s behavior is already covered indirectly — `SelfTestController.RunSelfTest` exercises `Application.Run("/hello", ...)` in-process as one of its own checks.

**Tooling gap closed while doing this**: the `icacls ... /grant "IIS_IUSRS:(OI)(CI)M"` grant on a site's `logs/` folder had been a manual one-off SSH command during earlier milestones, never actually scripted. Adding a second site made this an immediate, concrete problem (a manual step that's easy to forget once there are two sites to set up), so `tools/Setup-Site.ps1` now performs it automatically, idempotently (creates the `logs/` folder if missing, `icacls /grant` is itself idempotent - doesn't duplicate the ACE on repeat runs), scoped to whichever site's own logs folder is being set up (`Join-Path (Split-Path -Parent $PhysicalPath) 'logs'`, so it's always the correct sibling for that specific site).

**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.

+ 26
- 1
docs/TEST-RESULTS.md Переглянути файл

@@ -2,7 +2,7 @@

Host under test: `win2025test` (Tailscale 100.127.62.31), Windows Server 2025 Standard, build 10.0.26100, 64-bit.
Access: SSH (key-based, `Administrator`) from the OpenClaw Linux host, driving `cscript`/`powershell` remotely.
Site: `WscMvc`, binding `*:8090`, physical path `C:\Projects\wsc-mvc\public` (updated 2026-09-19; project root through the M1/M2 sections below), app pool `WscMvc` (64-bit, no managed code).
Sites: `WscMvc` (`*:8090`, physical path `C:\Projects\wsc-mvc\public`; project root through the M1/M2 sections below before the `public/` restructure) and, from the "test harness split" section onward, `WscMvcTests` (`*:8091`, `C:\Projects\wsc-mvc\test-app\public`). Both 64-bit, no managed code.
Date: 2026-09-19.

## M0 — Environment and feasibility
@@ -198,3 +198,28 @@ All three: **PASS**, identical to pre-restructure output (the client-facing cont
`GET /Framework/Application.wsc` -> `404`, now because the path is genuinely absent from the served tree rather than a `hiddenSegments` rule. **PASS**

Path-traversal safety of `enableParentPaths` verified directly, not assumed: `GET /../Framework/Application.wsc`, `GET /..%2fFramework/Application.wsc`, `GET /..%252fFramework/Application.wsc`, and `GET /../logs/app.log` against the live site all returned `404`/`403` — confirms `enableParentPaths` (a server-side script `MapPath` setting) has no effect on how IIS resolves client-supplied URLs. **PASS**

## Test harness split into its own app: `WscMvcTests` site (2026-09-19)

Commands run on `win2025test` after creating `test-app/public/` and adding the `logs/` ACL automation to `tools/Setup-Site.ps1`:

```
powershell -File tools\Setup-Site.ps1
powershell -File tools\Setup-Site.ps1 -SiteName WscMvcTests -PoolName WscMvcTests -PhysicalPath C:\Projects\wsc-mvc\test-app\public -Port 8091
```
Result: **PASS** for both. First run reconciled the existing production site and additionally granted the (previously manual, now automated) `logs/` ACL. Second run created the new `WscMvcTests` site, app pool, and its own `test-app/logs/` with the ACL grant. Re-ran both commands a second time to confirm idempotency: no changes reported either time. **PASS**

One real bug found and fixed in `tools/Setup-Site.ps1` itself while doing this: a bad nested-quoting invocation caused `New-Website` to fail (`Parameter 'PhysicalPath' should point to existing path`) but the script printed "Created site..." anyway and kept going, since `$ErrorActionPreference` defaulted to `Continue`. Fixed the invocation (script file instead of an inline nested-quoted command) and hardened the script itself: added `$ErrorActionPreference = 'Stop'` plus an explicit `Test-Path $PhysicalPath` pre-check, so a bad path now fails loudly immediately rather than printing a misleading success message. Re-verified clean runs after the fix.

```
curl http://100.127.62.31:8090/hello -> 200, unchanged
curl http://100.127.62.31:8090/self-test -> 404 (no longer exposed on production)
curl http://100.127.62.31:8091/self-test -> 200, correct JSON
curl http://100.127.62.31:8091/hello -> 404 (not routed on the test-app site)
curl http://100.127.62.31:8091/Framework/Application.wsc -> 404 (unreachable on this site too)
```
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**.

+ 8
- 4
public/web.config Переглянути файл

@@ -21,16 +21,20 @@
</fileExtensions>
</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.
-->
<rewrite>
<rules>
<rule name="WscMvc-Hello" stopProcessing="true">
<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>


+ 84
- 0
test-app/public/Default.asp Переглянути файл

@@ -0,0 +1,84 @@
<%@ 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

route = Request.QueryString("route")
httpMethod = Request.ServerVariables("REQUEST_METHOD")
' 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.
logDir = Server.MapPath("../logs")

Set ctx = Nothing
On Error Resume Next
Set ctx = Server.CreateObject("WscMvc.RequestContext")
If Err.Number <> 0 Then
Err.Clear
On Error Goto 0
Response.Status = "500 Internal Server Error"
Response.ContentType = "text/plain; charset=utf-8"
Response.Write "Internal Server Error"
Response.End
End If
On Error Goto 0

On Error Resume Next
ctx.Initialize route, httpMethod, logDir
If Err.Number <> 0 Then
Err.Clear
On Error Goto 0
Set ctx = Nothing
Response.Status = "500 Internal Server Error"
Response.ContentType = "text/plain; charset=utf-8"
Response.Write "Internal Server Error"
Response.End
End If
On Error Goto 0

Set app = Nothing
On Error Resume Next
Set app = Server.CreateObject("WscMvc.Application")
If Err.Number <> 0 Then
Err.Clear
On Error Goto 0
Set ctx = Nothing
Response.Status = "500 Internal Server Error"
Response.ContentType = "text/plain; charset=utf-8"
Response.Write "Internal Server Error"
Response.End
End If
On Error Goto 0

statusLine = ""
contentType = ""
body = ""

On Error Resume Next
app.Run ctx, statusLine, contentType, body
If Err.Number <> 0 Then
Err.Clear
On Error Goto 0
Set app = Nothing
Set ctx = Nothing
Response.Status = "500 Internal Server Error"
Response.ContentType = "text/plain; charset=utf-8"
Response.Write "Internal Server Error"
Response.End
End If
On Error Goto 0

Response.Status = statusLine
Response.ContentType = contentType
Response.Write body

Set app = Nothing
Set ctx = Nothing
%>

+ 36
- 0
test-app/public/web.config Переглянути файл

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<!--
Same physical-separation model as the production site's web.config
(see public/web.config and docs/ARCHITECTURE.md): this site's physical
path is test-app/public/ only. Framework/, Controllers/, tests/,
tools/, docs/, and this app's own logs/ (test-app/logs/) are all
outside it.
-->
<security>
<requestFiltering>
<fileExtensions>
<add fileExtension=".wsc" allowed="false" />
<add fileExtension=".vbs" allowed="false" />
<add fileExtension=".ps1" allowed="false" />
<add fileExtension=".md" allowed="false" />
</fileExtensions>
</requestFiltering>
</security>
<rewrite>
<rules>
<rule name="WscMvcTests-SelfTest" stopProcessing="true">
<match url="^self-test/?$" />
<action type="Rewrite" url="Default.asp?route=/self-test" appendQueryString="false" />
</rule>
</rules>
</rewrite>
<defaultDocument>
<files>
<clear />
<add value="Default.asp" />
</files>
</defaultDocument>
</system.webServer>
</configuration>

+ 13
- 10
tests/Test-Http.ps1 Переглянути файл

@@ -25,20 +25,23 @@ 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.
# /self-test lives on the separate test-app/ site now (see docs/ARCHITECTURE.md
# and tests/run-self-test.sh, which is what actually exercises it) - this
# script tests the production app only. Confirm the diagnostics endpoint is
# genuinely NOT exposed here, since that separation is the point.
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 $false "GET /self-test not exposed on production" "got $($selfTestResp.StatusCode)"
} catch [System.Net.WebException] {
$webResp = $_.Exception.Response
if ($webResp) {
$code = [int]$webResp.StatusCode
Report ($code -eq 404) "GET /self-test not exposed on production" "got $code"
} else {
Report $false "GET /self-test not exposed on production" $_.Exception.Message
}
Report ([bool]$selfTestJson.ok) "GET /self-test overall ok" "detail in individual checks above"
} catch {
Report $false "GET /self-test request" $_.Exception.Message
Report $false "GET /self-test not exposed on production" $_.Exception.Message
}

# Framework/ is a sibling of the "public" webroot, not a rule-denied


+ 18
- 0
tools/Setup-Site.ps1 Переглянути файл

@@ -6,6 +6,8 @@ param(
[int]$Port = 8090
)

$ErrorActionPreference = 'Stop'

Import-Module WebAdministration

if (-not $PhysicalPath) {
@@ -15,6 +17,10 @@ if (-not $PhysicalPath) {
$PhysicalPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'public'
}

if (-not (Test-Path $PhysicalPath)) {
throw "PhysicalPath does not exist: $PhysicalPath"
}

if (-not (Test-Path "IIS:\AppPools\$PoolName")) {
New-WebAppPool -Name $PoolName | Out-Null
Set-ItemProperty "IIS:\AppPools\$PoolName" -Name managedRuntimeVersion -Value ''
@@ -48,6 +54,18 @@ $appcmd = "$env:windir\system32\inetsrv\appcmd.exe"
$parentPathsNow = & $appcmd list config $SiteName -section:system.webServer/asp
Write-Output "enableParentPaths config: $parentPathsNow"

# logs/ is a sibling of $PhysicalPath (see Default.asp's Server.MapPath("../logs")).
# Classic ASP impersonates IUSR for anonymous requests on this host - grant it
# (via IIS_IUSRS) write access scoped to exactly this folder, nothing else.
# Idempotent: re-running icacls /grant is safe, it doesn't duplicate the ACE.
$logsPath = Join-Path (Split-Path -Parent $PhysicalPath) 'logs'
if (-not (Test-Path $logsPath)) {
New-Item -ItemType Directory -Path $logsPath | Out-Null
Write-Output "Created $logsPath"
}
& icacls $logsPath /grant "IIS_IUSRS:(OI)(CI)M" | Out-Null
Write-Output "Granted IIS_IUSRS write access on $logsPath"

Start-WebAppPool -Name $PoolName -ErrorAction SilentlyContinue
Start-Website -Name $SiteName -ErrorAction SilentlyContinue



Завантаження…
Відмінити
Зберегти

Powered by TurnKey Linux.