diff --git a/Controllers/HomeController.wsc b/Controllers/HomeController.wsc index c73e8ec..1890f72 100644 --- a/Controllers/HomeController.wsc +++ b/Controllers/HomeController.wsc @@ -10,7 +10,8 @@ - + + @@ -18,8 +19,13 @@ diff --git a/Framework/Application.wsc b/Framework/Application.wsc index ea67773..afb3b51 100644 --- a/Framework/Application.wsc +++ b/Framework/Application.wsc @@ -12,6 +12,7 @@ + @@ -27,7 +28,7 @@ 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, applicationName, statusLine, contentType, body, allowHeader) +Sub Run(ctx, applicationName, viewsDir, statusLine, contentType, body, allowHeader) Dim router, handlerKey statusLine = "" @@ -69,7 +70,7 @@ Sub Run(ctx, applicationName, statusLine, contentType, body, allowHeader) Select Case handlerKey Case "Home.Hello" - RunHomeHello ctx, statusLine, contentType, body, allowHeader + RunHomeHello ctx, viewsDir, statusLine, contentType, body, allowHeader Case "SelfTest.RunSelfTest" RunSelfTest ctx, statusLine, contentType, body, allowHeader Case Else @@ -79,8 +80,8 @@ Sub Run(ctx, applicationName, statusLine, contentType, body, allowHeader) LogOutcome ctx, statusLine End Sub -Sub RunHomeHello(ctx, statusLine, contentType, body, allowHeader) - Dim ctrl, helloBody +Sub RunHomeHello(ctx, viewsDir, statusLine, contentType, body, allowHeader) + Dim ctrl, viewName, message, data, renderer, renderedBody Set ctrl = Nothing On Error Resume Next @@ -93,9 +94,9 @@ Sub RunHomeHello(ctx, statusLine, contentType, body, allowHeader) End If On Error Goto 0 - helloBody = "" + viewName = "" : message = "" On Error Resume Next - ctrl.Hello helloBody + ctrl.Hello viewName, message If Err.Number <> 0 Then Err.Clear On Error Goto 0 @@ -104,12 +105,32 @@ Sub RunHomeHello(ctx, statusLine, contentType, body, allowHeader) Exit Sub End If On Error Goto 0 + Set ctrl = Nothing + + Set data = Nothing + Set renderer = Nothing + On Error Resume Next + Set data = CreateObject("Scripting.Dictionary") + data.Add "Message", message + Set renderer = CreateObject("WscMvc.ViewRenderer") + renderedBody = "" + renderer.Render viewsDir, viewName, data, renderedBody + If Err.Number <> 0 Then + Err.Clear + On Error Goto 0 + Set data = Nothing + Set renderer = Nothing + InternalServerError statusLine, contentType, body, allowHeader + Exit Sub + End If + On Error Goto 0 + Set data = Nothing + Set renderer = Nothing statusLine = "200 OK" contentType = "text/html; charset=utf-8" - body = helloBody + body = renderedBody allowHeader = "" - Set ctrl = Nothing End Sub Sub RunSelfTest(ctx, statusLine, contentType, body, allowHeader) diff --git a/Framework/ViewRenderer.wsc b/Framework/ViewRenderer.wsc new file mode 100644 index 0000000..0eb590e --- /dev/null +++ b/Framework/ViewRenderer.wsc @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 0e72bb4..f05cfb2 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -32,9 +32,9 @@ Gate: repeated/concurrent requests and failure cases behave predictably. **MET** Gate: route matrix and malformed URL tests pass. **MET** — see `docs/TEST-RESULTS.md`. WSH covers full Router/Application routing contract; IIS HTTP covers GET/POST-routed behavior and malformed route handling. PUT/DELETE can be intercepted by IIS before Classic ASP on the tested host, so those are not treated as framework HTTP-route assertions. ## M4 — Views -- [ ] Safe separate template loading, text-context encoding, default-deny for private templates. -- [ ] Template missing/escaping tests; add layout after renderer works. -Gate: output is deterministic and untrusted text does not become HTML. +- [x] Safe separate template loading, text-context encoding, default-deny for private templates. +- [x] Template missing/escaping tests; add layout after renderer works. +Gate: output is deterministic and untrusted text does not become HTML. **MET** for the vertical slice shipped — see `docs/TEST-RESULTS.md`. `Views/Layout.html` (a layout convention) is not yet added since there is still only one view; add it when a second view needs shared chrome, not speculatively. ## M5 — Data - [ ] ADODB contract and provider-specific integration test database. diff --git a/Views/Home.html b/Views/Home.html new file mode 100644 index 0000000..599537d --- /dev/null +++ b/Views/Home.html @@ -0,0 +1 @@ +{{Message}} \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 38d4d0b..8230eeb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,7 +12,9 @@ GET / or GET /hello -> CreateObject("WscMvc.Router") -> Router.Match(ctx, "production", ..., handlerKey) [Framework/Router.wsc] -> CreateObject("WscMvc.HomeController") - -> HomeController.Hello(body) [Controllers/HomeController.wsc] + -> HomeController.Hello(viewName, message) [Controllers/HomeController.wsc] + -> CreateObject("WscMvc.ViewRenderer") + -> ViewRenderer.Render(viewsDir, viewName, data, body) [Framework/ViewRenderer.wsc] -> LogOutcome ctx, statusLine (best-effort append to logs/app.log) -> Default.asp sets Response.Status/ContentType, optional Allow header, writes body ``` @@ -60,6 +62,7 @@ curl http://100.127.62.31:8091/self-test | `Framework/RequestContext.wsc` | `WscMvc.RequestContext` | `{1C36FA55-34DF-4974-94B9-D657389362B2}` | | `Framework/Router.wsc` | `WscMvc.Router` | `{C92F9338-B478-4EAD-B865-892FFB1E1C51}` | | `Framework/Application.wsc` | `WscMvc.Application` | `{851C7763-1638-42FE-A166-BF3DD3A96A88}` | +| `Framework/ViewRenderer.wsc` | `WscMvc.ViewRenderer` | `{4948DF84-5DC6-448A-9F1B-EB596C28842B}` | | `Controllers/HomeController.wsc` | `WscMvc.HomeController` | `{87488446-60BE-4068-8368-0B709BB68F3F}` | | `test-app/Controllers/SelfTestController.wsc` | `WscMvc.SelfTestController` | `{D2634944-4646-4C55-956E-4C05E7E10904}` | @@ -85,6 +88,16 @@ Both are 64-bit, no managed code, anonymous auth identity `IUSR`. Each site's `p - 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. +## Views (M4) + +`Framework/ViewRenderer.wsc` (`WscMvc.ViewRenderer`) loads `Views/.html` and substitutes `{{Key}}` placeholders from a `Scripting.Dictionary` built by `Application.wsc`'s handler subs (e.g. `RunHomeHello`) from a controller's plain scalar output — controllers (`HomeController.Hello(viewName, message)`) never build the Dictionary or touch rendering themselves, keeping them primitive-only like the rest of the request-data boundary. `viewsDir` is resolved by each `Default.asp` via `Server.MapPath("../Views")` (same parent-relative pattern as `logDir`) and threaded through `Application.Run`'s new `viewsDir` parameter — `ViewRenderer.wsc` itself never references ASP intrinsics. + +`Views/` is a sibling of `public/`, exactly like `Framework/`/`Controllers/`, so it is unreachable over HTTP by the same physical-separation guarantee (verified: `GET /Views/Home.html` → `404`) — no additional deny rule was needed. + +Substitution is a single deterministic left-to-right scan; template content is never executed as VBScript or evaluated as an expression (SPEC §8). Placeholder values are HTML-encoded (`&`, `<`, `>`, `"`, `'`) for text context only — **attribute/URL/JS-context encoding is not implemented**; this is a deliberate scope limit (SPEC §8 requires "separate, explicit handling" per context, and no current view needs anything but text context), not an oversight. An unterminated `{{`, a placeholder with no matching dictionary key, an invalid view name, or a missing template file are all safe errors (`Err.Raise` with a generic, path-free message) that flow into `Application.wsc`'s existing central 500 handling, which already discards `Err.Description` entirely. + +`Views/Home.html` is exactly `{{Message}}`; `HomeController` supplies `message = "Hello from WSC-MVC!"`, a string with no HTML-special characters, so `/hello`'s response body is byte-identical to the pre-M4 (M1-era) contract — the rendering pipeline was proven end-to-end without changing observable behavior. `Views/Layout.html` and a layout convention are deferred until a second view actually needs shared chrome (IMPLEMENTATION_PLAN M4 explicitly sequences "add layout after renderer works"). + ## Deferred to later milestones (do not implement early) -Per SPEC §3 non-goals and IMPLEMENTATION_PLAN M4+: HTML views/templates, ADODB, auth. A more robust logging mechanism (if complete coverage under concurrent load is ever required) is deferred to M6 — see the concurrency finding in `docs/DECISIONS.md`. +Per SPEC §3 non-goals and IMPLEMENTATION_PLAN M5+: ADODB, auth. A more robust logging mechanism (if complete coverage under concurrent load is ever required) is deferred to M6 — see the concurrency finding in `docs/DECISIONS.md`. A layout convention (`Views/Layout.html`) and non-text-context encoding are deferred until a concrete view needs them — see the Views section above. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a226faa..1d8d6a3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -117,3 +117,13 @@ Route path validation is intentionally conservative for M3. ASP/IIS has already The response contract gained an `allowHeader` out parameter from `Application.Run`. `Default.asp` is still the only layer that touches ASP `Response`; it emits the `Allow` header only when the framework returns one. This keeps routing/framework code ASP-intrinsic-free while still allowing correct HTTP behavior for app-routed `405 Method Not Allowed` responses. IIS/Classic ASP on the local Windows 11 test host intercepted `PUT`/`DELETE` requests before they reached the application, returning IIS's own `Allow: GET, HEAD, OPTIONS, TRACE`. Therefore HTTP integration tests assert framework 405 behavior using `POST /hello`, which Classic ASP does deliver to the app and which returns the framework's `Allow: GET`. The full Router/Application unsupported-method contract, including `DELETE /self-test -> Allow: GET, POST`, is covered by WSH component tests and the self-test controller's direct `Router.Match` checks. This matches the M3 scope: support GET and POST initially; distinguish unsupported methods where the request reaches the framework. + +## M4 — Views: ViewRenderer, controller/render split (2026-09-19) + +Added `Framework/ViewRenderer.wsc` (`WscMvc.ViewRenderer`) and `Views/Home.html`. Design choice: controllers (`HomeController.Hello`) stay primitive-only (return `viewName`, `message` as plain strings) and `Application.wsc`'s handler subs own building the `Scripting.Dictionary` and calling `ViewRenderer.Render` — matches the existing pattern of keeping business-logic components decoupled from any object more complex than what they strictly need to hand back, and avoids deciding a controller-to-Dictionary contract before a second controller actually needs one. `viewsDir` is threaded through as a new `Application.Run` parameter (not through `RequestContext`, which would have cascaded into every `ctx.Initialize` call site across both `Default.asp` files, `SelfTestController.wsc` (5 call sites), and the WSH tests, for a value that is pure Framework wiring rather than genuine per-request data) — smallest-footprint choice given `Application.Run`'s signature is already established as changeable pre-release (see the M2/M3 CLSID-stability note above). + +`/hello`'s existing exact-body contract (`"Hello from WSC-MVC!"`, fixed since M1/SPEC §4) was deliberately preserved rather than changed to prove the render pipeline: `Views/Home.html` is exactly `{{Message}}`, and the message string has no HTML-special characters, so HTML-encoding is a no-op and the response is byte-identical pre- and post-M4 — verified directly (`Content-Length: 19` unchanged) rather than assumed. This let the M1 acceptance test double as a live M4 regression check instead of requiring a new observable behavior to prove the slice works. + +No experimental surprises this milestone (unlike M0-M3, which each surfaced a real WSC/VBScript defect) — returning a newly-created object through a `Sub`'s `ByRef` out-parameter (`ctrl.Hello viewName, message`, both scalars in this case, but the same mechanism was also exercised for `Set data = CreateObject(...)` inside `RunHomeHello`) behaved exactly as ordinary VBScript `ByRef` semantics predict, and `Err.Raise` inside a WSC method, caught by the caller's existing `On Error Resume Next` + `Err.Number` pattern, worked identically to the naturally-thrown errors (`CreateObject` failures) already relied on elsewhere. Confirmed via the WSH suite's dedicated `ViewRenderer` contract tests (encoding, missing template, unresolved placeholder, invalid view name) before trusting it in `Application.wsc`. + +Confirmed `Views/` is unreachable over HTTP purely from being a sibling of `public/` (same guarantee already used for `Framework/`/`Controllers/`) — no new `web.config` rule was needed: `GET /Views/Home.html` → `404` on both sites. diff --git a/docs/TEST-RESULTS.md b/docs/TEST-RESULTS.md index 1ab7a7a..ecbb839 100644 --- a/docs/TEST-RESULTS.md +++ b/docs/TEST-RESULTS.md @@ -354,3 +354,68 @@ Result: **PASS** — `ok=true`, checks included `request_context_contract`, `cor `tests/run-self-test.sh` local note: first failed under local bash because the Windows checkout had CRLF line endings in the `.sh` file (`set: pipefail\r: invalid option name`). Added `.gitattributes` (`*.sh text eol=lf`) and normalized the script. After that, local WSL/bash still could not reach Windows IIS on `localhost:8091`/gateway IP from this environment (`curl: (7) Failed to connect`), so the bash wrapper is **NOT RUN/PASS locally**. The same HTTP JSON contract was verified with PowerShell above; the bash wrapper remains intended for a CLI that can reach the test-app URL, as in earlier VM/tailnet evidence. M3 gate status: **PASS** for framework and IIS GET/POST behavior. PUT/DELETE HTTP probes are not used as app-route assertions on this host because IIS intercepts them before Classic ASP; WSH/self-test Router checks cover the unsupported-method framework contract directly. + +## M4 — Views: separate templates, text-context encoding, default-deny (2026-09-19) + +Host under test: `DESKTOP-80D128R`, Microsoft Windows 11 Pro 10.0.26200, 64-bit — same local IIS sites as M3 (`WscMvc` on `:8090`, `WscMvcTests` on `:8091`), already running from this checkout. + +Added `Framework/ViewRenderer.wsc` (`WscMvc.ViewRenderer`, `{4948DF84-5DC6-448A-9F1B-EB596C28842B}`) and `Views/Home.html` (`{{Message}}`). `Controllers/HomeController.wsc`'s `Hello` now returns scalar view data (`viewName`, `message`) instead of a finished body string; `Framework/Application.wsc`'s `Run` gained a `viewsDir` parameter and `RunHomeHello` now builds a `Scripting.Dictionary` and calls `ViewRenderer.Render`. Both `Default.asp` files resolve `viewsDir = Server.MapPath("../Views")`, the same parent-relative pattern already used for `logDir`. + +Test-first check before implementation: + +``` +cscript //nologo tests\Test-Components.vbs +``` + +Result before M4 implementation: **FAIL**, as expected for the new contract: + +``` +FAIL: Application.Run raised error on /hello - Wrong number of arguments or invalid property assignment +FAIL: could not create WscMvc.ViewRenderer - ActiveX component can't create object +RESULT: FAILURE +``` + +Registration after implementation: + +``` +powershell -ExecutionPolicy Bypass -File tools\Register-Components.ps1 +``` + +Result: **PASS** — registered `RequestContext`, `Router`, new `ViewRenderer`, `Application`, `HomeController`, `SelfTestController`. + +WSH component contract: + +``` +cscript //nologo tests\Test-Components.vbs +``` + +Result: **PASS** (full output, ViewRenderer-specific lines): + +``` +PASS: Application.Run(/hello) body [unchanged: "Hello from WSC-MVC!" — regression proof the render pipeline preserves M1's exact-body contract] +PASS: ViewRenderer HTML-encodes placeholder value +PASS: ViewRenderer passes through template with no placeholders (data=Nothing) +PASS: ViewRenderer.Render on missing template raises a path-free error +PASS: ViewRenderer.Render raises on unresolved placeholder +PASS: ViewRenderer.Render rejects an invalid view name +RESULT: ALL PASS +``` + +HTTP integration: + +``` +powershell -ExecutionPolicy Bypass -File tests\Test-Http.ps1 -BaseUrl http://localhost:8090 -TestBaseUrl http://localhost:8091 +``` + +Result: **PASS**, all 14 existing checks including `GET /hello body` (unchanged) and `GET /Framework/Application.wsc unreachable`. + +Direct verification that `Views/` is unreachable over HTTP (physical separation, same guarantee as `Framework/`/`Controllers/` — `Views/` is a sibling of `public/`, never inside it): + +``` +curl -i http://localhost:8090/hello -> 200, "Hello from WSC-MVC!" (Content-Length: 19, unchanged from pre-M4) +curl -o /dev/null -w "%{http_code}" http://localhost:8090/Views/Home.html -> 404 +``` + +`tests/run-self-test.sh http://localhost:8091`: **NOT RUN locally** — this host's local Python is a Microsoft Store execution-alias stub, so the script's `json.tool` pretty-print step cannot run. The same JSON contract was already verified directly (`curl http://localhost:8091/self-test` → `{"ok":true,...}`, all 5 checks passing) and via `Test-Http.ps1` above; this is a convenience-script limitation on this specific host, not a framework regression — matches the pre-existing NOT RUN note for this same script under M3. + +M4 gate status: **PASS** for the vertical slice implemented (safe separate template loading from a physically non-served directory; deterministic `{{Key}}` substitution; default HTML-text-context encoding verified against a real escaping case; missing-template and unresolved-placeholder failures verified path-free and safe; `/hello`'s existing exact-body contract verified unchanged end-to-end through IIS). `Views/Layout.html` and a layout convention are deferred (IMPLEMENTATION_PLAN M4 sequences "add layout after renderer works" as a second step) — not yet needed since there is still only one view. Attribute/URL/JS-context encoding remains explicitly out of scope until a view needs it (documented on `ViewRenderer.wsc` and in `docs/ARCHITECTURE.md`). diff --git a/public/Default.asp b/public/Default.asp index ab83b3b..0aa4e26 100644 --- a/public/Default.asp +++ b/public/Default.asp @@ -1,15 +1,16 @@ <%@ Language="VBScript" %> <% Option Explicit %> <% -Dim route, httpMethod, logDir, ctx, app, applicationName, statusLine, contentType, body, allowHeader +Dim route, httpMethod, logDir, viewsDir, ctx, app, applicationName, statusLine, contentType, body, allowHeader 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 - +' logs/ and Views/ deliberately live outside the served "public" webroot (IIS +' site physical path = public/), so parent-relative MapPath is required here - ' requires enableParentPaths=true for this site. See docs/DECISIONS.md. logDir = Server.MapPath("../logs") +viewsDir = Server.MapPath("../Views") Set ctx = Nothing On Error Resume Next @@ -57,7 +58,7 @@ body = "" allowHeader = "" On Error Resume Next -app.Run ctx, applicationName, statusLine, contentType, body, allowHeader +app.Run ctx, applicationName, viewsDir, statusLine, contentType, body, allowHeader If Err.Number <> 0 Then Err.Clear On Error Goto 0 diff --git a/test-app/Controllers/SelfTestController.wsc b/test-app/Controllers/SelfTestController.wsc index e7a37ad..0cc91a1 100644 --- a/test-app/Controllers/SelfTestController.wsc +++ b/test-app/Controllers/SelfTestController.wsc @@ -87,7 +87,7 @@ Sub RunSelfTest(logDir, body) Set ctxUnknown = CreateObject("WscMvc.RequestContext") ctxUnknown.Initialize "/hello", "GET", logDir unkStatus = "" : unkType = "" : unkBody = "" : unkAllow = "" - app.Run ctxUnknown, "tests", unkStatus, unkType, unkBody, unkAllow + app.Run ctxUnknown, "tests", "", unkStatus, unkType, unkBody, unkAllow If Err.Number <> 0 Then unkDetail = Err.Description Err.Clear diff --git a/test-app/public/Default.asp b/test-app/public/Default.asp index a115e4a..dcc7d8a 100644 --- a/test-app/public/Default.asp +++ b/test-app/public/Default.asp @@ -5,15 +5,16 @@ ' 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, allowHeader +Dim route, httpMethod, logDir, viewsDir, ctx, app, applicationName, statusLine, contentType, body, allowHeader 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 - +' logs/ and Views/ deliberately live outside the served "public" webroot (IIS +' site physical path = public/), so parent-relative MapPath is required here - ' requires enableParentPaths=true for this site. See docs/DECISIONS.md. logDir = Server.MapPath("../logs") +viewsDir = Server.MapPath("../Views") Set ctx = Nothing On Error Resume Next @@ -61,7 +62,7 @@ body = "" allowHeader = "" On Error Resume Next -app.Run ctx, applicationName, statusLine, contentType, body, allowHeader +app.Run ctx, applicationName, viewsDir, statusLine, contentType, body, allowHeader If Err.Number <> 0 Then Err.Clear On Error Goto 0 diff --git a/tests/Test-Components.vbs b/tests/Test-Components.vbs index 128839b..ee9e93d 100644 --- a/tests/Test-Components.vbs +++ b/tests/Test-Components.vbs @@ -1,6 +1,6 @@ Option Explicit -Dim fso, logDir, pass +Dim fso, logDir, viewsDir, testViewsDir, pass pass = True Set fso = CreateObject("Scripting.FileSystemObject") @@ -9,6 +9,17 @@ If fso.FolderExists(logDir) Then fso.DeleteFolder logDir, True End If +' Real Views/ used for the /hello integration path (proves the end-to-end +' rendering pipeline, not just the ViewRenderer component in isolation). +viewsDir = fso.GetParentFolderName(WScript.ScriptFullName) & "\..\Views" + +' Disposable fixture directory for direct ViewRenderer contract tests below. +testViewsDir = fso.GetParentFolderName(WScript.ScriptFullName) & "\test-views" +If fso.FolderExists(testViewsDir) Then + fso.DeleteFolder testViewsDir, True +End If +fso.CreateFolder testViewsDir + Function NewContext(path, httpMethod) Dim ctx Set ctx = CreateObject("WscMvc.RequestContext") @@ -20,10 +31,18 @@ Sub RunApplication(app, path, httpMethod, applicationName, statusLine, contentTy Dim ctx Set ctx = NewContext(path, httpMethod) statusLine = "" : contentType = "" : body = "" : allowHeader = "" - app.Run ctx, applicationName, statusLine, contentType, body, allowHeader + app.Run ctx, applicationName, viewsDir, statusLine, contentType, body, allowHeader Set ctx = Nothing End Sub +Sub WriteFixture(fileName, content) + Dim stream + Set stream = fso.CreateTextFile(testViewsDir & "\" & fileName, True) + stream.Write content + stream.Close + Set stream = Nothing +End Sub + Sub CheckEqual(actual, expected, label) If actual = expected Then WScript.Echo "PASS: " & label @@ -177,6 +196,123 @@ If pass Then CheckEqual allowHeader, "", "Application.Run(malformed path) Allow header" End If +' --- ViewRenderer contract: substitution + HTML-encoding --- +Dim renderer +On Error Resume Next +Set renderer = CreateObject("WscMvc.ViewRenderer") +If Err.Number <> 0 Then + WScript.Echo "FAIL: could not create WscMvc.ViewRenderer - " & Err.Description + pass = False + Err.Clear +End If +On Error Goto 0 + +If pass Then + WriteFixture "Sample.html", "

