From 3c81720c8313d389069b7ae03925726d93b11fd7 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sun, 4 Oct 2026 17:05:05 -0500 Subject: [PATCH] Move board admin to Azure Static Web Apps with Entra ID sign-in Squashed from azure-migration (PR #21) so every commit on main is signed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- ...tic-web-apps-wonderful-field-0cd14c010.yml | 37 +++ .github/workflows/ci.yml | 30 +- .gitignore | 7 + admin/admin-auth.js | 30 -- admin/admin.css | 27 +- admin/admin.js | 17 +- admin/dashboard.html | 132 --------- admin/index.html | 131 ++++++++- api/_lib/categories.js | 4 +- api/_lib/clientPrincipal.js | 29 ++ api/_lib/githubGist.js | 2 +- api/_lib/http.js | 49 ++-- api/_lib/rateLimit.js | 48 +--- api/_lib/session.js | 88 ------ api/auth.js | 46 --- api/gist.js | 20 -- api/host.json | 15 + api/logout.js | 8 - api/package-lock.json | 49 ++++ api/package.json | 16 ++ api/{ => src/functions}/geocode.js | 40 ++- api/src/functions/gist.js | 30 ++ api/{ => src/functions}/publish.js | 48 ++-- index.html | 21 ++ package.json | 3 +- staticwebapp.config.json | 41 +++ test/api.test.js | 271 +++++++----------- 27 files changed, 584 insertions(+), 655 deletions(-) create mode 100644 .github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml delete mode 100644 admin/admin-auth.js delete mode 100644 admin/dashboard.html create mode 100644 api/_lib/clientPrincipal.js delete mode 100644 api/_lib/session.js delete mode 100644 api/auth.js delete mode 100644 api/gist.js create mode 100644 api/host.json delete mode 100644 api/logout.js create mode 100644 api/package-lock.json create mode 100644 api/package.json rename api/{ => src/functions}/geocode.js (55%) create mode 100644 api/src/functions/gist.js rename api/{ => src/functions}/publish.js (86%) create mode 100644 index.html create mode 100644 staticwebapp.config.json diff --git a/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml new file mode 100644 index 0000000..531ff03 --- /dev/null +++ b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml @@ -0,0 +1,37 @@ +name: Azure Static Web Apps CI/CD + +# main -> Production environment; staging -> "staging" branch environment. +# No pull_request trigger: new environments copy Production's settings (incl. the live GITHUB_PAT). +on: + push: + branches: + - main + - staging + +concurrency: + group: azure-swa-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + build_and_deploy_job: + runs-on: ubuntu-latest + name: Build and Deploy Job + steps: + - uses: actions/checkout@v3 + with: + submodules: true + lfs: false + - name: Stage categories.json for the API + run: cp categories.json api/categories.json + - name: Build And Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_WONDERFUL_FIELD_0CD14C010 }} + repo_token: ${{ secrets.GITHUB_TOKEN }} + action: "upload" + production_branch: "main" + app_location: "/" + api_location: "api" + output_location: "" + skip_app_build: true # static site; api/ is still built by Oryx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d59bba..5a46c89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,16 +21,24 @@ jobs: run: npm ci --ignore-scripts - name: Audit dependencies - # Production-only: this project ships zero runtime npm dependencies (the - # API functions use Node built-ins, the frontend loads Leaflet from a CDN). - # electron/electron-builder are devDependencies for the desktop packaging - # tool only — never shipped to the deployed map/admin/API — so their - # transitive vulnerabilities shouldn't block every unrelated PR. + # Production-only: the root project ships zero runtime npm dependencies + # (the public map loads Leaflet from a CDN). electron/electron-builder + # are devDependencies for the desktop packaging tool only — never + # shipped to the deployed map/admin/API — so their transitive + # vulnerabilities shouldn't block every unrelated PR. run: npm audit --omit=dev --audit-level=high + - name: Install api/ dependencies + # The Azure Functions API has its own package.json (@azure/functions is + # its one real runtime dependency), separate from the root project. + run: cd api && npm ci --ignore-scripts + + - name: Audit api/ dependencies + run: cd api && npm audit --omit=dev --audit-level=high + - name: Validate JSON files run: | - for f in $(find . -name "*.json" -not -path "./node_modules/*" -not -path "./.git/*"); do + for f in $(find . -name "*.json" -not -path "./node_modules/*" -not -path "./api/node_modules/*" -not -path "./.git/*"); do echo "Checking $f" node -e "JSON.parse(require('fs').readFileSync('$f', 'utf8'))" done @@ -50,18 +58,18 @@ jobs: - name: Check api/ function syntax run: | - for f in api/*.js api/_lib/*.js; do + for f in api/_lib/*.js api/src/functions/*.js; do echo "Checking $f" node --check "$f" done - name: Check admin/ front-end syntax - run: | - node --check admin/admin.js - node --check admin/admin-auth.js + run: node --check admin/admin.js - name: Run api/ unit tests (mocked Gist backend) - run: node test/api.test.js + run: | + cp categories.json api/categories.json + node test/api.test.js - name: Run public map data tests run: | diff --git a/.gitignore b/.gitignore index f03a7fe..a03f29b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ wheels/ *.pem *.key +# Azure Functions local development settings (holds secrets like GITHUB_PAT, +# AZURE_CLIENT_SECRET when running `func start` locally against api/) +api/local.settings.json + # IDE .vscode/ .idea/ @@ -56,6 +60,9 @@ yarn-error.log dist/ out/ +# Staged copy of the repo-root categories.json for the Azure Functions deploy (see workflow + pretest) +api/categories.json + # Local testing *.log .tmp-chrome/ diff --git a/admin/admin-auth.js b/admin/admin-auth.js deleted file mode 100644 index 01e2a60..0000000 --- a/admin/admin-auth.js +++ /dev/null @@ -1,30 +0,0 @@ -const form = document.getElementById('login-form'); -const errorEl = document.getElementById('login-error'); - -form.addEventListener('submit', async (e) => { - e.preventDefault(); - errorEl.hidden = true; - const passphrase = document.getElementById('passphrase').value; - - try { - const resp = await fetch('/api/auth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ passphrase }), - }); - const data = await resp.json(); - if (resp.ok && data.ok) { - window.location.href = '/admin/dashboard.html'; - return; - } - if (resp.status === 429) { - errorEl.textContent = 'Too many attempts. Please wait 15 minutes and try again.'; - } else { - errorEl.textContent = 'Incorrect passphrase.'; - } - errorEl.hidden = false; - } catch (err) { - errorEl.textContent = 'Could not reach the server. Please try again.'; - errorEl.hidden = false; - } -}); diff --git a/admin/admin.css b/admin/admin.css index 36fe6c0..fb44de0 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -7,29 +7,10 @@ body { color: #1f2937; } -/* --- Login page --- */ -.login-body { - height: 100vh; - display: flex; - align-items: center; - justify-content: center; -} -.login-card { - background: white; - padding: 32px; - border-radius: 10px; - box-shadow: 0 4px 16px rgba(0,0,0,0.1); - width: 320px; -} -.login-card h1 { font-size: 20px; margin: 0 0 4px; } -.login-card .subtitle { margin: 0 0 20px; color: #6b7280; font-size: 14px; } -.login-card form { display: flex; flex-direction: column; gap: 8px; } -.login-card label { font-size: 13px; font-weight: 600; } -.login-card input { padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; } -.login-card button { margin-top: 8px; padding: 10px; border: none; border-radius: 6px; background: #2563eb; color: white; font-weight: 600; cursor: pointer; } -.login-card button:hover { background: #1d4ed8; } - -/* --- Dashboard --- */ +/* --- Dashboard --- + The custom passphrase login page (and its .login-body/.login-card styles) + was removed — Microsoft Entra ID sign-in is handled entirely by Azure + Static Web Apps' built-in /.auth/login/aad, with no local login page. */ #admin-header { display: flex; justify-content: space-between; diff --git a/admin/admin.js b/admin/admin.js index aae93c8..a4a4f03 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -35,7 +35,7 @@ function hideFormError() { async function loadData() { const resp = await fetch('/api/gist', { credentials: 'same-origin' }); if (resp.status === 401) { - window.location.href = '/admin/index.html'; + window.location.href = '/.auth/login/aad'; return; } if (!resp.ok) { @@ -46,7 +46,7 @@ async function loadData() { organizations = data.organizations; categories = data.categories; currentVersion = data.version; - lastUpdatedEl.textContent = `Last updated: ${new Date(currentVersion).toLocaleString()}`; + lastUpdatedEl.textContent = `Last updated: ${new Date(data.updatedAt).toLocaleString()}`; renderOrgList(); } @@ -229,7 +229,7 @@ document.getElementById('geocode-btn').addEventListener('click', async () => { body: JSON.stringify({ address }), }); if (resp.status === 401) { - window.location.href = '/admin/index.html'; + window.location.href = '/.auth/login/aad'; return; } if (resp.status === 404) { @@ -261,7 +261,7 @@ document.getElementById('delete-btn').addEventListener('click', async () => { body: JSON.stringify({ mode: 'delete', orgId: editingOrgId, expectedVersion: currentVersion }), }); const data = await resp.json(); - if (resp.status === 401) { window.location.href = '/admin/index.html'; return; } + if (resp.status === 401) { window.location.href = '/.auth/login/aad'; return; } if (resp.status === 409) { showFormError(data.message + ' Reloading current data…'); await loadData(); @@ -279,9 +279,10 @@ document.getElementById('delete-btn').addEventListener('click', async () => { } }); -document.getElementById('logout-btn').addEventListener('click', async () => { - await fetch('/api/logout', { method: 'POST' }); - window.location.href = '/admin/index.html'; +document.getElementById('logout-btn').addEventListener('click', () => { + // Azure Static Web Apps' built-in auth handles sign-out; there's no local + // session/cookie of our own to clear anymore. + window.location.href = '/.auth/logout'; }); form.addEventListener('submit', async (e) => { @@ -329,7 +330,7 @@ form.addEventListener('submit', async (e) => { }); const data = await resp.json(); - if (resp.status === 401) { window.location.href = '/admin/index.html'; return; } + if (resp.status === 401) { window.location.href = '/.auth/login/aad'; return; } if (resp.status === 409) { showFormError(data.message); diff --git a/admin/dashboard.html b/admin/dashboard.html deleted file mode 100644 index 0f12073..0000000 --- a/admin/dashboard.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - - - Praxium - Allies and Partners: Admin - - - - -
-

Praxium - Allies and Partners: Admin

-
- - -
-
- - - -
-
-
- - -
-
    -
    - - -
    - - - - - diff --git a/admin/index.html b/admin/index.html index 660ba8a..0f12073 100644 --- a/admin/index.html +++ b/admin/index.html @@ -5,23 +5,128 @@ - Praxium - Allies and Partners: Admin Login + Praxium - Allies and Partners: Admin + - -
    -

    Praxium - Allies and Partners

    -

    Board member admin

    -
    - - - - -
    + +
    +

    Praxium - Allies and Partners: Admin

    +
    + + +
    +
    + + + +
    +
    +
    + + +
    +
      +
      + +
      - + + + diff --git a/api/_lib/categories.js b/api/_lib/categories.js index 57e8525..2090e0f 100644 --- a/api/_lib/categories.js +++ b/api/_lib/categories.js @@ -1,9 +1,9 @@ -// Reads the repo's canonical categories.json (single source of truth, see Phase 1). +// Reads categories.json staged into api/ by the deploy workflow and npm pretest (copied from the repo root, never committed). const fs = require('fs'); const path = require('path'); function readCategories() { - const filePath = path.join(__dirname, '..', '..', 'categories.json'); + const filePath = path.join(__dirname, '..', 'categories.json'); const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } diff --git a/api/_lib/clientPrincipal.js b/api/_lib/clientPrincipal.js new file mode 100644 index 0000000..3bf14da --- /dev/null +++ b/api/_lib/clientPrincipal.js @@ -0,0 +1,29 @@ +// Reads the identity Azure Static Web Apps injects into every authenticated request. +// +// SWA's own route rules (staticwebapp.config.json: "allowedRoles": ["authenticated"]) already +// block unauthenticated requests to /api/* before they reach this code — nobody without a valid +// Entra session, who's also been explicitly assigned to the app registration, can get this far. +// This check is defense-in-depth only, in case a function is ever invoked by a path that bypasses +// the configured routes (e.g. direct Functions-level invocation during local development). +const HEADER_NAME = 'x-ms-client-principal'; + +// Returns the parsed principal ({ identityProvider, userId, userDetails, userRoles }) if the +// request carries a valid, authenticated SWA identity, or null otherwise. +function getClientPrincipal(request) { + const header = request.headers.get(HEADER_NAME); + if (!header) return null; + + let principal; + try { + principal = JSON.parse(Buffer.from(header, 'base64').toString('utf8')); + } catch (err) { + return null; + } + + if (!principal || !Array.isArray(principal.userRoles) || !principal.userRoles.includes('authenticated')) { + return null; + } + return principal; +} + +module.exports = { getClientPrincipal }; diff --git a/api/_lib/githubGist.js b/api/_lib/githubGist.js index 3cd8829..5ffc048 100644 --- a/api/_lib/githubGist.js +++ b/api/_lib/githubGist.js @@ -70,7 +70,7 @@ async function fetchGist() { throw new Error('Gist file content must be a JSON array of organizations'); } - return { organizations, version: getGistVersion(gist), raw: content }; + return { organizations, version: getGistVersion(gist), updatedAt: gist.updated_at, raw: content }; } // Overwrites the Gist's data file with a new array of organizations. diff --git a/api/_lib/http.js b/api/_lib/http.js index b8e76cf..2e5f44a 100644 --- a/api/_lib/http.js +++ b/api/_lib/http.js @@ -1,29 +1,34 @@ -// Small helpers shared by the /api handlers. +// Small helpers shared by the /api functions. +// +// Azure Functions' v4 Node.js model has handlers *return* a response object +// ({ status, jsonBody, headers }) instead of mutating a res parameter like +// Vercel's model did — these helpers build that object consistently. -// Vercel's Node.js runtime auto-parses JSON bodies into req.body, but guard against it being -// a raw string (e.g. when a different content-type or invocation path is used). -function readJsonBody(req) { - if (req.body === undefined || req.body === null) return {}; - if (typeof req.body === 'object') return req.body; - try { - return JSON.parse(req.body); - } catch (err) { - return {}; - } +function jsonResponse(status, payload) { + return { + status, + jsonBody: payload, + headers: { + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }; } -function sendJson(res, status, payload) { - res - .status(status) - .setHeader('Content-Type', 'application/json; charset=utf-8') - .setHeader('Cache-Control', 'no-store') - .setHeader('X-Content-Type-Options', 'nosniff') - .end(JSON.stringify(payload)); +function methodNotAllowed(allowed) { + return { + status: 405, + jsonBody: { error: 'method_not_allowed' }, + headers: { + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + Allow: allowed.join(', '), + }, + }; } -function methodNotAllowed(res, allowed) { - res.setHeader('Allow', allowed.join(', ')); - sendJson(res, 405, { error: 'method_not_allowed' }); +function unauthorized() { + return jsonResponse(401, { error: 'unauthorized' }); } -module.exports = { readJsonBody, sendJson, methodNotAllowed }; +module.exports = { jsonResponse, methodNotAllowed, unauthorized }; diff --git a/api/_lib/rateLimit.js b/api/_lib/rateLimit.js index d3580cb..8a22b11 100644 --- a/api/_lib/rateLimit.js +++ b/api/_lib/rateLimit.js @@ -1,39 +1,11 @@ -// Best-effort in-memory rate limiting. Serverless cold starts reset this state, so it is -// defense-in-depth on top of the passphrase itself, not a hard guarantee — acceptable for a -// small trusted-board internal tool. Do not rely on this alone for a public-facing surface. -const buckets = new Map(); - -function recentAttempts(key, windowMs) { - const now = Date.now(); - const recent = (buckets.get(key) || []).filter((ts) => now - ts < windowMs); - if (recent.length) buckets.set(key, recent); - else buckets.delete(key); - return recent; -} - -// Returns true if the caller (keyed by e.g. IP) may make another attempt. -// Checking does not consume the budget; only failed authentication should. -function checkRateLimit(key, { max, windowMs }) { - return recentAttempts(key, windowMs).length < max; -} - -function recordRateLimitFailure(key, { windowMs }) { - const recent = recentAttempts(key, windowMs); - recent.push(Date.now()); - buckets.set(key, recent); -} - -function clearRateLimit(key) { - buckets.delete(key); -} - -function getClientIp(req) { - const forwarded = req.headers['x-forwarded-for']; - if (forwarded) return forwarded.split(',')[0].trim(); - return req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : 'unknown'; -} +// Throttling helper for the outbound Nominatim geocode proxy. The passphrase +// brute-force rate limiting that used to live here was removed along with the +// passphrase itself — Microsoft Entra ID now gates sign-in, so there's no +// local login attempt to rate-limit. // Simple last-call throttle used for the Nominatim geocode proxy (policy: max ~1 req/sec). +// Same best-effort caveat as before: serverless cold starts reset this, so it's a courtesy +// to Nominatim's shared infrastructure, not a hard per-instance guarantee. let lastGeocodeCallAt = 0; async function throttleGeocodeCall(minIntervalMs) { const now = Date.now(); @@ -44,10 +16,4 @@ async function throttleGeocodeCall(minIntervalMs) { lastGeocodeCallAt = Date.now(); } -module.exports = { - checkRateLimit, - recordRateLimitFailure, - clearRateLimit, - getClientIp, - throttleGeocodeCall, -}; +module.exports = { throttleGeocodeCall }; diff --git a/api/_lib/session.js b/api/_lib/session.js deleted file mode 100644 index 4303937..0000000 --- a/api/_lib/session.js +++ /dev/null @@ -1,88 +0,0 @@ -// Signs and verifies the admin session cookie using HMAC-SHA256. -// The token is `${expiresAtMs}.${signatureHex}` — no external JWT dependency needed -// for a single-purpose, single-role session like this. -const crypto = require('crypto'); - -const COOKIE_NAME = 'praxium_admin_session'; -const SESSION_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours - -function getSecret() { - const secret = process.env.SESSION_SECRET; - if (!secret) throw new Error('SESSION_SECRET is not configured'); - if (Buffer.byteLength(secret, 'utf8') < 32) { - throw new Error('SESSION_SECRET must be at least 32 bytes'); - } - return secret; -} - -function sign(expiresAt) { - const hmac = crypto.createHmac('sha256', getSecret()); - hmac.update(String(expiresAt)); - return hmac.digest('hex'); -} - -function createSessionToken() { - const expiresAt = Date.now() + SESSION_TTL_MS; - const signature = sign(expiresAt); - return `${expiresAt}.${signature}`; -} - -function verifySessionToken(token) { - if (!token || typeof token !== 'string') return false; - const parts = token.split('.'); - if (parts.length !== 2) return false; - const [expiresAtStr, signature] = parts; - const expiresAt = Number(expiresAtStr); - if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) return false; - - const expectedSignature = sign(expiresAt); - const a = Buffer.from(signature, 'hex'); - const b = Buffer.from(expectedSignature, 'hex'); - if (a.length !== b.length) return false; - return crypto.timingSafeEqual(a, b); -} - -function parseCookies(cookieHeader) { - const out = {}; - if (!cookieHeader) return out; - cookieHeader.split(';').forEach((pair) => { - const idx = pair.indexOf('='); - if (idx === -1) return; - const key = pair.slice(0, idx).trim(); - const value = pair.slice(idx + 1).trim(); - out[key] = decodeURIComponent(value); - }); - return out; -} - -function buildSessionCookie(token) { - const maxAgeSeconds = Math.floor(SESSION_TTL_MS / 1000); - return [ - `${COOKIE_NAME}=${encodeURIComponent(token)}`, - 'HttpOnly', - 'Secure', - 'SameSite=Strict', - 'Path=/', - `Max-Age=${maxAgeSeconds}`, - ].join('; '); -} - -function buildClearCookie() { - return `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`; -} - -// Returns true if the request carries a valid, unexpired session cookie. -function requireSession(req) { - const cookies = parseCookies(req.headers.cookie); - return verifySessionToken(cookies[COOKIE_NAME]); -} - -module.exports = { - COOKIE_NAME, - createSessionToken, - verifySessionToken, - parseCookies, - buildSessionCookie, - buildClearCookie, - requireSession, -}; diff --git a/api/auth.js b/api/auth.js deleted file mode 100644 index 37da147..0000000 --- a/api/auth.js +++ /dev/null @@ -1,46 +0,0 @@ -const crypto = require('crypto'); -const { createSessionToken, buildSessionCookie } = require('./_lib/session'); -const { - checkRateLimit, - recordRateLimitFailure, - clearRateLimit, - getClientIp, -} = require('./_lib/rateLimit'); -const { readJsonBody, sendJson, methodNotAllowed } = require('./_lib/http'); - -const MAX_ATTEMPTS = 5; -const WINDOW_MS = 15 * 60 * 1000; // 15 minutes - -// Compares two strings in constant time regardless of length, by comparing fixed-length -// SHA-256 digests instead of the raw values (avoids leaking passphrase length via timing). -function constantTimeEquals(a, b) { - const digestA = crypto.createHash('sha256').update(String(a)).digest(); - const digestB = crypto.createHash('sha256').update(String(b)).digest(); - return crypto.timingSafeEqual(digestA, digestB); -} - -module.exports = async (req, res) => { - if (req.method !== 'POST') return methodNotAllowed(res, ['POST']); - - const ip = getClientIp(req); - const rateLimitKey = `auth:${ip}`; - if (!checkRateLimit(rateLimitKey, { max: MAX_ATTEMPTS, windowMs: WINDOW_MS })) { - return sendJson(res, 429, { ok: false, error: 'too_many_attempts' }); - } - - const { passphrase } = readJsonBody(req); - const expected = process.env.ADMIN_PASSPHRASE; - if (!expected) { - return sendJson(res, 500, { ok: false, error: 'server_misconfigured' }); - } - - if (typeof passphrase !== 'string' || !passphrase || !constantTimeEquals(passphrase, expected)) { - recordRateLimitFailure(rateLimitKey, { windowMs: WINDOW_MS }); - return sendJson(res, 401, { ok: false, error: 'invalid_passphrase' }); - } - - clearRateLimit(rateLimitKey); - const token = createSessionToken(); - res.setHeader('Set-Cookie', buildSessionCookie(token)); - return sendJson(res, 200, { ok: true }); -}; diff --git a/api/gist.js b/api/gist.js deleted file mode 100644 index 2252f4e..0000000 --- a/api/gist.js +++ /dev/null @@ -1,20 +0,0 @@ -const { requireSession } = require('./_lib/session'); -const { fetchGist } = require('./_lib/githubGist'); -const { readCategories } = require('./_lib/categories'); -const { sendJson, methodNotAllowed } = require('./_lib/http'); - -module.exports = async (req, res) => { - if (req.method !== 'GET') return methodNotAllowed(res, ['GET']); - if (!requireSession(req)) return sendJson(res, 401, { error: 'unauthorized' }); - - try { - const [{ organizations, version }, categories] = await Promise.all([ - fetchGist(), - Promise.resolve(readCategories()), - ]); - return sendJson(res, 200, { organizations, categories, version }); - } catch (err) { - console.error('GET /api/gist failed:', err); - return sendJson(res, 502, { error: 'gist_fetch_failed' }); - } -}; diff --git a/api/host.json b/api/host.json new file mode 100644 index 0000000..06d01bd --- /dev/null +++ b/api/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/api/logout.js b/api/logout.js deleted file mode 100644 index 5bec6f6..0000000 --- a/api/logout.js +++ /dev/null @@ -1,8 +0,0 @@ -const { buildClearCookie } = require('./_lib/session'); -const { sendJson, methodNotAllowed } = require('./_lib/http'); - -module.exports = async (req, res) => { - if (req.method !== 'POST') return methodNotAllowed(res, ['POST']); - res.setHeader('Set-Cookie', buildClearCookie()); - return sendJson(res, 200, { ok: true }); -}; diff --git a/api/package-lock.json b/api/package-lock.json new file mode 100644 index 0000000..4550d93 --- /dev/null +++ b/api/package-lock.json @@ -0,0 +1,49 @@ +{ + "name": "praxium-map-admin-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "praxium-map-admin-api", + "version": "1.0.0", + "dependencies": { + "@azure/functions": "^4.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@azure/functions": { + "version": "4.16.5", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.16.5.tgz", + "integrity": "sha512-h0lVUeI/Q36SyE2xGQOXZqBbmSgQHNLk8pvMxhq3Dw1TaT3eGWs2X25dceGJ153aXXkjcTEdkxg3D2Gcz1uJ0w==", + "license": "MIT", + "dependencies": { + "@azure/functions-extensions-base": "0.3.0", + "cookie": "^0.7.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@azure/functions-extensions-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@azure/functions-extensions-base/-/functions-extensions-base-0.3.0.tgz", + "integrity": "sha512-Cux0hLu5ZXlC/Kb+yvJVhRLIdkfFwui2HeT5oGZL00r/GCUUkhGTzRfZUjRN4Bq729mPv3okPucz2z7SMQLStA==", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + } + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..38d7c0c --- /dev/null +++ b/api/package.json @@ -0,0 +1,16 @@ +{ + "name": "praxium-map-admin-api", + "version": "1.0.0", + "private": true, + "description": "Azure Functions API backing the Praxium board admin (/admin). Auth is handled entirely by Azure Static Web Apps' built-in Microsoft Entra ID integration, not by this code.", + "main": "src/functions/*.js", + "scripts": { + "start": "func start" + }, + "engines": { + "node": ">=22.12.0" + }, + "dependencies": { + "@azure/functions": "^4.0.0" + } +} diff --git a/api/geocode.js b/api/src/functions/geocode.js similarity index 55% rename from api/geocode.js rename to api/src/functions/geocode.js index f3a5056..9dac088 100644 --- a/api/geocode.js +++ b/api/src/functions/geocode.js @@ -1,6 +1,7 @@ -const { requireSession } = require('./_lib/session'); -const { throttleGeocodeCall } = require('./_lib/rateLimit'); -const { readJsonBody, sendJson, methodNotAllowed } = require('./_lib/http'); +const { app } = require('@azure/functions'); +const { throttleGeocodeCall } = require('../../_lib/rateLimit'); +const { getClientPrincipal } = require('../../_lib/clientPrincipal'); +const { jsonResponse, methodNotAllowed, unauthorized } = require('../../_lib/http'); // Nominatim's usage policy allows ~1 request/second per app and requires an identifying // User-Agent. This function is the *only* place that ever talks to Nominatim — the browser @@ -9,16 +10,16 @@ const NOMINATIM_MIN_INTERVAL_MS = 1100; const MAX_ADDRESS_LENGTH = 300; const USER_AGENT = 'Praxium-Map-Admin/1.0 (contact: info@praxiumfoundation.com)'; -module.exports = async (req, res) => { - if (req.method !== 'POST') return methodNotAllowed(res, ['POST']); - if (!requireSession(req)) return sendJson(res, 401, { error: 'unauthorized' }); +async function geocodeHandler(request) { + if (request.method !== 'POST') return methodNotAllowed(['POST']); + if (!getClientPrincipal(request)) return unauthorized(); - const { address } = readJsonBody(req); + const { address } = await request.json().catch(() => ({})); if (typeof address !== 'string' || !address.trim()) { - return sendJson(res, 400, { error: 'address_required' }); + return jsonResponse(400, { error: 'address_required' }); } if (address.trim().length > MAX_ADDRESS_LENGTH) { - return sendJson(res, 400, { error: 'address_too_long' }); + return jsonResponse(400, { error: 'address_too_long' }); } await throttleGeocodeCall(NOMINATIM_MIN_INTERVAL_MS); @@ -27,11 +28,11 @@ module.exports = async (req, res) => { const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(address)}`; const resp = await fetch(url, { headers: { 'User-Agent': USER_AGENT } }); if (!resp.ok) { - return sendJson(res, 502, { error: 'geocode_service_error' }); + return jsonResponse(502, { error: 'geocode_service_error' }); } const results = await resp.json(); if (!Array.isArray(results) || results.length === 0) { - return sendJson(res, 404, { error: 'not_found' }); + return jsonResponse(404, { error: 'not_found' }); } const best = results[0]; const lat = Number(best.lat); @@ -44,15 +45,24 @@ module.exports = async (req, res) => { || lng < -180 || lng > 180 ) { - return sendJson(res, 502, { error: 'geocode_service_error' }); + return jsonResponse(502, { error: 'geocode_service_error' }); } - return sendJson(res, 200, { + return jsonResponse(200, { lat, lng, displayName: typeof best.display_name === 'string' ? best.display_name : address.trim(), }); } catch (err) { console.error('POST /api/geocode failed:', err); - return sendJson(res, 502, { error: 'geocode_service_error' }); + return jsonResponse(502, { error: 'geocode_service_error' }); } -}; +} + +app.http('geocode', { + methods: ['POST'], + route: 'geocode', + authLevel: 'anonymous', + handler: geocodeHandler, +}); + +module.exports = { geocodeHandler }; diff --git a/api/src/functions/gist.js b/api/src/functions/gist.js new file mode 100644 index 0000000..ae6b4fc --- /dev/null +++ b/api/src/functions/gist.js @@ -0,0 +1,30 @@ +const { app } = require('@azure/functions'); +const { fetchGist } = require('../../_lib/githubGist'); +const { readCategories } = require('../../_lib/categories'); +const { getClientPrincipal } = require('../../_lib/clientPrincipal'); +const { jsonResponse, methodNotAllowed, unauthorized } = require('../../_lib/http'); + +async function gistHandler(request) { + if (request.method !== 'GET') return methodNotAllowed(['GET']); + if (!getClientPrincipal(request)) return unauthorized(); + + try { + const [{ organizations, version, updatedAt }, categories] = await Promise.all([ + fetchGist(), + Promise.resolve(readCategories()), + ]); + return jsonResponse(200, { organizations, categories, version, updatedAt }); + } catch (err) { + console.error('GET /api/gist failed:', err); + return jsonResponse(502, { error: 'gist_fetch_failed' }); + } +} + +app.http('gist', { + methods: ['GET'], + route: 'gist', + authLevel: 'anonymous', // access is enforced by Static Web Apps routing, not a Functions key + handler: gistHandler, +}); + +module.exports = { gistHandler }; diff --git a/api/publish.js b/api/src/functions/publish.js similarity index 86% rename from api/publish.js rename to api/src/functions/publish.js index 1b33754..30493ce 100644 --- a/api/publish.js +++ b/api/src/functions/publish.js @@ -1,7 +1,8 @@ -const { requireSession } = require('./_lib/session'); -const { fetchGist, updateGist } = require('./_lib/githubGist'); -const { readCategories } = require('./_lib/categories'); -const { readJsonBody, sendJson, methodNotAllowed } = require('./_lib/http'); +const { app } = require('@azure/functions'); +const { fetchGist, updateGist } = require('../../_lib/githubGist'); +const { readCategories } = require('../../_lib/categories'); +const { getClientPrincipal } = require('../../_lib/clientPrincipal'); +const { jsonResponse, methodNotAllowed, unauthorized } = require('../../_lib/http'); const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const FIELD_LIMITS = Object.freeze({ @@ -188,16 +189,16 @@ function stripConnectionsTo(organizations, removedId) { }); } -module.exports = async (req, res) => { - if (req.method !== 'POST') return methodNotAllowed(res, ['POST']); - if (!requireSession(req)) return sendJson(res, 401, { error: 'unauthorized' }); +async function publishHandler(request) { + if (request.method !== 'POST') return methodNotAllowed(['POST']); + if (!getClientPrincipal(request)) return unauthorized(); - const { mode, org, orgId, expectedVersion } = readJsonBody(req); + const { mode, org, orgId, expectedVersion } = await request.json().catch(() => ({})); if (!['create', 'update', 'delete'].includes(mode)) { - return sendJson(res, 400, { error: 'invalid_mode' }); + return jsonResponse(400, { error: 'invalid_mode' }); } if (!expectedVersion) { - return sendJson(res, 400, { error: 'expected_version_required' }); + return jsonResponse(400, { error: 'expected_version_required' }); } let current; @@ -205,11 +206,11 @@ module.exports = async (req, res) => { current = await fetchGist(); } catch (err) { console.error('publish: failed to re-fetch gist:', err); - return sendJson(res, 502, { error: 'gist_fetch_failed' }); + return jsonResponse(502, { error: 'gist_fetch_failed' }); } if (current.version !== expectedVersion) { - return sendJson(res, 409, { + return jsonResponse(409, { error: 'conflict', currentVersion: current.version, message: 'Data changed since you loaded it — please reload and reapply your edit.', @@ -221,9 +222,9 @@ module.exports = async (req, res) => { let resultOrgId = null; if (mode === 'delete') { - if (!Number.isInteger(orgId)) return sendJson(res, 400, { error: 'organization_id_required' }); + if (!Number.isInteger(orgId)) return jsonResponse(400, { error: 'organization_id_required' }); const idx = organizations.findIndex((o) => o.id === orgId); - if (idx === -1) return sendJson(res, 404, { error: 'organization_not_found' }); + if (idx === -1) return jsonResponse(404, { error: 'organization_not_found' }); organizations.splice(idx, 1); stripConnectionsTo(organizations, orgId); } else { @@ -239,7 +240,7 @@ module.exports = async (req, res) => { } } if (errors.length) { - return sendJson(res, 400, { error: 'validation_failed', details: errors }); + return jsonResponse(400, { error: 'validation_failed', details: errors }); } if (mode === 'create') { @@ -254,7 +255,7 @@ module.exports = async (req, res) => { } else { // update const idx = organizations.findIndex((o) => o.id === orgId); - if (idx === -1) return sendJson(res, 404, { error: 'organization_not_found' }); + if (idx === -1) return jsonResponse(404, { error: 'organization_not_found' }); const previousConnections = organizations[idx].connections || []; const finalized = finalizeOrgFields(org, orgId, organizations[idx]); organizations[idx] = finalized; @@ -266,9 +267,18 @@ module.exports = async (req, res) => { try { const result = await updateGist(organizations); const savedOrg = resultOrgId === null ? null : result.organizations.find((o) => o.id === resultOrgId); - return sendJson(res, 200, { ok: true, org: savedOrg, version: result.version }); + return jsonResponse(200, { ok: true, org: savedOrg, version: result.version }); } catch (err) { console.error('publish: failed to update gist:', err); - return sendJson(res, 502, { error: 'gist_update_failed' }); + return jsonResponse(502, { error: 'gist_update_failed' }); } -}; +} + +app.http('publish', { + methods: ['POST'], + route: 'publish', + authLevel: 'anonymous', + handler: publishHandler, +}); + +module.exports = { publishHandler }; diff --git a/index.html b/index.html new file mode 100644 index 0000000..7cf3cf8 --- /dev/null +++ b/index.html @@ -0,0 +1,21 @@ + + + + + + Praxium Map Admin + + + +

      Redirecting to the admin login…

      + + diff --git a/package.json b/package.json index 7de2eb1..ef24d48 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "electron/main.js", "scripts": { "start": "electron .", - "check": "node --check map.js && node --check network-data.js && node --check admin/admin.js && node --check admin/admin-auth.js && node --check admin-editor/app.js && node --check electron/main.js && node --check electron/preload.js", + "check": "node --check map.js && node --check network-data.js && node --check admin/admin.js && node --check admin-editor/app.js && node --check electron/main.js && node --check electron/preload.js && for f in api/_lib/*.js api/src/functions/*.js; do node --check \"$f\" || exit 1; done", + "pretest": "node -e \"require('fs').copyFileSync('categories.json', 'api/categories.json')\"", "test": "node test/public-assets.test.js && node test/network-data.test.js && node test/api.test.js", "validate": "npm run check && npm test", "pack": "electron-builder --dir", diff --git a/staticwebapp.config.json b/staticwebapp.config.json new file mode 100644 index 0000000..62423df --- /dev/null +++ b/staticwebapp.config.json @@ -0,0 +1,41 @@ +{ + "routes": [ + { "route": "/", "redirect": "/admin", "statusCode": 302 }, + { + "route": "/admin/*", + "allowedRoles": ["authenticated"], + "headers": { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: blob: https://server.arcgisonline.com https://cdnjs.cloudflare.com; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" + } + }, + { + "route": "/api/*", + "allowedRoles": ["authenticated"], + "headers": { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'" + } + } + ], + "responseOverrides": { + "401": { "redirect": "/.auth/login/aad", "statusCode": 302 } + }, + "auth": { + "identityProviders": { + "azureActiveDirectory": { + "registration": { + "openIdIssuer": "https://login.microsoftonline.com/5937888e-e143-44f6-92cc-3080289b15c9/v2.0", + "clientIdSettingName": "AZURE_CLIENT_ID", + "clientSecretSettingName": "AZURE_CLIENT_SECRET" + } + } + } + } +} diff --git a/test/api.test.js b/test/api.test.js index 2e40929..4955702 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -1,7 +1,13 @@ -// Lightweight functional test for the /api handlers, using an in-memory fake Gist backend -// (no real network calls to GitHub). Run with: node test/api.test.js -process.env.SESSION_SECRET = 'test-secret-value-not-real-at-least-32-bytes'; -process.env.ADMIN_PASSPHRASE = 'correct-horse-battery-staple'; +// Lightweight functional test for the /api Azure Functions, using an in-memory fake Gist +// backend (no real network calls to GitHub). Run with: node test/api.test.js +// +// Authentication itself (Microsoft Entra ID, restricted to an explicit board-member +// allow-list) is enforced by Azure Static Web Apps before a request ever reaches these +// functions — there's no local login/passphrase/rate-limiting logic left to test here. +// What IS tested: that each function still checks for a valid x-ms-client-principal +// (defense-in-depth, see api/_lib/clientPrincipal.js) and that every validation, +// optimistic-concurrency, reciprocal-connection, and cascade-delete behavior in +// publish.js carries over unchanged from the pre-migration Vercel version. process.env.GIST_ID = 'fake-gist-id'; process.env.GITHUB_PAT = 'fake-pat-not-real'; @@ -50,152 +56,81 @@ global.fetch = async (url, opts = {}) => { throw new Error('Unexpected fetch to ' + u); }; -function mockReqRes({ method = 'GET', body = {}, cookie = '', ip = '1.2.3.4' } = {}) { - let statusCode = 200; - let headers = {}; - let ended = ''; - const req = { method, body, headers: { cookie }, socket: { remoteAddress: ip } }; - const res = { - status(code) { statusCode = code; return res; }, - setHeader(k, v) { headers[k] = v; return res; }, - end(payload) { ended = payload; return res; }, - _result() { return { statusCode, headers, body: ended ? JSON.parse(ended) : null }; }, +// Builds a request matching Azure Functions v4's HttpRequest shape closely enough for our +// handlers: a `method`, a `headers.get(name)` accessor, and an async `json()` body reader. +function mockRequest({ method = 'GET', body = {}, principal = AUTHENTICATED_PRINCIPAL } = {}) { + const headerMap = new Map(); + if (principal) { + headerMap.set('x-ms-client-principal', Buffer.from(JSON.stringify(principal)).toString('base64')); + } + return { + method, + headers: { get: (name) => headerMap.get(String(name).toLowerCase()) ?? null }, + json: async () => body, }; - return { req, res }; } -function extractCookieValue(setCookieHeader) { - const match = setCookieHeader.match(/praxium_admin_session=([^;]+)/); - return match ? decodeURIComponent(match[1]) : null; -} +const AUTHENTICATED_PRINCIPAL = { + identityProvider: 'aad', + userId: 'test-user-id', + userDetails: 'board.member@praxiumfoundation.com', + userRoles: ['anonymous', 'authenticated'], +}; function assert(cond, message) { if (!cond) throw new Error('Assertion failed: ' + message); } async function run() { - const auth = require(path.join(REPO, 'api/auth.js')); - const gist = require(path.join(REPO, 'api/gist.js')); - const geocode = require(path.join(REPO, 'api/geocode.js')); - const publish = require(path.join(REPO, 'api/publish.js')); - const logout = require(path.join(REPO, 'api/logout.js')); - - console.log('--- wrong passphrase -> 401 ---'); - { - const { req, res } = mockReqRes({ method: 'POST', body: { passphrase: 'wrong' } }); - await auth(req, res); - assert(res._result().statusCode === 401, 'expected 401 for wrong passphrase'); - } - - console.log('--- correct passphrase -> 200 + session cookie ---'); - let sessionCookie; - { - const { req, res } = mockReqRes({ method: 'POST', body: { passphrase: 'correct-horse-battery-staple' } }); - await auth(req, res); - const r = res._result(); - assert(r.statusCode === 200, 'expected 200 for correct passphrase'); - assert(r.headers['Cache-Control'] === 'no-store', 'expected private API responses not to be cached'); - assert( - r.headers['X-Content-Type-Options'] === 'nosniff', - 'expected API responses to disable content-type sniffing', - ); - sessionCookie = extractCookieValue(r.headers['Set-Cookie']); - assert(sessionCookie, 'expected a session cookie to be set'); - } + const { gistHandler } = require(path.join(REPO, 'api/src/functions/gist.js')); + const { geocodeHandler } = require(path.join(REPO, 'api/src/functions/geocode.js')); + const { publishHandler } = require(path.join(REPO, 'api/src/functions/publish.js')); - console.log('--- rate limit kicks in after repeated wrong attempts ---'); + console.log('--- GET /api/gist without a client principal -> 401 ---'); { - let last; - for (let i = 0; i < 5; i++) { - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'nope' + i }, - ip: '5.6.7.8', - }); - await auth(req, res); - last = res._result(); - assert(last.statusCode === 401, `expected failed attempt ${i + 1} to return 401`); - } - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'one-too-many' }, - ip: '5.6.7.8', - }); - await auth(req, res); - last = res._result(); - assert(last.statusCode === 429, 'expected the request after 5 failures to be rate limited'); + const res = await gistHandler(mockRequest({ method: 'GET', principal: null })); + assert(res.status === 401, 'expected 401 without an authenticated client principal'); } - console.log('--- successful login clears prior failed attempts ---'); + console.log('--- GET /api/gist with an unauthenticated principal (missing "authenticated" role) -> 401 ---'); { - const ip = '9.8.7.6'; - for (let i = 0; i < 4; i++) { - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'wrong-' + i }, - ip, - }); - await auth(req, res); - assert(res._result().statusCode === 401, 'expected pre-success failure to return 401'); - } - { - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'correct-horse-battery-staple' }, - ip, - }); - await auth(req, res); - assert(res._result().statusCode === 200, 'expected success before limit'); - } - { - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'wrong-after-success' }, - ip, - }); - await auth(req, res); - assert(res._result().statusCode === 401, 'expected cleared failure bucket after success'); - } + const res = await gistHandler(mockRequest({ + method: 'GET', + principal: { identityProvider: 'aad', userId: 'x', userDetails: 'x@example.com', userRoles: ['anonymous'] }, + })); + assert(res.status === 401, 'expected 401 when the authenticated role is missing'); } - console.log('--- GET /api/gist without session -> 401 ---'); + console.log('--- GET /api/gist with a valid client principal -> 200 ---'); + let currentVersion; { - const { req, res } = mockReqRes({ method: 'GET' }); - await gist(req, res); - assert(res._result().statusCode === 401, 'expected 401 without session'); + const res = await gistHandler(mockRequest({ method: 'GET' })); + assert(res.status === 200, 'expected 200 with a valid client principal'); + assert(Array.isArray(res.jsonBody.organizations) && res.jsonBody.organizations.length === 2, 'expected 2 orgs'); + assert(Array.isArray(res.jsonBody.categories) && res.jsonBody.categories.length > 0, 'expected categories list'); + assert(res.jsonBody.version === 'revision-0', 'expected the immutable Gist revision as version'); + assert(res.headers['Cache-Control'] === 'no-store', 'expected private API responses not to be cached'); + assert(res.headers['X-Content-Type-Options'] === 'nosniff', 'expected content-type sniffing disabled'); + currentVersion = res.jsonBody.version; } - console.log('--- GET /api/gist with session -> 200 ---'); - let currentVersion; + console.log('--- geocode rejects overlong addresses before network access ---'); { - const { req, res } = mockReqRes({ method: 'GET', cookie: `praxium_admin_session=${sessionCookie}` }); - await gist(req, res); - const r = res._result(); - assert(r.statusCode === 200, 'expected 200 with session'); - assert(Array.isArray(r.body.organizations) && r.body.organizations.length === 2, 'expected 2 orgs'); - assert(Array.isArray(r.body.categories) && r.body.categories.length > 0, 'expected categories list'); - assert(r.body.version === 'revision-0', 'expected the immutable Gist revision as version'); - currentVersion = r.body.version; + const res = await geocodeHandler(mockRequest({ method: 'POST', body: { address: 'x'.repeat(301) } })); + assert(res.status === 400, 'expected an overlong address to be rejected'); + assert(res.jsonBody.error === 'address_too_long', 'expected address_too_long error'); } - console.log('--- geocode rejects overlong addresses before network access ---'); + console.log('--- geocode without a client principal -> 401 ---'); { - const { req, res } = mockReqRes({ - method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, - body: { address: 'x'.repeat(301) }, - }); - await geocode(req, res); - const r = res._result(); - assert(r.statusCode === 400, 'expected an overlong address to be rejected'); - assert(r.body.error === 'address_too_long', 'expected address_too_long error'); + const res = await geocodeHandler(mockRequest({ method: 'POST', body: { address: 'Vancouver' }, principal: null })); + assert(res.status === 401, 'expected 401 without an authenticated client principal'); } console.log('--- publish create with stale version -> 409 ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: 'stale-version-value', @@ -206,17 +141,15 @@ async function run() { contact: { website: 'https://three.example' }, connections: [1], }, }, - }); - await publish(req, res); - assert(res._result().statusCode === 409, 'expected 409 conflict for stale version'); + })); + assert(res.status === 409, 'expected 409 conflict for stale version'); } console.log('--- publish create with valid version applies reciprocal connections ---'); let newOrgId; { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: currentVersion, @@ -227,44 +160,49 @@ async function run() { contact: { website: 'https://three.example' }, connections: [1], }, }, - }); - await publish(req, res); - const r = res._result(); - assert(r.statusCode === 200, 'expected 200 for valid create'); - newOrgId = r.body.org.id; + })); + assert(res.status === 200, 'expected 200 for valid create'); + newOrgId = res.jsonBody.org.id; assert(newOrgId === 3, 'expected assigned id 3, got ' + newOrgId); - assert(r.body.org.country === 'Canada', 'expected country to be persisted'); + assert(res.jsonBody.org.country === 'Canada', 'expected country to be persisted'); assert( - r.body.org.relationship === 'A shared learning and community initiative.', + res.jsonBody.org.relationship === 'A shared learning and community initiative.', 'expected relationship context to be persisted', ); - currentVersion = r.body.version; + currentVersion = res.jsonBody.version; const orgs = JSON.parse(fakeGistFile); const orgOne = orgs.find((o) => o.id === 1); assert(orgOne.connections.includes(3), 'expected org 1 to reciprocally link to new org 3'); } + console.log('--- publish without a client principal -> 401 ---'); + { + const res = await publishHandler(mockRequest({ + method: 'POST', + principal: null, + body: { mode: 'delete', orgId: newOrgId, expectedVersion: currentVersion }, + })); + assert(res.status === 401, 'expected 401 without an authenticated client principal'); + } + console.log('--- validation failure (missing website) -> 400 ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: currentVersion, org: { name: 'Bad Org', location: 'Nowhere', coordinates: [0, 0], category: 'Health', description: 'x', contact: {}, connections: [] }, }, - }); - await publish(req, res); - assert(res._result().statusCode === 400, 'expected 400 validation failure'); + })); + assert(res.status === 400, 'expected 400 validation failure'); } console.log('--- validation failure (overlong fields) -> 400 ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: currentVersion, @@ -278,21 +216,18 @@ async function run() { connections: [], }, }, - }); - await publish(req, res); - const r = res._result(); - assert(r.statusCode === 400, 'expected 400 for an overlong field'); + })); + assert(res.status === 400, 'expected 400 for an overlong field'); assert( - r.body.details.some((detail) => detail.includes('name must be 200')), + res.jsonBody.details.some((detail) => detail.includes('name must be 200')), 'expected the name length limit in validation details', ); } console.log('--- validation failure (unknown connection) -> 400 ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: currentVersion, @@ -302,21 +237,18 @@ async function run() { contact: { website: 'https://unknown.example' }, connections: [999], }, }, - }); - await publish(req, res); - const r = res._result(); - assert(r.statusCode === 400, 'expected 400 for an unknown connection ID'); + })); + assert(res.status === 400, 'expected 400 for an unknown connection ID'); assert( - r.body.details.some((detail) => detail.includes('999')), + res.jsonBody.details.some((detail) => detail.includes('999')), 'expected the unknown connection ID in the validation details', ); } console.log('--- update removes a connection -> reciprocal removal applies ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'update', orgId: newOrgId, @@ -326,16 +258,14 @@ async function run() { category: 'Health', description: 'new org updated', contact: { website: 'https://three.example' }, connections: [], }, }, - }); - await publish(req, res); - const r = res._result(); - assert(r.statusCode === 200, 'expected 200 for update'); - assert(r.body.org.country === 'Canada', 'older update payloads should preserve country'); + })); + assert(res.status === 200, 'expected 200 for update'); + assert(res.jsonBody.org.country === 'Canada', 'older update payloads should preserve country'); assert( - r.body.org.relationship === 'A shared learning and community initiative.', + res.jsonBody.org.relationship === 'A shared learning and community initiative.', 'older update payloads should preserve relationship context', ); - currentVersion = r.body.version; + currentVersion = res.jsonBody.version; const orgs = JSON.parse(fakeGistFile); const orgOne = orgs.find((o) => o.id === 1); @@ -344,13 +274,11 @@ async function run() { console.log('--- delete cascades connection removal ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'delete', orgId: 2, expectedVersion: currentVersion }, - }); - await publish(req, res); - assert(res._result().statusCode === 200, 'expected 200 for delete'); + })); + assert(res.status === 200, 'expected 200 for delete'); const orgs = JSON.parse(fakeGistFile); assert(!orgs.find((o) => o.id === 2), 'expected org 2 to be deleted'); @@ -358,13 +286,6 @@ async function run() { assert(!orgOne.connections.includes(2), 'expected org 1 to no longer reference deleted org 2'); } - console.log('--- logout clears cookie ---'); - { - const { req, res } = mockReqRes({ method: 'POST' }); - await logout(req, res); - assert(res._result().statusCode === 200, 'expected 200 for logout'); - } - console.log('\nALL TESTS PASSED'); }