Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/playwright-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ jobs:
- name: Install dependencies
run: npm ci

# The postinstall plugin-dep install must not move packages off their locked versions
- name: Check installed packages match package-lock.json
run: node scripts/check-installed-lockfile.js

- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium

Expand Down Expand Up @@ -129,6 +133,13 @@ jobs:
if [ -n "$added" ]; then legacy="$added^"; else legacy=origin/${{ github.base_ref }}; fi
bash tests/migration/run-legacy-compat.sh "$legacy"

# 5.2.24 is the last tagged release before the auth refactor; its schema predates users.missions_viewing
- name: Check auth-migrate on a database from the last release
if: matrix.site_access == 'required' && github.event_name == 'pull_request'
run: |
git fetch --no-tags --depth=1 origin refs/tags/5.2.24:refs/tags/5.2.24
APPLY_WITH=cli bash tests/migration/run-legacy-compat.sh 5.2.24

- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# MMGIS - AI Agent Context

**Project**: MMGIS (Multi-Mission Geographic Information System)
**Version**: 5.4.0
**Last Updated**: 2026-09-15
**Version**: 5.5.5
**Last Updated**: 2026-11-11

> **New to this repo?** See [.knowledge/AI-GETTING-STARTED.md](./.knowledge/AI-GETTING-STARTED.md) for setup guide, port map, and common pitfalls.
> **Development workflow?** See [.knowledge/AI-DEVELOPMENT.md](./.knowledge/AI-DEVELOPMENT.md) for the spec-kit workflow.
Expand Down
5 changes: 4 additions & 1 deletion API/permissions/legacyImport.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const IMPORT_KEY = "legacy_import_v1";
// Rollback tracking from earlier builds of this release; no longer kept
const OLD_COLUMNS_KEYS = { [Op.or]: [{ [Op.eq]: "legacy_columns_v1" }, { [Op.like]: "legacy_columns:%" }] };
const SIGNED_IN_PERMISSIONS = [LEGACY_ADMIN, "001"];
const LEGACY_MISSION_COLUMNS = ["missions_managing", "missions_viewing"];