{{Name}}

" + Dim dataSample, outSample + Set dataSample = CreateObject("Scripting.Dictionary") + dataSample.Add "Name", "" + outSample = "" + On Error Resume Next + renderer.Render testViewsDir, "Sample", dataSample, outSample + If Err.Number <> 0 Then + WScript.Echo "FAIL: ViewRenderer.Render raised error on Sample - " & Err.Description + pass = False + Err.Clear + End If + On Error Goto 0 + If pass Then + CheckEqual outSample, "

<script>alert(1)</script>

", "ViewRenderer HTML-encodes placeholder value" + End If + Set dataSample = Nothing +End If + +' --- ViewRenderer contract: template with no placeholders passes through unchanged, data = Nothing --- +If pass Then + WriteFixture "Plain.html", "Plain text, no placeholders." + Dim outPlain + outPlain = "" + On Error Resume Next + renderer.Render testViewsDir, "Plain", Nothing, outPlain + If Err.Number <> 0 Then + WScript.Echo "FAIL: ViewRenderer.Render raised error on Plain (data=Nothing) - " & Err.Description + pass = False + Err.Clear + End If + On Error Goto 0 + If pass Then + CheckEqual outPlain, "Plain text, no placeholders.", "ViewRenderer passes through template with no placeholders (data=Nothing)" + End If +End If + +' --- ViewRenderer contract: missing template file is a safe, path-free error --- +If pass Then + Dim outMissing, missingOk, missingDetail + missingOk = False + outMissing = "" + On Error Resume Next + renderer.Render testViewsDir, "DoesNotExist", Nothing, outMissing + If Err.Number <> 0 Then + missingDetail = Err.Description + If InStr(1, missingDetail, testViewsDir, 1) = 0 Then + missingOk = True + End If + Err.Clear + End If + On Error Goto 0 + If missingOk Then + WScript.Echo "PASS: ViewRenderer.Render on missing template raises a path-free error" + Else + WScript.Echo "FAIL: ViewRenderer.Render on missing template -> Err.Number after call, detail=[" & missingDetail & "]" + pass = False + End If +End If + +' --- ViewRenderer contract: unresolved placeholder (no matching dictionary key) is a safe error --- +If pass Then + WriteFixture "Missing.html", "{{Unknown}}" + Dim dataEmpty, outMissingKey, unresolvedOk + Set dataEmpty = CreateObject("Scripting.Dictionary") + unresolvedOk = False + outMissingKey = "" + On Error Resume Next + renderer.Render testViewsDir, "Missing", dataEmpty, outMissingKey + If Err.Number <> 0 Then + unresolvedOk = True + Err.Clear + End If + On Error Goto 0 + If unresolvedOk Then + WScript.Echo "PASS: ViewRenderer.Render raises on unresolved placeholder" + Else + WScript.Echo "FAIL: ViewRenderer.Render did not raise on unresolved placeholder -> got [" & outMissingKey & "]" + pass = False + End If + Set dataEmpty = Nothing +End If + +' --- ViewRenderer contract: invalid view name (path traversal attempt) is rejected --- +If pass Then + Dim outTraversal, traversalOk + traversalOk = False + outTraversal = "" + On Error Resume Next + renderer.Render testViewsDir, "../secret", Nothing, outTraversal + If Err.Number <> 0 Then + traversalOk = True + Err.Clear + End If + On Error Goto 0 + If traversalOk Then + WScript.Echo "PASS: ViewRenderer.Render rejects an invalid view name" + Else + WScript.Echo "FAIL: ViewRenderer.Render did not reject invalid view name -> got [" & outTraversal & "]" + pass = False + End If +End If + +Set renderer = Nothing + ' --- Logging: best-effort log file was written with both outcomes --- If pass Then Dim logPath, logContent @@ -205,6 +341,9 @@ Set ctx2 = Nothing If fso.FolderExists(logDir) Then fso.DeleteFolder logDir, True End If +If fso.FolderExists(testViewsDir) Then + fso.DeleteFolder testViewsDir, True +End If Set fso = Nothing If pass Then diff --git a/tools/Register-Components.ps1 b/tools/Register-Components.ps1 index 59df449..53594f9 100644 --- a/tools/Register-Components.ps1 +++ b/tools/Register-Components.ps1 @@ -14,6 +14,7 @@ $components = @( # docs/ARCHITECTURE.md. (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'), (Join-Path $ProjectRoot 'Framework\Router.wsc'), + (Join-Path $ProjectRoot 'Framework\ViewRenderer.wsc'), (Join-Path $ProjectRoot 'Framework\Application.wsc'), (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'), (Join-Path $ProjectRoot 'test-app\Controllers\SelfTestController.wsc') diff --git a/tools/Unregister-Components.ps1 b/tools/Unregister-Components.ps1 index cfcbe2b..b623d29 100644 --- a/tools/Unregister-Components.ps1 +++ b/tools/Unregister-Components.ps1 @@ -18,6 +18,7 @@ $components = @( @{ 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\ViewRenderer.wsc'); ProgId = 'WscMvc.ViewRenderer'; ClassId = '{4948DF84-5DC6-448A-9F1B-EB596C28842B}' }, @{ Path = (Join-Path $ProjectRoot 'Framework\Router.wsc'); ProgId = 'WscMvc.Router'; ClassId = '{C92F9338-B478-4EAD-B865-892FFB1E1C51}' }, @{ Path = (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'); ProgId = 'WscMvc.RequestContext'; ClassId = '{1C36FA55-34DF-4974-94B9-D657389362B2}' } )