Skip to content

create-a-container: MVC manifesto + phases 0-1 (test scaffolding, apikeys pilot migration)#405

Open
runleveldev wants to merge 9 commits into
mieweb:mainfrom
runleveldev:mvc
Open

create-a-container: MVC manifesto + phases 0-1 (test scaffolding, apikeys pilot migration)#405
runleveldev wants to merge 9 commits into
mieweb:mainfrom
runleveldev:mvc

Conversation

@runleveldev

@runleveldev runleveldev commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

First installment of the create-a-container MVC refactor: the manager's route files have grown into "god routes" (routers/api/v1/containers.js is 860 lines; its POST handler alone is ~175 lines mixing validation, business logic, eight model writes, manual transactions, and error mapping). This PR establishes the target architecture, the test safety net, and proves the pattern end-to-end on the smallest resource.

Before you balk at the diff size

The raw diff is ~6,400 insertions. 94% of that is not application code:

Cut Lines
Raw diff +6,363 / −1,425 (25 files)
...minus package-lock.json (~5,100), tests + helpers (~570), manifesto doc (~430), jest/Make config +374 / −187 (12 files)

And within that +374 of application code:

Category Lines What
Moved, not written ~130 server.js main() relocated verbatim into app.js (see the matching −95 in server.js)
New shared infrastructure 53 middlewares/validate.js (zod validation → standard error envelope)
apikeys resource slice +154 / −89 six files in resources/apikeys/ replacing the 89-line route file
Genuinely new behavior ~20 session-sweeper unref(), secure: 'auto' cookie, app-level csrfGuard + jsonErrorHandler
Wiring/config ~10 router mount, package.json deps + test script

Net: roughly +65 lines of new production behavior, plus +65 net for the apikeys migration — the growth there is the cost of layer boundaries (imports/exports/docblocks across six files instead of one) and is front-loaded: the shared scaffolding amortizes across every subsequent resource, and the big files (containers.js) already contain duplicated serializers and inline query builders that these layers absorb.

Suggested review order: the manifesto, then resources/apikeys/ against the deleted routers/api/v1/apikeys.js, then app.js side-by-side with the old server.js, then the tests.

The architecture

Read first: create-a-container/docs/mvc-manifesto.md — normative for humans and agents. It defines:

  • Resource-first layout: resources/<name>/ containing router.js, controller.js, service.js, repository.js, serializer.js, validator.js, with strict downward-only dependencies. Layers that outgrow one file become folders (service/index.js + focused modules).
  • A frozen wire contract: same envelope ({ data } / { error: { code, message, fields? } }), status codes, and paths — the React client and openapi.v1.yaml must not notice the refactor. Built on the existing middlewares/api.js contract (ApiError, asyncHandler, jsonErrorHandler), not a replacement for it.
  • A strangler-fig migration playbook: pin behavior with integration tests first, then extract serializer → repository → service → controller/router, one resource per PR, ordered smallest (apikeys) to largest (containers).

What's in this PR

Commit What
f7848d2f The manifesto
4d4f7962 Phase 0: zod + jest + supertest; shared middlewares/validate.js; sqlite test DB; user/API-key factories
0078dd19 10 integration tests pinning the /api/v1/apikeys wire contract before touching it
42e1b6a5 Phase 1: routers/api/v1/apikeys.js (89 lines) → six-file slice in resources/apikeys/; router is now pure wiring; +5 service unit tests
1068d51f Split app.js (buildApp) from server.js (migrations + secrets + listen) so tests exercise the real production middleware stack, not a hand-rolled copy
893dbfd4 make test added to the repo Makefile standard (root loop + all three components)
168da729 Verbose jest output; fix an intermittent session-store/schema race in test setup
7ad1d19d CodeQL: secure: 'auto' session cookie (object form, statically analyzable); app-wide csrfGuard + app-level jsonErrorHandler