function models() {
const permissionModels = require("./models");
Expand Down Expand Up @@ -41,8 +42,10 @@ async function plan({ dryRun = false } = {}) {
let lookup = missions.uuidForName;
if (dryRun) lookup = await readOnlyMissionLookup();
else await missions.backfill();
// Databases from older releases may not have every legacy column yet; a missing one is unset
const columns = await User.describe();
const users = await User.findAll({
attributes: ["id", "username", "permission", "missions_managing", "missions_viewing"],
attributes: ["id", "username", "permission", ...LEGACY_MISSION_COLUMNS.filter((c) => c in columns)],
order: [["id", "ASC"]],
raw: true,
});
Expand Down
122 changes: 66 additions & 56 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ authors:
given-names: "Amanda"
orcid: "https://orcid.org/0009-0004-8926-7859"
title: "MMGIS (Multi-Mission Geographic Information System)"
version: 5.0.0
version: 5.5.5
doi: 10.5281/zenodo.15237385
date-released: 2026-05-01
date-released: 2026-11-11
url: "https://github.com/NASA-AMMOS/MMGIS"
17 changes: 9 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ COPY . .
RUN node scripts/resolve-plugin-deps.js

RUN --mount=type=cache,target=/root/.npm \
node scripts/install-plugin-deps.js
node scripts/install-plugin-deps.js && \
node scripts/check-installed-lockfile.js

RUN if [ -s plugin-python-requirements.txt ] && grep -qv '^#' plugin-python-requirements.txt; then \
echo "Installing plugin pip dependencies..." && \
Expand Down Expand Up @@ -156,16 +157,16 @@ RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production

# Re-install plugin npm deps in the runtime stage. Plugin deps are
# installed in the builder with `--no-save --no-package-lock`, so they
# are NOT recorded in `package.json`/`package-lock.json` and would
# otherwise be lost by the `npm ci` above. Backend plugins that
# `require()` their declared npm deps at runtime would fail without
# this step. (Frontend plugin deps are unaffected because webpack
# bundled them into `./build` during the builder stage.)
# installed in the builder with `--no-save`, so they are NOT recorded in
# `package.json`/`package-lock.json` and would otherwise be lost by the
# `npm ci` above. Backend plugins that `require()` their declared npm
# deps at runtime would fail without this step. (Frontend plugin deps
# are unaffected because webpack bundled them into `./build` during the
# builder stage.) `npm_config_omit=dev` keeps devDependencies out.
COPY --from=builder /usr/src/app/plugin-package.json ./plugin-package.json
COPY --from=builder /usr/src/app/scripts/install-plugin-deps.js ./scripts/install-plugin-deps.js
RUN --mount=type=cache,target=/root/.npm \
node scripts/install-plugin-deps.js
npm_config_omit=dev node scripts/install-plugin-deps.js

# Copy built artifacts from builder
COPY --from=builder /usr/src/app/build ./build
Expand Down
36 changes: 36 additions & 0 deletions scripts/check-installed-lockfile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* Fails if any installed package in node_modules is at a different version
* than package-lock.json pins. Run after `npm ci` (whose postinstall also
* installs plugin deps) to catch installs that re-resolve the locked tree.
*
* node scripts/check-installed-lockfile.js
*
* Packages the lockfile lists but that are not installed (optional or
* omitted dev deps) are skipped.
*/
const fs = require("fs");

Check warning on line 12 in scripts/check-installed-lockfile.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=NASA-AMMOS_MMGIS&issues=AaHMLlzQ3YlNnwsoO1Fi&open=AaHMLlzQ3YlNnwsoO1Fi&pullRequest=1167
const path = require("path");

Check warning on line 13 in scripts/check-installed-lockfile.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=NASA-AMMOS_MMGIS&issues=AaHMLlzQ3YlNnwsoO1Fj&open=AaHMLlzQ3YlNnwsoO1Fj&pullRequest=1167

const ROOT = path.resolve(__dirname, "..");
const lock = JSON.parse(fs.readFileSync(path.join(ROOT, "package-lock.json"), "utf8"));

const drifted = [];
let checked = 0;
for (const [key, entry] of Object.entries(lock.packages || {})) {
if (!key.startsWith("node_modules/") || entry.link || !entry.version) continue;
const pkgFile = path.join(ROOT, key, "package.json");
if (!fs.existsSync(pkgFile)) continue;
const installed = JSON.parse(fs.readFileSync(pkgFile, "utf8")).version;
checked++;
if (installed !== entry.version) drifted.push(`${key}: installed ${installed}, locked ${entry.version}`);
}

if (drifted.length) {
console.error(`${drifted.length} installed package(s) differ from package-lock.json:`);
drifted.slice(0, 30).forEach((d) => console.error(` ${d}`));
if (drifted.length > 30) console.error(` ...and ${drifted.length - 30} more`);
console.error("Reinstall with `npm ci`.");
process.exit(1);
}
console.log(`All ${checked} installed packages match package-lock.json.`);
7 changes: 4 additions & 3 deletions scripts/install-plugin-deps.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
* silently (no plugins declared npm deps — normal state for stock
* MMGIS).
* - Otherwise invokes
* `npm install <pkg@ver> ... --no-save --no-package-lock --ignore-scripts`.
* `--no-save` / `--no-package-lock` keep the root lockfile clean.
* `npm install <pkg@ver> ... --no-save --ignore-scripts`.
* `--no-save` keeps `package.json` and `package-lock.json` unchanged.
* The lockfile is still read, so the root deps stay at their locked
* versions (`--no-package-lock` would re-resolve the whole tree).
* `--ignore-scripts` prevents re-entering this postinstall (and
* also skips the lifecycle scripts of the installed packages,
* matching the Dockerfile's behaviour).
Expand Down Expand Up @@ -130,7 +132,6 @@ const result = spawnSync(
[
"install",
"--no-save",
"--no-package-lock",
"--ignore-scripts",
...specs,
],
Expand Down
10 changes: 10 additions & 0 deletions tests/migration/run-legacy-compat.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
#
# tests/migration/run-legacy-compat.sh [base-ref] (default: origin/development)
#
# APPLY_WITH=cli applies the import with `npm run auth:migrate` before the
# upgraded server starts; the default lets the server import on first start.
#
# Uses only the mmgis-test database, which is dropped and recreated.
set -euo pipefail
cd "$(dirname "$0")/../.."
ROOT=$PWD
BASE_REF=${1:-origin/development}
APPLY_WITH=${APPLY_WITH:-server}
OLD_PORT=${OLD_PORT:-8890}
NEW_PORT=${NEW_PORT:-8891}
WORK=${LEGACY_WORKTREE:-$ROOT/test-results/legacy-base}
Expand Down Expand Up @@ -58,6 +62,12 @@ before=$(node tests/migration/legacy-accounts.js snapshot)
after=$(node tests/migration/legacy-accounts.js snapshot)
[ "$before" = "$after" ] || { echo "Dry run changed the database"; exit 1; }

if [ "$APPLY_WITH" = "cli" ]; then
echo "== Applying the import with auth-migrate"
env -u AUTH SITE_ACCESS=required AUTH_PROVIDERS='["local"]' NODE_ENV=test DB_NAME=mmgis-test \
node scripts/auth-migrate.js | grep -v '^{"level"'
fi

echo "== Starting this checkout on :$NEW_PORT"
env -u AUTH SITE_ACCESS=required AUTH_PROVIDERS='["local"]' PORT=$NEW_PORT NODE_ENV=test DB_NAME=mmgis-test \
node scripts/server.js > "$LOGS/legacy-new-server.log" 2>&1 &
Expand Down
32 changes: 30 additions & 2 deletions tests/unit/permissions-legacyImport.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,21 @@ const USERS = [
{ id: 5, username: 'locked', permission: '000', missions_managing: null, missions_viewing: null },
{ id: 6, username: 'locked2', permission: '010', missions_managing: null, missions_viewing: null },
];
const ALL_COLUMNS = ['id', 'username', 'password', 'permission', 'missions_managing', 'missions_viewing'];
let columns = ALL_COLUMNS;
const STUBS = {
'API/permissions/models.js': {},
'plugins/core/backend/Users/models/user.js': { User: { findAll: async () => USERS } },
'plugins/core/backend/Users/models/user.js': {
User: {
describe: async () => Object.fromEntries(columns.map((c) => [c, {}])),
// Like a raw SELECT: only the requested columns, and a column that does not exist is an error
findAll: async ({ attributes }) => {
const missing = attributes.filter((a) => !columns.includes(a));
if (missing.length) throw new Error(`column "${missing[0]}" does not exist`);
return USERS.map((u) => Object.fromEntries(attributes.map((a) => [a, u[a]])));
},
},
},
'API/permissions/missions.js': { uuidForName: async (n) => `uuid-${n}`, backfill: async () => {} },
};
const IMPORT = path.join(root, 'API/permissions/legacyImport.js');
Expand Down Expand Up @@ -44,7 +56,10 @@ test.afterAll(() => {
const ENV = ['SITE_ACCESS', 'AUTH_PROVIDERS'];
let saved;
test.beforeEach(() => (saved = ENV.map((k) => process.env[k])));
test.afterEach(() => ENV.forEach((k, i) => (saved[i] == null ? delete process.env[k] : (process.env[k] = saved[i]))));
test.afterEach(() => {
ENV.forEach((k, i) => (saved[i] == null ? delete process.env[k] : (process.env[k] = saved[i])));
columns = ALL_COLUMNS;
});

const rolesOf = (bindings, id) =>
bindings
Expand Down Expand Up @@ -73,4 +88,17 @@ test.describe('legacy permission import', () => {
for (const id of [3, 4, 5, 6]) expect(rolesOf(bindings, id)).toEqual([]);
expect(bindings.filter((b) => b.subject_type === 'audience').map((b) => b.subject_id)).toEqual(['everyone']);
});

// 5.2.24 and earlier have no users.missions_viewing column
test('databases without missions_viewing treat every account as unrestricted', async () => {
columns = ALL_COLUMNS.filter((c) => c !== 'missions_viewing');
process.env.SITE_ACCESS = 'required';
process.env.AUTH_PROVIDERS = 'local';
const { bindings } = await legacyImport.plan();
expect(rolesOf(bindings, 1)).toEqual(['superadmin@global']);
expect(rolesOf(bindings, 2)).toEqual(['mission_admin@uuid-M', 'viewer@global']);
expect(rolesOf(bindings, 3)).toEqual(['viewer@global']);
expect(rolesOf(bindings, 4)).toEqual(['viewer@global']);
expect(rolesOf(bindings, 5)).toEqual([]);
});
});
Loading