diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index ad59c3429..d031fc8f4 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -7,9 +7,43 @@ on: branches: [master, main, development] jobs: + # Unit tests run once, outside the deployment-mode matrix. They are hermetic + # (Vitest + jsdom, no DB/browser) and already cover BOTH modes within a single + # run by busting the require cache per-test (see tests/unit/deploymentMode.spec.js + # and bakeGuards.spec.js). Matrixing them would just waste CI time. + unit: + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run Unit Tests + run: npx vitest run + + # The e2e / app-boot leg runs once per deployment shape. The mode is resolved + # once at process start (deploymentMode.js), so each shape needs its own boot — + # hence the matrix. MMGIS_DEPLOYMENT_MODE is written into .env in the setup + # step below and read by both the server and the present/absent e2e test. test: + name: e2e (${{ matrix.mode }}) timeout-minutes: 20 runs-on: ubuntu-latest + needs: unit + + strategy: + fail-fast: false + matrix: + mode: [full, lean] services: postgres: @@ -39,12 +73,6 @@ jobs: - name: Install dependencies run: npm ci - # Unit tests run under Vitest in a jsdom environment — hermetic, so they - # need neither the database nor a browser. Run them before the e2e-only - # setup so a DB/browser problem can't mask a unit result. - - name: Run Unit Tests - run: npx vitest run - - name: Install Playwright Browsers run: npx playwright install --with-deps chromium @@ -62,19 +90,58 @@ jobs: echo "ENABLE_MMGIS_WEBSOCKETS=false" >> .env echo "ENABLE_CONFIG_WEBSOCKETS=false" >> .env echo "HIDE_CONFIG=true" >> .env + echo "MMGIS_DEPLOYMENT_MODE=${{ matrix.mode }}" >> .env + + # The lean leg must boot WITHOUT the sidecar services it doesn't deploy. + # sample.env ships several WITH_* sidecars on (TIPG/TITILER/TITILER_PGSTAC/ + # VELOSERVER); leaving them on in lean would have init-db try to create the + # mmgis-stac catalog DB and run pypgstac — neither exists in the lean + # topology. deploymentMode/init-db already gate the STAC DB on isFull(), + # but we also turn the WITH_* flags off here so the lean leg's env honestly + # reflects a lean deployment (no sidecars, no spatial-catalog DB). + - name: Disable sidecar services for the lean leg + if: matrix.mode == 'lean' + run: | + echo "WITH_STAC=false" >> .env + echo "WITH_TIPG=false" >> .env + echo "WITH_TITILER=false" >> .env + echo "WITH_TITILER_PGSTAC=false" >> .env + echo "WITH_VELOSERVER=false" >> .env + # init-db must succeed under the running mode. In lean this also proves the + # spatial-catalog (mmgis-stac) DB is NOT created (it's gated on isFull()). - name: Initialize Database run: node scripts/init-db.js - - name: Run E2E Tests - run: npx playwright test tests/e2e --project=chromium + # Acceptance #4: features gated OFF in lean must still have their DB tables + # (model sync runs unconditionally; only route mounts are gated), so a + # later mode flip needs no data migration. This step boots the app once + # (which runs sequelize.sync()) and then asserts the gated tables exist. + # No continue-on-error: a missing table fails the leg. + - name: Assert gated tables exist + run: node tests/ci/assert-gated-tables.js + + # Legacy smoke / event-bus e2e tests. These boot the full SPA and have + # historically been allowed to not block the build (continue-on-error), + # so a genuinely-flaky legacy assertion doesn't turn the leg red. Kept + # non-gating here to preserve that behavior; the gating happens in the + # mode-shape step below. + - name: Run legacy E2E Tests (non-gating) + run: npx playwright test tests/e2e/smoke.spec.js tests/e2e/eventbus-integration.spec.js --project=chromium continue-on-error: true + # Acceptance #1 + #5: the deployment-mode present/absent check MUST fail + # CI when a shape breaks. NO continue-on-error here — a mis-gated feature + # (wrongly on in lean, or wrongly off in full) turns this leg red. This is + # the step that makes "a breaking change to either shape fails CI" real. + - name: Run deployment-mode shape check (gating) + run: npx playwright test tests/e2e/deployment-mode.spec.js --project=chromium + - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: - name: playwright-report + name: playwright-report-${{ matrix.mode }} path: playwright-report/ retention-days: 30 @@ -82,6 +149,6 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-${{ matrix.mode }} path: test-results/ retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 360ab55e4..3e47232ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,15 @@ Use MCP tools when possible for code analysis, symbol navigation, and code modifications. Local development uses hot-reloading and therefore there is little reason to run `npm run build` for the user. +## Deployment modes (two shapes from one codebase) + +MMGIS deploys in two shapes selected by `MMGIS_DEPLOYMENT_MODE` (resolved once at startup in `API/Backend/Utils/deploymentMode.js`): + +- **`full`** (default) — the complete application as shipped today. +- **`lean`** — a gated-down deployment: geodata management, the bundled sidecar services/proxy, the on-disk mission filesystem, the link shortener, and server-side raster utilities are turned OFF; the dashboard publish flow (Deployments) is turned ON. Models still sync in both modes, so the gated-off tables exist regardless of mode (no migration needed to flip modes). + +Contributor rule: **author your change for the full app first, then confirm lean still passes.** CI runs the e2e/boot suite once per mode (`.github/workflows/playwright-tests.yml`), with a present/absent check (`tests/e2e/deployment-mode.spec.js`) whose expected on/off mapping is hand-written from the feature inventory — keep it independent of any capability table. + ## Project Overview **MMGIS** is a web-based mapping and localization solution for science operations on planetary missions, developed by NASA-AMMOS. It provides spatial data infrastructure for mission-critical geospatial visualization and collaboration, supporting both 2D (Leaflet) and 3D (Cesium) mapping with real-time multi-user collaboration. diff --git a/README.md b/README.md index 4b1b66e2d..778a78632 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,17 @@ --- +## Deployment modes + +MMGIS builds from one codebase into two deployment shapes, selected by the `MMGIS_DEPLOYMENT_MODE` environment variable: + +- **`full`** (default) — the complete MMGIS application as shipped today. Used when the variable is unset. +- **`lean`** — a lighter, smaller-footprint deployment that deliberately turns a set of server features off (geodata management, the bundled sidecar services/proxy, the on-disk mission filesystem, the link shortener, server-side raster utilities) and turns on the dashboard publish flow. Database tables for the gated-off features are still created, so switching a deployment from one mode to the other needs no data migration. + +CI runs the end-to-end / boot suite once per mode. Contributors should author changes for the full app first, then confirm lean still passes. + +--- + ## Installation --- diff --git a/tests/ci/assert-gated-tables.js b/tests/ci/assert-gated-tables.js new file mode 100644 index 000000000..35f56fefb --- /dev/null +++ b/tests/ci/assert-gated-tables.js @@ -0,0 +1,95 @@ +/** + * assert-gated-tables.js + * + * Acceptance #4 of the both-modes CI coverage: features that are gated OFF in a + * given deployment mode must STILL have their database tables created, so a + * later mode flip needs no data migration. Model registration + sequelize.sync() + * run unconditionally on boot — only the route MOUNTS are gated — so the tables + * should exist regardless of mode. + * + * This script mirrors what scripts/server.js does at boot WITHOUT starting the + * HTTP server (and therefore without a webpack build): it loads every backend + * setup module (each of which requires its Sequelize models, registering them on + * the shared connection), runs sequelize.sync() to create the tables, then + * asserts the gated-feature tables are present. + * + * ORDERING: sequelize.sync() creates tables with PostGIS geometry columns (e.g. + * user_features), so the postgis extension must already exist. The CI workflow + * runs scripts/init-db.js (which enables postgis) BEFORE this script. Do not run + * it standalone against a bare database or sync() throws + * `type "geometry" does not exist`. + * + * It runs in BOTH legs of the CI matrix. In lean, the gated-OFF features + * (datasets, geodatasets, draw/user-files, the link shortener) have no mounted + * routes — this proves their tables exist anyway. In full, the lean-only + * Deployments feature is the gated-OFF one — its table must exist too. Rather + * than special-case per mode, we assert the union: every table below must exist + * in every mode. + * + * Exits non-zero (failing the CI leg) if any expected table is missing. + */ + +require("dotenv").config({ path: __dirname + "/../../.env" }); + +const { MODE } = require("../../API/Backend/Utils/deploymentMode"); +const setups = require("../../API/setups"); +const { sequelize } = require("../../API/connection"); + +// Hand-written from the deployment feature inventory: the tables behind the +// features that are gated OFF in one mode or the other. They must exist in BOTH +// modes. The shortener model is `url_shortener`; Sequelize pluralizes it to +// `url_shorteners` by default, so accept either spelling. +const REQUIRED_TABLE_GROUPS = [ + ["datasets"], // geodata management (datasets) + ["geodatasets"], // geodata management (geodatasets) + ["user_files"], // on-disk mission filesystem / drawing + ["user_features"], // drawing (vector features) + ["url_shorteners", "url_shortener"], // link shortener + ["deployments"], // lean-only dashboard publish flow +]; + +async function main() { + await new Promise((resolve) => { + // Loading the backend setups requires each feature's setup.js, which in + // turn requires its models — registering them on the shared sequelize. + setups.getBackendSetups(() => resolve()); + }); + + await sequelize.authenticate(); + await sequelize.sync(); + + const tables = await sequelize + .getQueryInterface() + .showAllTables(); + const present = new Set(tables.map((t) => String(t).toLowerCase())); + + const missing = []; + for (const group of REQUIRED_TABLE_GROUPS) { + const found = group.some((name) => present.has(name.toLowerCase())); + if (!found) missing.push(group.join(" | ")); + } + + if (missing.length > 0) { + console.error( + `[assert-gated-tables] MODE=${MODE}: missing expected table(s): ${missing.join( + ", " + )}` + ); + console.error( + `[assert-gated-tables] tables present: ${[...present] + .sort() + .join(", ")}` + ); + process.exit(1); + } + + console.log( + `[assert-gated-tables] MODE=${MODE}: all ${REQUIRED_TABLE_GROUPS.length} gated-feature table groups present.` + ); + process.exit(0); +} + +main().catch((err) => { + console.error("[assert-gated-tables] Unexpected failure:", err); + process.exit(1); +}); diff --git a/tests/e2e/deployment-mode.spec.js b/tests/e2e/deployment-mode.spec.js new file mode 100644 index 000000000..5d0d432f3 --- /dev/null +++ b/tests/e2e/deployment-mode.spec.js @@ -0,0 +1,151 @@ +import { test, expect, request } from '@playwright/test' + +/** + * Deployment-mode present/absent checks (acceptance #2 of the both-modes CI + * coverage issue). + * + * The same codebase ships in two shapes selected by MMGIS_DEPLOYMENT_MODE: + * - full: the complete application as shipped today. + * - lean: a gated-down deployment that turns a set of server features OFF. + * + * The CI matrix boots the app once per shape and passes the mode in via + * MMGIS_DEPLOYMENT_MODE; this test reads that and, per feature, asserts the + * feature is reachable when it belongs to the running mode and gone otherwise. + * + * HAND-WRITTEN feature inventory — IMPORTANT: the expected on/off mapping below + * is written by a person from the deployment ADR feature inventory, NOT read + * from any capability table or from the gated code. That independence is the + * whole point: a test that takes its expected answers from the thing it is + * testing cannot catch a wrong entry. The route PATHS were looked up in the + * code, but which mode each belongs to is hand-asserted here. Do NOT later + * rewrite this to read its expectations from a capability table. + * + * How a route reports present vs. absent (verified against the code): + * - A MOUNTED route answers from its own handler. With AUTH=off these handlers + * return HTTP 200 with a JSON failure body (auth/guard rejection) or real + * data, and the sidecar proxy answers with a proxy/upstream error — in every + * case the status is NOT 404. + * - An UNMOUNTED route falls through to the app's catch-all + * (`app.all('*')` -> res.status(404).render('error')), i.e. a real HTTP 404. + * So the reliable discriminator is: mounted => status !== 404; absent => 404. + */ + +const MODE = process.env.MMGIS_DEPLOYMENT_MODE || 'full' + +// Each feature: where it belongs, and a single HTTP probe that exercises a route +// the feature owns. `method`/`path` only — no body needed, because we only care +// whether the route is mounted (any non-404) or absent (404). +const FEATURES = [ + // --- Full-only: present in full, absent in lean --- + { + name: 'geodata management (datasets)', + mode: 'full', + method: 'post', + // ensureAdmin allow-lists /api/datasets/get, so in full it reaches the + // mounted router even without admin auth. + path: '/api/datasets/get', + }, + { + name: 'geodata management (geodatasets)', + mode: 'full', + method: 'get', + // ensureAdmin allow-lists /api/geodatasets/get. + path: '/api/geodatasets/get', + }, + { + name: 'drawing (draw API)', + mode: 'full', + method: 'post', + path: '/api/draw/add', + }, + { + name: 'on-disk mission filesystem (files API)', + mode: 'full', + method: 'post', + path: '/api/files/getfiles', + }, + { + name: 'link shortener', + mode: 'full', + method: 'post', + path: '/api/shortener/shorten', + }, + { + name: 'server-side raster utilities', + mode: 'full', + method: 'post', + // /api/utils is mounted in both modes, but the raster endpoints inside + // it (getbands/getprofile/...) are registered only when isFull(). + path: '/api/utils/getbands', + }, + { + name: 'bundled sidecar services / proxy (titiler)', + mode: 'full', + method: 'get', + // The /titiler proxy is mounted only when isFull() AND WITH_TITILER=true + // (the full CI leg keeps the sample.env sidecar flags on). The lean leg + // turns the WITH_* flags off and the proxy is also isFull()-gated, so it + // is absent there. + path: '/titiler/healthz', + }, + + // --- Lean-only: present in lean, absent in full --- + { + name: 'dashboard publish flow (deployments)', + mode: 'lean', + method: 'get', + // Mounted only when isLean(); ensureAdmin rejects with a 200 JSON body + // (not 404) when present. + path: '/api/deployments', + }, +] + +async function probe(api, feature) { + const res = + feature.method === 'post' + ? await api.post(feature.path, { data: {} }) + : await api.get(feature.path) + return res.status() +} + +test.describe(`Deployment mode present/absent — MODE=${MODE}`, () => { + let api + + test.beforeAll(async () => { + api = await request.newContext({ + baseURL: process.env.TEST_BASE_URL || 'http://localhost:8888', + ignoreHTTPSErrors: true, + }) + }) + + test.afterAll(async () => { + await api.dispose() + }) + + for (const feature of FEATURES) { + const belongsToRunningMode = feature.mode === MODE + + test(`${feature.name} is ${ + belongsToRunningMode ? 'present' : 'absent' + } in ${MODE}`, async () => { + const status = await probe(api, feature) + + if (belongsToRunningMode) { + // Reachable: the route is mounted, so it must NOT hit the + // catch-all 404. (It may legitimately answer 200 with a guard + // failure, real data, or a proxy/upstream error status.) + expect( + status, + `${feature.name} should be MOUNTED in ${MODE} (got ${status}); a 404 means the route is gone` + ).not.toBe(404) + } else { + // Gone: the route is unmounted and falls through to the + // app catch-all, which returns a real 404. + expect( + status, + `${feature.name} should be ABSENT in ${MODE} (got ${status}); anything but 404 means the route is still mounted` + ).toBe(404) + } + }) + } +})