| 1 | # PrutCMS v10 — quick smoke test against a running dev server.
|
|---|
| 2 | # Usage:
|
|---|
| 3 | # 1. In one terminal: npm run dev
|
|---|
| 4 | # 2. In another: .\deploy\verify.ps1
|
|---|
| 5 | #
|
|---|
| 6 | # Hits public + auth-gated endpoints, checks status codes + key strings in
|
|---|
| 7 | # response bodies. Doesn't try to upload — just verifies routing, rendering,
|
|---|
| 8 | # CSP, and signed-URL surface.
|
|---|
| 9 |
|
|---|
| 10 | param(
|
|---|
| 11 | [string]$BaseUrl = 'http://localhost:3000'
|
|---|
| 12 | )
|
|---|
| 13 |
|
|---|
| 14 | $ErrorActionPreference = 'Stop'
|
|---|
| 15 | $script:pass = 0
|
|---|
| 16 | $script:fail = 0
|
|---|
| 17 |
|
|---|
| 18 | function Check {
|
|---|
| 19 | param([string]$Name, [bool]$Ok, [string]$Detail = '')
|
|---|
| 20 | $msg = if ($Detail) { "$Name -- $Detail" } else { $Name }
|
|---|
| 21 | if ($Ok) {
|
|---|
| 22 | Write-Host "[OK] $msg" -ForegroundColor Green
|
|---|
| 23 | $script:pass++
|
|---|
| 24 | } else {
|
|---|
| 25 | Write-Host "[FAIL] $msg" -ForegroundColor Red
|
|---|
| 26 | $script:fail++
|
|---|
| 27 | }
|
|---|
| 28 | }
|
|---|
| 29 |
|
|---|
| 30 | function Get-Url {
|
|---|
| 31 | param([string]$Path)
|
|---|
| 32 | try {
|
|---|
| 33 | return Invoke-WebRequest -Uri ($BaseUrl + $Path) -UseBasicParsing -SkipHttpErrorCheck -MaximumRedirection 0 -ErrorAction SilentlyContinue
|
|---|
| 34 | } catch {
|
|---|
| 35 | Write-Host "Request failed: $Path -- $($_.Exception.Message)" -ForegroundColor Yellow
|
|---|
| 36 | return $null
|
|---|
| 37 | }
|
|---|
| 38 | }
|
|---|
| 39 |
|
|---|
| 40 | Write-Host "`n=== PrutCMS verify @ $BaseUrl ===`n" -ForegroundColor Cyan
|
|---|
| 41 |
|
|---|
| 42 | # ── Server up at all? ─────────────────────────────────────────────
|
|---|
| 43 | $home = Get-Url '/'
|
|---|
| 44 | $serverUp = ($home -ne $null) -and ($home.StatusCode -eq 200 -or $home.StatusCode -eq 302)
|
|---|
| 45 | Check 'Server responds on /' $serverUp
|
|---|
| 46 |
|
|---|
| 47 | if (-not $serverUp) {
|
|---|
| 48 | Write-Host "`nServer not reachable. Is 'npm run dev' running?" -ForegroundColor Red
|
|---|
| 49 | exit 1
|
|---|
| 50 | }
|
|---|
| 51 |
|
|---|
| 52 | # ── Manifest & feeds ──────────────────────────────────────────────
|
|---|
| 53 | $mf = Get-Url '/manifest.webmanifest'
|
|---|
| 54 | Check 'Manifest responds 200' ($mf -ne $null -and $mf.StatusCode -eq 200)
|
|---|
| 55 | $mfJson = $null
|
|---|
| 56 | if ($mf -and $mf.Content) {
|
|---|
| 57 | try { $mfJson = $mf.Content | ConvertFrom-Json } catch {}
|
|---|
| 58 | }
|
|---|
| 59 | Check 'Manifest has scope field' ($mfJson -ne $null -and $mfJson.scope -ne $null)
|
|---|
| 60 | Check 'Manifest id starts prutcms-' ($mfJson -ne $null -and $mfJson.id -like 'prutcms-*')
|
|---|
| 61 | Check 'Manifest scope ends with /' ($mfJson -ne $null -and $mfJson.scope.EndsWith('/'))
|
|---|
| 62 |
|
|---|
| 63 | $feed = Get-Url '/feed.xml'
|
|---|
| 64 | Check 'RSS /feed.xml: 200 + xml' ($feed -ne $null -and $feed.StatusCode -eq 200 -and $feed.Content -like '*<rss*')
|
|---|
| 65 | $atom = Get-Url '/atom.xml'
|
|---|
| 66 | Check 'Atom /atom.xml: 200 + xml' ($atom -ne $null -and $atom.StatusCode -eq 200 -and $atom.Content -like '*<feed*')
|
|---|
| 67 | $sm = Get-Url '/sitemap.xml'
|
|---|
| 68 | Check 'Sitemap (200 or 404)' ($sm -ne $null -and ($sm.StatusCode -eq 200 -or $sm.StatusCode -eq 404))
|
|---|
| 69 |
|
|---|
| 70 | # ── Search ─────────────────────────────────────────────────────────
|
|---|
| 71 | $s = Get-Url '/search?q=test'
|
|---|
| 72 | Check 'Search: 200' ($s -ne $null -and $s.StatusCode -eq 200)
|
|---|
| 73 | Check 'Search has form input' ($s -ne $null -and $s.Content -like '*name="q"*')
|
|---|
| 74 |
|
|---|
| 75 | # ── Audio streaming guards ────────────────────────────────────────
|
|---|
| 76 | $noToken = Get-Url '/audio/stream/foo.mp3'
|
|---|
| 77 | Check 'No token: 403' ($noToken -ne $null -and $noToken.StatusCode -eq 403)
|
|---|
| 78 |
|
|---|
| 79 | $badToken = Get-Url '/audio/stream/foo.mp3?t=deadbeef&exp=9999999999'
|
|---|
| 80 | Check 'Bad token: 403' ($badToken -ne $null -and $badToken.StatusCode -eq 403)
|
|---|
| 81 |
|
|---|
| 82 | $traverse = Get-Url '/audio/stream/..%2Fevil'
|
|---|
| 83 | Check 'Path traversal: 4xx' ($traverse -ne $null -and $traverse.StatusCode -ge 400 -and $traverse.StatusCode -lt 500)
|
|---|
| 84 |
|
|---|
| 85 | # ── Auth-gated routes preserve ?next= ──────────────────────────────
|
|---|
| 86 | $account = Get-Url '/account'
|
|---|
| 87 | Check '/account redirects (302)' ($account -ne $null -and $account.StatusCode -eq 302)
|
|---|
| 88 | $loc = if ($account) { $account.Headers.Location } else { $null }
|
|---|
| 89 | Check 'Redirect contains ?next=' ($loc -ne $null -and $loc -like '*/auth/login?next=*')
|
|---|
| 90 |
|
|---|
| 91 | $admin = Get-Url '/admin'
|
|---|
| 92 | Check '/admin requires auth' ($admin -ne $null -and ($admin.StatusCode -eq 302 -or $admin.StatusCode -eq 403))
|
|---|
| 93 |
|
|---|
| 94 | # ── Auth pages ─────────────────────────────────────────────────────
|
|---|
| 95 | $login = Get-Url '/auth/login'
|
|---|
| 96 | Check 'Login page: 200' ($login -ne $null -and $login.StatusCode -eq 200)
|
|---|
| 97 | Check "Login has 'Forgot password?'" ($login -ne $null -and $login.Content -like '*Forgot password*')
|
|---|
| 98 |
|
|---|
| 99 | $reset = Get-Url '/auth/reset-request'
|
|---|
| 100 | Check 'Reset-request page: 200' ($reset -ne $null -and $reset.StatusCode -eq 200)
|
|---|
| 101 |
|
|---|
| 102 | # ── Reserved slugs ─────────────────────────────────────────────────
|
|---|
| 103 | $tag = Get-Url '/tag/anything'
|
|---|
| 104 | Check '/tag/:tag responds' ($tag -ne $null -and ($tag.StatusCode -eq 200 -or $tag.StatusCode -eq 404))
|
|---|
| 105 |
|
|---|
| 106 | $users = Get-Url '/users/nonexistent'
|
|---|
| 107 | Check '/users/:username 404 when missing' ($users -ne $null -and $users.StatusCode -eq 404)
|
|---|
| 108 |
|
|---|
| 109 | # ── Prutter (route exists; status depends on enable_prutter + login) ──
|
|---|
| 110 | $prutter = Get-Url '/prutter'
|
|---|
| 111 | Check '/prutter route present' ($prutter -ne $null -and ($prutter.StatusCode -eq 302 -or $prutter.StatusCode -eq 404))
|
|---|
| 112 |
|
|---|
| 113 | # ── HTMX bundled locally ──────────────────────────────────────────
|
|---|
| 114 | $htmx = Get-Url '/assets/js/htmx.min.js'
|
|---|
| 115 | Check 'HTMX file served' ($htmx -ne $null -and $htmx.StatusCode -eq 200)
|
|---|
| 116 | Check 'HTMX is the real lib (>20 KB, not the loader stub)' `
|
|---|
| 117 | ($htmx -ne $null -and $htmx.Content.Length -gt 20000)
|
|---|
| 118 |
|
|---|
| 119 | # ── CSP / security headers ────────────────────────────────────────
|
|---|
| 120 | $csp = if ($home) { $home.Headers.'Content-Security-Policy' } else { $null }
|
|---|
| 121 | Check 'CSP present' ($csp -ne $null)
|
|---|
| 122 | Check 'CSP no longer references unpkg.com' ($csp -ne $null -and $csp -notlike '*unpkg*')
|
|---|
| 123 |
|
|---|
| 124 | $nosniff = if ($home) { $home.Headers.'X-Content-Type-Options' } else { $null }
|
|---|
| 125 | Check 'Helmet active (X-Content-Type-Options: nosniff)' ($nosniff -eq 'nosniff')
|
|---|
| 126 |
|
|---|
| 127 | # ── Summary ───────────────────────────────────────────────────────
|
|---|
| 128 | Write-Host "`n────────────────────────────────────────"
|
|---|
| 129 | $summary = "$($script:pass) passed, $($script:fail) failed."
|
|---|
| 130 | if ($script:fail -eq 0) {
|
|---|
| 131 | Write-Host $summary -ForegroundColor Green
|
|---|
| 132 | } else {
|
|---|
| 133 | Write-Host $summary -ForegroundColor Red
|
|---|
| 134 | exit 1
|
|---|
| 135 | }
|
|---|