Rebased onto current main (#373 package enhancements): the merged server.js keeps the new umzug runMigrations startup step and the fatal-error exit handler on the server.js side of the app/server split, and package.json folds umzug + the sequelize-cli devDependencies move together with the new test dependencies.

Behavior notes

The pinned integration tests pass unchanged across the migration. Deliberate error-path changes:

  • apikeys: malformed non-UUID :id400 invalid_request (previously 404 on sqlite / would be 500 on postgres); description > 255 chars → 400 (previously dialect-dependent DB error)
  • CSRF failures and errors on non-API surfaces now render the JSON error envelope instead of Express's default HTML page with a stack trace (app-level jsonErrorHandler)
  • mutations to unknown paths → 403 (CSRF) instead of 404 (app-wide csrfGuard; all real state-changing routes live under /api/v1 where the guard already applied)

CodeQL status

js/clear-text-cookie is fixed (secure: 'auto'). js/missing-token-validation remains open as a false positive: CodeQL does not model csrf-sync (only csurf/tiny-csrf/lusca/fastify), and the synchronizer-token check runs inside the library where the analyzer doesn't look. The protection is real — csrfGuard wraps csrf-sync's csrfSynchronisedProtection and now covers every handler app-wide. Will be dismissed manually.

Conflict review

Checked all open PRs before starting: #394, #365, #318, #296 touch containers.js/nodes.js/models — none touch apikeys, middlewares/, server.js, or the Makefiles. The playbook sequences nodes after #394 and containers last partly for this reason.

Testing

  • make test from the repo root: 23 tests (10 pinned integration via supertest against the real app + sqlite, 5 service unit, 8 validate middleware), verified over 5+ consecutive runs and re-run after the rebase
  • Boot smoke tests: startup migrations run, /api/v1/health 200, unauthenticated /api/v1/apikeys 401, CSRF failure returns the exact {"error":{"code":"csrf_invalid"}} envelope

Next (separate PRs)

Phase 2: settings, groups, external-domains per the playbook. A CI workflow invoking make test would make the pinning tests guard those migrations — happy to add it here or separately.

Defines the target architecture — resource-first folders
(resources/<name>/ containing router, controller, service, repository,
serializer, validator) — a complete new-route template built on the
existing middlewares/api.js contract, and a strangler-fig migration
playbook ordered from the apikeys pilot through the containers.js god
routes.

Normative for all new endpoints on this branch; the wire contract
(response envelope, status codes, paths) is frozen throughout the
migration.
Per docs/mvc-manifesto.md phase 0:

- add zod (runtime) and jest + supertest (dev); npm test script
- middlewares/validate.js: shared zod validation middleware producing
  ApiError(400, invalid_request) with per-field messages and parsed
  values on req.validated
- jest.config.js + tests/setup-env.js: sqlite test database in tmpdir,
  env pinned before config/config.js loads
- tests/helpers/db.js: sequelize.sync-based reset, baseline groups,
  user and API-key factories
- tests/helpers/app.js: listen-less express harness mounting /api/v1
  with in-memory sessions for supertest
- unit tests for validate middleware (8 passing)

Manifesto snippet updated to reference the real middleware instead of
inlining it.
Manifesto §6 step 1 for the apikeys pilot: supertest integration tests
against the legacy router covering auth (401s), list scoping/ordering/
shape, get (200/404 cross-user), create (201, one-time plaintext key,
null description), and delete (204, cross-user 404). Must pass
unchanged after the resource moves to resources/apikeys/.
Manifesto §6 steps 2-5 for the pilot resource. routers/api/v1/apikeys.js
(89 lines mixing auth lookup, queries, serialization, and HTTP) becomes
the six-file vertical slice:

- resources/apikeys/router.js      wiring only (apiAuth, validate, ctrl)
- resources/apikeys/controller.js  req/res translation, no model imports
- resources/apikeys/service.js     owner resolution + not-found policy
- resources/apikeys/repository.js  Sequelize queries (incl. the session-
                                   uid User lookup, to move behind the
                                   users service once that resource exists)
- resources/apikeys/serializer.js  single serializer, no keyHash leakage
- resources/apikeys/validator.js   zod schemas for body + :id param

Mount in routers/api/v1/index.js now points at the resource router.

Pinned integration tests pass unchanged (wire contract intact); adds
service unit tests with a mocked repository. Only deliberate behavior
delta: malformed (non-UUID) :id now 400 invalid_request instead of a
dialect-dependent 404/500, and description >255 chars now 400 instead
of a DB error.
…rtup

server.js previously built the express app inside main() and called
listen(), making the real app unreachable from tests — tests/helpers/
app.js had to rebuild a partial copy of the middleware stack, which
could silently drift from production.

- app.js: new buildApp({ sessionSecrets, rateLimit, accessLog })
  containing the entire HTTP-visible stack (verbatim from server.js);
  also unref()s the session store's expired-session sweeper so the
  interval never keeps the process alive by itself (fixes jest hanging
  on open handles)
- server.js: now only dotenv + DB-backed session secrets + listen()
- tests/helpers/app.js: consumes the real buildApp with a fixed secret,
  rate limiting off (4xx assertions must not burn the limiter budget),
  and access log off

Suite (23 tests) passes against the real app; boot smoke-tested:
/api/v1/health 200, unauthenticated /api/v1/apikeys 401.
Root Makefile gains a test target delegated to every component via the
existing uniform loop:

- create-a-container: installs devDependencies with the same
  lockfile-safe `npm install --no-package-lock` pattern as `dev`,
  then runs the jest suite
- pull-config: no-op (plain bash, nothing to test)
- mie-opensource-landing: no-op (the docs site is validated by build)

`make test` from the repo root runs all three; only create-a-container
currently executes anything (23 tests).
…ma race

- jest.config.js: verbose: true so make test / npm test list every
  individual test, not just suite results
- resetDb() must complete before buildApp(): the session store fires an
  unawaited CREATE TABLE for Sessions at construction, which raced
  resetDb's force-sync schema rebuild and could fail the API suite
  intermittently (seen once after a cold npm install). Order swapped in
  the apikeys suite and documented in the test helper.
Comment thread create-a-container/app.js Dismissed
Comment thread create-a-container/app.js Fixed
…CSRF coverage

Two alerts fired on app.js (code newly moved from server.js):

- js/clear-text-cookie: replace the per-request cookie function with the
  object form using secure: 'auto' — express-session derives the flag
  from issecure(req, trustProxy), identical semantics to the previous
  secure: req.secure, but statically analyzable
- js/missing-token-validation: mount csrfGuard app-wide after the rate
  limiter. Behavior-preserving: the guard skips GET/HEAD/OPTIONS and
  Bearer-only requests, and all state-changing routes live under
  /api/v1 where the v1 router already applied it; this extends coverage
  to the GET-only surfaces (templates, swagger, SPA, static) and
  rejects mutations to unknown paths early (403 instead of 404)

Because the app-level guard runs before the v1 router, its failures
would have hit Express's default HTML error page; jsonErrorHandler is
now also mounted as the app's final error handler so CSRF failures keep
the frozen {"error":{"code":"csrf_invalid"}} envelope (verified by
live smoke test) and non-API errors render as JSON instead of stack
traces.

23/23 tests green.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR lays down the first slice of the create-a-container MVC refactor by introducing a resource-first architecture contract (“MVC manifesto”), adding Jest/Supertest scaffolding, splitting app construction from server startup, and migrating the /api/v1/apikeys routes into a vertical resources/apikeys/ slice while keeping the API envelope stable.

Changes:

  • Adds a normative MVC/resource layering guide and introduces shared zod-based request validation middleware.
  • Adds server-side test scaffolding (Jest + Supertest) with SQLite-backed integration tests to pin /api/v1/apikeys behavior plus service unit tests.
  • Refactors startup by splitting app.js (Express app construction) from server.js (migrations + secrets + listen) and migrates apikeys routes into resources/apikeys/.

Reviewed changes

Copilot reviewed 24 out of 25 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Makefile Adds repo-wide make test delegation across components.
pull-config/Makefile Adds a no-op test target for uniform repo-wide test looping.
mie-opensource-landing/Makefile Adds a no-op test target (docs validated by build) for uniform repo-wide test looping.
create-a-container/Makefile Adds test target (jest) for the Manager component.
create-a-container/package.json Adds npm test (jest) and test deps (jest, supertest, zod).
create-a-container/jest.config.js Adds Jest config (node env, setupFiles, serialized workers).
create-a-container/tests/setup-env.js Pins test env vars and per-worker sqlite storage location.
create-a-container/tests/helpers/db.js Adds sqlite lifecycle helpers + factories for integration tests.
create-a-container/tests/helpers/app.js Builds the real app stack for tests with test-friendly options and Bearer helper.
create-a-container/middlewares/validate.js Adds shared zod validation → ApiError(400 invalid_request) mapping.
create-a-container/middlewares/tests/validate.test.js Unit tests for validate middleware behavior.
create-a-container/docs/mvc-manifesto.md Adds the normative architecture + migration playbook documentation.
create-a-container/app.js New buildApp() that builds the full middleware/router stack and adds app-level CSRF + json error handling.
create-a-container/server.js Simplifies startup: run migrations → fetch secrets → buildApp() → listen.
create-a-container/routers/api/v1/index.js Switches /apikeys mount to the new resources/apikeys/router.
create-a-container/routers/api/v1/apikeys.js Deletes legacy apikeys “god route” implementation.
create-a-container/resources/apikeys/router.js New apikeys router (wiring-only) using shared validate middleware.
create-a-container/resources/apikeys/controller.js New controller translating req/session to service calls + response helpers.
create-a-container/resources/apikeys/service.js New service for apikey operations (ownership checks + create/delete).
create-a-container/resources/apikeys/repository.js New repository encapsulating Sequelize queries.
create-a-container/resources/apikeys/serializer.js New serializer enforcing non-disclosure of hashes/plaintext key.
create-a-container/resources/apikeys/validator.js New zod schemas for body + :id param validation.
create-a-container/resources/apikeys/tests/apikeys.api.test.js Integration tests pinning /api/v1/apikeys wire contract.
create-a-container/resources/apikeys/tests/service.test.js Unit tests for apikeys service with mocked repository.

Comment thread create-a-container/Makefile Outdated
Comment thread create-a-container/resources/apikeys/validator.js
- Makefile: ship app.js and resources/ in the packaged install —
  server.js requires ./app and the v1 router requires
  resources/apikeys/router, so an installed package would have failed
  at runtime. Also prune __tests__ directories from the staged tree
  (middlewares/ and resources/ now contain colocated tests).
  Verified via make install DESTDIR=<stage>: both files ship, zero
  __tests__ dirs staged.
- validator: description accepts explicit JSON null (nullish) —
  the legacy router passed null through to storage; the schema was
  rejecting it with 400, a wire-contract regression. Pinned with a new
  integration test (24 total).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants