diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7329a33..12ec419 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.3', '8.4'] + php: ['8.4'] steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.3', '8.4'] + php: ['8.4'] steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 diff --git a/CLAUDE.md b/CLAUDE.md index 30478b1..426bd15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — gcgov/framework -Guidance for Claude when working **on this framework** or **on any application/plugin built on it**. +Guidance for Claude when working **on this framework** or **on any application built on it**. This file is the fast path to a correct mental model. For exhaustive reference, see `README.md` and the `readme/` directory (especially `readme/mongodb.md`). @@ -8,12 +8,12 @@ This file is the fast path to a correct mental model. For exhaustive reference, ## 1. What this is -`gcgov/framework` is a small, opinionated PHP 8.3+ framework for building **REST APIs** (and optionally +`gcgov/framework` is a small, opinionated PHP 8.4+ framework for building **REST APIs** (and optionally SSR apps) for Garrett County Government. Composer package name: `gcgov/framework`, PSR-4 root `gcgov\framework\` → `src/`. A full API with Microsoft OAuth authentication, user CRUD, and OpenAPI docs can be assembled with **almost -no custom code** by installing framework-service plugins (see §12). The framework's standout feature is its +no custom code** by enabling Framework Services in config.json (see §12). The framework's standout feature is its **MongoDB document-modeling system** (`\gcgov\framework\services\mongodb`), which is where most of the code and most of the complexity lives (§7). @@ -32,11 +32,14 @@ src/ ├── framework.php # entry point: runApp() drives the whole lifecycle ├── router.php # framework router: merges service + app routes, runs auth guards ├── renderer.php # invokes the matched controller, serializes the response -├── config.php # static config + path resolver (app dir, srv dir, app.json, environment.json) +├── config.php # static config + path resolver (app dir, srv dir, the unified {root}/config.json) ├── cli/ # the gf command line tool (§16): application, appContext, commands/* ├── interfaces/ # contracts an app must implement (app, router, render, controller, auth\user, ...) ├── models/ # route, routeHandler, controller*Response, authUser, config/* DTOs, customConstraints -├── services/ # log, guid, http, formatting, request, jwtAuth, pdodb, microsoft(deprecated), mongodb/* +├── services/ # framework services: log, guid, http, formatting, request, jwtAuth, pdodb, +│ # chrome, cronMonitor, environment, microsoft(deprecated), mongodb/* +│ # Framework Services (§12): auth/, userCrud/, documentation/ +│ # always-on: health/ ├── exceptions/ # configException, routeException, controllerException, modelException, serviceException, ... └── traits/ # userTrait readme/ # long-form docs (mongodb.md is the authoritative Mongo reference; gf.md for the CLI) @@ -48,7 +51,7 @@ phpstan.neon.dist # PHPStan level config; phpstan-stubs/ holds stubs - **Class names are lowercase**: `class inspection`, `class user`, `class router`, `class app`, `controllerDataResponse`. This is deliberate and pervasive. File name == class name (`inspection.php`). - App code lives under namespace `\app` mapped to the app's `/app` directory. Framework code is - `\gcgov\framework\...`. Plugins are `\gcgov\framework\services\\...`. + `\gcgov\framework\...`. Framework Services are `\gcgov\framework\services\\...`. - Do not "modernize" to StudlyCase class names — you will break PSR-4 autoloading and every reference. --- @@ -60,20 +63,21 @@ An app that runs a full request lifecycle must supply, in its `/app` directory: | File | Class | Must implement | |------|-------|----------------| | `/app/app.php` | `\app\app` | `\gcgov\framework\interfaces\app` | -| `/app/router.php` | `\app\router` | `\gcgov\framework\interfaces\router` | +| `/app/router.php` | `\app\router` | `\gcgov\framework\interfaces\appRouter` | | `/app/renderer.php` | `\app\renderer` | `\gcgov\framework\interfaces\render` | | `/app/controllers/*.php` | e.g. `\app\controllers\widget` | `\gcgov\framework\interfaces\controller` | -Required config files (missing either throws `configException` at request time): -- `/app/config/app.json` -- `/app/config/environment.json` +Required config file (missing it throws `configException` at request time): +- `/config.json` — the unified configuration at the application ROOT (v7 merge of the former + `app/config/app.json` + `app/config/environment.json`), with secrets/per-env values via `%env(...)%`. -Typical app tree (scaffolding template adds more — `srv/`, `db/`, `scripts/`, `www/web.config`, etc.): +Typical app tree (scaffolding template adds more — `srv/`, `db/`, `docker/`, `Dockerfile`, etc.): ``` /api +├── config.json # unified config (committed; every %env(...) ref is REQUIRED) +├── .env # gitignored local values (generate with `gf env --init`) ├── app/{app,router,renderer,constants}.php │ ├── cli/index.php # CLI entry -│ ├── config/{app,environment}.json │ ├── controllers/{name}.php │ └── models/{name}.php └── www/index.php # HTTP entry (web root) @@ -100,9 +104,10 @@ hooks defined by the `lifecycle\before` / `lifecycle\after` interfaces: ``` www/index.php app::_before() - new app() → app->registerFrameworkServiceNamespaces() # returns plugin namespaces to load + new app() # no longer asked which services to load router::_before() - new framework\router(serviceNamespaces) # instantiates each plugin's \{ns}\router if present, then \app\router + new framework\router() # health, then each service enabled in config.json's + # `services` section, then \app\router framework\router->route() # FastRoute dispatch + auth guards → routeHandler (or routeException) router::_after() renderer::_before() @@ -118,14 +123,15 @@ www/index.php Rules a controller method must obey: - **Always return a `controllerResponse` subtype (§6). Never `die()`/`exit`** — it skips the rest of the - lifecycle. (The documentation plugin's `yaml()` is the one deliberate exception.) + lifecycle. (The documentation service's `yaml()` is the one deliberate exception.) A redirect is a + response too: return a `controllerDataResponse` with a `Location` header and status 302. - Route method parameters are bound positionally from the URL pattern placeholders. --- ## 5. Routing -`\app\router::getRoutes()` returns `\gcgov\framework\models\route[]`. Plugin routers contribute routes too; +`\app\router::getRoutes()` returns `\gcgov\framework\models\route[]`. Service routers contribute routes too; the framework merges **service routes first, then app routes** (`framework\router::getRoutes()`). ```php @@ -146,20 +152,33 @@ $routes[] = new route('GET', 'structure/{_id}', '\app\controllers\structure', $routes[] = new route('POST', 'structure/{_id}', '\app\controllers\structure', 'save', true, [constants::ROLE_STRUCTURE_READ, constants::ROLE_STRUCTURE_WRITE]); $routes[] = new route('CLI', '/cli/cleanup', '\app\controllers\cli\import','cleanup',false); ``` -If the app is not served at the domain root, prepend a base path (commonly -`config::getEnvironmentConfig()->getBasePath()`, which is what plugin routers use). +If the app is not served at the domain root, prepend a base path: use +`config::getRoutePrefix()`, which is what the framework's own routers use. (`getBasePath()` +returns `/` at the domain root — right for the token audience, wrong for a route prefix, +where it produces `//user`.) ### Authentication guard flow (`framework\router::route()`) For a matched route with `authentication === true`: 1. `\app\router::authentication($routeHandler)` runs **first** (your custom checks). Return `false` → 401. -2. Then **each plugin router's** `authentication()` runs — unless `\app\router` defines - `getRunFrameworkServiceRouteAuthentication($routeHandler): bool` and returns `false` for that route. -3. Auth plugins (oauth-server / auth-ms-front) validate the JWT from the `Authorization: Bearer …` header - (or `?fileAccessToken=` when `allowShortLivedUrlTokens`), populate the request-scoped `authUser`, and - enforce `requiredRoles` (missing header → 401, missing role → 403). - -Routes with `authentication === false` skip all of this. There is **no built-in auth**; it comes from a -plugin (§12). A `routeException` thrown anywhere in this flow becomes the HTTP error response. +2. Then **each enabled service router's** `authentication()` runs — unless `\app\router` implements + `\gcgov\framework\interfaces\router\skipsServiceAuthentication` and returns `false` for that route. +3. The auth service validates the JWT from the `Authorization: Bearer …` header + (or `?fileAccessToken=` when `allowShortLivedUrlTokens`) and populates the request-scoped + `authUser` (missing header → 401). +4. Finally `framework\router` itself enforces the route's `requiredRoles` against that + `authUser` — **after** the whole chain, so it holds however the caller was authenticated, + including on routes that opted out at step 2 (missing role → 403; no user established at + all → 401). Roles are declared on `route`, so the framework enforces them; `\app\router` + does not have to implement anything for them to take effect, but a router that + authenticates its own routes must record the caller with + `request::getAuthUser()->setFromUser($user)` or those routes are refused. + +Routes with `authentication === false` skip all of this. A `routeException` thrown anywhere in this flow +becomes the HTTP error response. + +**The framework refuses to boot** if any route sets `authentication: true` while no auth service is +enabled and `\app\router::providesAuthentication()` returns `false` — such routes look protected and are +open to anyone, because the scaffolded `authentication()` returns `true`. --- @@ -188,6 +207,10 @@ matching branch in `framework\renderer::render()`. decides the JSON error shape (template default: `{error, message, status}`). - Exception → status: `routeException`/`controllerException`/`modelException` carry a code used as the HTTP status; uncaught `\Throwable` → 500. +- Anything thrown while routing that is **not** a `routeException` (`configException` from the fail-closed + checks, FastRoute's `BadRouteException`, a `\TypeError` from a mistyped `\app\router`) is caught by + `runApp()`, logged in full, and rendered as a generic 500 — the detail never reaches the client, because + those messages carry route patterns, config paths and environment-variable names. --- @@ -311,7 +334,7 @@ returning group keys, and tag constraints with `groups: [...]`. `getFile()`, `deleteFile()`. Pair with `controllerFileResponse` to serve them. ### Auditing & encryption (config-driven, per database) -- **Audit**: enable per-DB in `environment.json` (`audit`, `auditForward`, optional separate audit DB). Writes +- **Audit**: enable per-DB in `config.json` (`audit`, `auditForward`, optional separate audit DB). Writes JSON-patch diffs of changes. - **Queryable encryption**: optional `encryption` block per DB (GCP KMS). Encrypted collections must be created explicitly: `(new mdb($collection))->createEncryptedCollection($collection)`; rotate with `->rotateKeys()`. @@ -321,32 +344,80 @@ returning group keys, and tag constraints with `groups: [...]`. ## 8. Config -`\gcgov\framework\config` is a static accessor. Paths are derived by reflecting `\app\app`'s file location, so -`config::getAppDir()`, `getRootDir()`, `getConfigDir()`, `getModelsDir()`, `getSrvDir()`, `getTempDir()` all -work without setup. Config DTOs are `jsonDeserialize`-hydrated from the two JSON files. - -**`app.json`** → `\gcgov\framework\models\appConfig`: +`\gcgov\framework\config` is the single static configuration API. Paths are derived by reflecting +`\app\app`'s file location, so `config::getAppDir()`, `getRootDir()`, `getModelsDir()`, `getSrvDir()`, +`getTempDir()`, `getConfigFilePath()` all work without setup. Configuration values come from the +**unified `{root}/config.json`** (hydrated once into `\gcgov\framework\models\unifiedConfig`) and are +exposed **directly on `config`** — there are no separate appConfig/environmentConfig objects (v7; +`getAppConfig()`/`getEnvironmentConfig()` remain as deprecated pass-throughs returning the unified object): +`config::getApp()` (title/guid), `getEmail()`, `getSettings()`, `getType()`, `isLocal()`, +`getRootUrl()`, `getBaseUrl()`, `getBasePath()`, `getRoutePrefix()`, `getLogging()`, `getMongoDatabases()`, +`getSqlDatabases()`, `getDefaultSqlDatabase()`, `getSqlDatabaseByName($name)`, `getMicrosoft()`, +`getJwtAuth()`, `getTokenIssuedBy()`, `getTokenPermittedFor()`, `getJwtKeyPath()`, +`getPayjunction()`, `getAppDictionary()`, `getServices()`, `getCronMonitor()`. +`serverName`, `cookieUrl` and `phpPath` were **removed in v7** — nothing read them (confirmed across +the framework and all five framework services). The PHP interpreter is `GF_PHP` / `gf cli --php`. + +### Environment variables in config — `%env(...)%` +config.json supports **Symfony-style `%env(...)%` references**, resolved at load time by +`\gcgov\framework\services\environment\envVarResolver` (see `readme/environment-variables.md`). +This keeps secrets out of the committed config and lets them come from the process +environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hosting. +- A file with no `%env(` substring is loaded byte-for-byte as before. You opt in by writing `%env(...)%`. +- **Every reference is REQUIRED.** There is no `default:` processor (removed in v7), and a variable + set to the empty string counts as unset. A missing value is a startup failure naming the variable. + A value that does not vary between environments is written as a literal, not referenced. +- Whole-value ref → typed result (`"%env(int:SMTP_PORT)%"` → `587`); embedded ref → string + substitution. Processors, applied right-to-left: `secret, file, trim, int, bool, json`. +- **`secret`** implements the conventional `_FILE` indirection: `%env(secret:MONGO_URI)%` reads the + file named by `MONGO_URI_FILE` if that is set, else `MONGO_URI`. A `_FILE` pointing at a missing + file is a hard error and **never** falls back — that fallback would silently substitute a stale + environment value for a secret that failed to mount. This is what lets one committed config.json + serve both a developer machine (plain vars in `.env`) and production (files at `/run/secrets`). + `secret` must be the innermost processor. +- `.env` loading (via `symfony/dotenv`, `dotEnvLoader::loadOnce()`): `{root}/.env` and/or + `.env.local` (either may exist alone); **real environment always wins**; precedence + `real env > .env.local > .env`. No `APP_ENV` cascade — an environment IS the variable set the + process is given; nothing is activated or copied. +- **Reserved names**: CGI meta-variable names (`HTTP_*`, `SERVER_*`, `REQUEST_*`, `REMOTE_*`, + `PHP_AUTH_*`, `SCRIPT_*`, `DOCUMENT_*`, `HTTPS`, `QUERY_STRING`, `CONTENT_*`, `AUTH_TYPE`, + `GATEWAY_INTERFACE`, `PHP_SELF`, `PATH_INFO`, `PATH_TRANSLATED`) are never resolved from the + ambient environment (request data can reach it under CGI/FastCGI) — they act as unset. +- Missing/unresolvable var → `configException` (runtime) / `cliException` (gf), naming the variable. +- `gf env --list` prints every referenced variable; `gf env --init` writes the `.env` skeleton from + config.json itself, so the manifest cannot drift. + +**`{root}/config.json`** → `\gcgov\framework\models\unifiedConfig` (one file, all sections): ```jsonc { "app": { "title": "...", "guid": "..." }, "email": { "fromAddress": "", "fromName": "", "useSMTP": false, "SMTPHost": "", "SMTPPort": 587, "...": "" }, - "settings": { "useSession": false, "forceMfaForPasswordUsers": false } -} -``` -**`environment.json`** → `\gcgov\framework\models\environmentConfig` (accessor helpers: `getRootUrl()`, -`getBaseUrl()`, `getBasePath()`, `isLocal()`, `getDefaultSqlDatabase()`, `getSqlDatabaseByName()`): -```jsonc -{ - "type": "local|prod", "serverName": "", "rootUrl": "", "basePath": "", "baseUrl": "", "cookieUrl": "", - "logging": { "lifecycle": false, "renderer": false }, // lifecycle=true logs the whole request pipeline + "settings": { "forceMfaForPasswordUsers": false }, + "type": "local|prod", "rootUrl": "", "basePath": "", + "logging": { "lifecycle": false, "renderer": false, + "destination": "stderr|file|both" }, // stderr (default) emits JSON lines "mongoDatabases": [ { "default": true, "database": "", "uri": "mongodb+srv://...", "logging": true, "audit": false, "include_meta": true, "encryption": { /* optional */ } } ], "sqlDatabases": [ { "default": true, "name": "", "dsn": "", "readAccount": {}, "writeAccount": {} } ], "microsoft": { "clientId": "", "clientSecret": "", "tenant": "", "driveId": "", "fromAddress": "" }, - "jwtAuth": { "tokenIssuedBy": "", "tokenPermittedFor": "", "redirectAfterLoginUrl": "", "redirectAfterLogoutUrl": "" }, - "appDictionary": { } // free-form key/values plugins read (e.g. cronMonitorUrl) + "jwtAuth": { "tokenIssuedBy": "", "tokenPermittedFor": "", // empty → derived from rootUrl / basePath + "redirectAfterLoginUrl": "", "redirectAfterLogoutUrl": "", + "keyPath": "" }, // empty → {root}/srv/jwtCertificates + "cronMonitor": { "url": "" }, // empty disables cron run reporting + "services": { // presence enables; absent = off; contents are that service's settings + "auth": { "provider": "oauth", // "oauth" | "msFront" — required when auth is present + "blockNewUsers": true, + "defaultNewUserRoles": [], + "oauth": { "authorizeUrlParameters": {} } }, // only for provider "oauth" + "userCrud": { }, + "documentation": { } + }, + "appDictionary": { } // free-form key/values an application reads } ``` +`services.auth` is fail-closed: an unknown `provider`, or a block for the provider that is **not** +selected, is a startup failure. A missing block for the provider that *is* selected hydrates to its +defaults, like every other section. --- @@ -354,14 +425,14 @@ work without setup. Config DTOs are `jsonDeserialize`-hydrated from the two JSON | Call | Purpose | |------|---------| -| `services\log::{debug,info,notice,warning,error,critical,alert,emergency}($channel,$msg,$context=[])` | Monolog-backed; writes `/logs/{channel}.log`. | -| `services\request::getAuthUser(): authUser` | Request-scoped authenticated user (roles, id, email…). Populated by the auth plugin's guard. | +| `services\log::{debug,info,notice,warning,error,critical,alert,emergency}($channel,$msg,$context=[])` | Monolog-backed. Destination is `logging.destination`: `stderr` (default, JSON lines) / `file` (`/logs/{channel}.log`) / `both`. | +| `services\request::getAuthUser(): authUser` | Request-scoped authenticated user (roles, id, email…). Populated by the auth service's guard. | | `services\request::getUserClassFqdn(): string` | Resolves the app's user model FQDN: `\app\models\user`, else the Mongo `…\models\auth\user`. | | `services\request::getPostData(): array` | Parsed request body. | | `services\guid::create($trim=true)` | GUID string. | | `services\http::statusText($code)` | HTTP status text. | | `services\formatting::fileName() / xlsxTabName() / getDateIntervalHumanText()` | Sanitizers/formatters. | -| `services\jwtAuth\jwtAuth` | JWT create/validate for access & refresh tokens; JWKS. Used by auth plugins — don't hand-roll auth. | +| `services\jwtAuth\jwtAuth` | JWT create/validate for access & refresh tokens; JWKS. Used by the auth service — don't hand-roll auth. | | `services\chrome\chrome::getExecutablePath() / ::getBrowserFactory()` | Headless Chrome: path to the gf-installed chrome-headless-shell binary, or a ready `\HeadlessChromium\BrowserFactory` (chrome-php/chrome). Throws `serviceException` until `gf chrome:install` has run. | | `new services\pdodb\pdodb($readOnly=true, $databaseName='')` | Thin PDO wrapper using `sqlDatabases` config (read vs write account). | | `services\microsoft\*` | **Deprecated** — use `andrewsauder/microsoftServices` instead. | @@ -406,53 +477,77 @@ List routes with `gf cli:list`; debug with `gf cli /path --debug`. - Every `model` needs `public \MongoDB\BSON\ObjectId $_id;`. Every embeddable-in-an-array needs a `@var Type[]`. - Never `exit`/`die` in a controller (breaks the lifecycle + `_after` hooks); return a response. - `aggregation()` does **not** auto-apply the typemap. +- **Mongo must be a replica set.** `save`/`saveMany`/`delete`/`deleteMany`/`deleteManyBy` each open a + transaction when not handed a session, so a standalone `mongod` serves every read and fails every + write with "Transaction numbers are only allowed on a replica set member or mongos". A single + member is enough; the app template's `docker-compose.yml` starts one. - Deeply nested/mutually-referential models can infinite-loop the typemap → use `#[excludeFromTypemapWhenThisClassNotRoot]`. -- There's no auth without an auth plugin; and auth plugins register a **global guard** over every - `authentication:true` route in the app. -- Set `logging.lifecycle=true` in `environment.json` to trace the entire pipeline when debugging routing/auth. +- There's no auth without `services.auth`; it registers a **global guard** over every + `authentication:true` route. The framework refuses to boot if authenticated routes exist without it + (and without `\app\router::providesAuthentication()`), rather than serving them unprotected. +- `requiredRoles` is enforced by `framework\router`, not by the auth service — so it applies to + self-authenticated routes and to `skipsServiceAuthentication` opt-outs too. Whatever authenticates + must populate `authUser`, or a role-gated route 401s. +- Set `logging.lifecycle=true` in `config.json` to trace the entire pipeline when debugging routing/auth. +- Every `%env()` reference is required — there is no default and `FOO=` counts as unset. `gf env` says + which one is missing. +- Logs go to **stderr** by default, not `logs/*.log`. An app on IIS sets `logging.destination: "file"`. +- JWT signing keys are gitignored, so they are never in a built image: a container must point + `jwtAuth.keyPath` at a provisioned directory or authentication cannot work. --- -## 12. Extensions / plugins +## 12. Framework Services + +Framework Services ship **inside** the framework (`src/services/`). Enable one by adding its block to the +`services` section of `config.json` — presence enables, and the block's contents are its settings, so +activation and configuration are one statement. See ADR 0003. + +| Config key | Namespace | Adds | +|------------|-----------|------| +| `services.auth` (`provider: "oauth"`) | `\gcgov\framework\services\auth` | Full OAuth server (password + third-party + MFA), JWKS, file tokens, global JWT guard. | +| `services.auth` (`provider: "msFront"`) | same | Exchange a Microsoft token the front end holds for an app JWT, plus the same JWKS/file tokens/guard. | +| `services.userCrud` | `\gcgov\framework\services\userCrud` | `/user` CRUD over the resolved user model (`User.Read` / `User.Write`). | +| `services.documentation` | `\gcgov\framework\services\documentation` | `GET /documentation.yaml` (OpenAPI from annotations). | -Register a plugin by adding its namespace to `\app\app::registerFrameworkServiceNamespaces()`; the framework -then auto-discovers `\{namespace}\router` and merges its routes + auth guard. +There is **one** auth service with two providers, so two cannot be active at once — it is unrepresentable +rather than merely discouraged. -| Plugin (repo) | Namespace to register | Adds | -|---------------|-----------------------|------| -| `gcgov/framework-service-documentation` | `\gcgov\framework\services\documentation` | `GET /documentation.yaml` (OpenAPI from annotations). | -| `gcgov/framework-service-auth-ms-front` | `\gcgov\framework\services\authmsfront` | Exchange a Microsoft token for an app JWT; global JWT guard. | -| `gcgov/framework-service-auth-oauth-server` | `\gcgov\framework\services\authoauth` | Full OAuth server (password + third-party + MFA); global JWT guard. | -| `gcgov/framework-service-user-crud` | `\gcgov\framework\services\usercrud` | `/user` CRUD over the resolved user model. | -| `gcgov/framework-service-gcgov-cron-monitor` | `\gcgov\framework\services\cronMonitor` | Report cron start/end to a monitor service. | +`\gcgov\framework\services\cronMonitor\cronMonitor` is **not** a Framework Service: it registers no +routes and takes no part in the lifecycle. Construct it directly and configure `cronMonitor.url`. -Each plugin repo has its own `CLAUDE.md` with specifics. Only **one** authentication plugin should be active -at a time (oauth-server OR auth-ms-front). +The separately published `gcgov/framework-service-*` packages still exist for **v6** applications. The +framework declares a `conflict` against all five, so a v7 application cannot install both. --- -## 13. Authoring a new plugin (framework-service) -- `composer.json`: `"type": "framework-service"`, PSR-4 `gcgov\framework\services\\ → src/`. -- Provide `src/router.php` = `\gcgov\framework\services\\router implements \gcgov\framework\interfaces\router` - with `getRoutes()`, `authentication()`, static `_before()/_after()`. Prefix routes with - `config::getEnvironmentConfig()->getBasePath()`. -- Controllers live under `\gcgov\framework\services\\controllers\…` and implement `controller`. -- Config via a singleton (`getInstance()`) the app tweaks in `app::_before()` (see oauth-server's `oauthConfig`), - and/or `environment.json.appDictionary`. -- Return `false` from a plugin `authentication()` only to deny; return `true` to allow. -- Optional: contribute gf commands with `src/cli/commandProvider.php` = - `\gcgov\framework\services\\cli\commandProvider implements \gcgov\framework\cli\commandProvider` - returning symfony/console command instances (namespace the command names, e.g. `docs:regenerate`). See §16. +## 13. Adding a new Framework Service +Services live in this repository; there is no out-of-tree extension point (ADR 0003). An application +needing routes of its own puts them in `\app\router`, which already runs first in the guard chain. + +- Code in `src/services//`, namespace `\gcgov\framework\services\`. +- `src/services//router.php` implements `\gcgov\framework\interfaces\router` — just `getRoutes()` + and `authentication()`, no lifecycle hooks. Prefix routes with `config::getRoutePrefix()`. +- Controllers under `\gcgov\framework\services\\controllers\…` implementing `controller`. +- Config: add a nullable property to `\gcgov\framework\models\config\services` and a DTO beside it in + `src/models/config/services/`. Nullable means absent = disabled. Give the router its typed config as a + constructor argument — no singletons. +- Construct it in `framework\router::__construct()` behind `if( $services-> !== null )`. +- Return `false` from `authentication()` only to deny; `true` to allow. +- Mirror the tests under `tests/Unit/Services//`. `composer ci` before pushing. --- ## 14. Build / test / CI -- Install: `composer install`. PHP `>=8.3`; ext `mongodb`, `sodium`, `fileinfo`, `pdo`. +- Install: `composer install`. PHP `>=8.4`; ext `mongodb`, `sodium`, `fileinfo`, `pdo`. - Static analysis: `composer phpstan` (PHPStan; `phpstan-stubs/` provides stubs for optional deps). - Tests: `composer test` (PHPUnit; `tests/` mirrors `src/`, uses `tests/Shims/MongoDBShims.php` so unit tests run without a live Mongo). `composer ci` = phpstan + test. -- GitHub Actions (`.github/workflows/ci.yml`) runs both on PHP 8.3 and 8.4. **Run `composer ci` before pushing.** +- GitHub Actions (`.github/workflows/ci.yml`) runs on PHP 8.4. **Run `composer ci` before pushing.** +- Every application gets `GET {basePath}/health` (liveness, no I/O) and `/health/ready` (readiness, + pings Mongo, 503 when a dependency is down) from `services/health/` — contributed by the framework + router itself, not opt-in, because a deploy pipeline cannot gate on an endpoint an app might omit. - When you change `src/`, add/adjust the mirrored test under `tests/Unit/…`. --- @@ -460,9 +555,10 @@ at a time (oauth-server OR auth-ms-front). ## 15. Where to look - Full narrative + app file system: `README.md`. - Core file examples: `readme/{index.php,cli-index.php,app.php,router.php,renderer.php}.md`. +- **Running an app locally (the rules; the template holds the commands)**: `readme/local-development.md`. - **Mongo (authoritative, deep)**: `readme/mongodb.md`. - **gf CLI (authoritative)**: `readme/gf.md`. -- A real, minimal consuming controller: the user-crud plugin's `src/controllers/user.php`. +- A real, minimal consuming controller: `src/services/userCrud/controllers/user.php`. --- @@ -471,34 +567,78 @@ at a time (oauth-server OR auth-ms-front). The framework ships a symfony/console-based command line tool exposed as a composer bin: every consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `readme/gf.md`. -- **Commands** (canonical names; `gf db restore` auto-resolves to `db:restore`): `cli`, `cli:list`, - `cert:generate-auth`, `chrome:install`, `chrome:update`, `chrome:status`, `db:restore`, `db:run`, - `env`, `setup`, `deploy`, `completion`, `completion:powershell`. Bare `gf` lists everything. +- **Commands** (canonical names; `gf db run` auto-resolves to `db:run`): `cli`, `cli:list`, + `cert:generate-auth`, `chrome:install`, `chrome:update`, `chrome:status`, `db:run`, `env`, `init`, + `migrate`, `user:create`, `completion`, `completion:powershell`. Bare `gf` lists everything. + **Removed in v7**: `deploy` (a Release is an immutable image pinned by digest — see ADR 0002), + `db:restore` (it required production credentials on every workstation), and `setup` (replaced by + the non-interactive `init`, since bootstrap belongs in a scaffolding script or a devcontainer). +- **`gf env`** validates that config.json resolves; `--list` prints every referenced variable and + whether it is currently set; `--init` writes the `.env` skeleton, and on an existing file + **appends only the references it does not already declare** — a .env carries values and variables + config.json knows nothing about. `--force` rewrites from config.json alone, discarding both. **`gf init --title="…"`** bootstraps a scaffolded app: title, guid, `.env`, JWT keys, + chrome. **`gf migrate`** converts a v6 app — its `plan()` is a pure function of `app.json` + + `environment.json`, so it is unit-tested rather than run hopefully. + **`gf user:create --email=… --roles="…"`** creates the account you sign in as, saved through the + resolved user model so the password is hashed by it. An app with `services.auth` enabled has no + other way to get its first user: `blockNewUsers` defaults true and `/user` needs `User.Write`. + `--force` updates an existing email in place, leaving options you did not pass alone. - **chrome-headless-shell**: `chrome:install`/`chrome:update` download the Chrome for Testing Stable build for the current platform into `srv/chrome/{version}/` (manifest: - `srv/chrome/installation.json`; needs ext-zip; `gf setup` auto-installs, `--skip-chrome` opts + `srv/chrome/installation.json`; needs ext-zip; `gf init` auto-installs, `--skip-chrome` opts out; update prunes old versions). Apps consume it via `services\chrome\chrome` (§9); shared logic lives in `services/chrome/chromeInstallation.php`, download orchestration in `src/cli/chromeInstaller.php` (injectable Guzzle client — tests are network-free). - **Architecture** (`src/cli/`): `application` (command registration + provider discovery), - `appContext` (app-root locator: composer autoload path first, then cwd walk-up; lazy config - access via `loadEnvironmentConfig($variant)` — never boots the request lifecycle), + `appContext` (app-root locator: composer autoload path first, then cwd walk-up; config via + `loadConfig()` and `configReferences()`, both delegating to `services\environment\configLoader`; + never boots the request lifecycle), `routeCatalog` (CLI-route enumeration via `router::getMergedRoutes()`), `phpProcess`, - `environmentFiles`, `tokenReplacer`, `mongoTools`, `cliException` (user-facing errors), + `mongoTools`, `cliException` (user-facing errors), `internal/run-route.php` (child-process route runner; maps response status ≥400 → exit 1). + - **Command tiers**: no context (list/help/completion — must work anywhere, including this repo); - root-only (env, db:*, cert:*, deploy, setup — config JSON only, no `\app` boot); - app-boot (cli, cli:list — `assertAppLoadable()`; `\app\app::_before()` is deliberately NOT called). + root-only (env, db:run, cert:*, init, migrate — config JSON only, no `\app` boot); + app-boot (cli, cli:list, user:create — `assertAppLoadable()`; `\app\app::_before()` is deliberately + NOT called). `user:create` runs in-process — `config` bootstraps itself lazily, and only `gf cli` + needs a child process (fresh Xdebug INI, `exit()` isolation). - **`gf cli `** always spawns a fresh PHP child process (Xdebug flags need fresh INI; - isolates `exit()`; interpreter picked via `--php` > `GF_PHP` > environment.json `phpPath` > current). + isolates `exit()`; interpreter picked via `--php` > `GF_PHP` > current). The interpreter must be the CLI binary — `php-cgi`/`php-fpm`/`php-win` are swapped for the `php`/`php.exe` beside them, else rejected; the child always gets `-dregister_argc_argv=1`, and `internal/run-route.php` assumes neither `$argv` nor `STDERR` exists until it has checked. -- **Expandability**: apps (`\app\cli\commandProvider`) and plugins - (`{ns}\cli\commandProvider`) implement `\gcgov\framework\cli\commandProvider::getCommands()`. - Discovery is fail-safe — errors never break gf (visible with `-v`). +- **Expandability**: an app implements `\app\cli\commandProvider` + (`\gcgov\framework\cli\commandProvider::getCommands()`); discovery is fail-safe — errors never + break gf (visible with `-v`). Framework Services register commands directly in + `application::__construct()`, since they are part of the framework. - When adding a command: lowercase lowerCamelCase class in `src/cli/commands/`, `#[AsCommand]` attribute, register it in `application::__construct()`, throw `cliException` for user errors, - add a mirrored test in `tests/Unit/Cli/` (external binaries are exercised via pure - arg-builder methods, e.g. `dbRestoreCommand::buildDumpCommand()`). -- The legacy `scripts/*.ps1` are deprecated wrappers kept for backward compatibility. + add a mirrored test in `tests/Unit/Cli/`. Keep the logic in a pure static method the test can + call directly (e.g. `migrateCommand::plan()`, `migrateCommand::encodeEnvValue()`) rather than driving + everything through CommandTester. + +--- + +## Agent skills + +### Issue tracker + +Issues live in GitHub Issues for `gcgov/framework`, driven by the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The five canonical roles, each label string equal to its role name. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: one root `CONTEXT.md` plus `docs/adr/`. See `docs/agents/domain.md`. + +`CONTEXT.md` is the glossary — read it before naming anything. Note especially that **Environment** +(a deployment target, defined by the variable set a process is given) and **Zone** (a network +isolation boundary) are different things, and that v6's "environment variant" no longer exists. + +ADRs recorded so far: 0001 fail-closed configuration · 0002 immutable Release pinned by digest · +0003 Framework Services are built in and config-activated · 0004 writes are transactional so +MongoDB is a replica set. The four operational ADRs (secrets never decrypt, one runner per Zone, +Let's Encrypt DNS-01, Azure Key Vault) moved to `gcgov/deploy` in the v7 review — see +`docs/adr/README.md` for the old-to-new mapping. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..2445d0f --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,187 @@ +# gcgov/framework + +The domain language of the framework itself and of the applications built on it. This file is a +glossary: it fixes what each term means so that code, documentation, and conversation use one word +per concept. It is not a specification — see `README.md`, `readme/`, and `docs/adr/` for those. + +## Language + +### Applications and extensions + +**Application**: +A deployable unit that runs in exactly one Zone and is built from its own repository. It takes one +of two Application Kinds — an `api` built on the framework as a library, or a `frontend` that talks +to one. +_Avoid_: project, site, instance, consumer + +**Application Kind**: +Which of the two shapes an Application takes: `api` or `frontend`. The Kind decides which images a +Release is made of, and whether the Application holds Secrets at all. +_Avoid_: type, flavour, variant, shape + +**Framework Service**: +An optional part of the framework that contributes routes, controllers and an auth guard to an +Application when the Application enables it in the `services` section of its Unified Config. A +Framework Service ships inside the framework; it is not separately installable. +_Avoid_: plugin, module, extension, package + +**Provider**: +One of the ways the authentication Framework Service can establish an identity — a full OAuth server, +or the exchange of a Microsoft token the front end already holds. Exactly one is selected, because +`provider` is a single key. +_Avoid_: driver, strategy, backend, adapter + +**Scaffold**: +The one-time act of creating a new Application from the application template. +_Avoid_: setup, generate + +**Bootstrap**: +Bringing a scaffolded Application to a runnable state — naming it, giving it the values its Config +References need, and generating the keys it signs with. Unlike a Scaffold it is idempotent: it is +re-run as an Application's configuration grows, and adds only what is missing. +_Avoid_: setup, init, provisioning, first-run + +### Request handling + +**Route**: +A binding of an HTTP method and URL pattern to a controller method, together with the +authentication and role requirements that reaching it implies. + +**CLI Route**: +A Route dispatched from the command line rather than from an HTTP request. CLI Routes are never +authenticated. +_Avoid_: command, task, job + +**Auth Guard**: +A router's authentication check, run before a Route with authentication enabled is dispatched. An +Application has its own; each Framework Service may add one. +_Avoid_: middleware, filter, interceptor + +**Auth User**: +The authenticated identity for the current request, carrying its roles. Populated by an Auth Guard +and absent on unauthenticated Routes. +_Avoid_: current user, principal, session user + +**Bootstrap User**: +The first user of an Application, created out of band because nothing else can create it. An +Application that admits only users already stored, and that gates user administration on a role, +can produce no first identity from the outside — the Bootstrap User is what breaks that circle. +_Avoid_: admin user, seed user, root account, initial user + +**Controller Response**: +The value a controller method returns, describing what to send and how to serialize it. Returning +one is the only way a controller may end a request. +_Avoid_: result, output, payload + +### Documents + +**Model**: +A document that is stored as a collection in its own right and can be loaded, saved, and deleted +independently. +_Avoid_: entity, record, document class + +**Embeddable**: +A document that exists only nested inside a Model or another Embeddable, and is never stored in a +collection of its own. +_Avoid_: sub-document, nested model, value object + +**Embedded Copy**: +A duplicate of a Model's data stored inside other documents for read convenience, which the +framework refreshes wherever it appears whenever the original Model is saved. +_Avoid_: join, denormalization, reference, cache + +**Typemap**: +The declaration of which class each part of a stored document hydrates into. Typed arrays require +an explicit element type; without one the array cannot be hydrated. + +### Configuration and deployment + +**Environment**: +A deployment target — local, production — distinguished *only* by the set of variables its +processes are given. Nothing is activated, copied, or selected by name; supplying a different +variable set is what makes an Environment different. +_Avoid_: variant, stage, tier, environment file, profile + +**Unified Config**: +The single committed configuration file at an Application's root. It is Environment-invariant: the +same bytes are correct in every Environment. +_Avoid_: app config, environment config, config files, settings file + +**Config Reference**: +A placeholder inside the Unified Config naming an environment variable, optionally through a chain +of processors. Every Config Reference is required — an unresolvable one is a startup failure, never +a silent fallback. +_Avoid_: token, placeholder, interpolation, variable expansion + +**Secret**: +A configuration value that must never be committed and must never enter a process's environment — +credentials, connection strings, signing keys, API keys. +_Avoid_: credential, sensitive value, private setting + +**Secret File**: +The file a Secret is delivered as at runtime, named by a Config Reference rather than carrying the +Secret's value in the environment itself. + +**Zone**: +A network isolation boundary, defined by what it can reach and what can reach it. Three exist: +internal-only, public with internal access, and public without. An Application in production runs in +exactly one Zone; a Zone is not an Environment, and the two vary independently. +_Avoid_: server, host, network, tier, segment + +**Ops Repo**: +The single private repository describing what runs on every host — encrypted Secrets, production +compose definitions, and the shared proxy stack — organized by Zone. An Application repository never +holds production topology or Secrets. +_Avoid_: infra repo, config repo + +**Provisioning**: +Writing an Application's configuration onto a host — its decrypted Secrets, its compose file, and its +Zone's values. Performed by an operator as a step deliberately separate from deploying, so that no +host holds a decryption key and no deploy needs one. +_Avoid_: secret sync, secret deploy, key distribution + +**Release**: +A tagged, immutable build of an Application, identified in production by a set of named content +digests — one per image the Application's compose file declares — rather than by tag or branch. +Deploying and rolling back are both the act of pointing a host at a different Release. +_Avoid_: version, build, deployment + +**Zone Key Vault**: +The vault holding the single key that encrypts one Zone's Secrets, and nothing else. Three exist, +one per Zone, deliberately apart from the cloud project holding an Application's data-encryption +keys — those are reachable from a host and these must never be. +_Avoid_: KMS, key store, secrets vault, ops project + +**Delegation Zone**: +A DNS zone holding only the ACME challenge records for one Zone, so that Zone's DNS credential can be +scoped to it rather than to a domain that serves traffic. +_Avoid_: ACME zone, challenge domain, validation domain + +**Break-glass Key**: +The offline key every file in the Ops Repo is encrypted to in addition to its Zone's key, so that a +total loss of cloud access is still recoverable. Retrieving it obliges replacing it. +_Avoid_: recovery key, backup key, master key + +**Escrow Custodian**: +The person who may retrieve the Break-glass Key from physical escrow. A second person can open the +same safe, so the key survives the custodian's absence; that second person is a control on +availability, not a witness to retrieval. +_Avoid_: key holder, key owner, keeper + +### Retired language + +These terms named real things in v6 and no longer name anything. They are listed so that they are +recognized as history rather than reintroduced. + +- **App config / Environment config** — the two configuration files merged into the Unified Config. +- **Environment variant** — a named Environment whose connection details were committed. Removed + along with the ability to read another Environment's database from a workstation. +- **Scaffolding token** — a marker replaced once at Scaffold time. Replaced by Config References + and by generated developer environment files. +- **Service namespace registration** — the array of namespace strings an Application returned from + `\app\app::registerFrameworkServiceNamespaces()` to enable Framework Services. Replaced by the + `services` section of the Unified Config, so that enabling a service and configuring it are one + statement. The separately published `gcgov/framework-service-*` packages remain real, but only for + v6 Applications; the framework conflicts with them. +- **Auth plugin** — either of the two separate authentication packages. There is now one + authentication Framework Service with two Providers. diff --git a/README.md b/README.md index f0de15b..9bebf97 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ corresponding front end application. Framework package requirements from `composer.json`: -* PHP `>=8.3` +* PHP `>=8.4` * PHP extensions: `ext-mongodb`, `ext-fileinfo`, `ext-pdo` Install dependencies with Composer: @@ -30,17 +30,23 @@ composer install The framework expects these app classes/files to exist in your `/app` directory: * `\app\app` implementing `\gcgov\framework\interfaces\app` -* `\app\router` implementing `\gcgov\framework\interfaces\router` +* `\app\router` implementing `\gcgov\framework\interfaces\appRouter` * `\app\renderer` implementing `\gcgov\framework\interfaces\render` Controllers should implement `\gcgov\framework\interfaces\controller`. Required configuration files: -* `/app/config/app.json` -* `/app/config/environment.json` +* `/config.json` — the unified configuration at the application root (merged app + environment sections, + secrets and per-environment values referenced via `%env(...)%`) -If either file is missing, the framework throws a config exception during request handling. +If it is missing, the framework throws a config exception during request handling. + +config.json supports **Symfony-style `%env(...)%` environment-variable references**, so +secrets (Mongo URIs, client secrets, SMTP credentials) can be injected from the process +environment, Docker/Kubernetes secrets, or a `.env` file instead of being stored in the files. +Existing plain-JSON config keeps working unchanged. See +**[readme/environment-variables.md](readme/environment-variables.md)**. ## System Architecture @@ -50,19 +56,14 @@ All apps utilizing the framework for an entire lifecycle should use this file st ``` /api +├── config.json ├── app │ ├── app.php │ ├── constants.php │ ├── renderer.php │ ├── router.php │ ├── cli -│ │ ├── index.php -│ │ ├── local.bat -│ │ ├── local-debug.bat -│ │ └── prod.bat -│ ├── config -│ │ ├── app.json -│ │ └── environment.json +│ │ └── index.php │ ├── controllers │ │ └── {controller.php} │ └── models @@ -77,22 +78,16 @@ automatically start with some extra folders and tools. ``` /api │... +├── config.json # committed unified config; secrets/per-env values via %env(...) +├── .env.example # copy to .env (gitignored); holds gf db:*/env PROD_* vars too ├── www │ │... -│ ├── web.config -│ ├── web-local.config -│ └── web-prod.config ├── app │ │... -│ └── config -│ └── environment-local.json -│ └── environment-prod.json -├── scripts -│ ├── create-jwt-keys.ps1 -│ └── setup.ps1 +├── docker +│ └── nginx +│ └── default.conf.template ├── srv -│ ├── {env} -│ │ └── php.ini │ ├── tmp │ │ ├── files │ │ ├── opcache @@ -101,11 +96,10 @@ automatically start with some extra folders and tools. │ │ └── tmp │ └── jwtCertificates ├── db -│ ├── backup -│ ├── restore-live-to-local.ps1 │ └── local-createuser.js ├── logs -└── update-production.ps1 +├── Dockerfile +└── docker-compose.yml ``` ### Core Files and Application Namespacing @@ -219,22 +213,30 @@ gf cli /structure/cleanup # run a CLI route (replaces app/cli/{env}.bat) gf cli /structure/cleanup --debug# run with Xdebug (replaces local-debug.bat) gf cli:list # list the app's CLI routes gf cert:generate-auth # JWT signing keys (replaces create-jwt-keys.ps1) -gf db:restore --from=prod # pull a source env's mongo dbs into the local env gf db:run db/script.js # run a mongosh script with config-managed credentials -gf env local # activate environment config file variants -gf setup # bootstrap a scaffolded app (replaces setup.ps1) -gf deploy # tag-based deployment (replaces update-production.ps1) +gf env # validate config.json resolves against this environment +gf env --list # every variable config.json references, and which are set +gf env --init # write/extend the .env skeleton from config.json +gf init --title="My API" # bootstrap a scaffolded app (replaces setup.ps1) +gf migrate # convert a v6 application to the v7 layout +gf user:create --email=… --roles=… # create the account you sign in as (the first user) ``` +Removed in v7: `deploy` (a Release is an immutable image pinned by digest — see ADR 0002), +`db:restore` (it required production credentials on every workstation), and `setup` +(replaced by the non-interactive `init`). + Tab completion is available for bash/zsh/fish (`gf completion --help`) and PowerShell (`gf completion:powershell`), including dynamic completion of the app's CLI route names. Apps and plugins can add their own gf commands via a `cli\commandProvider` class. -**See [readme/gf.md](readme/gf.md) for the full reference and the migration guide.** +**See [readme/gf.md](readme/gf.md) for the full reference and the migration guide**, and +[readme/local-development.md](readme/local-development.md) for what an application needs in order to +run on a development computer. The legacy per-app entry (`> app/cli/{env}.bat {url-path}`, `local-debug.bat` for XDebug) keeps working, but new apps should use gf. The `scripts/*.ps1` files shipped with the framework are -deprecated in favor of `gf setup` and `gf cert:generate-auth`. +deprecated in favor of `gf init` and `gf cert:generate-auth`. ## Framework Services @@ -340,7 +342,7 @@ For full reference, configuration options, attributes, and detailed examples, se ### PDODB -Initiate PDO connections using SQL connection details in app/config/environment.json. It is only a small wrapper around +Initiate PDO connections using SQL connection details in config.json (`sqlDatabases`). It is only a small wrapper around the native PDO class. Read user connection: `new gcgov\framework\services\pdodb\pdodb(true, $databaseName)` @@ -351,18 +353,26 @@ Write user connection: `new gcgov\framework\services\pdodb\pdodb(false, $databas ## Extensions Extensions add service or app level functionality to the app that registers them. Extensions may expose new endpoints. -* **Open API Documentation** `gcgov/framework-service-documentation` - * https://github.com/gcgov/framework-service-documentation - * Add namespace `\gcgov\framework\services\documentation` to `\app\app->registerFrameworkServiceNamespaces()` -* **Microsoft Auth Token Exchange** `gcgov/framework-service-auth-ms` - * https://github.com/gcgov/framework-service-auth-ms-front - * Add namespace `\gcgov\framework\services\authmsfront` to `\app\app->registerFrameworkServiceNamespaces()` -* **Oauth Server Service** `gcgov/framework-service-auth-oauth-server` - * https://github.com/gcgov/framework-service-auth-oauth-server - * Add namespace `\gcgov\framework\services\authoauth` to `\app\app->registerFrameworkServiceNamespaces()` -* **User CRUD** `gcgov/framework-service-user-crud` - * https://github.com/gcgov/framework-service-user-crud - * Add namespace `\gcgov\framework\services\usercrud` to `\app\app->registerFrameworkServiceNamespaces()` -* **Cron Monitor** `gcgov/framework-service-gcgov-cron-monitor` - * https://github.com/gcgov/framework-service-gcgov-cron-monitor/ - * Add namespace `gcgov\framework\services\cronMonitor` to `\app\app->registerFrameworkServiceNamespaces()` +Framework Services ship inside the framework. Enable one by adding its block to the `services` +section of `config.json` — presence enables it, and the block's contents are its settings. + +```jsonc +"services": { + "auth": { "provider": "oauth" }, // or "msFront" + "userCrud": { }, + "documentation": { } +} +``` + +* **Authentication** `services.auth` — one service, two providers. + * `provider: "oauth"` — full OAuth server: password, third-party and authorization-code grants, MFA. + * `provider: "msFront"` — exchange a Microsoft token the front end already holds for an app JWT. + * Either way you get `/.well-known/jwks.json`, `/auth/fileToken`, and a JWT guard over every + `authentication: true` route. +* **User CRUD** `services.userCrud` — `/user` CRUD over the resolved user model. +* **Open API Documentation** `services.documentation` — `GET /documentation.yaml`. +* **Cron Monitor** — not a Framework Service; construct + `\gcgov\framework\services\cronMonitor\cronMonitor` directly and set `cronMonitor.url`. + +The separately published `gcgov/framework-service-*` packages remain available for **v6** applications. +The framework conflicts with them, so a v7 application cannot install both. diff --git a/composer.json b/composer.json index c213f99..a788cfe 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "bin/gf" ], "require": { - "php": ">=8.3", + "php": ">=8.4", "nikic/fast-route": "^1.3", "phpmailer/phpmailer": "^6.2", "mongodb/mongodb": "^2.1", @@ -28,6 +28,7 @@ "symfony/property-access": "^7.1", "symfony/console": "^7.1", "symfony/process": "^7.1", + "symfony/dotenv": "^7.1", "zircote/swagger-php": "^6.1", "hybridauth/hybridauth": "^3.13", "swaggest/json-diff": "^3.11", @@ -37,7 +38,18 @@ "ext-pdo": "*", "ext-sodium": "*", "ext-openssl": "*", - "spatie/typescript-transformer": "^2.4" + "spatie/typescript-transformer": "^2.4", + "doctrine/annotations": "^2.0", + "robthree/twofactorauth": "^3.0", + "bacon/bacon-qr-code": "^3.0", + "andrewsauder/microsoft-services": "^1.4" + }, + "conflict": { + "gcgov/framework-service-auth-oauth-server": "*", + "gcgov/framework-service-auth-ms-front": "*", + "gcgov/framework-service-user-crud": "*", + "gcgov/framework-service-documentation": "*", + "gcgov/framework-service-gcgov-cron-monitor": "*" }, "suggest": { "ext-zip": "Required by `gf chrome:install` / `gf chrome:update` to extract the chrome-headless-shell download" @@ -55,6 +67,9 @@ "scripts": { "phpstan": "phpstan analyse --memory-limit=512M", "test": "phpunit", - "ci": ["@phpstan", "@test"] + "ci": [ + "@phpstan", + "@test" + ] } } diff --git a/docs/adr/0001-fail-closed-configuration.md b/docs/adr/0001-fail-closed-configuration.md new file mode 100644 index 0000000..67f0b15 --- /dev/null +++ b/docs/adr/0001-fail-closed-configuration.md @@ -0,0 +1,32 @@ +# Configuration is fail-closed: one committed file, every reference required + +An Application's configuration is a single committed `config.json` whose environment-varying values +are `%env(...)%` references. Every reference is **required**: there is no `default:` processor, and a +variable that is set but empty counts as unset. A missing value is a startup failure naming the +variable, never a silent fallback. + +## Considered Options + +v6 split configuration across `app/config/app.json` and per-environment `environment-{name}.json` +files, and an early v7 draft kept a Symfony-style `default:` processor so a value could fall back to +a literal baked in at scaffold time. + +`default:` was removed because it made the dangerous case the quiet one. `type` defaulted to +`local`, and `isLocal()` gates real behavior — so a production container that forgot `APP_TYPE` +booted successfully in development posture. Scaffolding also wrote its `{tokens}` *inside* the +default argument, so skipping a setup prompt shipped the literal string `{app_root_url}` to +production as a URL. Both failures were silent. + +## Consequences + +- Developer environments need real values for every reference. `gf env --init` generates the `.env` + skeleton by walking `config.json`, so the manifest cannot drift from the config. +- Optional integrations (Microsoft, PayJunction, SMTP) are **absent** from the template rather than + present-and-blank. An Application adds the block when it needs it; a missing section hydrates to + its defaults. +- Removing `default:` removed the resolver's greedy-argument parsing, which existed only to let a + fallback literal contain colons. A reference still ends at the first `)` and a leftover `%env(` + after resolution is still an error — those are plain syntax rules that catch typos, not + consequences of the removed processor. +- The processor set shrank to `secret, file, trim, int, bool, json`. `string`, `not`, `float` and + `base64` had no users, and each one is an API surface, a test and an error path to carry. diff --git a/docs/adr/0002-immutable-release-digest-pinning.md b/docs/adr/0002-immutable-release-digest-pinning.md new file mode 100644 index 0000000..403978f --- /dev/null +++ b/docs/adr/0002-immutable-release-digest-pinning.md @@ -0,0 +1,25 @@ +# Deployment ships an immutable Release, pinned by digest + +A Release is a container image built once by CI, pushed to GHCR, and identified in production by +**content digest**. Deploying and rolling back are the same operation: point a host at a different +digest and restart. Nothing is built, resolved, or updated on a production host. + +## Considered Options + +v6 deployed by running `gf deploy` on the server: `git pull`, `git checkout tags/X`, `composer +update`. That resolves dependencies in production at deploy time, which means two hosts running +"the same tag" can be running different code, and rollback requires a second dependency resolution +that may not reproduce the earlier one. + +A moving tag (`:latest`, `:prod`) was rejected for the same reason in miniature: it makes "what is +running right now" unanswerable without trusting a mutable pointer. + +## Consequences + +- `gf deploy` is deleted. Deployment is a GitHub Actions workflow plus a compose file, not a PHP + command shipped inside the artifact it deploys. +- `composer.lock` must be committed; without it the image is not reproducible. +- Rollback is re-pinning a prior digest — seconds, no rebuild. +- Anything an Application writes to its own filesystem is lost on every deploy. This is why logging + goes to stderr rather than `logs/*.log`, and why JWT signing keys are provisioned onto the host + rather than living in the application tree. diff --git a/docs/adr/0003-framework-services-are-built-in-and-config-activated.md b/docs/adr/0003-framework-services-are-built-in-and-config-activated.md new file mode 100644 index 0000000..dde68d4 --- /dev/null +++ b/docs/adr/0003-framework-services-are-built-in-and-config-activated.md @@ -0,0 +1,64 @@ +# Framework Services live in the framework and are activated from the Unified Config + +The five Framework Services are part of `gcgov/framework` and are switched on by a `services` section +of `config.json`. `\app\app::registerFrameworkServiceNamespaces()` is deleted. The two authentication +services become one, selected by `provider`. `cronMonitor` stops being a Framework Service at all. + +## Considered Options + +Keeping them as separate packages and moving only *activation* into config was the smaller change, and +it is what the evidence first appeared to support — the packages looked untagged and therefore +unreleased. That was a misreading of clones that had not fetched tags: all five are properly released +(`auth-oauth-server` is on v2.2.1, 39 releases between them) and consumed by semver through a committed +lock file. So folding in trades away real, working independent versioning. It is worth it for three +reasons the packaging split caused and could not fix: + +- **Configuration had two homes.** Activation and `oauthConfig::setBlockNewUsers()` were PHP in + `app::_before()`; `jwtAuth` and `appDictionary` were `config.json`. "How is auth configured here?" had + two answers. Worse, `gf` deliberately skips `_before()`, so a service was unconfigured during CLI route + enumeration — the HTTP and CLI paths genuinely disagreed. +- **Forgetting a namespace was silent.** The router swallowed the `ReflectionException` for a missing + `{ns}\router`, so a mistyped or omitted namespace produced 404s, not an error. +- **The duplication was unfixable in place.** `oauthConfig` and `msAuthConfig` were the same class twice + and the two guards the same fifty lines twice, because there was no shared package below them to hold + the common part. Merging the auth services removes it and, by making `provider` a single key, makes + two active auth providers unrepresentable rather than merely discouraged. + +An out-of-tree extension point was not preserved. A package can still ship routes and a guard — +`\app\router` composes them explicitly — and `\app\router` already contributes routes, already runs a +guard, and already runs first in the chain. Auto-discovery only added magic, and keeping it would mean +carrying the mechanism being deleted for a case that has never occurred: every Framework Service ever +written is first-party. Reopen this if a second internal service is wanted by three or more applications +and genuinely does not belong in the framework. + +Deleting `registerFrameworkServiceNamespaces()` outright, rather than deprecating it, is possible +because no v7 application is deployed. `v7.0.0-rc.1` is published, but a release candidate is where a +break like this belongs. Carrying both mechanisms would have reintroduced "which list won?" — the same +quiet ambiguity ADR 0001 removed the `default:` processor to avoid. + +## Consequences + +- **The standalone packages are not retired.** They stay published on their v1/v2 lines for v6 + applications until those migrate. Nothing is withdrawn and no v6 application breaks; they simply never + gain v7 support. A guard fix therefore lands in three places during the transition, and the + duplication is only truly gone once the last v6 application migrates. +- **The framework declares a `conflict` against all five packages.** `documentation` and `cronMonitor` + keep their namespaces, so an application with both installed would have two definitions of the same + class. This is the normal case mid-migration, not an edge case: `gf migrate` only exists in v7, so an + application must upgrade the framework *before* it can migrate. Composer refuses at resolution time, + naming the packages, and `gf migrate` removes them from the application's `composer.json`. +- **Presence enables.** A service's block being absent means off; present — even `{}` — means on, and + the block's contents are its settings. This reuses the nullable-section pattern `kmsProviders::$gcp` + already established, so `%env(...)%` and `gf env --list` work inside it with no new machinery. +- **The framework refuses to boot** when routes declare `authentication: true` and neither an auth + service nor `\app\router::providesAuthentication()` will guard them. Those routes were previously + reachable by anyone while looking protected, because the scaffolded `authentication()` returns true. +- **Four dependencies become unconditional** — `doctrine/annotations`, `robthree/twofactorauth`, + `bacon/bacon-qr-code`, `andrewsauder/microsoft-services` — matching the framework's existing posture, + where `hybridauth` and `swagger-php` are already required and unused by the core. `ext-imagick` is + *not*: `BaconQrCodeProvider` defaults to the Imagick backend, but takes a `format` argument, and the + SVG backend needs no extension to draw a square. +- **`interfaces\router` no longer carries lifecycle hooks.** It required `_before()`/`_after()` of every + router and the framework only ever called `\app\router`'s. Those move to `interfaces\appRouter`, and + the guard-skip method — previously duck-typed through `method_exists()` with no interface declaring + it — becomes `interfaces\router\skipsServiceAuthentication`. diff --git a/docs/adr/0004-writes-are-transactional-so-mongodb-is-a-replica-set.md b/docs/adr/0004-writes-are-transactional-so-mongodb-is-a-replica-set.md new file mode 100644 index 0000000..fa5a361 --- /dev/null +++ b/docs/adr/0004-writes-are-transactional-so-mongodb-is-a-replica-set.md @@ -0,0 +1,44 @@ +# Writes are transactional, so MongoDB is a replica set everywhere + +`factory::save()`, `saveMany()`, `delete()`, `deleteMany()` and `deleteManyBy()` each open a +transaction when they are not handed a session. MongoDB offers transactions only on a replica set +or a sharded cluster, so every Environment an Application runs in — production, CI, and a +developer's machine — must provide one. A standalone `mongod` is not a supported configuration. + +The reason is that a save is not one write. A single `save()` writes the document, advances any +`#[autoIncrement]` counters, and dispatches the Model's Embedded Copies into every other collection +that holds one. A partial application of that set does not fail a request; it leaves Embedded +Copies disagreeing with the Model they copy, which is a corruption nothing detects and no later +save repairs. + +## Considered Options + +**Open a transaction only when the save spans more than one write.** A Model with no `#[foreignKey]` +Embedded Copies and no `#[autoIncrement]` field really does perform exactly one write, with nothing +to be atomic about, and skipping the session there would let a standalone `mongod` serve an +Application completely. This is the change a future reader will propose on finding a session opened +around a single-document write, so it is worth saying why it was rejected. + +It trades an unconditional invariant for a conditional one. "A save is atomic" becomes "a save is +atomic when the framework judged it needed to be" — and that judgement is made from attributes on a +class that changes over time. Adding a `#[foreignKey]` to an existing Model would silently move it +from one regime to the other, and the failure that follows is invisible in the failing request and +surfaces later as data that disagrees with itself. The condition is also not local: whether a save +is single-write depends on which *other* Models embed a copy of this one, which the Model being +saved cannot see. + +**Require a replica set only in production, and let development run standalone.** Rejected because +it makes the Environments differ in a way that hides exactly the class of bug transactions exist to +prevent: development would pass on writes production would roll back, and vice versa. + +## Consequences + +The cost falls entirely on development and CI. Production was never affected — Applications connect +to a managed cluster over `mongodb+srv://`, which is a replica set already — which is why the +constraint went unwritten until someone tried to run a scaffolded Application locally and found that +reads worked and writes did not. + +A single-member replica set satisfies it, and is what the application template's compose stack now +runs. The failure mode when it is missing is worth recognising on sight: reads succeed, so an +Application starts, answers `/health`, and lists documents; only writing fails, with *"Transaction +numbers are only allowed on a replica set member or mongos"*. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..61971ea --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,36 @@ +# Architecture decision records + +These ADRs record decisions about the framework itself. During the `v7` review, four +operational ADRs moved out to `gcgov/deploy`, and the ADRs left here were renumbered into a +clean `0001`-`0004` sequence. + +The four that moved carry the county's operational threat model — where secrets decrypt, how +the deploy runners are isolated, how certificates issue, and where the SOPS keys live. This +repository is public; that material belongs in the Ops Repo beside the mechanism it +describes. See `gcgov/deploy` `docs/adr/README.md`. + +## What stayed, and its number + +| Now | Was | Decision | +|---|---|---| +| `0001` | `0001` | Fail-closed configuration | +| `0002` | `0002` | Immutable Release, pinned by digest | +| `0003` | `0005` | Framework Services are built in and config-activated | +| `0004` | `0008` | Writes are transactional, so MongoDB is a replica set | + +## What moved to `gcgov/deploy` + +| Was here | Now (`gcgov/deploy`) | Decision | +|---|---|---| +| `0003` | `0001` | Secrets never decrypt in CI or on hosts | +| `0004` | `0002` | One self-hosted runner per Zone | +| `0006` | `0003` | Let's Encrypt DNS-01 on a shared registered domain | +| `0007` | `0004` | Azure Key Vault per Zone for deployment secrets | + +## How to cite an ADR + +Both repositories now number `0001`-`0004`, so a bare "ADR 0002" is ambiguous. Every +citation names its repository: + +- A local ADR: `docs/adr/0002-immutable-release-digest-pinning.md` +- A foreign ADR: `gcgov/deploy docs/adr/0002-self-hosted-runners-per-zone.md` diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..3524904 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists: it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..b258aeb --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,45 @@ +# Issue tracker: GitHub + +Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v`; `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`). +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies**, the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only, the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me`, the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..b716855 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/phpstan-stubs/app.php b/phpstan-stubs/app.php index 220cb3a..ad70fa0 100644 --- a/phpstan-stubs/app.php +++ b/phpstan-stubs/app.php @@ -12,19 +12,16 @@ class app implements \gcgov\framework\interfaces\app { public static function _before(): void {} public static function _after(): void {} - - - public function registerFrameworkServiceNamespaces(): array - { - return []; - } - } -class router implements \gcgov\framework\interfaces\router { +class router implements \gcgov\framework\interfaces\appRouter { public static function _before(): void {} public static function _after(): void {} + public function providesAuthentication(): bool { + return false; + } + /** * @return \gcgov\framework\models\route[] */ diff --git a/readme/app.php.md b/readme/app.php.md index b213656..061718e 100644 --- a/readme/app.php.md +++ b/readme/app.php.md @@ -1,8 +1,16 @@ # /app/app.php -\app\app will be the first app class instantiated and the instance will last the entire lifecycle of the request. +`\app\app` is the first app class instantiated, and the instance lasts the entire lifecycle of the +request. -`registerFrameworkServiceNamespaces` can be used to register framework extensions. Extensions can provide services and/or app functionality like adding a documentation endpoint or adding JWT authentication. +It declares no methods of its own beyond the two lifecycle hooks — but it is not optional. +`\gcgov\framework\config` derives every path in the framework by reflecting on this class's file +location, so an application without it cannot resolve its own root. + +Framework Services used to be registered here, by returning their namespaces from +`registerFrameworkServiceNamespaces()`. They are now enabled in the `services` section of +`config.json`, so that switching a service on and configuring it are one statement rather than two. +See ADR 0003. ```php namespace app; @@ -13,23 +21,29 @@ final class app implements \gcgov\framework\interfaces\app { */ public static function _before() : void { } - + /** * Processed after lifecycle is complete with this instance */ public static function _after() : void { } - - /** - * Register framework extensions - */ - public function registerFrameworkServiceNamespaces(): array { - return [ - //enable framework extensions - //'gcgov\framework\services\cronMonitor', - //'\gcgov\framework\services\documentation', - ]; - } } ``` + +Enabling services is now a matter of configuration: + +```jsonc +// config.json +"services": { + "auth": { "provider": "oauth", "blockNewUsers": false, "defaultNewUserRoles": [ "Widget.Read" ] }, + "userCrud": { }, + "documentation": { } +} +``` + +Presence enables: a block that is absent means the service is off, a block that is present — even +empty — means it is on, and the block's contents are that service's settings. `blockNewUsers` and +`defaultNewUserRoles` were previously set by calling a singleton from `_before()`; note that `gf` +deliberately does not run `_before()`, so a service configured that way was unconfigured whenever the +CLI enumerated routes. diff --git a/readme/environment-variables.md b/readme/environment-variables.md new file mode 100644 index 0000000..6709569 --- /dev/null +++ b/readme/environment-variables.md @@ -0,0 +1,201 @@ +# Configuration and environment variables + +An application has **one** committed configuration file, `{root}/config.json`. The same bytes are +correct in every Environment: everything that varies is an `%env(...)%` reference, and the values +come from the process environment, a provisioned secret file, or a `.env` on a developer's machine. + +Two rules carry most of the design: + +1. **Every reference is required.** There is no default. A variable that is unset — or set to the + empty string — is a startup failure that names the variable. A value that does *not* vary + between Environments is written as a literal, not referenced. +2. **Secrets never have to be in the environment.** `%env(secret:NAME)%` reads a provisioned file + when one is mounted, so the same config serves a laptop and production without a second file. + +> See also: `CONTEXT.md` for what "Environment" and "Secret" mean here, and +> `docs/adr/0001-fail-closed-configuration.md` for why the fallback mechanism was removed. + +--- + +## Why every reference is required + +v7 briefly had a Symfony-style `default:` processor. It was removed, because it made the dangerous +case the quiet one: + +```jsonc +"type": "%env(default:local:APP_TYPE)%" // ← removed in v7 +``` + +`isLocal()` gates real behaviour. A production container that forgot `APP_TYPE` booted successfully +in development posture and said nothing. The same pattern let a skipped scaffolding prompt ship the +literal string `{app_root_url}` to production as a URL. + +Failing at startup with *"Required environment variable APP_TYPE is not set"* is the whole point. If +a value has a sensible fixed answer, that answer belongs in `config.json` as a literal. + +Empty counts as unset for the same reason: an `.env` copied from an example, with blank lines where +the credentials go, must fail rather than configure an application with empty ones. + +--- + +## Where values come from + +| Source | Precedence | Notes | +|---|---|---| +| Real process environment | highest | What a container is given. Always wins. | +| `{root}/.env.local` | | Machine-local overrides. | +| `{root}/.env` | lowest | Generated by `gf env --init`; gitignored. | + +Either `.env` file may exist alone. There is no `APP_ENV` cascade and nothing is activated or +copied: an Environment simply *is* the set of variables a process is given. + +Loading happens once per process, in `dotEnvLoader::loadOnce()`, before `config.json` is resolved. +`usePutenv()` is on, so call sites reading through `getenv()` see `.env` values too. + +--- + +## Syntax + +``` +%env(PROCESSOR:...:VARIABLE_NAME)% +``` + +The last `:`-delimited segment is the variable name; anything before it is a processor chain applied +**right to left**, innermost first — `%env(int:trim:file:PORT_FILE)%` is `int(trim(file(env(PORT_FILE))))`. + +A reference that is the **whole** value produces a typed result: + +```jsonc +"SMTPPort": "%env(int:SMTP_PORT)%" // → 587, an int +"useSMTP": "%env(bool:SMTP_ENABLED)%" // → true, a bool +``` + +A reference **embedded** in a larger string is substituted as text: + +```jsonc +"rootUrl": "https://%env(APP_HOST)%/api" +``` + +A reference ends at the first `)`. A configuration value cannot contain the literal text `%env(` — +there is no escape syntax, and a leftover `%env(` after resolution is an error rather than something +shipped verbatim. A file containing no `%env(` at all is loaded byte-for-byte, untouched. + +--- + +## Processors + +| Processor | Effect | +|---|---| +| `secret` | The `_FILE` indirection — see below. Must be innermost. | +| `file` | Replace the value with the contents of the file it names. | +| `trim` | Strip surrounding whitespace. | +| `int` | Cast to int; error if not numeric. | +| `bool` | `true/1/yes/on` → `true`, otherwise `false`. | +| `json` | Parse as JSON into an object/array. | + +`string`, `not`, `float` and `base64` existed in an early v7 draft and were removed — nothing used +them, and each one is a public API surface, a test, and an error path to carry. Ask if you need one +back; adding is easy, removing later is not. + +--- + +## `secret` — one config file for a laptop and for production + +```jsonc +"uri": "%env(secret:MONGO_URI)%" +``` + +resolves in one of two ways: + +- If **`MONGO_URI_FILE`** is set, its value is a path, and the (trimmed) contents of that file are + the result. This is production: the ops repository provisions `/run/secrets/permits-api/mongo_uri` + and sets `MONGO_URI_FILE` to point at it. The credential never enters the process environment, so + it is not visible in `docker inspect` or `/proc//environ`, and not inherited by child + processes. +- Otherwise **`MONGO_URI`** is read directly. This is a developer machine, with the value in `.env`. + +**A `_FILE` variable that is set but names a missing or unreadable file is an error.** It does not +fall back to the plain variable. That fallback is exactly the failure you do not want: a secret +mount that silently did not happen, quietly replaced by whatever stale value the environment +happens to hold. + +`secret` must sit immediately before the variable name, since it decides *where* the value is read +rather than transforming one. + +--- + +## Finding out what an application needs + +The manifest is derived from `config.json`, never hand-maintained: + +```bash +gf env # resolve config.json against the current environment; name the first failure +gf env --list # every referenced variable, whether it is a secret, whether it is set +gf env --init # write a .env skeleton from that list (--force to overwrite) +``` + +`.env` also carries variables `config.json` knows nothing about — docker compose ports, CORS +origins. Those live in the template's `.env.example`; the two files have deliberately disjoint +ownership so neither can drift into the other's territory. + +--- + +## Reserved names + +Under CGI and FastCGI, request headers reach the real process environment as `HTTP_*`, and +`$_SERVER` carries request-derived CGI meta-variables. So that a `%env()` reference can never be +satisfied by request data, these names are treated as **unset** in every lookup source: + +`HTTP_*`, `SERVER_*`, `REQUEST_*`, `REMOTE_*`, `PHP_AUTH_*`, `SCRIPT_*`, `DOCUMENT_*`, `HTTPS`, +`QUERY_STRING`, `CONTENT_TYPE`, `CONTENT_LENGTH`, `AUTH_TYPE`, `GATEWAY_INTERFACE`, `PHP_SELF`, +`PATH_INFO`, `PATH_TRANSLATED`. + +Do not name a configuration variable after one of these. Referencing one produces an error that +says so. + +--- + +## A complete example + +```jsonc +{ + "app": { "title": "Permits API", "guid": "…" }, // literals: they never vary + "settings": { "forceMfaForPasswordUsers": false }, + + "type": "%env(APP_TYPE)%", + "rootUrl": "%env(APP_ROOT_URL)%", + "basePath": "%env(APP_BASE_PATH)%", + + "logging": { "destination": "stderr" }, + + "mongoDatabases": [ + { + "default": true, + "database": "%env(MONGO_DATABASE)%", + "uri": "%env(secret:MONGO_URI)%" + } + ], + + "jwtAuth": { "keyPath": "/run/secrets/permits-api/jwt" } + // tokenIssuedBy / tokenPermittedFor omitted: they derive from rootUrl / basePath +} +``` + +Five variables. A developer sets them in `.env`; production supplies four as environment variables +and `MONGO_URI` as a provisioned file. + +--- + +## Migrating a v6 application + +`gf migrate` does the deterministic half: merges `app/config/app.json` and +`app/config/environment.json` into `config.json`, turns their values into references, writes the +extracted values to `.env`, and deletes the IIS and batch files. Run `gf migrate --dry-run` first +and read the diff. + +It reports rather than guesses: `sqlDatabases` credentials, a missing `app.guid`, and the dropped +`serverName` / `cookieUrl` / `phpPath` keys all come back as warnings. It also pins +`logging.destination` to `"file"` so an application's logging behaviour does not change underneath +it — switch that to `"stderr"` when the application moves into a container. + +See `DOCKER.md` in the application template for the deployment side. diff --git a/readme/gf.md b/readme/gf.md index f772fd7..24e59b0 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -14,8 +14,8 @@ Run it with no arguments to see everything available: Tip: add `vendor/bin` to your PATH (or use `composer exec gf`) so you can type `gf` alone. Throughout this document `gf` means `vendor/bin/gf` (`vendor\bin\gf.bat` on Windows). -Command names use the `namespace:command` convention (`db:restore`). The space-separated -spelling also works — `gf db restore` resolves to `db:restore` automatically. +Command names use the `namespace:command` convention (`db:run`). The space-separated +spelling also works — `gf db run` resolves to `db:run` automatically. | Command | Replaces | Purpose | |---|---|---| @@ -25,11 +25,11 @@ spelling also works — `gf db restore` resolves to `db:restore` automatically. | `gf chrome:install` | manual Chrome installs | Download chrome-headless-shell into srv/chrome | | `gf chrome:update` | — | Update chrome-headless-shell to current Stable + remove old versions | | `gf chrome:status` | — | Show whether chrome-headless-shell is installed and what version | -| `gf db:restore` | `db/restore-live-to-local.ps1` | Copy a source environment's mongo databases into a target environment | | `gf db:run ` | ad-hoc `mongosh "" script.js` | Run a mongosh script using config-managed connections | -| `gf env ` | manual `Copy-Item` steps | Activate an environment's config file variants | -| `gf setup` | `scripts/setup.ps1` | Bootstrap a freshly scaffolded application | -| `gf deploy` | `update-production.ps1` | Tag-based production deployment | +| `gf env` | manual `Copy-Item` steps | Validate that config.json resolves; `--list` its variables; `--init` a .env skeleton | +| `gf init` | `scripts/setup.ps1` | Bootstrap a freshly scaffolded application (non-interactive) | +| `gf user:create` | hand written mongosh inserts | Create the application user you sign in as | +| `gf migrate` | — | Convert a v6 application's configuration to v7 | | `gf completion` / `gf completion:powershell` | — | Shell tab completion | `gf` never requires a Windows shell: everything is implemented in PHP or shells out to @@ -52,7 +52,7 @@ gf cli /cli/generate-shifts --debug # run with Xdebug (replaces local-debug. - **Exit codes**: `0` on success, `1` when the response status is 400+ — so Task Scheduler / cron can detect failures. (The legacy `.bat` entry always exited 0.) - **Interpreter selection** (first match wins): `--php=`, the `GF_PHP` - environment variable, `phpPath` in `environment.json`, the PHP running gf. Any of these may + environment variable, `phpPath` in `config.json`, the PHP running gf. Any of these may include trailing arguments after the binary, e.g. `C:\path\php.exe -c C:\path\php.ini` (quote a binary or argument that contains spaces). - **It must be the CLI binary.** `php-cgi.exe` (what an IIS FastCGI handler mapping points at), @@ -88,7 +88,7 @@ $routes[] = new route( 'CLI', '/cli/generate-shifts', '\app\controllers\cli\gene ## JWT signing keys: `gf cert:generate-auth` ``` -gf cert:generate-auth # 5 RSA-2048 keypairs -> srv/jwtCertificates + guids.json +gf cert:generate-auth # 5 RSA-2048 keypairs -> jwtAuth.keyPath (default srv/jwtCertificates) + guids.json gf cert:generate-auth --count=3 --yes ``` @@ -120,7 +120,7 @@ that touches the network, and a network failure there only warns). `srv/chrome/installation.json` manifest recording the active version; the directory is git-ignored automatically. Installation is atomic — an interrupted download never leaves a half-installed version. -- `gf setup` runs the install automatically (`--skip-chrome` to opt out). `chrome:update` is +- `gf init` runs the install automatically (`--skip-chrome` to opt out). `chrome:update` is idempotent and safe to run on a schedule. - Requires the PHP **zip** extension (`extension=zip` in php.ini on Windows, `php-zip` on Linux). - macOS note: if Gatekeeper ever blocks the binary, clear the quarantine attribute with @@ -145,78 +145,136 @@ installation exists. The `chrome-php/chrome` library is a framework dependency, --- -## Databases: `gf db:restore` and `gf db:run` +## Databases: `gf db:run` -Connection strings come from the environment variant config files -(`app/config/environment-{env}.json` → `mongoDatabases[]`) — never hardcode credentials in -scripts again. +Runs a `.js` script through `mongosh` against the application's configured connection, so scripts +stop carrying hardcoded connection strings: -``` -gf db:restore # dump prod -> restore into the active environment.json (--drop) -gf db:restore --from=prod --to=local -gf db:restore --db=AppsSchedule # only the named database(s) -gf db:restore --keep-dump --dump-dir=db/backup +```bash +gf db:run db/create-admin.js +gf db:run db/seed.js --db=reporting # pick a database when the app has several +gf db:run db/report.js -- --quiet # everything after -- goes to mongosh ``` -- Source/target databases are paired by database name (falling back to the two `default` - entries); differing names are remapped with `--nsFrom/--nsTo`. -- Restoring **into** an environment whose `type` is `prod` is refused unless `--allow-prod`. -- Requires the [MongoDB Database Tools](https://www.mongodb.com/try/download/database-tools) - (`mongodump`, `mongorestore`) on PATH. -- The plan (with passwords redacted) is shown and confirmed before anything runs; `--yes` skips. +Requires `mongosh` on PATH. Connection details come from `config.json`'s `mongoDatabases`; the URI +is redacted in all output. +> **`gf db:restore` was removed in v7.** It pulled another Environment's databases down to a +> workstation, which meant every developer's `.env` held production credentials. A dump file +> travels instead of the credentials — see +> [Data to work with](local-development.md#data-to-work-with). + +--- + +## Configuration: `gf env` + +Configuration is one committed `config.json` whose environment-varying values are `%env(...)%` +references, every one of them required. `gf env` is how you find out what an Environment is missing +before the application does. + +```bash +gf env # resolve config.json against the current environment +gf env --list # every variable it references, whether each is a secret, whether each is set +gf env --init # write a .env skeleton, or append what an existing file lacks (--force rewrites) ``` -gf db:run db/create-admin.js # against the active environment.json default db -gf db:run db/migrate.js --env=prod --db=AppsSchedule -gf db:run db/seed.js -- --quiet # everything after -- goes to mongosh -``` -Requires [mongosh](https://www.mongodb.com/try/download/shell) on PATH. +Validation prints the resolved type, urls, logging destination and Mongo connections (URIs +redacted), or fails naming the first unresolvable variable. `--list` and `--init` read `config.json` +without resolving anything, so they work on a fresh clone with no `.env` at all. + +Because the manifest is derived from `config.json`, it cannot drift from it. `.env` also holds +variables `config.json` never sees — compose ports, CORS origins — which live in the template's +`.env.example`. On an existing file `--init` appends only the references the file does not +already declare, leaving every filled-in value and unrelated variable alone; `--force` rewrites +from `config.json` alone, discarding both. + +Full reference: **[Environment variables in config](environment-variables.md)**. --- -## Environments: `gf env` +## Project bootstrap: `gf init` +Run once after scaffolding from `gcgov/framework-app-template`: + +```bash +gf init --title="Timesheet API" ``` -gf env local # environment-local.json -> environment.json, - # composer-local.json -> composer.json, - # www/web-local.config -> www/web.config -gf env prod --dry-run -``` -Missing variant files are skipped with a note; it is an error only if no variant exists at all. +It writes the title and guid into `config.json`, writes a `.env` skeleton, generates JWT signing +keypairs, and installs chrome-headless-shell. `--skip-env`, `--skip-keys` and `--skip-chrome` opt +out of each step; `--guid` sets the guid explicitly. + +The guid is the OAuth `client_id`, so re-running `init` on an application that already has one keeps +it rather than minting a new one and invalidating every registered client. + +It is deliberately **non-interactive**, which is what lets it run from a scaffolding script, a +devcontainer `postCreateCommand`, or CI. It replaces v6's `gf setup` wizard, whose prompts filled +`{placeholder}` tokens in `php.ini` and `web.config` files that no longer exist. --- -## Project bootstrap: `gf setup` +## The first user: `gf user:create` -Interactive replacement for `scripts/setup.ps1`. Run once after scaffolding a project from -`gcgov/framework-app-template` (after `composer install`): prompts for the project values, -generates the app GUID, then replaces the `{placeholder}` tokens across the project's -`.ini/.json/.php/.config/.bat/.ps1` files — including the per-environment `php.ini` files under -`srv/` (`vendor/`, `.git/`, `node_modules/` are excluded). Pressing enter skips a value and -leaves its token for a later re-run. +``` +gf user:create --email=dev@example.test --roles="User.Read,User.Write" +gf user:create --email=dev@example.test --password="…" --name="Dev" --username=dev --roles="User.Read" +gf user:create --email=dev@example.test --roles="User.Read,User.Write,Widget.Read" --force +``` -Setup finishes by downloading chrome-headless-shell (the `gf chrome:install` step); a failure -there — offline machine, missing php-zip — only prints a warning and never fails setup. Pass -`--skip-chrome` to skip it entirely. +An application whose `config.json` enables `services.auth` starts with no way in. +`blockNewUsers` defaults to true, so only users already in the database may sign in; every `/user` +route requires a caller who already holds `User.Write`. Nothing can authenticate, so nothing can +create the first user. Writing the document by hand does not break the cycle either — the user +model hashes the password as it serialises, so a `mongosh` insert has no password anyone can sign +in with. + +- The user is saved through the model the application actually resolves — `\app\models\user` when + it defines one, otherwise the framework's Mongo user model — so hashing and every model hook run + exactly as they do when the application writes a user itself. +- **Omit `--password`** and one is generated and printed once. It is stored hashed and is not + recoverable, so pass your own when you would otherwise be copying it out of the terminal. +- `--username` defaults to the email address. `verifyUsernamePassword()` matches on username + first, so a user created without one could not sign in by username. +- An email that already exists is refused unless `--force`, which updates that user in place. On + an update, an option you do not pass is left alone — including the password — so + `--force --roles="…"` is the way to grant a role. +- Roles are not validated against anything: they are strings an application's routes compare + against. Give the first user the roles its own `requiredRoles` name, plus `User.Read` and + `User.Write` if you want it to administer other users through `services.userCrud`. + +This is an **app-boot** command and it writes to the database, so the configuration must resolve +and MongoDB must be reachable — and must be a replica set, because writing a user is a +transactional write like any other (see [mongodb.md](mongodb.md)). Where this command sits in +bringing an Application up: [local-development.md](local-development.md). --- -## Deployment: `gf deploy` +## Migrating a v6 application: `gf migrate` -Cross-platform replacement for the per-app `update-production.ps1`: +Converts the configuration half of a v6 application. Run it on a clean working tree so the result +is reviewable as a diff: +```bash +gf migrate --dry-run # show the plan +gf migrate # apply it ``` -gf deploy # interactive tag picker, env=prod -gf deploy --tag=v2.4.1 --yes # non-interactive -gf deploy --env=local --no-composer -``` -Steps: `git fetch/pull` → pick a tag (newest first, `--tags=N` to widen) → confirm → -`git checkout tags/` → `git submodule sync/update` → `gf env ` copy step → write -`version.json` (`{"version": "", "inherit": true}`) → `composer update`. -Any failing step aborts the deploy with that step's exit code. +It merges `app/config/app.json` and `app/config/environment.json` into `{root}/config.json`, turns +their environment-varying values into `%env()` references (credentials become `%env(secret:…)%`), +writes the extracted values to `.env`, and deletes the v6 IIS, batch and PowerShell files. + +What it will not do is guess. `sqlDatabases` credentials, a missing `app.guid`, and the removed +`serverName` / `cookieUrl` / `phpPath` keys are reported for you to handle. It pins +`logging.destination` to `"file"` so an application's logging does not silently change on upgrade — +switch it to `"stderr"` when the application moves into a container. + +It does not write a Dockerfile, choose a Zone, or move secrets into the ops repository. Those need +judgement; the companion skill covers them. + +> **`gf deploy` was removed in v7.** It deployed by running `git checkout` and `composer update` on +> the server, which resolves dependencies in production at deploy time — two hosts on "the same tag" +> could be running different code. A Release is now an immutable image pinned by digest, deployed by +> a GitHub Actions workflow. See `docs/adr/0002-immutable-release-digest-pinning.md`. --- @@ -230,7 +288,7 @@ Any failing step aborts the deploy with that step's exit code. ``` Completion is dynamic: `gf cli ` suggests the application's actual CLI routes (with -descriptions), `gf env ` suggests the environment variants present in `app/config/`. +descriptions), `gf ` completes command names. --- @@ -259,15 +317,16 @@ class commandProvider implements \gcgov\framework\cli\commandProvider { Commands are ordinary [symfony/console](https://symfony.com/doc/current/console.html) commands. gf discovers providers in the `\app` namespace and in every namespace the app registers via -`\app\app::registerFrameworkServiceNamespaces()`. Name plugin commands with a namespace prefix +Framework Services register their commands directly in `application::__construct()`, since they are +part of the framework. Name commands with a namespace prefix (`docs:regenerate`) to avoid collisions. Discovery is fail-safe: a broken provider never takes down gf itself (run with `-v` to see discovery errors). Useful helpers for custom commands (all in `\gcgov\framework\cli`): - `appContext::require()` / `appContext::locate()` — application root + config access -- `appContext->loadEnvironmentConfig($variant)` — parse an environment variant file -- `environmentFiles::apply($root, $env)` — the `gf env` copy step +- `appContext->loadConfig()` / `configReferences()` — resolve config.json, or list what it references +- `configLoader::load($root)` / `loadVariantEnvironment($root, $name)` — the shared config-load pipeline (also used by `gcgov rameworknfig`) - `mongoTools::findBinary()/redactUri()/uriWithDatabase()` - `phpProcess::findPhpBinary()/requiredIniFlags()/xdebugFlags()` - throw `cliException` for user-facing errors @@ -282,16 +341,30 @@ Useful helpers for custom commands (all in `\gcgov\framework\cli`): | `app\cli\prod.bat /cli/x` (Task Scheduler) | `vendor\bin\gf.bat cli /cli/x` | | `app\cli\local-debug.bat /cli/x` | `vendor/bin/gf cli /cli/x --debug` | | `scripts\create-jwt-keys.ps1` | `vendor/bin/gf cert:generate-auth` | -| `scripts\setup.ps1` | `vendor/bin/gf setup` | -| `db\restore-live-to-local.ps1` | `vendor/bin/gf db:restore --from=prod` | +| `scripts\setup.ps1` | `vendor/bin/gf init --title="…"` | +| hand written user inserts in `db/*.js` | `vendor/bin/gf user:create --email=… --roles="…"` | | `mongosh "mongodb://user:pass@..." db\fix.js` | `vendor/bin/gf db:run db/fix.js --env=prod` | -| `update-production.ps1` | `vendor/bin/gf deploy` | -| `Copy-Item composer-local.json composer.json` (+ 2 more) | `vendor/bin/gf env local` | +| `update-production.ps1` | removed — deployment is a GitHub Actions workflow (ADR 0002) | +| `Copy-Item composer-local.json composer.json` (+ 2 more) | nothing — config is committed and environment-variable driven (v7); `gf env` validates it | Files an app can delete once migrated: `app/cli/local.bat`, `app/cli/local-debug.bat`, `app/cli/prod.bat`, `scripts/setup.ps1`, `scripts/create-jwt-keys.ps1`, `db/restore-live-to-local.ps1`, `update-production.ps1` — and `app/cli/index.php` once no scheduler entry references it (gf ships its own route runner). -Move any secrets that were hardcoded in those scripts into the environment variant config -files (`environment-{env}.json`), which the `db:*` commands read. +Reference any secrets that were hardcoded in those scripts via `%env(...)%` in the committed +the root `config.json` — the `db:*` commands and the request lifecycle both resolve +them. Keep the actual values in the process environment, Docker/Kubernetes secrets, or a +gitignored `.env` file (per-variant values for the `db:*` commands go in gitignored +See **[Environment variables in config](environment-variables.md)** +and **[Migrating a v6 app to v7](#migrating-a-v6-app-to-v7)** above. + +For example, instead of a plaintext URI: + +```jsonc +"uri": "%env(MONGO_URI)%" // fail loud if unset +"uri": "%env(trim:file:MONGO_URI_FILE)%" // …or read a Docker secret file +``` + +`.env` files (`{app-root}/.env`, then `.env.local`) are loaded automatically before config is +resolved; the real process environment always wins over them. diff --git a/readme/local-development.md b/readme/local-development.md new file mode 100644 index 0000000..211a0df --- /dev/null +++ b/readme/local-development.md @@ -0,0 +1,197 @@ +# Running an Application on a development computer + +What every Application built on this framework needs in order to run locally, and why. The +**commands** belong to the Application: `gcgov/framework-app-template` ships a container stack and a +[LOCAL-DEVELOPMENT.md](https://github.com/gcgov/framework-app-template/blob/main/LOCAL-DEVELOPMENT.md) +that names its own service names, ports and routes. This page is the half that is true whatever +stack an Application runs, and it is the half that changes — it reaches you through Composer, while +a scaffolded Application's own copy of anything is frozen at Scaffold time. + +Four things stand between a fresh checkout and a request that returns data. Three of them fail in +ways that look like something else. + +--- + +## 1. The database must be a replica set + +Every write the framework makes runs inside a transaction. `save()`, `saveMany()`, `delete()`, +`deleteMany()` and `deleteManyBy()` each open one when they are not handed a session, because a +single `save()` is already several writes — the document, its `#[autoIncrement]` counters, and the +Embedded Copies the dispatcher pushes into every other collection that holds one. + +MongoDB offers transactions only on a replica set or a sharded cluster. A standalone `mongod` serves +every **read** perfectly and fails every **write**: + +``` +Transaction numbers are only allowed on a replica set member or mongos +``` + +That asymmetry is why this is the first thing to check and the last thing anyone suspects: the +Application starts, `/health` is green, list endpoints return data, and only saving fails. A +single-member set is enough: + +```bash +mongod --replSet rs0 --dbpath /path/to/data +mongosh --eval 'rs.initiate()' +``` + +Production is unaffected — a managed cluster reached over `mongodb+srv://` is a replica set already. +The reasoning, and the alternative that was rejected, are in +[ADR 0004](../docs/adr/0004-writes-are-transactional-so-mongodb-is-a-replica-set.md). + +## 2. Configuration resolves, or the Application refuses to start + +Every Config Reference in the Unified Config is required. There are no defaults, and a variable set +to the empty string counts as unset — a half-configured Application should refuse to start rather +than run in a posture nobody chose ([ADR 0001](../docs/adr/0001-fail-closed-configuration.md)). + +```bash +vendor/bin/gf env # resolve it, or name the first thing missing +vendor/bin/gf env --list # every variable, which are Secrets, which are set +``` + +Two consequences catch people out: + +- **A path is `/`, not blank.** `basePath` at the domain root is `/`. Blank is unset, and unset is a + startup failure like any other. +- **`.env` is loaded, but the real environment always wins.** Precedence is + real env > `.env.local` > `.env`. A variable exported in your shell silently outranks the file you + are editing. + +Full reference: [environment variables](environment-variables.md). + +### Variables whose correct value depends on who is reading + +When an Application runs in a container while its `gf` CLI runs on the host, some references cannot +have one correct value. A connection string names `localhost` from the host and a service name from +inside the network; a key directory is relative on one filesystem and absolute on the other. + +The rule: **`.env` carries the host's value, and the container's value is pinned where `.env` cannot +reach it** — for Docker Compose that is the service's `environment:` block, which beats `env_file:`. +One file then serves both, and neither side has to know about the other. An Application's own +documentation should say which of its variables are in this category; getting one wrong is silent, +because the host's value resolves perfectly well inside the container and simply points nowhere. + +## 3. JWT signing keys exist and the Application is pointed at them + +Signing keys are Secrets, so they are gitignored and never enter a build context or an image. They +are generated per Environment: + +```bash +vendor/bin/gf cert:generate-auth +``` + +They go to `jwtAuth.keyPath`, defaulting to `{root}/srv/jwtCertificates`. Mounting keys without +pointing the Application at them is the failure that looks like success — which is why, when +`services.auth` is enabled, readiness checks that the directory holds usable keys rather than +letting the first sign-in discover it. Regenerating keys invalidates every issued token. + +## 4. The Bootstrap User + +An Application with `services.auth` enabled starts with no way in, and the circle is closed on every +side: + +- `blockNewUsers` defaults true, so only users already stored may sign in. +- Every `/user` route from `services.userCrud` requires a caller already holding `User.Write`. +- Writing the document by hand does not help: the user model hashes the password as it serialises, + so a `mongosh` insert has no password anyone can sign in with. + +`gf user:create` is the way out. It saves through the model the Application actually resolves — +`\app\models\user` when it defines one, otherwise the framework's — so hashing and every model hook +run exactly as they do when the Application writes a user itself. + +```bash +vendor/bin/gf user:create --email=dev@example.test --roles="User.Read,User.Write" +``` + +Roles are strings a Route's `requiredRoles` compares against; nothing validates them. Give the +Bootstrap User the roles its own Routes name, plus `User.Read` and `User.Write` to administer others +through `services.userCrud`. `--force` updates an existing email in place and leaves every option +you did not pass alone, including the password — so it is also how you grant a role later. + +Creating a user is a transactional write like any other, so §1 applies: this is usually the first +command that discovers a standalone `mongod`. + +Full reference: [the gf CLI](gf.md). + +--- + +## Bootstrap, in order + +```bash +vendor/bin/gf init --title="…" # title, guid, .env, signing keys, chrome +# fill in .env +vendor/bin/gf env # does it resolve? +# start the Application +vendor/bin/gf user:create --email=… --roles="…" +``` + +`gf init` is idempotent and meant to be re-run as configuration grows — every step adds only what is +missing, and an existing guid is kept rather than reminted. It cannot create the Bootstrap User, +because nothing can be written to a database that `.env` does not yet describe. + +## Is it working? + +Every Application gets two endpoints from the framework itself, so a deploy pipeline can gate on +something no Application can forget to provide. + +| | `{basePath}/health` | `{basePath}/health/ready` | +|---|---|---| +| Answers | is this process able to serve? | should traffic be sent here right now? | +| Does I/O | no | pings every configured database; checks signing keys when `services.auth` is on | +| Backs | the container healthcheck | the deploy gate | + +They are separate deliberately. A readiness failure that also failed liveness would restart every +replica at once, turning a brief database outage into a crash loop. + +`/health/ready` answers `503` with the failing check named, which makes it the right first stop for +all three failures above: + +```json +{ "status": "ok", "version": "unknown", "checks": { "mongo:myapp": "ok", "jwtKeys": "ok" } } +``` + +`mongo:*` failing means §1 or §2. `jwtKeys` failing means §3. Neither appears in `/health`, which +stays I/O-free on purpose. + +When a request is answered by something you did not expect, set `logging.lifecycle: true` in the +Unified Config to trace routing and every Auth Guard end to end. Logs go to stderr by default, not +to `logs/*.log`. + +--- + +## Data to work with + +An empty database is a poor way to work on an Application that holds years of documents. A copy of +real data is the usual answer, and the framework takes no part in producing one — but three things +about that copy are true of every Application built on this framework. + +**A development computer never reads another Environment's database.** v6 shipped `gf db:restore`, +which pulled one down over the network. Every developer's configuration then held production +credentials, so v7 removed the command along with the committed connection details it needed. A +file replaced it: someone who already has access dumps the database, and the dump travels instead +of the credentials. Nothing on the workstation points anywhere but at its own database. + +**A restored account is not a way in by itself.** The user model hashes a password as it +serializes, so a restored account carries a hash and nothing else — you can sign in as a user whose +password you already know, and as nobody else. That is the closed circle §4 describes, reached by a +different route. `gf user:create --force` sets a password on an account that already exists and +leaves every option you did not pass alone. + +```bash +vendor/bin/gf user:create --force --email=dev@example.test --roles="User.Read,User.Write" +``` + +**A dump of an encrypted collection is ciphertext.** Documents in a collection with queryable +encryption decrypt through the KMS keys that Environment's configuration names. A development +computer without those keys restores the documents and reads nothing in the encrypted fields. Seed +those collections rather than restore them. + +The backup holds whatever the source database holds, so the rules that govern the source database +govern it too: keep it out of git, keep it off shared disks, and keep it on the workstation no +longer than the work needs it. + +`gcgov/framework-app-template` ships one implementation of all this — a one-shot `mongo-restore` +container that restores `db/backup/{DatabaseName}` into the development database over the compose +network, with no MongoDB tools on the host. See its +[LOCAL-DEVELOPMENT.md](https://github.com/gcgov/framework-app-template/blob/main/LOCAL-DEVELOPMENT.md). diff --git a/readme/mongodb.md b/readme/mongodb.md index 5c8d21b..d6be221 100644 --- a/readme/mongodb.md +++ b/readme/mongodb.md @@ -7,8 +7,32 @@ You will primarily interact with this service through extended classes that model your data structure. Data classes will extend `\gcgov\framework\services\mongodb\model` or `\gcgov\framework\services\mongodb\embedded`. +## The database must be a replica set + +Every write this service makes runs inside a transaction. `save()`, `saveMany()`, `delete()`, +`deleteMany()` and `deleteManyBy()` each start one when they are not handed a session, because a +single `save()` is already several writes — the document itself, its auto-increment counters, and +the embedded copies the dispatcher pushes into other collections — and a half-applied save is a +corrupt denormalisation rather than a failed request. + +MongoDB only offers transactions on a **replica set** or a sharded cluster. A standalone `mongod` +serves every read perfectly well and then fails every write with: + +``` +Transaction numbers are only allowed on a replica set member or mongos +``` + +which is why a standalone survives a casual smoke test — the list endpoints work, and only writing +fails. A single-member replica set is enough, and is what the application template's +`docker-compose.yml` runs locally; MongoDB Atlas and any production deployment are replica sets +already. + +The reasoning, and the conditional-transaction alternative that was considered and rejected, are in +[ADR 0004](../docs/adr/0004-writes-are-transactional-so-mongodb-is-a-replica-set.md). Running an +Application locally: [local-development.md](local-development.md). + ## Config -`environment.json` +`{root}/config.json` (`mongoDatabases` section) ```json { "...": "...", diff --git a/readme/router.php.md b/readme/router.php.md index 8900be0..542f324 100644 --- a/readme/router.php.md +++ b/readme/router.php.md @@ -6,7 +6,7 @@ namespace app; use gcgov\framework\models\route; -class router implements \gcgov\framework\interfaces\router { +class router implements \gcgov\framework\interfaces\appRouter { public function __construct() { } @@ -56,7 +56,7 @@ class router implements \gcgov\framework\interfaces\router { * @throws \gcgov\framework\exceptions\routeException */ public function authentication( \gcgov\framework\models\routeHandler $routeHandler ) : bool { - //if you are utilizing the \gcgov\framework\services\authoauth service or \gcgov\framework\services\authmsfront + //if you have enabled services.auth (either provider) // it automatically adds our authentication guard // you can add additional, custom authentication checks here // your custom checks will run before the service authentication checks @@ -68,10 +68,29 @@ class router implements \gcgov\framework\interfaces\router { return true; } - //optional method that can be added to prevent the service authentication checks from running - //private $runFrameworkServiceRouteAuthentication = true; - //public function getRunFrameworkServiceRouteAuthentication(): bool { - // return $this->runFrameworkServiceRouteAuthentication; + /** + * Does this application authenticate its own routes? + * + * Required by \gcgov\framework\interfaces\appRouter. The framework refuses to boot + * when routes declare authentication:true, no authentication service is enabled, and + * this returns false — such routes would be reachable by anyone, because the + * authentication() above returns true for every caller. Return true ONLY if + * authentication() genuinely establishes and verifies the caller's identity. + */ + public function providesAuthentication() : bool { + return false; + } + + //optional: to prevent the Framework Service auth guards from running for some routes, + //also implement \gcgov\framework\interfaces\router\skipsServiceAuthentication: + // + // class router implements \gcgov\framework\interfaces\appRouter, \gcgov\framework\interfaces\router\skipsServiceAuthentication + // + //and add the method it declares. Note the $routeHandler parameter — the opt-out is + //per route, and was duck-typed via method_exists() before the interface existed: + // + //public function getRunFrameworkServiceRouteAuthentication( \gcgov\framework\models\routeHandler $routeHandler ): bool { + // return true; //} } diff --git a/src/cli/appContext.php b/src/cli/appContext.php index 66a3fd9..04701bc 100644 --- a/src/cli/appContext.php +++ b/src/cli/appContext.php @@ -2,7 +2,7 @@ namespace gcgov\framework\cli; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; /** * Locates the consuming application's root directory and provides lazy access @@ -11,7 +11,7 @@ * gf command tiers: * - no context needed: list, help, completion — work anywhere * - root only: env, db:*, cert:*, deploy — need locate() + config JSON - * - app boot: cli, cli:list — need assertAppLoadable() + getServiceNamespaces() + * - app boot: cli, cli:list — need assertAppLoadable() */ final class appContext { @@ -110,8 +110,9 @@ public function getAppDir(): string { } - public function getConfigDir(): string { - return $this->rootDir . '/app/config'; + /** The unified {root}/config.json read by loadConfig(). */ + public function getConfigPath(): string { + return $this->rootDir . '/config.json'; } @@ -138,62 +139,44 @@ public function assertAppLoadable(): void { /** - * Service namespaces registered by the app. Instantiates \app\app but deliberately - * does NOT run \app\app::_before() — no lifecycle side effects for enumeration. + * Load and resolve {root}/config.json — no \app boot, no ext-mongodb. + * {root}/.env is loaded first; the real process environment wins. * - * @return string[] * @throws \gcgov\framework\cli\cliException */ - public function getServiceNamespaces(): array { - $this->assertAppLoadable(); - $app = new \app\app(); + public function loadConfig(): unifiedConfig { + if( !file_exists( $this->getConfigPath() ) ) { + throw new cliException( 'Missing config file: ' . $this->getConfigPath() . '. Commit a config.json at the application root that references environment variables with %env(...) and supply values via the process environment or a .env file. Migrating a v6 application? Run `gf migrate`.' ); + } - return $app->registerFrameworkServiceNamespaces(); + try { + return \gcgov\framework\services\environment\configLoader::load( $this->rootDir ); + } + catch( \gcgov\framework\services\environment\environmentException $e ) { + throw new cliException( $e->getMessage(), 0, $e ); + } } /** - * Parse app/config/environment{-$variant}.json directly — no \app boot, no ext-mongodb. - * $variant '' loads the active environment.json. + * Every variable config.json references, and whether each is a secret. Read without + * resolving anything, so it works on a fresh clone with no .env. * + * @return array * @throws \gcgov\framework\cli\cliException */ - public function loadEnvironmentConfig( string $variant = '' ): environmentConfig { - $file = $this->getEnvironmentConfigPath( $variant ); - if( !file_exists( $file ) ) { - $hint = $variant==='' ? ' Run `gf env ` to activate an environment first.' : ''; - throw new cliException( 'Missing environment config file: ' . $file . '.' . $hint ); - } - + public function configReferences(): array { try { - return environmentConfig::jsonDeserialize( (string)file_get_contents( $file ) ); + return \gcgov\framework\services\environment\configLoader::references( $this->rootDir ); } - catch( \andrewsauder\jsonDeserialize\exceptions\jsonDeserializeException $e ) { - throw new cliException( 'Failed to parse ' . $file . ': ' . $e->getMessage(), 0, $e ); + catch( \gcgov\framework\services\environment\environmentException $e ) { + throw new cliException( $e->getMessage(), 0, $e ); } } - public function getEnvironmentConfigPath( string $variant = '' ): string { - $suffix = $variant==='' ? '' : '-' . $variant; - - return $this->getConfigDir() . '/environment' . $suffix . '.json'; - } - - - /** - * Environment variant names available in app/config (environment-{name}.json). - * - * @return string[] - */ - public function getEnvironmentVariants(): array { - $variants = []; - foreach( glob( $this->getConfigDir() . '/environment-*.json' ) ?: [] as $file ) { - $variants[] = substr( basename( $file, '.json' ), strlen( 'environment-' ) ); - } - sort( $variants ); - - return $variants; + public function getEnvFilePath(): string { + return $this->rootDir . '/.env'; } } diff --git a/src/cli/application.php b/src/cli/application.php index 85dc0af..b5e2c3e 100644 --- a/src/cli/application.php +++ b/src/cli/application.php @@ -32,11 +32,11 @@ public function __construct( ?string $composerAutoloadPath = null ) { new commands\chromeInstallCommand(), new commands\chromeUpdateCommand(), new commands\chromeStatusCommand(), - new commands\dbRestoreCommand(), new commands\dbRunCommand(), new commands\envCommand(), - new commands\setupCommand(), - new commands\deployCommand(), + new commands\initCommand(), + new commands\migrateCommand(), + new commands\userCreateCommand(), new commands\completionPowershellCommand(), ] ); @@ -84,9 +84,12 @@ public function doRun( InputInterface $input, OutputInterface $output ): int { /** - * Register commands contributed by the app (\app\cli\commandProvider) and by each - * framework-service plugin ({serviceNamespace}\cli\commandProvider). Failures never - * break gf itself — built-in commands always remain available. + * Register commands contributed by the application (\app\cli\commandProvider). + * Failures never break gf itself — built-in commands always remain available. + * + * Framework Services no longer contribute commands through discovery: they live in + * the framework now, so a service command is registered in this constructor like any + * other built-in. */ private function discoverProviderCommands(): void { try { @@ -95,14 +98,9 @@ private function discoverProviderCommands(): void { return; } - $namespaces = $context->getServiceNamespaces(); - $namespaces[] = '\app'; - - foreach( $namespaces as $namespace ) { - $providerClass = '\\' . trim( $namespace, '\\' ) . '\cli\commandProvider'; - if( class_exists( $providerClass ) && is_a( $providerClass, commandProvider::class, true ) ) { - $this->addCommands( $providerClass::getCommands() ); - } + $providerClass = '\app\cli\commandProvider'; + if( class_exists( $providerClass ) && is_a( $providerClass, commandProvider::class, true ) ) { + $this->addCommands( $providerClass::getCommands() ); } } catch( \Throwable $e ) { diff --git a/src/cli/commandProvider.php b/src/cli/commandProvider.php index 2df65aa..fd3c0a0 100644 --- a/src/cli/commandProvider.php +++ b/src/cli/commandProvider.php @@ -5,14 +5,14 @@ /** * Contribute custom commands to the gf CLI. * - * Applications implement this as \app\cli\commandProvider (file: app/cli/commandProvider.php). - * Framework-service plugins implement it as \gcgov\framework\services\{name}\cli\commandProvider - * (file: src/cli/commandProvider.php in the plugin repo). gf discovers implementations - * automatically for the app namespace and every namespace returned by - * \app\app::registerFrameworkServiceNamespaces() — no additional registration required. + * Applications implement this as \app\cli\commandProvider (file: app/cli/commandProvider.php); + * gf discovers it automatically — no additional registration required. * - * Commands are ordinary symfony/console commands. Plugin commands should namespace their - * names to avoid collisions (e.g. 'docs:regenerate'). + * Framework Services do not use this. They live in the framework, so a command belonging + * to one is registered directly in \gcgov\framework\cli\application's constructor. + * + * Commands are ordinary symfony/console commands. Namespace their names to avoid + * collisions with the built-ins (e.g. 'widget:reindex'). */ interface commandProvider { diff --git a/src/cli/commands/certGenerateAuthCommand.php b/src/cli/commands/certGenerateAuthCommand.php index b51c6fe..1c2486e 100644 --- a/src/cli/commands/certGenerateAuthCommand.php +++ b/src/cli/commands/certGenerateAuthCommand.php @@ -4,6 +4,9 @@ use gcgov\framework\cli\appContext; use gcgov\framework\cli\cliException; +use gcgov\framework\services\environment\dotEnvLoader; +use gcgov\framework\services\environment\environmentException; +use gcgov\framework\services\environment\envVarResolver; use gcgov\framework\services\guid; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -12,7 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -#[AsCommand( name: 'cert:generate-auth', description: 'Generate the JWT signing keypairs in srv/jwtCertificates (replaces create-jwt-keys.ps1)' )] +#[AsCommand( name: 'cert:generate-auth', description: 'Generate the JWT signing keypairs in jwtAuth.keyPath (default srv/jwtCertificates)' )] final class certGenerateAuthCommand extends Command { protected function configure(): void { @@ -32,10 +35,12 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( '--count must be at least 1' ); } - $context = appContext::require(); - $certificateDir = $context->getSrvDir() . '/jwtCertificates'; + $context = appContext::require(); + $io = new SymfonyStyle( $input, $output ); - $io = new SymfonyStyle( $input, $output ); + // Resolved through the same accessor jwtAuth reads at runtime, so a configured + // jwtAuth.keyPath gets the keys written where the framework will look for them. + $certificateDir = self::resolveCertificateDir( $context, $io ); $existingKeys = glob( $certificateDir . '/*.pem' ) ?: []; if( count( $existingKeys )>0 && !$input->getOption( 'yes' ) ) { @@ -66,7 +71,12 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $guids = []; for( $i = 0; $i<$count; $i++ ) { - $keyGuid = guid::create(); + // Lowercased at the source: on Windows guid::create() returns uppercase GUIDs + // (com_create_guid), and the ops repository's provisioning lowercases every + // secret filename it writes to the host — so an uppercase GUID here would put + // a lowercase file on a case-sensitive filesystem while guids.json still + // names the uppercase spelling jwtAuth looks up, and every sign-in fails. + $keyGuid = strtolower( guid::create() ); $guids[] = $keyGuid; $privateKey = openssl_pkey_new( [ @@ -100,4 +110,72 @@ protected function execute( InputInterface $input, OutputInterface $output ): in return Command::SUCCESS; } + + /** + * The directory the keys belong in: jwtAuth.keyPath through the runtime's accessor — + * but without demanding that the REST of config.json resolve. Key generation needs + * one path, and it runs earliest of all: `gf init` calls it on a scaffold whose .env + * is still empty, where loadConfig() fails on the first of many unrelated references + * (MONGO_URI among them) and key generation used to fail with it, breaking init's + * own contract of producing JWT keys. + * + * A relative configured path is anchored to the application root rather than the + * cwd, so one committed value serves the host CLI wherever gf is invoked from. + */ + private static function resolveCertificateDir( appContext $context, SymfonyStyle $io ): string { + try { + $dir = $context->loadConfig()->getJwtKeyPath( $context->getSrvDir() ); + } + catch( cliException $e ) { + $dir = self::keyPathWithoutFullConfig( $context, $io, $e ); + } + + $dir = rtrim( str_replace( '\\', '/', $dir ), '/' ); + if( !preg_match( '~^(?:/|[A-Za-z]:/)~', $dir ) ) { + $dir = $context->rootDir . '/' . $dir; + } + + return $dir; + } + + + /** + * jwtAuth.keyPath alone, from the raw config document. A literal is used as-is; a + * %env()% reference is resolved by itself; only when that one reference has no value + * does this fall back to the default directory — loudly, because a configured path + * silently ignored would put the keys where the runtime will not look. + */ + private static function keyPathWithoutFullConfig( appContext $context, SymfonyStyle $io, cliException $loadFailure ): string { + $default = $context->getSrvDir() . '/jwtCertificates'; + + $raw = file_exists( $context->getConfigPath() ) ? (string)file_get_contents( $context->getConfigPath() ) : ''; + $decoded = json_decode( $raw ); + $configured = $decoded instanceof \stdClass && isset( $decoded->jwtAuth->keyPath ) ? trim( (string)$decoded->jwtAuth->keyPath ) : ''; + + if( $configured==='' ) { + return $default; + } + + if( str_contains( $configured, '%env(' ) ) { + // loadConfig() may have thrown before its own .env load ran. + dotEnvLoader::loadOnce( $context->rootDir ); + try { + $configured = trim( (string)envVarResolver::resolveDecoded( (object)[ 'keyPath' => $configured ], 'config.json jwtAuth.keyPath' )->keyPath ); + } + catch( environmentException $e ) { + $io->warning( 'config.json does not fully resolve (' . $loadFailure->getMessage() . ') and jwtAuth.keyPath itself has no value yet (' . $e->getMessage() . '). Writing the keys to the default ' . $default . ' — if that variable is meant to point somewhere else, set it and re-run cert:generate-auth.' ); + + return $default; + } + } + + if( $configured==='' ) { + return $default; + } + + $io->note( 'config.json does not fully resolve yet — key generation needs only jwtAuth.keyPath, so continuing with it.' ); + + return $configured; + } + } diff --git a/src/cli/commands/cliCommand.php b/src/cli/commands/cliCommand.php index 6f07d7c..2320daf 100644 --- a/src/cli/commands/cliCommand.php +++ b/src/cli/commands/cliCommand.php @@ -25,7 +25,7 @@ protected function configure(): void { $this->addOption( 'debug', null, InputOption::VALUE_NONE, 'Run the route with Xdebug step debugging enabled (replaces local-debug.bat)' ); $this->addOption( 'debug-host', null, InputOption::VALUE_REQUIRED, 'Xdebug client host', '127.0.0.1' ); $this->addOption( 'debug-port', null, InputOption::VALUE_REQUIRED, 'Xdebug client port', '9003' ); - $this->addOption( 'php', null, InputOption::VALUE_REQUIRED, 'PHP binary (or its directory) to run the route with. Defaults to GF_PHP, then environment.json phpPath, then the PHP running gf.' ); + $this->addOption( 'php', null, InputOption::VALUE_REQUIRED, 'PHP binary (or its directory) to run the route with. Defaults to GF_PHP, then the PHP running gf.' ); $this->setHelp( 'Executes the route through the full framework lifecycle in a fresh PHP process, exactly like the legacy app/cli/index.php entry. Exit code is 0 on success and 1 when the response status is 400 or higher.' ); } @@ -45,15 +45,14 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $context = appContext::require(); $context->assertAppLoadable(); - $environmentConfig = null; - try { - $environmentConfig = $context->loadEnvironmentConfig(); - } - catch( cliException ) { - // environment.json missing — the child process will report it through the framework lifecycle - } + // Load .env before choosing the interpreter. findPhpBinary() falls back to + // getenv('GF_PHP'), and dotEnvLoader enables usePutenv() precisely so getenv() sees + // .env values — but nothing on this path had loaded it, so a GF_PHP set in {root}/.env + // (which the loader's own docblock offers as the example) was invisible and the route + // silently ran on whatever PHP happened to be on PATH. + \gcgov\framework\services\environment\dotEnvLoader::loadOnce( $context->rootDir ); - $commandLine = array_merge( phpProcess::findPhpBinary( $input->getOption( 'php' ), $environmentConfig ), phpProcess::requiredIniFlags() ); + $commandLine = array_merge( phpProcess::findPhpBinary( $input->getOption( 'php' ) ), phpProcess::requiredIniFlags() ); if( $input->getOption( 'debug' ) ) { $commandLine = array_merge( $commandLine, phpProcess::xdebugFlags( (string)$input->getOption( 'debug-host' ), (int)$input->getOption( 'debug-port' ) ) ); diff --git a/src/cli/commands/dbRestoreCommand.php b/src/cli/commands/dbRestoreCommand.php deleted file mode 100644 index 32484f7..0000000 --- a/src/cli/commands/dbRestoreCommand.php +++ /dev/null @@ -1,206 +0,0 @@ -addOption( 'from', null, InputOption::VALUE_REQUIRED, 'Source environment variant (reads app/config/environment-{from}.json)', 'prod', envCommand::suggestEnvironments( ... ) ); - $this->addOption( 'to', null, InputOption::VALUE_REQUIRED, 'Target environment variant. Omit to use the active app/config/environment.json.', '', envCommand::suggestEnvironments( ... ) ); - $this->addOption( 'db', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Restrict to the named database(s). Repeatable. Default: every database in the source config.' ); - $this->addOption( 'dump-dir', null, InputOption::VALUE_REQUIRED, 'Directory to write the mongodump output to. Default: srv/tmp/mongodump-{timestamp}.' ); - $this->addOption( 'keep-dump', null, InputOption::VALUE_NONE, 'Keep the dump directory after a successful restore' ); - $this->addOption( 'yes', 'y', InputOption::VALUE_NONE, 'Skip the confirmation prompt' ); - $this->addOption( 'allow-prod', null, InputOption::VALUE_NONE, 'Allow restoring INTO an environment whose type is "prod" (refused otherwise)' ); - $this->setHelp( 'Cross-platform replacement for the per-app restore-live-to-local.ps1: connection strings come from the environment variant config files instead of being hardcoded. Requires the MongoDB Database Tools (mongodump/mongorestore) on PATH.' ); - } - - - protected function execute( InputInterface $input, OutputInterface $output ): int { - $context = appContext::require(); - $io = new SymfonyStyle( $input, $output ); - - $fromVariant = (string)$input->getOption( 'from' ); - $toVariant = (string)$input->getOption( 'to' ); - if( $fromVariant==='' ) { - throw new cliException( '--from requires an environment variant name (e.g. --from=prod)' ); - } - - $sourceConfig = $context->loadEnvironmentConfig( $fromVariant ); - $targetConfig = $context->loadEnvironmentConfig( $toVariant ); - - if( $targetConfig->type==='prod' && !$input->getOption( 'allow-prod' ) ) { - throw new cliException( 'Refusing to restore into an environment with type "prod" (' . $context->getEnvironmentConfigPath( $toVariant ) . '). Pass --allow-prod if you really mean it.' ); - } - - $pairs = self::pairDatabases( $sourceConfig->mongoDatabases, $targetConfig->mongoDatabases, $input->getOption( 'db' ) ); - if( count( $pairs[ 'matched' ] )===0 ) { - throw new cliException( 'No database pairs to restore. Source config databases: ' . implode( ', ', array_map( fn( mongoDatabase $db ) => $db->database, $sourceConfig->mongoDatabases ) ) ); - } - foreach( $pairs[ 'unmatched' ] as $unmatchedName ) { - $io->warning( 'Source database "' . $unmatchedName . '" has no matching database in the target config — skipped.' ); - } - - // resolve the tools before doing anything - $mongodumpBinary = mongoTools::findBinary( 'mongodump' ); - $mongorestoreBinary = mongoTools::findBinary( 'mongorestore' ); - - $io->section( 'Restore plan (' . $fromVariant . ' -> ' . ( $toVariant===''?'active environment.json':$toVariant ) . ')' ); - foreach( $pairs[ 'matched' ] as [ $sourceDb, $targetDb ] ) { - $io->text( ' ' . $sourceDb->database . ' @ ' . mongoTools::redactUri( $sourceDb->uri ) . ' -> ' . $targetDb->database . ' @ ' . mongoTools::redactUri( $targetDb->uri ) . ' (--drop)' ); - } - - if( !$input->getOption( 'yes' ) && !$io->confirm( 'The target database(s) will be DROPPED and replaced. Continue?', false ) ) { - $io->text( 'Aborted. No changes made.' ); - - return Command::FAILURE; - } - - $dumpDir = (string)( $input->getOption( 'dump-dir' ) ?? '' ); - if( $dumpDir==='' ) { - $dumpDir = $context->getSrvDir() . '/tmp/mongodump-' . date( 'Ymd-His' ); - } - if( !is_dir( $dumpDir ) && !mkdir( $dumpDir, 0775, true ) ) { - throw new cliException( 'Failed to create dump directory ' . $dumpDir ); - } - - foreach( $pairs[ 'matched' ] as [ $sourceDb, $targetDb ] ) { - $io->section( 'Dumping ' . $sourceDb->database ); - $exitCode = $this->stream( new Process( self::buildDumpCommand( $mongodumpBinary, $sourceDb, $dumpDir ), $context->rootDir, null, null, null ), $output ); - if( $exitCode!==0 ) { - throw new cliException( 'mongodump exited with code ' . $exitCode . ' — aborting before restore. Dump directory: ' . $dumpDir ); - } - - $io->section( 'Restoring into ' . $targetDb->database ); - $exitCode = $this->stream( new Process( self::buildRestoreCommand( $mongorestoreBinary, $sourceDb, $targetDb, $dumpDir ), $context->rootDir, null, null, null ), $output ); - if( $exitCode!==0 ) { - throw new cliException( 'mongorestore exited with code ' . $exitCode . '. Dump directory kept for inspection: ' . $dumpDir ); - } - } - - if( $input->getOption( 'keep-dump' ) ) { - $io->text( 'Dump kept at ' . $dumpDir ); - } - else { - self::deleteDirectory( $dumpDir ); - } - - $io->success( 'Restore complete.' ); - - return Command::SUCCESS; - } - - - /** - * Pair source databases with target databases by database name; when the source or - * target has exactly one default database and no name match exists, fall back to - * pairing the two default databases. - * - * @param mongoDatabase[] $sourceDatabases - * @param mongoDatabase[] $targetDatabases - * @param string[] $onlyDatabases - * - * @return array{matched: array, unmatched: string[]} - */ - public static function pairDatabases( array $sourceDatabases, array $targetDatabases, array $onlyDatabases = [] ): array { - $matched = []; - $unmatched = []; - - $targetsByName = []; - foreach( $targetDatabases as $targetDb ) { - $targetsByName[ $targetDb->database ] = $targetDb; - } - $defaultTarget = null; - foreach( $targetDatabases as $targetDb ) { - if( $targetDb->default ) { - $defaultTarget = $targetDb; - break; - } - } - - foreach( $sourceDatabases as $sourceDb ) { - if( count( $onlyDatabases )>0 && !in_array( $sourceDb->database, $onlyDatabases, true ) ) { - continue; - } - - if( isset( $targetsByName[ $sourceDb->database ] ) ) { - $matched[] = [ $sourceDb, $targetsByName[ $sourceDb->database ] ]; - } - elseif( $sourceDb->default && $defaultTarget!==null ) { - $matched[] = [ $sourceDb, $defaultTarget ]; - } - else { - $unmatched[] = $sourceDb->database; - } - } - - return [ 'matched' => $matched, 'unmatched' => $unmatched ]; - } - - - /** - * @return string[] - */ - public static function buildDumpCommand( string $mongodumpBinary, mongoDatabase $sourceDb, string $dumpDir ): array { - $command = [ $mongodumpBinary, '--uri=' . $sourceDb->uri ]; - if( $sourceDb->database!=='' ) { - $command[] = '--db=' . $sourceDb->database; - } - $command[] = '--out=' . $dumpDir; - - return $command; - } - - - /** - * @return string[] - */ - public static function buildRestoreCommand( string $mongorestoreBinary, mongoDatabase $sourceDb, mongoDatabase $targetDb, string $dumpDir ): array { - $command = [ $mongorestoreBinary, '--uri=' . $targetDb->uri, '--drop' ]; - if( $sourceDb->database!=='' && $targetDb->database!=='' && $sourceDb->database!==$targetDb->database ) { - $command[] = '--nsFrom=' . $sourceDb->database . '.*'; - $command[] = '--nsTo=' . $targetDb->database . '.*'; - } - $command[] = $dumpDir . '/' . $sourceDb->database; - - return $command; - } - - - private function stream( Process $process, OutputInterface $output ): int { - $process->setTimeout( null ); - $process->run( function( string $type, string $buffer ) use ( $output ): void { - $output->write( $buffer ); - } ); - - return $process->getExitCode() ?? 1; - } - - - private static function deleteDirectory( string $directory ): void { - if( !is_dir( $directory ) ) { - return; - } - $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); - foreach( $iterator as $file ) { - /** @var \SplFileInfo $file */ - $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); - } - rmdir( $directory ); - } - -} diff --git a/src/cli/commands/dbRunCommand.php b/src/cli/commands/dbRunCommand.php index 12ee826..669468b 100644 --- a/src/cli/commands/dbRunCommand.php +++ b/src/cli/commands/dbRunCommand.php @@ -19,9 +19,8 @@ final class dbRunCommand extends Command { protected function configure(): void { $this->addArgument( 'script', InputArgument::REQUIRED, 'Path to the .js script to execute with mongosh' ); $this->addArgument( 'mongoshArgs', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Extra arguments passed through to mongosh (prefix with --)' ); - $this->addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment variant to read the connection from (reads app/config/environment-{env}.json). Omit to use the active environment.json.', '', envCommand::suggestEnvironments( ... ) ); $this->addOption( 'db', null, InputOption::VALUE_REQUIRED, 'Database name from the mongoDatabases config to run against. Default: the entry marked default (or the only entry).' ); - $this->setHelp( 'Replaces per-script mongosh invocations with hardcoded connection strings, e.g.: gf db:run db/create-admin.js --env=local. Everything after -- is forwarded to mongosh.' ); + $this->setHelp( 'Replaces per-script mongosh invocations with hardcoded connection strings, e.g.: gf db:run db/create-admin.js. Everything after -- is forwarded to mongosh.' ); } @@ -33,18 +32,18 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( 'Script not found: ' . $scriptPath ); } - $environmentConfig = $context->loadEnvironmentConfig( (string)$input->getOption( 'env' ) ); + $mongoDatabases = $context->loadConfig()->mongoDatabases; $databaseName = (string)( $input->getOption( 'db' ) ?? '' ); $mongoDatabase = null; - foreach( $environmentConfig->mongoDatabases as $candidate ) { - if( $databaseName!=='' ? $candidate->database===$databaseName : ( $candidate->default || count( $environmentConfig->mongoDatabases )===1 ) ) { + foreach( $mongoDatabases as $candidate ) { + if( $databaseName!=='' ? $candidate->database===$databaseName : ( $candidate->default || count( $mongoDatabases )===1 ) ) { $mongoDatabase = $candidate; break; } } if( $mongoDatabase===null ) { - $available = implode( ', ', array_map( fn( $db ) => $db->database, $environmentConfig->mongoDatabases ) ); + $available = implode( ', ', array_map( fn( $db ) => $db->database, $mongoDatabases ) ); throw new cliException( $databaseName==='' ? 'No default mongo database found in the environment config. Available: ' . $available . '. Choose one with --db.' : 'No mongo database named "' . $databaseName . '" in the environment config. Available: ' . $available ); } diff --git a/src/cli/commands/deployCommand.php b/src/cli/commands/deployCommand.php deleted file mode 100644 index e470d59..0000000 --- a/src/cli/commands/deployCommand.php +++ /dev/null @@ -1,139 +0,0 @@ -addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment whose config variants to activate after checkout', 'prod', envCommand::suggestEnvironments( ... ) ); - $this->addOption( 'tag', null, InputOption::VALUE_REQUIRED, 'Tag to deploy. Omit to pick interactively from the most recent tags.' ); - $this->addOption( 'tags', null, InputOption::VALUE_REQUIRED, 'How many recent tags to offer in the interactive picker', '15' ); - $this->addOption( 'no-composer', null, InputOption::VALUE_NONE, 'Skip the composer update step' ); - $this->addOption( 'yes', 'y', InputOption::VALUE_NONE, 'Skip the confirmation prompts' ); - } - - - protected function execute( InputInterface $input, OutputInterface $output ): int { - $context = appContext::require(); - $io = new SymfonyStyle( $input, $output ); - - $gitBinary = ( new ExecutableFinder() )->find( 'git' ); - if( $gitBinary===null ) { - throw new cliException( 'git was not found on PATH.' ); - } - - $isWorkTree = $this->capture( [ $gitBinary, 'rev-parse', '--is-inside-work-tree' ], $context->rootDir ); - if( trim( $isWorkTree )!=='true' ) { - throw new cliException( $context->rootDir . ' is not a git working tree.' ); - } - - $composerBinary = null; - if( !$input->getOption( 'no-composer' ) ) { - $composerBinary = ( new ExecutableFinder() )->find( 'composer' ) ?? ( new ExecutableFinder() )->find( 'composer.phar' ); - if( $composerBinary===null ) { - throw new cliException( 'composer was not found on PATH. Install it or pass --no-composer to skip dependency updates.' ); - } - } - - $environment = (string)$input->getOption( 'env' ); - - $io->section( 'Fetching' ); - $this->runStep( [ $gitBinary, 'fetch', '--all', '--tags', '--prune' ], $context->rootDir, $output ); - $this->runStep( [ $gitBinary, 'pull' ], $context->rootDir, $output ); - - $tag = (string)( $input->getOption( 'tag' ) ?? '' ); - if( $tag==='' ) { - $tagList = array_values( array_filter( explode( "\n", $this->capture( [ $gitBinary, 'tag', '--sort=-creatordate' ], $context->rootDir ) ) ) ); - if( count( $tagList )===0 ) { - throw new cliException( 'No git tags found — create a release tag before deploying, or pass --tag.' ); - } - $tagList = array_slice( $tagList, 0, max( 1, (int)$input->getOption( 'tags' ) ) ); - $tag = (string)$io->choice( 'Select the tag to deploy', $tagList, $tagList[ 0 ] ); - } - - $dirtyFiles = trim( $this->capture( [ $gitBinary, 'status', '--porcelain' ], $context->rootDir ) ); - if( $dirtyFiles!=='' ) { - $io->warning( "The working tree has uncommitted changes:\n" . $dirtyFiles ); - } - - if( !$input->getOption( 'yes' ) && !$io->confirm( 'Deploy tag ' . $tag . ' with environment "' . $environment . '"?', false ) ) { - $io->text( 'Aborted. No changes made.' ); - - return Command::FAILURE; - } - - $io->section( 'Checking out ' . $tag ); - $this->runStep( [ $gitBinary, 'checkout', 'tags/' . $tag ], $context->rootDir, $output ); - $io->text( '(A detached HEAD at the tag is expected.)' ); - - $io->section( 'Submodules' ); - $this->runStep( [ $gitBinary, 'submodule', 'sync', '--recursive' ], $context->rootDir, $output ); - $this->runStep( [ $gitBinary, 'submodule', 'update', '--init', '--recursive' ], $context->rootDir, $output ); - - $io->section( 'Activating environment "' . $environment . '"' ); - foreach( environmentFiles::apply( $context->rootDir, $environment ) as $result ) { - $io->text( $result[ 'status' ] . ( str_starts_with( $result[ 'status' ], 'skipped' ) ? '' : ': ' . $result[ 'source' ] . ' -> ' . $result[ 'target' ] ) ); - } - - file_put_contents( $context->rootDir . '/version.json', json_encode( [ 'version' => $tag, 'inherit' => true ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); - $io->text( 'Wrote version.json (version ' . $tag . ')' ); - - if( $composerBinary!==null ) { - $io->section( 'composer update' ); - $this->runStep( [ $composerBinary, 'update', '--no-interaction' ], $context->rootDir, $output ); - } - - $io->success( 'Deployed ' . $tag . ' (' . $environment . ').' ); - - return Command::SUCCESS; - } - - - /** - * Run a step, streaming output; abort the deploy on a non-zero exit. - * - * @param string[] $commandLine - * - * @throws \gcgov\framework\cli\cliException - */ - private function runStep( array $commandLine, string $workingDirectory, OutputInterface $output ): void { - $process = new Process( $commandLine, $workingDirectory, null, null, null ); - $process->run( function( string $type, string $buffer ) use ( $output ): void { - $output->write( $buffer ); - } ); - - if( !$process->isSuccessful() ) { - throw new cliException( implode( ' ', $commandLine ) . ' exited with code ' . (string)$process->getExitCode() . ' — deploy aborted.' ); - } - } - - - /** - * @param string[] $commandLine - * - * @throws \gcgov\framework\cli\cliException - */ - private function capture( array $commandLine, string $workingDirectory ): string { - $process = new Process( $commandLine, $workingDirectory ); - $process->run(); - if( !$process->isSuccessful() ) { - throw new cliException( implode( ' ', $commandLine ) . ' failed: ' . trim( $process->getErrorOutput() ) ); - } - - return $process->getOutput(); - } - -} diff --git a/src/cli/commands/envCommand.php b/src/cli/commands/envCommand.php index 21dbb6e..e33ea53 100644 --- a/src/cli/commands/envCommand.php +++ b/src/cli/commands/envCommand.php @@ -3,55 +3,272 @@ namespace gcgov\framework\cli\commands; use gcgov\framework\cli\appContext; -use gcgov\framework\cli\environmentFiles; +use gcgov\framework\cli\cliException; +use gcgov\framework\cli\mongoTools; +use gcgov\framework\services\environment\envVarResolver; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; -#[AsCommand( name: 'env', description: 'Activate an environment: copy environment-{env}.json, composer-{env}.json, and www/web-{env}.config to their canonical names' )] +#[AsCommand( name: 'env', description: 'Validate that config.json resolves; list the variables it needs, or write a .env skeleton' )] final class envCommand extends Command { protected function configure(): void { - $this->addArgument( 'environment', InputArgument::REQUIRED, 'Environment name, e.g. local or prod', null, self::suggestEnvironments( ... ) ); - $this->addOption( 'dry-run', null, InputOption::VALUE_NONE, 'Show what would be copied without changing any files' ); + $this->addOption( 'list', null, InputOption::VALUE_NONE, 'Print the variables config.json references, marking which are secrets, and exit' ); + $this->addOption( 'init', null, InputOption::VALUE_NONE, 'Write a .env skeleton containing every variable config.json references' ); + $this->addOption( 'force', null, InputOption::VALUE_NONE, 'With --init, overwrite an existing .env' ); + $this->setHelp( <<<'HELP' + Configuration is one committed config.json whose environment-varying values are + %env(...) references. Every reference is required — there are no defaults — so this + command is how you find out what an environment is missing before the application does. + + gf env validate: resolve config.json against the current environment + gf env --list the variables config.json references, and which are secrets + gf env --init write a .env skeleton from that same list + + The manifest is derived from config.json rather than hand-maintained, so it cannot + drift. Note that .env also carries variables config.json knows nothing about (docker + compose ports, CORS origins); --init leaves anything already in the file alone. + + A secret reference (%env(secret:NAME)%) is satisfied either by NAME or by NAME_FILE + pointing at a provisioned file — which is how one config.json serves both a developer + machine and production. + HELP ); } protected function execute( InputInterface $input, OutputInterface $output ): int { - $context = appContext::require(); - $environment = $input->getArgument( 'environment' ); + $context = appContext::require(); + $io = new SymfonyStyle( $input, $output ); - $results = environmentFiles::apply( $context->rootDir, $environment, (bool)$input->getOption( 'dry-run' ) ); + if( $input->getOption( 'list' ) ) { + return $this->listReferences( $context, $io ); + } - foreach( $results as $result ) { - if( str_starts_with( $result[ 'status' ], 'skipped' ) ) { - $output->writeln( '' . $result[ 'status' ] . '' ); - } - else { - $output->writeln( '' . $result[ 'status' ] . ': ' . $result[ 'source' ] . ' -> ' . $result[ 'target' ] ); + if( $input->getOption( 'init' ) ) { + return $this->writeEnvFile( $context, $io, (bool)$input->getOption( 'force' ) ); + } + + return $this->validate( $context, $io ); + } + + + private function validate( appContext $context, SymfonyStyle $io ): int { + $io->section( 'config.json + the current environment' ); + + try { + $config = $context->loadConfig(); + } + catch( cliException $e ) { + $io->error( $e->getMessage() ); + $io->text( 'Run `gf env --list` to see every variable config.json needs, or `gf env --init` to write a .env skeleton.' ); + + return Command::FAILURE; + } + + $io->text( 'type: ' . $config->type ); + if( $config->rootUrl!=='' ) { + $io->text( 'rootUrl: ' . $config->rootUrl . ' basePath: ' . $config->getBasePath() ); + } + $io->text( 'logging: ' . $config->logging->destination ); + foreach( $config->mongoDatabases as $mongoDatabase ) { + $io->text( 'mongo: ' . $mongoDatabase->database . ' @ ' . mongoTools::redactUri( $mongoDatabase->uri ) . ( $mongoDatabase->default ? ' (default)' : '' ) ); + } + + $io->success( 'Resolved successfully — every %env(...) reference has a value.' ); + + return Command::SUCCESS; + } + + + private function listReferences( appContext $context, SymfonyStyle $io ): int { + $references = $context->configReferences(); + + if( count( $references )===0 ) { + $io->text( 'config.json references no environment variables.' ); + + return Command::SUCCESS; + } + + $rows = []; + foreach( $references as $name => $isSecret ) { + // A reserved CGI meta-variable name is never resolvable, whatever is set — + // reporting it as MISSING sends the developer filling in a value forever. + $state = envVarResolver::isReservedName( $name ) + ? 'RESERVED — a CGI meta-variable name is never resolved; rename it' + : ( $this->isSet( $name ) ? 'set' : 'MISSING' ); + $rows[] = [ $name, $isSecret ? 'secret' : '', $state ]; + } + $io->table( [ 'Variable', 'Kind', 'Current environment' ], $rows ); + + return Command::SUCCESS; + } + + + /** + * Write, or extend, the .env skeleton. + * + * Additive by default, which is what the help text has always promised: a .env carries + * variables config.json knows nothing about (compose ports, CORS origins, GF_PHP) along + * with the values the developer has already filled in, and this command cannot + * reconstruct any of them. It used to replace the file wholesale whenever --force was + * given, which is exactly what a developer reaching for --force after the + * already-exists refusal would do. + * + * --force keeps its meaning — rewrite from config.json alone — but now says what it + * discards, and is no longer needed merely to pick up a newly added reference. + */ + private function writeEnvFile( appContext $context, SymfonyStyle $io, bool $force ): int { + $envPath = $context->getEnvFilePath(); + $references = $context->configReferences(); + + $existing = file_exists( $envPath ) ? (string)file_get_contents( $envPath ) : ''; + + if( $existing!=='' && $force ) { + $io->warning( 'Replacing ' . $envPath . ' from config.json. Every value it holds, and every variable config.json does not reference, is discarded.' ); + $existing = ''; + } + + if( $existing==='' ) { + $contents = $this->renderEnvFile( $references ); + $added = count( $references ); + } + else { + $declared = self::declaredNames( $existing, $envPath ); + $missing = array_filter( $references, static fn( bool $isSecret, string $name ): bool => !isset( $declared[ $name ] ) && !isset( $declared[ $name . envVarResolver::SECRET_FILE_SUFFIX ] ), ARRAY_FILTER_USE_BOTH ); + + if( count( $missing )===0 ) { + $io->success( $envPath . ' already declares every variable config.json references. Nothing to add.' ); + + return Command::SUCCESS; } + + $contents = rtrim( $existing, "\n" ) . "\n\n" . implode( "\n", array_merge( [ '# Added by `gf env --init` from config.json.' ], self::renderReferenceLines( $missing ) ) ) . "\n"; + $added = count( $missing ); + } + + if( file_put_contents( $envPath, $contents )===false ) { + throw new cliException( 'Failed writing ' . $envPath ); } + $io->success( 'Wrote ' . $envPath . ' with ' . $added . ' variable(s). Fill in the values — the application will not start until every one has one.' ); + return Command::SUCCESS; } /** - * @return string[] + * The variable names a .env already declares, so --init can skip them. + * + * Parsed with the same symfony/dotenv parser the framework loads the file with, so + * "declared" here is exactly what the runtime will see. The hand-rolled regex this + * replaces disagreed with it on multi-line quoted values: a NAME= at line start + * inside one counted as a declaration, and --init skipped appending a variable the + * file does not actually define. (A commented `# NAME_FILE=` hint is guidance, not a + * declaration, in both readings.) + * + * @return array + * @throws \gcgov\framework\cli\cliException */ - public static function suggestEnvironments( CompletionInput $completionInput ): array { + private static function declaredNames( string $env, string $envPath ): array { try { - $context = appContext::locate(); + $parsed = ( new Dotenv() )->parse( $env, $envPath ); + } + catch( \Symfony\Component\Dotenv\Exception\FormatException $e ) { + throw new cliException( 'Cannot read ' . $envPath . ': ' . $e->getMessage(), 0, $e ); + } + + $names = []; + foreach( array_keys( $parsed ) as $name ) { + $names[ (string)$name ] = true; + } + + return $names; + } + + + /** + * @param array $references variable name => is a secret + */ + public function renderEnvFile( array $references ): string { + $lines = [ + '# Generated by `gf env --init` from config.json. Never commit this file.', + '#', + '# Every variable below is REQUIRED: config.json has no defaults, and a variable', + '# set to the empty string counts as unset. Re-run `gf env` to check.', + '', + ]; + + return implode( "\n", array_merge( $lines, self::renderReferenceLines( $references ) ) ) . "\n"; + } + - return $context===null ? [] : $context->getEnvironmentVariants(); + /** + * The variable lines themselves, shared by the fresh-file and append paths so the two + * cannot describe the secret convention differently. + * + * @param array $references variable name => is a secret + * + * @return string[] + */ + private static function renderReferenceLines( array $references ): array { + $lines = []; + $secrets = array_keys( array_filter( $references ) ); + $plain = array_keys( array_filter( $references, static fn( bool $isSecret ): bool => !$isSecret ) ); + + foreach( $plain as $name ) { + $lines[] = self::referenceLine( $name ); } - catch( \Throwable ) { - return []; + + if( count( $secrets )>0 ) { + $lines[] = ''; + $lines[] = '# Secrets. In production these are provisioned as files and read through'; + $lines[] = '# the companion {NAME}_FILE variable instead — set one or the other, never both.'; + foreach( $secrets as $name ) { + $lines[] = self::referenceLine( $name ); + if( !envVarResolver::isReservedName( $name ) ) { + $lines[] = self::secretFileHint( $name ); + } + } } + + return $lines; + } + + + /** + * A live `NAME=` line — or, for a reserved CGI meta-variable name, guidance instead: + * the resolver never satisfies such a name, so a live line would be filled in and + * still report MISSING forever. The fix is renaming the reference, not a value. + */ + private static function referenceLine( string $name ): string { + return envVarResolver::isReservedName( $name ) + ? '# ' . $name . ' is a reserved CGI meta-variable name the framework never resolves — rename the %env(' . $name . ')% reference in config.json' + : $name . '='; + } + + + /** + * The commented `{NAME}_FILE` hint written beside a secret's plain line — the one + * writer of the convention, shared with `gf migrate` so the two commands cannot + * describe it differently. The path carries the per-application segment the + * deployment convention uses (bin/provision writes /etc/gcgov/secrets//, + * mounted into the container at /run/secrets//): a set _FILE never falls + * back, so a hint without the segment pointed everyone who uncommented it at a file + * that never exists. + */ + public static function secretFileHint( string $name ): string { + return '# ' . $name . envVarResolver::SECRET_FILE_SUFFIX . '=/run/secrets//' . strtolower( $name ); + } + + + /** Whether a variable currently has a value, by either the plain or the _FILE name. */ + private function isSet( string $name ): bool { + return envVarResolver::isSatisfied( $name ); } } diff --git a/src/cli/commands/initCommand.php b/src/cli/commands/initCommand.php new file mode 100644 index 0000000..04646b2 --- /dev/null +++ b/src/cli/commands/initCommand.php @@ -0,0 +1,192 @@ +addOption( 'title', null, InputOption::VALUE_REQUIRED, 'Human readable application title (e.g. "Timesheet API")' ); + $this->addOption( 'guid', null, InputOption::VALUE_REQUIRED, 'Application guid. Omit to mint one. This is the OAuth client_id, so an application being re-initialised must keep its existing value.' ); + $this->addOption( 'skip-env', null, InputOption::VALUE_NONE, 'Do not write .env' ); + $this->addOption( 'skip-keys', null, InputOption::VALUE_NONE, 'Do not generate JWT signing keys' ); + $this->addOption( 'skip-chrome', null, InputOption::VALUE_NONE, 'Do not download chrome-headless-shell' ); + $this->setHelp( <<<'HELP' + Bring a scaffolded application to a runnable state. + + gf init --title="Timesheet API" + + It writes the title and guid into config.json, adds the variables config.json + references to .env, generates JWT signing keypairs, and installs chrome-headless-shell. + Everything else about an application's configuration is either a committed literal or + an environment variable you supply. + + **Idempotent**, and meant to be re-run as an application's configuration grows: every + step adds only what is missing. .env keeps every value already filled in and every + variable config.json does not reference, and an existing guid is kept rather than + reminted — it is the OAuth client_id, so a new one would invalidate every registered + client. + + Deliberately non-interactive, so it can run from a scaffolding script, a devcontainer + postCreateCommand, or CI. It replaces the v6 `gf setup` wizard, whose prompts filled + {placeholder} tokens in php.ini and web.config files that no longer exist. + + It does not create the application's first user: nothing can be written to the + database until .env carries a connection string, which is a step later. See + `gf user:create`. + HELP ); + } + + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $context = appContext::locateScaffold(); + if( $context===null ) { + throw new cliException( 'gf init must be run from inside a scaffolded application (a directory containing composer.json and an app/ directory).' ); + } + + $io = new SymfonyStyle( $input, $output ); + $io->title( 'gcgov/framework application setup' ); + $io->text( 'Application root: ' . $context->rootDir ); + + $this->writeIdentity( $context, $io, (string)( $input->getOption( 'title' ) ?? '' ), (string)( $input->getOption( 'guid' ) ?? '' ) ); + + if( !$input->getOption( 'skip-env' ) ) { + $io->section( '.env' ); + // Delegated to `env --init` rather than written here, because that command is + // additive: it appends only the references the file does not already declare and + // leaves every filled-in value, and every variable config.json knows nothing + // about, alone. This step used to skip an existing .env entirely, which broke the + // documented bootstrap the moment it began with `cp .env.example .env` — the file + // existed, so the application's own variables were never appended and `gf env` + // then failed on the first of them. + $this->runSubCommand( 'env', [ '--init' => true ], $output, $io ); + } + + if( !$input->getOption( 'skip-keys' ) ) { + $io->section( 'JWT signing keys' ); + $this->runSubCommand( 'cert:generate-auth', [ '--yes' => true ], $output, $io ); + } + + if( !$input->getOption( 'skip-chrome' ) ) { + $io->section( 'chrome-headless-shell' ); + try { + ( new \gcgov\framework\cli\chromeInstaller( $context->getSrvDir() ) )->install( $io ); + } + catch( \Throwable $e ) { + $io->warning( 'chrome-headless-shell was not installed: ' . $e->getMessage() . ' Install it later with `vendor/bin/gf chrome:install`.' ); + } + } + + $io->success( 'Initialised. Next: fill in .env, then `gf env` to check it resolves.' ); + + return Command::SUCCESS; + } + + + /** + * Write app.title and app.guid into config.json, touching nothing else. + * + * @throws \gcgov\framework\cli\cliException + */ + private function writeIdentity( appContext $context, SymfonyStyle $io, string $title, string $guid ): void { + $configPath = $context->getConfigPath(); + if( !file_exists( $configPath ) ) { + throw new cliException( 'Missing ' . $configPath . '. Scaffold from gcgov/framework-app-template, which ships one.' ); + } + + $identity = self::applyIdentity( (string)file_get_contents( $configPath ), $title, $guid, $configPath ); + + if( file_put_contents( $configPath, $identity[ 'json' ] )===false ) { + throw new cliException( 'Failed writing ' . $configPath ); + } + + $io->section( 'Identity' ); + $io->text( 'title: ' . $identity[ 'title' ] ); + $io->text( 'guid: ' . $identity[ 'guid' ] . ( $identity[ 'guidKept' ] ? ' (kept)' : '' ) ); + } + + + /** + * Stamp the title and guid into a config.json document, returning the rewritten JSON. + * + * Pure, so the rewrite can be tested directly rather than through the filesystem. + * + * Decoded as objects rather than associative arrays: json_decode( $raw, true ) maps an + * empty JSON object to [], which json_encode writes back as []. In config.json `{}` + * carries meaning — `"services": { "userCrud": {} }` is how a Framework Service is + * enabled, since presence is what activates it — so the assoc round-trip rewrote every + * service block the template declared into an array that no longer hydrates the nullable + * service properties, silently disabling userCrud and documentation on the very first + * command a new project runs. + * + * @return array{json: string, title: string, guid: string, guidKept: bool} + * @throws \gcgov\framework\cli\cliException + */ + public static function applyIdentity( string $rawConfigJson, string $title, string $guid, string $sourceDescription = 'config.json' ): array { + $decoded = json_decode( $rawConfigJson, false ); + if( !$decoded instanceof \stdClass ) { + throw new cliException( 'Failed to parse ' . $sourceDescription . ': the file is not a valid JSON object.' ); + } + + if( !isset( $decoded->app ) || !$decoded->app instanceof \stdClass ) { + $decoded->app = new \stdClass(); + } + + $existingGuid = isset( $decoded->app->guid ) ? (string)$decoded->app->guid : ''; + // The guid is the OAuth client_id: minting a new one for an application that already + // has one invalidates every registered client. + $decoded->app->guid = $guid!=='' ? $guid : ( $existingGuid!=='' ? $existingGuid : guid::create() ); + if( $title!=='' ) { + $decoded->app->title = $title; + } + + $encoded = json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + if( $encoded===false ) { + throw new cliException( 'Failed encoding ' . $sourceDescription ); + } + + return [ + 'json' => $encoded . "\n", + 'title' => isset( $decoded->app->title ) ? (string)$decoded->app->title : '', + 'guid' => (string)$decoded->app->guid, + 'guidKept' => $existingGuid!=='' && $existingGuid===$decoded->app->guid, + ]; + } + + + /** + * @param array $arguments + * + * @throws \gcgov\framework\cli\cliException + */ + private function runSubCommand( string $name, array $arguments, OutputInterface $output, SymfonyStyle $io ): void { + $application = $this->getApplication(); + if( $application===null ) { + return; + } + + try { + $exitCode = $application->find( $name )->run( new \Symfony\Component\Console\Input\ArrayInput( $arguments ), $output ); + } + catch( \Throwable $e ) { + $io->warning( $name . ' did not complete: ' . $e->getMessage() . ' Run `vendor/bin/gf ' . $name . '` yourself.' ); + + return; + } + + if( $exitCode!==Command::SUCCESS ) { + $io->warning( $name . ' exited with code ' . $exitCode . '. Run `vendor/bin/gf ' . $name . '` yourself.' ); + } + } + +} diff --git a/src/cli/commands/migrateCommand.php b/src/cli/commands/migrateCommand.php new file mode 100644 index 0000000..7f23e3f --- /dev/null +++ b/src/cli/commands/migrateCommand.php @@ -0,0 +1,575 @@ + + */ + public const array REMOVED_KEYS = [ + 'serverName' => 'nothing read it', + 'cookieUrl' => 'nothing read it', + 'phpPath' => 'a property of a developer machine, not of the application — use GF_PHP', + 'baseUrl' => 'deprecated in v6; derived from rootUrl + basePath', + ]; + + /** + * Scalar config paths that become environment references, and the variable each uses. + * `secret` entries resolve through `%env(secret:NAME)%`, so production can supply them + * as provisioned files instead. + * + * @var array + */ + public const array EXTRACTED = [ + 'type' => [ 'var' => 'APP_TYPE', 'secret' => false ], + 'rootUrl' => [ 'var' => 'APP_ROOT_URL', 'secret' => false ], + 'basePath' => [ 'var' => 'APP_BASE_PATH', 'secret' => false ], + 'jwtAuth.redirectAfterLoginUrl' => [ 'var' => 'APP_REDIRECT_AFTER_LOGIN', 'secret' => false ], + 'jwtAuth.redirectAfterLogoutUrl' => [ 'var' => 'APP_REDIRECT_AFTER_LOGOUT', 'secret' => false ], + 'microsoft.clientId' => [ 'var' => 'MICROSOFT_CLIENT_ID', 'secret' => false ], + 'microsoft.clientSecret' => [ 'var' => 'MICROSOFT_CLIENT_SECRET', 'secret' => true ], + 'microsoft.tenant' => [ 'var' => 'MICROSOFT_TENANT', 'secret' => false ], + 'microsoft.driveId' => [ 'var' => 'MICROSOFT_DRIVE_ID', 'secret' => false ], + 'payjunction.username' => [ 'var' => 'PAYJUNCTION_USERNAME', 'secret' => false ], + 'payjunction.password' => [ 'var' => 'PAYJUNCTION_PASSWORD', 'secret' => true ], + 'payjunction.apiKey' => [ 'var' => 'PAYJUNCTION_API_KEY', 'secret' => true ], + 'email.SMTPUsername' => [ 'var' => 'SMTP_USERNAME', 'secret' => false ], + 'email.SMTPPassword' => [ 'var' => 'SMTP_PASSWORD', 'secret' => true ], + ]; + + /** Files that only made sense under IIS or the v6 config layout. */ + public const array DEAD_PATHS = [ + 'app/config/app.json', + 'app/config/environment.json', + 'app/cli/local.bat', + 'app/cli/local-debug.bat', + 'app/cli/prod.bat', + 'update-production.ps1', + 'scripts/setup.ps1', + 'scripts/create-jwt-keys.ps1', + 'www/web-local.config', + 'www/web-prod.config', + 'composer-local.json', + 'composer-prod.json', + ]; + + /** + * v6 Framework Service namespaces, and the config.json `services` key each becomes. + * + * @var array + */ + public const array SERVICE_NAMESPACES = [ + 'gcgov\\framework\\services\\documentation' => 'documentation', + 'gcgov\\framework\\services\\usercrud' => 'userCrud', + 'gcgov\\framework\\services\\authoauth' => 'auth:oauth', + 'gcgov\\framework\\services\\authmsfront' => 'auth:msFront', + 'gcgov\\framework\\services\\cronMonitor' => 'cronMonitor', + ]; + + /** + * Service configuration that used to be applied by calling a singleton in + * \app\app::_before(). The values are arbitrary PHP expressions, so these are + * reported for the developer to transcribe rather than guessed at. + * + * @var array + */ + public const array SINGLETON_CALLS = [ + 'setBlockNewUsers' => 'services.auth.blockNewUsers / services.auth.defaultNewUserRoles', + 'setAuthorizeUrlParameters' => 'services.auth.oauth.authorizeUrlParameters', + ]; + + /** Framework Service packages that are now part of the framework itself. */ + public const array SERVICE_PACKAGES = [ + 'gcgov/framework-service-auth-oauth-server', + 'gcgov/framework-service-auth-ms-front', + 'gcgov/framework-service-user-crud', + 'gcgov/framework-service-documentation', + 'gcgov/framework-service-gcgov-cron-monitor', + ]; + + + protected function configure(): void { + $this->addOption( 'dry-run', null, InputOption::VALUE_NONE, 'Show what would change without writing anything' ); + $this->addOption( 'force', null, InputOption::VALUE_NONE, 'Proceed even though config.json already exists (it will be overwritten)' ); + $this->addOption( 'keep-dead-files', null, InputOption::VALUE_NONE, 'Leave the v6 IIS/batch/config files in place' ); + $this->setHelp( <<<'HELP' + Converts the configuration half of a v6 application to v7. Run it on a clean working + tree so the result is reviewable as a diff, and read that diff before committing. + + gf migrate --dry-run see the plan + gf migrate apply it + + It writes {root}/config.json, writes {root}/.env with the values it extracted, and + deletes the v6 IIS and batch files. It does NOT write a Dockerfile, choose a Zone, + or decide what belongs in your secret store — those need judgement, and the + companion skill covers them. + + Anything it cannot convert safely is reported rather than guessed at. + HELP ); + } + + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $context = appContext::locateScaffold(); + if( $context===null ) { + throw new cliException( 'gf migrate must be run from inside an application (a directory containing composer.json and an app/ directory).' ); + } + + $io = new SymfonyStyle( $input, $output ); + $dryRun = (bool)$input->getOption( 'dry-run' ); + + $appJsonPath = $context->rootDir . '/app/config/app.json'; + $environmentJsonPath = $context->rootDir . '/app/config/environment.json'; + if( !file_exists( $appJsonPath ) && !file_exists( $environmentJsonPath ) ) { + throw new cliException( 'Neither app/config/app.json nor app/config/environment.json exists — this does not look like a v6 application. Nothing to migrate.' ); + } + + if( file_exists( $context->getConfigPath() ) && !$input->getOption( 'force' ) && !$dryRun ) { + throw new cliException( $context->getConfigPath() . ' already exists — this application appears to be migrated. Pass --force to overwrite it.' ); + } + + $appPhpPath = $context->rootDir . '/app/app.php'; + $detected = file_exists( $appPhpPath ) + ? self::detectServices( (string)file_get_contents( $appPhpPath ) ) + : [ 'services' => [], 'singletons' => [] ]; + + $plan = self::plan( + self::readJson( $appJsonPath ), + self::readJson( $environmentJsonPath ), + $detected + ); + + $io->title( 'v6 → v7 migration' . ( $dryRun ? ' (dry run)' : '' ) ); + + $io->section( 'config.json' ); + $io->text( 'Extracted ' . count( $plan[ 'env' ] ) . ' value(s) into environment references.' ); + + $io->section( '.env' ); + foreach( $plan[ 'env' ] as $name => $value ) { + $io->text( ' ' . $name . '=' . ( $plan[ 'secrets' ][ $name ] ?? false ? '•••••••• (secret — provision as a file in production)' : $value ) ); + } + + if( count( $plan[ 'warnings' ] )>0 ) { + $io->section( 'Review these yourself' ); + foreach( $plan[ 'warnings' ] as $warning ) { + $io->text( ' · ' . $warning ); + } + } + + $composerPath = $context->rootDir . '/composer.json'; + $composerRemoved = []; + $composerJson = null; + if( file_exists( $composerPath ) ) { + $decoded = json_decode( (string)file_get_contents( $composerPath ), true ); + if( is_array( $decoded ) ) { + [ 'json' => $composerJson, 'removed' => $composerRemoved ] = self::removeServiceRequires( $decoded ); + } + } + if( count( $composerRemoved )>0 ) { + $io->section( 'composer.json' ); + $io->text( 'These are part of the framework now, and conflict with it:' ); + foreach( $composerRemoved as $package ) { + $io->text( ' - ' . $package ); + } + } + + $deadFiles = self::deadFilesPresent( $context->rootDir ); + if( count( $deadFiles )>0 && !$input->getOption( 'keep-dead-files' ) ) { + $io->section( 'Deleting' ); + foreach( $deadFiles as $deadFile ) { + $io->text( ' ' . $deadFile ); + } + } + + if( $dryRun ) { + $io->note( 'Dry run — nothing was written.' ); + + return Command::SUCCESS; + } + + $encoded = json_encode( $plan[ 'config' ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + if( $encoded===false || file_put_contents( $context->getConfigPath(), $encoded . "\n" )===false ) { + throw new cliException( 'Failed writing ' . $context->getConfigPath() ); + } + + $this->writeEnvFile( $context->getEnvFilePath(), $plan[ 'env' ], $plan[ 'secrets' ] ); + + if( count( $composerRemoved )>0 && is_array( $composerJson ) ) { + $encodedComposer = json_encode( $composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + if( $encodedComposer===false || file_put_contents( $composerPath, $encodedComposer . "\n" )===false ) { + throw new cliException( 'Failed writing ' . $composerPath ); + } + } + + if( !$input->getOption( 'keep-dead-files' ) ) { + foreach( $deadFiles as $deadFile ) { + @unlink( $context->rootDir . '/' . $deadFile ); + } + } + + $io->success( 'Migrated. Review the diff, then run `gf env` to confirm the configuration resolves.' ); + if( count( $composerRemoved )>0 ) { + $io->text( 'Run `composer update` — composer.json changed.' ); + } + $io->text( 'Still to do by hand: the Dockerfile and compose entry, the Zone this application belongs in, and moving its secrets into the ops repository.' ); + + $io->section( '\app\router' ); + $io->text( 'Two router contracts changed in a way nothing else will tell you about:' ); + $io->text( ' · It must now implement \gcgov\framework\interfaces\appRouter (which adds providesAuthentication()).' ); + $io->text( ' Implementing only \gcgov\framework\interfaces\router is a TypeError on the first request.' ); + $io->text( ' · The service-auth opt-out is now the \gcgov\framework\interfaces\router\skipsServiceAuthentication' ); + $io->text( ' interface, not a duck-typed method. A getRunFrameworkServiceRouteAuthentication() left over from v6' ); + $io->text( ' is silently ignored, so routes the application authenticates itself start returning 401 — and the' ); + $io->text( ' method now takes a $routeHandler.' ); + + return Command::SUCCESS; + } + + + /** + * The whole conversion, as a pure function of the two v6 documents — which is what + * makes it testable against all thirty applications without touching a filesystem. + * + * @param array $appJson Decoded app/config/app.json + * @param array $environmentJson Decoded app/config/environment.json + * @param array{services: string[], singletons: string[]} $detected Result of detectServices() + * + * @return array{config: array, env: array, secrets: array, warnings: string[]} + */ + public static function plan( array $appJson, array $environmentJson, array $detected = [ 'services' => [], 'singletons' => [] ] ): array { + $config = $environmentJson; + $env = []; + $secrets = []; + $warnings = []; + + // app.json's three sections move in wholesale — they never varied by environment. + foreach( [ 'app', 'email', 'settings' ] as $section ) { + if( isset( $appJson[ $section ] ) && is_array( $appJson[ $section ] ) ) { + $config[ $section ] = array_merge( $config[ $section ] ?? [], $appJson[ $section ] ); + } + } + + foreach( self::REMOVED_KEYS as $key => $reason ) { + if( array_key_exists( $key, $config ) ) { + unset( $config[ $key ] ); + $warnings[] = 'Dropped "' . $key . '" (' . $reason . '). Grep the application for it before committing.'; + } + } + + foreach( self::EXTRACTED as $path => $extraction ) { + $value = self::readPath( $config, $path ); + if( $value===null || !is_scalar( $value ) || (string)$value==='' ) { + continue; + } + $env[ $extraction[ 'var' ] ] = (string)$value; + $secrets[ $extraction[ 'var' ] ] = $extraction[ 'secret' ]; + self::writePath( $config, $path, self::reference( $extraction[ 'var' ], $extraction[ 'secret' ] ) ); + } + + // Mongo connections: one variable pair per database, suffixed past the first so + // an application with several keeps them distinct. + $databases = $config[ 'mongoDatabases' ] ?? []; + if( is_array( $databases ) ) { + foreach( array_values( $databases ) as $index => $database ) { + if( !is_array( $database ) ) { + continue; + } + $suffix = $index===0 ? '' : '_' . ( $index + 1 ); + foreach( [ 'uri' => [ 'MONGO_URI' . $suffix, true ], 'database' => [ 'MONGO_DATABASE' . $suffix, false ] ] as $key => [ $varName, $isSecret ] ) { + if( !isset( $database[ $key ] ) || !is_scalar( $database[ $key ] ) || (string)$database[ $key ]==='' ) { + continue; + } + $env[ $varName ] = (string)$database[ $key ]; + $secrets[ $varName ] = $isSecret; + $config[ 'mongoDatabases' ][ $index ][ $key ] = self::reference( $varName, $isSecret ); + } + } + } + + if( isset( $config[ 'sqlDatabases' ] ) && is_array( $config[ 'sqlDatabases' ] ) && count( $config[ 'sqlDatabases' ] )>0 ) { + $warnings[] = 'sqlDatabases was left as-is: its read/write accounts hold credentials that must become %env(secret:...) references and move to the ops repository. Convert them by hand.'; + } + + // Logging: v6 had no destination and always wrote files. Say so explicitly rather + // than letting an IIS application silently change behaviour on upgrade. + $config[ 'logging' ] = is_array( $config[ 'logging' ] ?? null ) ? $config[ 'logging' ] : []; + $config[ 'logging' ][ 'destination' ] = 'file'; + $warnings[] = 'logging.destination was set to "file" to preserve v6 behaviour. Change it to "stderr" when this application moves into a container — a container filesystem does not survive a deploy.'; + + if( ( $config[ 'app' ][ 'guid' ] ?? '' )==='' ) { + $warnings[] = 'app.guid is empty. The oauth server uses it as the OAuth client_id, so set it before deploying.'; + } + + // Framework Services: from namespaces returned by \app\app to a config section. + $services = []; + foreach( $detected[ 'services' ] ?? [] as $service ) { + if( $service==='cronMonitor' ) { + continue; + } + if( str_starts_with( $service, 'auth:' ) ) { + $provider = substr( $service, 5 ); + if( isset( $services[ 'auth' ] ) ) { + $warnings[] = 'Both authentication services were registered. Only one provider can be active, so "' . $services[ 'auth' ][ 'provider' ] . '" was kept — change services.auth.provider if that is the wrong one.'; + continue; + } + $services[ 'auth' ] = [ 'provider' => $provider ]; + continue; + } + $services[ $service ] = new \stdClass(); + } + if( count( $services )>0 ) { + $config[ 'services' ] = $services; + } + + // cronMonitor is no longer a Framework Service, so its url gets a typed home of + // its own rather than living in the untyped appDictionary. + $cronMonitorUrl = $config[ 'appDictionary' ][ 'cronMonitorUrl' ] ?? null; + if( is_string( $cronMonitorUrl ) && $cronMonitorUrl!=='' ) { + $config[ 'cronMonitor' ] = [ 'url' => $cronMonitorUrl ]; + unset( $config[ 'appDictionary' ][ 'cronMonitorUrl' ] ); + $warnings[] = 'appDictionary.cronMonitorUrl moved to cronMonitor.url. Update any application code still reading it from appDictionary.'; + } + + foreach( $detected[ 'singletons' ] ?? [] as $call ) { + $warnings[] = $call . '() is called in app/app.php. That configuration moved to ' . ( self::SINGLETON_CALLS[ $call ] ?? 'config.json' ) . ' — copy the values across by hand, then delete the call.'; + } + + ksort( $env ); + + return [ 'config' => $config, 'env' => $env, 'secrets' => $secrets, 'warnings' => $warnings ]; + } + + + public static function reference( string $varName, bool $isSecret ): string { + return '%env(' . ( $isSecret ? 'secret:' : '' ) . $varName . ')%'; + } + + + /** + * Find the Framework Services an application registers, and the service-configuration + * singletons it calls, by reading app/app.php. + * + * Comments are stripped with the tokenizer rather than by matching text, because the + * scaffolded app.php ships the alternatives commented out directly above the live + * array — a plain search would report services the application does not run. + * + * Impure input, pure function: execute() reads the file, this interprets it. + * + * @return array{services: string[], singletons: string[]} + */ + public static function detectServices( string $appSource ): array { + $code = ''; + foreach( @token_get_all( $appSource ) as $token ) { + if( is_array( $token ) ) { + if( $token[ 0 ]===T_COMMENT || $token[ 0 ]===T_DOC_COMMENT ) { + continue; + } + $code .= $token[ 1 ]; + continue; + } + $code .= $token; + } + + $services = []; + foreach( self::SERVICE_NAMESPACES as $namespace => $service ) { + if( str_contains( $code, $namespace ) ) { + $services[] = $service; + } + } + + $singletons = []; + foreach( array_keys( self::SINGLETON_CALLS ) as $call ) { + if( str_contains( $code, $call ) ) { + $singletons[] = $call; + } + } + + return [ 'services' => $services, 'singletons' => $singletons ]; + } + + + /** + * Drop the Framework Service packages from an application's composer.json. + * + * The framework declares a `conflict` against them, so leaving them in place makes the + * application unresolvable rather than merely untidy. + * + * @param array $composerJson + * + * @return array{json: array, removed: string[]} + */ + public static function removeServiceRequires( array $composerJson ): array { + $removed = []; + foreach( [ 'require', 'require-dev' ] as $section ) { + if( !isset( $composerJson[ $section ] ) || !is_array( $composerJson[ $section ] ) ) { + continue; + } + foreach( self::SERVICE_PACKAGES as $package ) { + if( array_key_exists( $package, $composerJson[ $section ] ) ) { + unset( $composerJson[ $section ][ $package ] ); + $removed[] = $package; + } + } + } + + return [ 'json' => $composerJson, 'removed' => array_values( array_unique( $removed ) ) ]; + } + + + /** + * @return string[] Dead v6 files that actually exist, relative to the root + */ + public static function deadFilesPresent( string $rootDir ): array { + $present = []; + foreach( self::DEAD_PATHS as $path ) { + if( file_exists( $rootDir . '/' . $path ) ) { + $present[] = $path; + } + } + // Every environment-{variant}.json, whatever the variant is called. + foreach( glob( $rootDir . '/app/config/environment-*.json' ) ?: [] as $variantFile ) { + $present[] = 'app/config/' . basename( $variantFile ); + } + + return $present; + } + + + /** + * @param array $data + * + * @return mixed + */ + private static function readPath( array $data, string $path ): mixed { + $cursor = $data; + foreach( explode( '.', $path ) as $segment ) { + if( !is_array( $cursor ) || !array_key_exists( $segment, $cursor ) ) { + return null; + } + $cursor = $cursor[ $segment ]; + } + + return $cursor; + } + + + /** + * @param array $data + */ + private static function writePath( array &$data, string $path, string $value ): void { + $segments = explode( '.', $path ); + $cursor = &$data; + foreach( $segments as $index => $segment ) { + if( $index===count( $segments ) - 1 ) { + $cursor[ $segment ] = $value; + break; + } + if( !isset( $cursor[ $segment ] ) || !is_array( $cursor[ $segment ] ) ) { + return; + } + $cursor = &$cursor[ $segment ]; + } + } + + + /** + * @return array + * @throws \gcgov\framework\cli\cliException + */ + private static function readJson( string $path ): array { + if( !file_exists( $path ) ) { + return []; + } + $decoded = json_decode( (string)file_get_contents( $path ), true ); + if( !is_array( $decoded ) ) { + throw new cliException( 'Failed to parse ' . $path . ': the file is not a valid JSON object.' ); + } + + return $decoded; + } + + + /** + * Quote a value for .env. + * + * The values this command writes are the credentials lifted out of the v6 config — + * MONGO_URI, MICROSOFT_CLIENT_SECRET, SMTP_PASSWORD, PAYJUNCTION_PASSWORD. Written bare + * they are silently corrupted: symfony/dotenv interpolates $VAR in an unquoted value, + * treats a whitespace-preceded # as the start of a comment, and rejects an embedded + * quote outright. Because every %env() reference is required and '' counts as unset, + * the damage surfaces later as a wrong-credential auth failure, not as a startup error. + * + * Single quotes suppress interpolation; an embedded single quote is closed, escaped and + * reopened. Verified against symfony/dotenv 7 for values containing $, ${}, #, spaces, + * both quote characters, backslashes and newlines. + */ + public static function encodeEnvValue( string $value ): string { + return "'" . str_replace( "'", "'\\''", $value ) . "'"; + } + + + /** + * @param array $env + * @param array $secrets + * + * @throws \gcgov\framework\cli\cliException + */ + private function writeEnvFile( string $path, array $env, array $secrets ): void { + $lines = [ + '# Written by `gf migrate` from the v6 configuration. Never commit this file.', + '#', + '# Values marked as secrets below are provisioned as files in production and read', + '# through the companion {NAME}_FILE variable — see the ops repository.', + '', + ]; + foreach( $env as $name => $value ) { + $lines[] = $name . '=' . self::encodeEnvValue( $value ); + if( $secrets[ $name ] ?? false ) { + // The one writer of the secret-file convention, shared with `gf env --init`, + // so the two commands cannot describe the indirection differently. + $lines[] = envCommand::secretFileHint( $name ); + } + } + + if( file_exists( $path ) ) { + $lines[] = ''; + $lines[] = '# --- appended by gf migrate; the pre-existing contents are above ---'; + $existing = (string)file_get_contents( $path ); + if( file_put_contents( $path, $existing . "\n" . implode( "\n", $lines ) . "\n" )===false ) { + throw new cliException( 'Failed appending to ' . $path ); + } + + return; + } + + if( file_put_contents( $path, implode( "\n", $lines ) . "\n" )===false ) { + throw new cliException( 'Failed writing ' . $path ); + } + } + +} diff --git a/src/cli/commands/setupCommand.php b/src/cli/commands/setupCommand.php deleted file mode 100644 index 4cd0163..0000000 --- a/src/cli/commands/setupCommand.php +++ /dev/null @@ -1,181 +0,0 @@ - prompt key => label */ - private const array PROMPTS = [ - 'app_title' => 'Human readable title of application (ex: Timesheet API)', - 'app_root_url' => 'DEV Root url of app (ex: https://local-app.garrettcountymd.gov/)', - 'app_base_path' => 'DEV Base url path of app (ex: /api/, Or: / if site is at url root)', - 'app_frontend_root_url' => 'DEV If using this app as an API and you have a separate frontend, enter the root of the frontend app (ex: https://localhost:8080/)', - 'app_redirect_after_login' => 'DEV If appConfig.enableAuthRoutes==true, user will be redirected to this url after successful login (ex: https://localhost:8080/auth/sign-in)', - 'app_redirect_after_logout' => 'DEV If appConfig.enableAuthRoutes==true, user will be redirected to this url after successful logout (ex: https://localhost:8080/auth/sign-out)', - 'app_smtp_server' => 'DEV SMTP server address (ex: tenant-com.mail.protection.outlook.com)', - 'app_smtp_sendmail_from_address' => 'DEV Default email address to send emails from (ex: noreply@tenant.com)', - 'app_smtp_sendmail_from_name' => 'DEV Default human-readable name that will appear as the sender of emails (ex: Tenant Company)', - 'app_ssl_path' => 'DEV Absolute path to a current cacert.pem file for CURL and OpenSSL extensions (path only)', - 'app_php_path' => 'DEV Absolute path to the PHP executable root directory', - 'prod_app_root_url' => 'PROD Root url of app (ex: https://app.garrettcountymd.gov/)', - 'prod_app_base_path' => 'PROD Base url path of app (ex: /api/, Or: / if site is at url root)', - 'prod_app_frontend_root_url' => 'PROD If using this app as an API and you have a separate frontend, enter the root of the frontend app (ex: https://app.garrettcountymd.gov/)', - 'prod_app_redirect_after_login' => 'PROD If appConfig.enableAuthRoutes==true, user will be redirected to this url after successful login (ex: https://app.garrettcountymd.gov/app/auth/sign-in)', - 'prod_app_redirect_after_logout' => 'PROD If appConfig.enableAuthRoutes==true, user will be redirected to this url after successful logout (ex: https://app.garrettcountymd.gov/app/auth/sign-out)', - 'prod_app_absolute_path' => 'PROD Production absolute path to root directory (ex: E:\Web\api)', - 'prod_app_ssl_path' => 'PROD Absolute path to a current cacert.pem file for CURL and OpenSSL extensions (path only)', - 'prod_app_php_path' => 'PROD Absolute path to the PHP executable root directory', - ]; - - /** @var array */ - private const array MICROSOFT_PROMPTS = [ - 'app_microsoft_client_id' => 'DEV Microsoft Azure App client id', - 'app_microsoft_client_secret' => 'DEV Microsoft Azure App client secret', - 'app_microsoft_tenant' => 'DEV Microsoft Azure App tenant', - 'app_microsoft_drive_id' => 'DEV Microsoft Azure App drive id', - 'app_microsoft_default_from_address' => 'DEV Microsoft Azure App default from address', - 'prod_app_microsoft_client_id' => 'PROD Microsoft Azure App client id', - 'prod_app_microsoft_client_secret' => 'PROD Microsoft Azure App client secret', - 'prod_app_microsoft_tenant' => 'PROD Microsoft Azure App tenant', - 'prod_app_microsoft_drive_id' => 'PROD Microsoft Azure App drive id', - 'prod_app_microsoft_default_from_address' => 'PROD Microsoft Azure App default from address', - ]; - - - protected function configure(): void { - $this->addOption( 'skip-chrome', null, \Symfony\Component\Console\Input\InputOption::VALUE_NONE, 'Skip downloading chrome-headless-shell' ); - $this->setHelp( 'Run once after scaffolding a project from gcgov/framework-app-template. Prompts for the project configuration values and replaces the {placeholder} tokens across the project files. Press enter at any prompt to skip that value (the token stays in place for a later re-run). Also downloads chrome-headless-shell into srv/chrome (skip with --skip-chrome).' ); - } - - - protected function execute( InputInterface $input, OutputInterface $output ): int { - if( !$input->isInteractive() ) { - throw new cliException( 'gf setup is interactive — run it from a terminal without --no-interaction.' ); - } - - $context = appContext::locateScaffold(); - if( $context===null ) { - throw new cliException( 'gf setup must be run from inside a scaffolded application (a directory containing composer.json and an app/ directory).' ); - } - - $io = new SymfonyStyle( $input, $output ); - $io->title( 'gcgov/framework application setup' ); - $io->text( [ 'Application root: ' . $context->rootDir, 'To skip replacing a value, press enter.', '' ] ); - - $prompts = self::PROMPTS; - if( $io->confirm( 'Do you want to define Microsoft Azure App ids during set up?', false ) ) { - $prompts = array_merge( $prompts, self::MICROSOFT_PROMPTS ); - } - - $inputs = []; - foreach( $prompts as $key => $label ) { - $inputs[ $key ] = (string)( $io->ask( $label ) ?? '' ); - } - - // review/edit loop - while( true ) { - $io->section( 'Review' ); - $index = 1; - $keysByIndex = []; - foreach( $prompts as $key => $label ) { - $io->text( $index . '. ' . $key . ': ' . $inputs[ $key ] ); - $keysByIndex[ $index ] = $key; - $index++; - } - - $selection = (int)( $io->ask( 'Enter the number of a value to edit, or 0 to confirm all', '0' ) ?? '0' ); - if( $selection===0 ) { - break; - } - if( isset( $keysByIndex[ $selection ] ) ) { - $key = $keysByIndex[ $selection ]; - $inputs[ $key ] = (string)( $io->ask( 'Enter the new value for ' . $key ) ?? '' ); - } - else { - $io->error( 'Invalid selection. Enter a number between 1 and ' . count( $prompts ) . ', or 0 to finish.' ); - } - } - - $replacements = $this->buildReplacementTable( $inputs, $context->rootDir ); - $modifiedFiles = tokenReplacer::replaceInTree( $context->rootDir, $replacements ); - - if( count( $modifiedFiles )===0 ) { - $io->text( 'No files contained tokens to replace. (Already set up, or all values were skipped.)' ); - } - else { - $io->section( 'Updated files' ); - foreach( $modifiedFiles as $file ) { - $io->text( ' ' . $file ); - } - } - - if( !$input->getOption( 'skip-chrome' ) ) { - $io->section( 'chrome-headless-shell' ); - try { - ( new \gcgov\framework\cli\chromeInstaller( $context->getSrvDir() ) )->install( $io ); - } - catch( \Throwable $e ) { - $io->warning( 'chrome-headless-shell was not installed: ' . $e->getMessage() . ' You can install it later with `vendor/bin/gf chrome:install`.' ); - } - } - - $io->success( 'Setup complete. Next: `gf env local`, then `gf cert:generate-auth`.' ); - - return Command::SUCCESS; - } - - - /** - * @param array $inputs - * - * @return array token => replacement value (empty values are dropped by tokenReplacer) - */ - public function buildReplacementTable( array $inputs, string $rootDir ): array { - $value = fn( string $key ): string => $inputs[ $key ] ?? ''; - $trimmedValue = fn( string $key ): string => rtrim( $value( $key ), '/\\' ); - - $replacements = [ - '{app_guid}' => guid::create(), - '{app_title}' => $value( 'app_title' ), - '{app_root_url}' => $trimmedValue( 'app_root_url' ), - '{app_base_path}' => $value( 'app_base_path' )==='' ? '' : tokenReplacer::formatRelativeUrl( $value( 'app_base_path' ) ), - '{app_relative_url}' => $value( 'app_base_path' )==='' ? '' : tokenReplacer::formatRelativeUrl( $value( 'app_base_path' ), true, false ), - '{app_frontend_root_url}' => $trimmedValue( 'app_frontend_root_url' ), - '{app_redirect_after_login}' => $value( 'app_redirect_after_login' ), - '{app_redirect_after_logout}' => $value( 'app_redirect_after_logout' ), - '{app_absolute_path}' => rtrim( $rootDir, '/\\' ), - '{app_smtp_server}' => $value( 'app_smtp_server' ), - '{app_smtp_sendmail_from_address}' => $value( 'app_smtp_sendmail_from_address' ), - '{app_smtp_sendmail_from_name}' => $value( 'app_smtp_sendmail_from_name' ), - '{app_ssl_path}' => $trimmedValue( 'app_ssl_path' ), - '{app_php_path}' => $trimmedValue( 'app_php_path' ), - '{prod_app_root_url}' => $trimmedValue( 'prod_app_root_url' ), - '{prod_app_base_path}' => $value( 'prod_app_base_path' )==='' ? '' : tokenReplacer::formatRelativeUrl( $value( 'prod_app_base_path' ) ), - '{prod_app_relative_url}' => $value( 'prod_app_base_path' )==='' ? '' : tokenReplacer::formatRelativeUrl( $value( 'prod_app_base_path' ), true, false ), - '{prod_app_frontend_root_url}' => $trimmedValue( 'prod_app_frontend_root_url' ), - '{prod_app_redirect_after_login}' => $value( 'prod_app_redirect_after_login' ), - '{prod_app_redirect_after_logout}' => $value( 'prod_app_redirect_after_logout' ), - '{prod_app_absolute_path}' => $trimmedValue( 'prod_app_absolute_path' ), - '{prod_app_ssl_path}' => $trimmedValue( 'prod_app_ssl_path' ), - '{prod_app_php_path}' => $trimmedValue( 'prod_app_php_path' ), - ]; - - foreach( array_keys( self::MICROSOFT_PROMPTS ) as $microsoftKey ) { - $replacements[ '{' . $microsoftKey . '}' ] = $value( $microsoftKey ); - } - - return $replacements; - } - -} diff --git a/src/cli/commands/userCreateCommand.php b/src/cli/commands/userCreateCommand.php new file mode 100644 index 0000000..79d45cf --- /dev/null +++ b/src/cli/commands/userCreateCommand.php @@ -0,0 +1,276 @@ +addOption( 'email', null, InputOption::VALUE_REQUIRED, 'The user\'s email address. Also the default username.' ); + $this->addOption( 'password', null, InputOption::VALUE_REQUIRED, 'Password. Omit to have one generated and printed once.' ); + $this->addOption( 'name', null, InputOption::VALUE_REQUIRED, 'Display name' ); + $this->addOption( 'username', null, InputOption::VALUE_REQUIRED, 'Username to sign in with. Defaults to the email address.' ); + $this->addOption( 'roles', null, InputOption::VALUE_REQUIRED, 'Comma separated authorization roles, e.g. "User.Read,User.Write"' ); + $this->addOption( 'force', null, InputOption::VALUE_NONE, 'Update the existing user with this email instead of refusing' ); + $this->setHelp( <<<'HELP' + Create the account an application is signed into with. + + An application whose config.json enables services.auth starts with no way in: + blockNewUsers defaults to true, so only users already in the database may sign + in, and every /user route requires a caller already holding User.Write. Nothing + can authenticate, so nothing can create the first user. A direct mongosh insert + cannot break the cycle either — the user model hashes the password as it writes, + so a hand written document has no password anyone can sign in with. + + gf user:create --email=dev@example.test --roles="User.Read,User.Write" + + The user is saved through the model the application actually resolves — + \app\models\user when it defines one, otherwise the framework's Mongo user model + — so the password is hashed and every model hook runs exactly as they do when the + application writes a user itself. Pass the password rather than having one + generated when you would otherwise be copying it out of the terminal anyway. + + Writing a user is a transactional write, so the database must be a replica set + (or mongos). A standalone mongod fails this command, and every other write the + application makes, with "Transaction numbers are only allowed on a replica set + member or mongos". + + An email that already exists is refused unless --force, which updates that user + in place. On an update, an option you do not pass is left as it is — including + the password, so --force is safe to use to add a role. + HELP ); + } + + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $context = appContext::require(); + $context->assertAppLoadable(); + + $io = new SymfonyStyle( $input, $output ); + + $email = trim( (string)( $input->getOption( 'email' ) ?? '' ) ); + if( $email==='' ) { + throw new cliException( '--email is required.' ); + } + + /** @var class-string $userClass */ + $userClass = request::getUserClassFqdn(); + + $existing = self::findByEmail( $userClass, $email ); + if( $existing!==null && !$input->getOption( 'force' ) ) { + throw new cliException( 'A user with the email ' . $email . ' already exists. Pass --force to update it in place.' ); + } + + // Generated only for a NEW user with no --password. On an update, an omitted + // password means "leave the stored one alone" — the model unsets an empty password + // rather than writing it, so a --force run adding a role must not invent one. + $password = (string)( $input->getOption( 'password' ) ?? '' ); + $generated = $password==='' && $existing===null; + if( $generated ) { + $password = self::generatePassword(); + } + + $user = $existing ?? new $userClass(); + self::applyTo( $user, [ + 'email' => $email, + 'username' => (string)( $input->getOption( 'username' ) ?? '' ), + 'name' => (string)( $input->getOption( 'name' ) ?? '' ), + 'password' => $password, + 'roles' => $input->getOption( 'roles' )===null ? null : self::parseRoles( (string)$input->getOption( 'roles' ) ), + ] ); + + try { + $userClass::save( $user ); + } + catch( modelException $e ) { + throw new cliException( 'Saving the user failed: ' . $e->getMessage() . self::saveHint( $e ), 0, $e ); + } + + $io->success( ( $existing===null ? 'Created ' : 'Updated ' ) . $email ); + $io->text( 'id: ' . (string)$user->getId() ); + $io->text( 'username: ' . $user->getUsername() ); + $io->text( 'roles: ' . ( count( $user->getRoles() )>0 ? implode( ', ', $user->getRoles() ) : '(none — every role gated route will answer 403)' ) ); + if( $generated ) { + $io->text( 'password: ' . $password ); + $io->warning( 'This password is shown once and is not recoverable — it is stored hashed.' ); + } + + self::warnAboutSignIn( $io ); + + return Command::SUCCESS; + } + + + /** + * Copy the requested values onto a user model. + * + * Pure, and deliberately typed against a plain object rather than the user interface: + * it writes the trait's public properties, which the interface only exposes as getters. + * Keeping it here rather than inline in execute() is what lets the test drive the + * mapping — role parsing, the username default, the empty-password rule — without a + * database. + * + * A null entry means "not supplied": the property is left as it is, which on an update + * preserves the stored value. An empty password is likewise left alone, because the + * model's _beforeBsonSerialize() unsets an empty password rather than storing it, and + * hashing happens there — never here, or the hash would be hashed again and no password + * would ever verify. + * + * @param array{email: string, username?: string, name?: string, password?: string, roles?: string[]|null} $options + */ + public static function applyTo( object $user, array $options ): void { + $email = trim( $options[ 'email' ] ); + + $user->email = $email; + + // The username is what verifyUsernamePassword() matches first, so a user created + // without one could never sign in by username. The email is the sensible default + // and is what every caller of this command would otherwise type twice. + $username = trim( (string)( $options[ 'username' ] ?? '' ) ); + if( $username!=='' || !isset( $user->username ) || $user->username==='' ) { + $user->username = $username!=='' ? $username : $email; + } + + $name = trim( (string)( $options[ 'name' ] ?? '' ) ); + if( $name!=='' ) { + $user->name = $name; + } + + $password = (string)( $options[ 'password' ] ?? '' ); + if( $password!=='' ) { + $user->password = $password; + } + + if( isset( $options[ 'roles' ] ) ) { + $user->roles = $options[ 'roles' ]; + } + + $user->active = true; + + // A model whose $_id is a typed, non-nullable ObjectId is read unconditionally by + // factory::save() to build its update filter, so leaving it uninitialized is a fatal + // Error rather than an insert. The framework's own user model assigns one in its + // constructor; an application model that does not still gets one here. + if( !isset( $user->_id ) ) { + $user->_id = new \MongoDB\BSON\ObjectId(); + } + } + + + /** + * Split a --roles value into role names. + * + * Pure. Empty entries are dropped so a trailing comma, or the empty string, means no + * roles rather than one role named "". + * + * @return string[] + */ + public static function parseRoles( string $roles ): array { + $parsed = []; + foreach( explode( ',', $roles ) as $role ) { + $role = trim( $role ); + if( $role!=='' && !in_array( $role, $parsed, true ) ) { + $parsed[] = $role; + } + } + + return $parsed; + } + + + /** + * The existing user with this email, or null when there is none. + * + * getOneByEmail() reports "not found" by throwing, which is the right shape for the + * request lifecycle and the wrong one here: not finding a user is this command's normal + * case. A modelException carrying any other status is a real failure and is re-thrown. + * + * @param class-string $userClass + * + * @throws \gcgov\framework\cli\cliException + */ + private static function findByEmail( string $userClass, string $email ): ?object { + try { + return $userClass::getOneByEmail( $email ); + } + catch( modelException $e ) { + if( $e->getCode()===404 ) { + return null; + } + + throw new cliException( 'Looking up ' . $email . ' failed: ' . $e->getMessage() . self::saveHint( $e ), 0, $e ); + } + } + + + /** + * The failure a developer running this on a fresh local stack is most likely to hit is + * a standalone mongod, whose driver message ("Transaction numbers are only allowed on + * a replica set member or mongos") explains what happened but not what to do about it. + */ + private static function saveHint( \Throwable $e ): string { + $message = $e->getMessage() . ( $e->getPrevious()?->getMessage() ?? '' ); + if( stripos( $message, 'transaction numbers are only allowed' )===false ) { + return ''; + } + + return ' The database is a standalone mongod, and every write this framework makes runs in a transaction. Run MongoDB as a replica set — the application template\'s docker-compose.yml starts one.'; + } + + + /** + * What stands between this account and a working sign-in, when it is not simply "nothing". + * + * Both cases are ones where the command succeeds and the account still cannot be used, which + * is the failure worth saying out loud — the same reason readiness checks the signing keys + * rather than letting an unusable deployment report itself healthy. + */ + private static function warnAboutSignIn( SymfonyStyle $io ): void { + try { + if( config::getServices()->auth===null ) { + $io->warning( 'config.json does not enable services.auth, so nothing in this application signs a user in. The account is stored, but no route will accept it.' ); + + return; + } + + if( config::getSettings()->forceMfaForPasswordUsers ) { + $io->warning( 'settings.forceMfaForPasswordUsers is on, so this account cannot sign in with its password alone. The first POST /auth/authorize returns an MFA enrolment challenge and a token carrying NO roles; the account can do nothing until it completes POST /auth/verifyMfaSecret and then POST /auth/verifyMfaCode.' ); + } + } + catch( \Throwable ) { + // Configuration that does not resolve cannot have got this far — the save above + // reads it. Nothing to warn about that the caller has not already seen. + } + } + + + private static function generatePassword(): string { + $password = ''; + $max = strlen( self::PASSWORD_ALPHABET ) - 1; + for( $i = 0; $i canonical file pairs, relative to the application root. - * - * @return array - */ - public static function filePairs( string $environment ): array { - return [ - 'app/config/environment-' . $environment . '.json' => 'app/config/environment.json', - 'composer-' . $environment . '.json' => 'composer.json', - 'www/web-' . $environment . '.config' => 'www/web.config', - ]; - } - - - /** - * Copy every existing variant file for $environment to its canonical name. - * Missing variant files are skipped (not every app has every pair). - * - * @return array - * @throws \gcgov\framework\cli\cliException When no variant file exists at all or a copy fails - */ - public static function apply( string $rootDir, string $environment, bool $dryRun = false ): array { - $rootDir = rtrim( str_replace( '\\', '/', $rootDir ), '/' ); - $results = []; - $copied = 0; - - foreach( self::filePairs( $environment ) as $source => $target ) { - $sourcePath = $rootDir . '/' . $source; - $targetPath = $rootDir . '/' . $target; - - if( !file_exists( $sourcePath ) ) { - $results[] = [ 'source' => $source, 'target' => $target, 'status' => 'skipped (no ' . $source . ')' ]; - continue; - } - - if( !$dryRun ) { - if( !copy( $sourcePath, $targetPath ) ) { - throw new cliException( 'Failed to copy ' . $sourcePath . ' to ' . $targetPath ); - } - } - - $results[] = [ 'source' => $source, 'target' => $target, 'status' => $dryRun ? 'would copy' : 'copied' ]; - $copied++; - } - - if( $copied===0 ) { - throw new cliException( 'No environment variant files found for environment "' . $environment . '" in ' . $rootDir . '. Expected at least one of: ' . implode( ', ', array_keys( self::filePairs( $environment ) ) ) ); - } - - return $results; - } - -} diff --git a/src/cli/internal/run-route.php b/src/cli/internal/run-route.php index 0e4a5c9..6c25108 100644 --- a/src/cli/internal/run-route.php +++ b/src/cli/internal/run-route.php @@ -32,7 +32,7 @@ }; if( PHP_SAPI!=='cli' ) { - $gfWriteError( 'application CLI routes must run under the PHP CLI binary, but this process is running the "' . PHP_SAPI . '" SAPI. Point gf at php/php.exe instead of php-cgi/php-fpm with `gf cli --php=`, the GF_PHP environment variable, or "phpPath" in app/config/environment.json.' ); + $gfWriteError( 'application CLI routes must run under the PHP CLI binary, but this process is running the "' . PHP_SAPI . '" SAPI. Point gf at php/php.exe instead of php-cgi/php-fpm with `gf cli --php=`, or the GF_PHP environment variable.' ); exit( 2 ); } @@ -49,6 +49,17 @@ exit( 2 ); } +// Checked rather than left to require's fatal, which this process cannot rely on being +// seen. Whether that fatal reaches the caller depends entirely on the host php.ini: +// display_errors is Off in php.ini-production, and error_log usually names a file, so the +// message goes to that file and the child exits 255 having printed nothing at all. `gf cli` +// is what Task Scheduler and cron run, so "failed, no diagnostic" is the one outcome this +// script exists to prevent — it already guards $argv and STDERR for the same reason. +if( !is_file( $gfArguments[ 1 ] ) ) { + $gfWriteError( 'the composer autoloader was not found at "' . $gfArguments[ 1 ] . '". Run `composer install` in the application root, or point gf at the right application.' ); + exit( 2 ); +} + require $gfArguments[ 1 ]; $_SERVER[ 'REQUEST_METHOD' ] = 'CLI'; diff --git a/src/cli/mongoTools.php b/src/cli/mongoTools.php index 676a892..8df7280 100644 --- a/src/cli/mongoTools.php +++ b/src/cli/mongoTools.php @@ -6,7 +6,7 @@ /** * Shared helpers for the gf db:* commands. These commands read connection info - * from app/config/environment{-variant}.json and shell out to the MongoDB + * from the resolved config.json and shell out to the MongoDB * command line tools — no ext-mongodb required. */ final class mongoTools { diff --git a/src/cli/phpProcess.php b/src/cli/phpProcess.php index 8ce1787..58c6365 100644 --- a/src/cli/phpProcess.php +++ b/src/cli/phpProcess.php @@ -2,7 +2,6 @@ namespace gcgov\framework\cli; -use gcgov\framework\models\environmentConfig; use Symfony\Component\Process\PhpExecutableFinder; /** @@ -25,9 +24,10 @@ final class phpProcess { * Priority: * 1. --php option * 2. GF_PHP environment variable - * 3. environmentConfig->phpPath (a directory per the setup convention — php/php.exe appended; - * a full binary path, optionally followed by CLI arguments, is also accepted) - * 4. Symfony PhpExecutableFinder / PHP_BINARY (the interpreter running gf) + * 3. Symfony PhpExecutableFinder / PHP_BINARY (the interpreter running gf) + * + * The interpreter is a property of the machine, not of the application, so it is + * deliberately not configurable in the committed config.json. * * Sources 1-3 may include trailing arguments after the binary, e.g. * `C:\path\php.exe -c C:\path\php.ini` — the binary and each argument become separate @@ -40,7 +40,7 @@ final class phpProcess { * @return string[] Command array — first element is the binary, remaining elements are arguments. * @throws \gcgov\framework\cli\cliException */ - public static function findPhpBinary( ?string $optionValue = null, ?environmentConfig $environmentConfig = null ): array { + public static function findPhpBinary( ?string $optionValue = null ): array { $candidates = []; if( $optionValue!==null && $optionValue!=='' ) { @@ -52,10 +52,6 @@ public static function findPhpBinary( ?string $optionValue = null, ?environmentC $candidates[ $envValue ] = 'GF_PHP environment variable'; } - if( $environmentConfig!==null && $environmentConfig->phpPath!=='' ) { - $candidates[ $environmentConfig->phpPath ] = 'environment.json phpPath'; - } - foreach( $candidates as $candidate => $sourceDescription ) { $resolved = self::resolveBinary( (string)$candidate ); if( $resolved!==null ) { @@ -87,7 +83,7 @@ public static function requiredIniFlags(): array { /** * Ensure the resolved command runs the CLI interpreter. php-cgi/php-fpm/php-win are - * silently swapped for the php/php.exe beside them (a phpPath copied from an IIS FastCGI + * silently swapped for the php/php.exe beside them (a path copied from an IIS FastCGI * handler mapping is the common case); when no CLI binary is there, fail with an * actionable message instead of letting the child process die on undefined $argv/STDERR. * @@ -109,7 +105,7 @@ private static function requireCliBinary( array $command, string $sourceDescript return $command; } - throw new cliException( 'PHP binary from ' . $sourceDescription . ' is not the CLI interpreter: ' . $binary . '. gf runs application code through the PHP CLI binary (php/php.exe) — php-cgi, php-fpm, and php-win cannot run CLI routes ($argv and STDERR are unavailable there). No CLI binary was found beside it, so point --php, GF_PHP, or environment.json phpPath at php.exe or the directory containing it.' ); + throw new cliException( 'PHP binary from ' . $sourceDescription . ' is not the CLI interpreter: ' . $binary . '. gf runs application code through the PHP CLI binary (php/php.exe) — php-cgi, php-fpm, and php-win cannot run CLI routes ($argv and STDERR are unavailable there). No CLI binary was found beside it, so point --php or GF_PHP at php.exe or the directory containing it.' ); } diff --git a/src/cli/routeCatalog.php b/src/cli/routeCatalog.php index e6b4071..c29ad1c 100644 --- a/src/cli/routeCatalog.php +++ b/src/cli/routeCatalog.php @@ -37,10 +37,10 @@ public static function getAllRoutes( appContext $context ): array { $context->assertAppLoadable(); try { - return \gcgov\framework\router::getMergedRoutes( $context->getServiceNamespaces() ); + return \gcgov\framework\router::getMergedRoutes(); } catch( \gcgov\framework\exceptions\configException $e ) { - throw new cliException( 'Could not load routes: ' . $e->getMessage() . ' Run `gf env ` to activate an environment first.', 0, $e ); + throw new cliException( 'Could not load routes: ' . $e->getMessage() . ' Ensure {root}/config.json exists and every %env(...) it references has a value (validate with `gf env`).', 0, $e ); } catch( \gcgov\framework\exceptions\routeException $e ) { throw new cliException( 'Could not load routes: ' . $e->getMessage(), 0, $e ); diff --git a/src/cli/tokenReplacer.php b/src/cli/tokenReplacer.php deleted file mode 100644 index 4620d73..0000000 --- a/src/cli/tokenReplacer.php +++ /dev/null @@ -1,113 +0,0 @@ - $replacements token => value, e.g. '{app_title}' => 'Timesheet API' - * - * @return string[] Paths of files that were modified - */ - public static function replaceInTree( string $rootDir, array $replacements ): array { - $replacements = array_filter( $replacements, fn( string $value ) => $value!=='' ); - if( count( $replacements )===0 ) { - return []; - } - - $modifiedFiles = []; - - foreach( self::findEligibleFiles( $rootDir ) as $filePath ) { - $contents = file_get_contents( $filePath ); - if( $contents===false ) { - continue; - } - - $isJson = strtolower( pathinfo( $filePath, PATHINFO_EXTENSION ) )==='json'; - $newContents = $contents; - - foreach( $replacements as $token => $value ) { - $replacementValue = $isJson ? str_replace( '\\', '\\\\', $value ) : $value; - $newContents = str_replace( $token, $replacementValue, $newContents ); - } - - if( $newContents!==$contents ) { - file_put_contents( $filePath, $newContents ); - $modifiedFiles[] = $filePath; - } - } - - return $modifiedFiles; - } - - - /** - * @return string[] - */ - public static function findEligibleFiles( string $rootDir ): array { - $rootDir = rtrim( str_replace( '\\', '/', $rootDir ), '/' ); - if( !is_dir( $rootDir ) ) { - return []; - } - - $directoryIterator = new \RecursiveDirectoryIterator( $rootDir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS ); - $filterIterator = new \RecursiveCallbackFilterIterator( $directoryIterator, function( \SplFileInfo $file ): bool { - if( $file->isDir() ) { - return !in_array( $file->getFilename(), self::EXCLUDED_DIRECTORIES, true ); - } - - return in_array( strtolower( $file->getExtension() ), self::EXTENSIONS, true ); - } ); - - $files = []; - foreach( new \RecursiveIteratorIterator( $filterIterator ) as $file ) { - /** @var \SplFileInfo $file */ - $files[] = $file->getPathname(); - } - sort( $files ); - - return $files; - } - - - /** - * Normalize a url path segment: '/api/' style with configurable leading/trailing slashes. - * Port of setup.ps1's FormatRelativeUrl. - */ - public static function formatRelativeUrl( string $path, bool $trailingSlash = true, bool $leadingSlash = true ): string { - $path = trim( $path ); - $path = str_replace( '\\', '/', $path ); - $path = trim( $path, '/' ); - - if( $path==='' ) { - return $leadingSlash || $trailingSlash ? '/' : ''; - } - - if( $trailingSlash ) { - $path .= '/'; - } - if( $leadingSlash ) { - $path = '/' . $path; - } - - return (string)preg_replace( '#//+#', '/', $path ); - } - -} diff --git a/src/config.php b/src/config.php index 197b511..03cf46e 100644 --- a/src/config.php +++ b/src/config.php @@ -3,17 +3,39 @@ namespace gcgov\framework; -use gcgov\framework\models\appConfig; -use gcgov\framework\models\environmentConfig; - - +use gcgov\framework\models\config\app\app; +use gcgov\framework\models\config\app\email; +use gcgov\framework\models\config\app\settings; +use gcgov\framework\models\config\environment\cronMonitor; +use gcgov\framework\models\config\environment\jwtAuth; +use gcgov\framework\models\config\environment\logging; +use gcgov\framework\models\config\environment\microsoft; +use gcgov\framework\models\config\environment\payjunction; +use gcgov\framework\models\config\environment\sqlDatabase; +use gcgov\framework\models\config\services; +use gcgov\framework\models\unifiedConfig; + + +/** + * Static configuration access for the application. + * + * Paths are derived by reflecting \app\app's file location. Configuration values come + * from the single {root}/config.json (the v7 merge of the former app/config/app.json + * and app/config/environment.json), resolved with %env(...) environment-variable + * references, and are exposed directly on this class — e.g. config::getBasePath(), + * config::getMongoDatabases(), config::getEmail(). + */ final class config { private static string $rootDir = ''; - private static string $appDir = ''; + /** @deprecated v7 — memoized view backing the deprecated getAppConfig() pass-through. */ + private static ?\gcgov\framework\models\appConfig $appConfig = null; + + /** The unifiedConfig $appConfig is a view onto, so the memo cannot go stale. */ + private static ?unifiedConfig $appConfigSource = null; - private static string $configDir = ''; + private static string $appDir = ''; private static string $modelsDir = ''; @@ -21,9 +43,7 @@ final class config { private static string $srvDir = ''; - private static appConfig $appConfig; - - private static environmentConfig $environmentConfig; + private static unifiedConfig $unifiedConfig; public static function getTempDir(): string { @@ -81,21 +101,6 @@ private static function setModelsDir(): void { } - - public static function getConfigDir(): string { - if( self::$configDir==='' ) { - self::setConfigDir(); - } - - return self::$configDir; - } - - - private static function setConfigDir(): void { - self::$configDir = self::getAppDir() . '/config/'; - } - - public static function getServicesDir(): string { if( self::$servicesDir==='' ) { self::setServicesDir(); @@ -125,54 +130,249 @@ private static function setSrvDir(): void { /** - * @return \gcgov\framework\models\appConfig + * The absolute path of the unified config file. + */ + public static function getConfigFilePath(): string { + return \gcgov\framework\services\environment\configLoader::configFilePath( self::getRootDir() ); + } + + + /** + * @deprecated v7 — the app/config directory no longer exists (configuration is the + * single {root}/config.json; see getConfigFilePath()). Kept so v6 code + * that located files under app/config keeps resolving the same path. + */ + #[\JetBrains\PhpStorm\Deprecated( reason: 'v7: configuration is the single {root}/config.json', replacement: '\gcgov\framework\config::getConfigFilePath()' )] + public static function getConfigDir(): string { + return self::getAppDir() . '/config/'; + } + + + /** * @throws \gcgov\framework\exceptions\configException */ - public static function getAppConfig(): appConfig { - if( !isset( self::$appConfig ) ) { - self::setAppConfig(); + private static function unifiedConfig(): unifiedConfig { + if( !isset( self::$unifiedConfig ) ) { + self::setUnifiedConfig(); } - return self::$appConfig; + return self::$unifiedConfig; } /** * @throws \gcgov\framework\exceptions\configException */ - private static function setAppConfig(): void { - $appDir = self::getAppDir(); - $appConfigFile = $appDir . '/config/app.json'; - if( !file_exists( $appConfigFile ) ) { - throw new \gcgov\framework\exceptions\configException( 'Missing app config file at ' . $appConfigFile ); + private static function setUnifiedConfig(): void { + try { + self::$unifiedConfig = \gcgov\framework\services\environment\configLoader::load( self::getRootDir() ); + } + catch( \gcgov\framework\services\environment\environmentException $e ) { + throw new \gcgov\framework\exceptions\configException( $e->getMessage(), 500, $e ); } - self::$appConfig = appConfig::jsonDeserialize( file_get_contents( $appConfigFile ) ); + } + + + // --- deprecated v6 pass-throughs (migration aids) --- + + /** + * @deprecated v7 — use the flattened static accessors instead: `config::getEnvironmentConfig()->getBasePath()` + * becomes `config::getBasePath()`, `->mongoDatabases` becomes `config::getMongoDatabases()`, etc. + * Returns the unified config object, which carries every former environmentConfig field and helper, + * so existing call sites keep working until they migrate. + * @throws \gcgov\framework\exceptions\configException + */ + #[\JetBrains\PhpStorm\Deprecated( reason: 'v7: config values are exposed directly on config', replacement: '\gcgov\framework\config' )] + public static function getEnvironmentConfig(): unifiedConfig { + return self::unifiedConfig(); } /** - * @return \gcgov\framework\models\environmentConfig + * @deprecated v7 — use the flattened static accessors instead: `config::getAppConfig()->settings` becomes + * `config::getSettings()`, `->app` becomes `config::getApp()`, `->email` becomes `config::getEmail()`. + * Returns a v6-shaped VIEW (app/email/settings only) over the unified config. Reading the + * sections and serializing the object both work; see the class docblock for what a v6 + * appConfig could do that this cannot. * @throws \gcgov\framework\exceptions\configException */ - public static function getEnvironmentConfig(): environmentConfig { - if( !isset( self::$environmentConfig ) ) { - self::setEnvironmentConfig(); + #[\JetBrains\PhpStorm\Deprecated( reason: 'v7: config values are exposed directly on config', replacement: '\gcgov\framework\config' )] + public static function getAppConfig(): \gcgov\framework\models\appConfig { + // Memoized, as it was in v6. A fresh view per call is not only an allocation on a + // path that may run per document — it also breaks identity, so a v6 call site that + // compared or cached the object saw a different one every time. Keyed on the + // unifiedConfig it views, so replacing the configuration (as the tests do) does not + // leave a view onto the old sections behind. + $unifiedConfig = self::unifiedConfig(); + if( self::$appConfig===null || self::$appConfigSource!==$unifiedConfig ) { + self::$appConfigSource = $unifiedConfig; + self::$appConfig = new \gcgov\framework\models\appConfig( $unifiedConfig ); } - return self::$environmentConfig; + return self::$appConfig; + } + + + // --- application identity (formerly app.json) --- + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getApp(): app { + return self::unifiedConfig()->app; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getEmail(): email { + return self::unifiedConfig()->email; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getSettings(): settings { + return self::unifiedConfig()->settings; + } + + + // --- environment (formerly environment.json) --- + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getType(): string { + return self::unifiedConfig()->type; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function isLocal(): bool { + return self::unifiedConfig()->isLocal(); + } + + + /** Normalized (no trailing slash). @throws \gcgov\framework\exceptions\configException */ + public static function getRootUrl(): string { + return self::unifiedConfig()->getRootUrl(); + } + + + /** {rootUrl}/{basePath}. @throws \gcgov\framework\exceptions\configException */ + public static function getBaseUrl(): string { + return self::unifiedConfig()->getBaseUrl(); + } + + + /** Normalized '/api' style ('/' at domain root). @throws \gcgov\framework\exceptions\configException */ + public static function getBasePath(): string { + return self::unifiedConfig()->getBasePath(); } /** + * The base path in the form a route pattern is built from: '' at the domain root, '/api' otherwise. + * Use this, not getBasePath(), when prefixing a route — see {@see unifiedConfig::getRoutePrefix()}. + * * @throws \gcgov\framework\exceptions\configException */ - private static function setEnvironmentConfig(): void { - $appDir = self::getAppDir(); - $environmentConfigFile = $appDir . '/config/environment.json'; - if( !file_exists( $environmentConfigFile ) ) { - throw new \gcgov\framework\exceptions\configException( 'Missing environment config file at ' . $environmentConfigFile ); - } - self::$environmentConfig = environmentConfig::jsonDeserialize( file_get_contents( $environmentConfigFile ) ); + public static function getRoutePrefix(): string { + return self::unifiedConfig()->getRoutePrefix(); + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getLogging(): logging { + return self::unifiedConfig()->logging; + } + + + /** + * @return \gcgov\framework\models\config\environment\mongoDatabase[] + * @throws \gcgov\framework\exceptions\configException + */ + public static function getMongoDatabases(): array { + return self::unifiedConfig()->mongoDatabases; + } + + + /** + * @return \gcgov\framework\models\config\environment\sqlDatabase[] + * @throws \gcgov\framework\exceptions\configException + */ + public static function getSqlDatabases(): array { + return self::unifiedConfig()->sqlDatabases; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getDefaultSqlDatabase(): ?sqlDatabase { + return self::unifiedConfig()->getDefaultSqlDatabase(); + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getSqlDatabaseByName( string $name ): ?sqlDatabase { + return self::unifiedConfig()->getSqlDatabaseByName( $name ); + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getMicrosoft(): microsoft { + return self::unifiedConfig()->microsoft; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getJwtAuth(): jwtAuth { + return self::unifiedConfig()->jwtAuth; + } + + + /** Token issuer, defaulting to the application's root url. @throws \gcgov\framework\exceptions\configException */ + public static function getTokenIssuedBy(): string { + return self::unifiedConfig()->getTokenIssuedBy(); + } + + + /** Token audience, defaulting to the application's base path. @throws \gcgov\framework\exceptions\configException */ + public static function getTokenPermittedFor(): string { + return self::unifiedConfig()->getTokenPermittedFor(); + } + + + /** + * Directory holding the JWT signing keypairs — the configured jwtAuth.keyPath, or + * the default {root}/srv/jwtCertificates. Always returned with a trailing slash. + * + * @throws \gcgov\framework\exceptions\configException + */ + public static function getJwtKeyPath(): string { + return self::unifiedConfig()->getJwtKeyPath( self::getSrvDir() ); + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getPayjunction(): payjunction { + return self::unifiedConfig()->payjunction; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getAppDictionary(): array { + return self::unifiedConfig()->appDictionary; + } + + + /** + * Which Framework Services this application runs. A service whose block is absent is + * not constructed and contributes no routes. + * + * @throws \gcgov\framework\exceptions\configException + */ + public static function getServices(): services { + return self::unifiedConfig()->services; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getCronMonitor(): cronMonitor { + return self::unifiedConfig()->cronMonitor; } } diff --git a/src/framework.php b/src/framework.php index 5dbba4c..80dcaf3 100644 --- a/src/framework.php +++ b/src/framework.php @@ -19,18 +19,38 @@ public function runApp() : string { //appConfig \app\app::_before(); + // Held for the lifetime of the request, as it always has been. Since Framework + // Services moved into config.json there is nothing left to ask it for, but an + // application may still do work in its constructor. $app = new \app\app(); - $serviceNamespaces = $app->registerFrameworkServiceNamespaces(); //router \app\router::_before(); try { - $router = new \gcgov\framework\router( $serviceNamespaces ); + $router = new \gcgov\framework\router(); $routeHandler = $router->route(); } catch( routeException $e ) { $routeException = $e; } + catch( \Throwable $e ) { + // Routing throws several classes that are not routeException, and every one of + // them used to escape runApp() as a bare PHP fatal — skipping \app\router::_after(), + // the renderer and \app\app::_after(), and returning no framework error body at all: + // + // configException the fail-closed checks (authenticated routes with no auth + // service, a missing config.json, an unresolved %env() reference) + // — it extends \LogicException, unrelated to routeException + // BadRouteException an application defining a route the framework already registers + // \TypeError an \app\router that does not implement interfaces\appRouter + // + // Refusing loudly is the whole point of those checks, so they are rendered like any + // other failure. The detail goes to the log and never to the client: these messages + // carry route patterns, config file paths and the names of missing environment + // variables. services\log falls back to stderr when config itself is what failed. + \gcgov\framework\services\log::critical( 'Framework Lifecycle', $e->getMessage(), [ 'exception' => $e ] ); + $routeException = new routeException( 'Server configuration error', 500, $e ); + } \app\router::_after(); //renderer and controller (renderer handles calling controller lifecycle methods) diff --git a/src/interfaces/app.php b/src/interfaces/app.php index ee23195..2ee0d60 100644 --- a/src/interfaces/app.php +++ b/src/interfaces/app.php @@ -3,12 +3,17 @@ namespace gcgov\framework\interfaces; +/** + * The application entry class — \app\app. + * + * It declares no methods of its own beyond the lifecycle hooks, but it is not optional: + * \gcgov\framework\config derives every path in the framework by reflecting on this + * class's file location, so an application without it cannot resolve its own root. + * + * Framework Services used to be registered here, by returning their namespaces from + * registerFrameworkServiceNamespaces(). They are now declared in the `services` section + * of config.json, so that activation and configuration are one statement rather than two. + */ interface app extends lifecycle\before, lifecycle\after { - /** - * Return an array of the namespaces where framework services installed via composer - * @return string[] - */ - public function registerFrameworkServiceNamespaces() : array; - } diff --git a/src/interfaces/appRouter.php b/src/interfaces/appRouter.php new file mode 100644 index 0000000..8c0c91b --- /dev/null +++ b/src/interfaces/appRouter.php @@ -0,0 +1,34 @@ +setFromUser( $user )`. The + * framework enforces each route's requiredRoles against it after the guard chain + * ({@see \gcgov\framework\router::assertRequiredRoles()}), so a router that verifies + * identity without recording it leaves those routes refused with a 401 rather than + * silently unchecked. + */ + public function providesAuthentication() : bool; +} diff --git a/src/interfaces/router.php b/src/interfaces/router.php index f88bba6..4ae8e39 100644 --- a/src/interfaces/router.php +++ b/src/interfaces/router.php @@ -3,7 +3,17 @@ namespace gcgov\framework\interfaces; -interface router extends lifecycle\before, lifecycle\after { +/** + * Contributes routes, and guards the ones that require authentication. + * + * Implemented by the framework's own routers (health, and each Framework Service the + * application enables) and, through {@see appRouter}, by \app\router. + * + * Note there are no lifecycle hooks here. Only \app\router's _before()/_after() are + * invoked by the framework, so requiring them of every router described a contract that + * was never honoured; they live on {@see appRouter} instead. + */ +interface router { /** * @return \gcgov\framework\models\route[] @@ -12,6 +22,9 @@ public function getRoutes() : array; /** + * Return false to deny the request. Throw a routeException to deny it with a specific + * status and message. + * * @param \gcgov\framework\models\routeHandler $routeHandler * * @return bool @@ -19,4 +32,4 @@ public function getRoutes() : array; * @throws \gcgov\framework\exceptions\routeException */ public function authentication( \gcgov\framework\models\routeHandler $routeHandler ) : bool; -} \ No newline at end of file +} diff --git a/src/interfaces/router/skipsServiceAuthentication.php b/src/interfaces/router/skipsServiceAuthentication.php new file mode 100644 index 0000000..ff7990c --- /dev/null +++ b/src/interfaces/router/skipsServiceAuthentication.php @@ -0,0 +1,34 @@ +setFromUser( $user )`. Without that + * there is no user to check the roles against and the request is refused with a 401. + * + * This was previously duck-typed — the framework looked for the method with + * method_exists() and no interface declared it, so neither PHPStan nor an IDE could see + * it and a typo in the name silently disabled the opt-out. + */ +interface skipsServiceAuthentication { + + /** + * Return false to skip the Framework Service auth guards for this route. + * + * @param \gcgov\framework\models\routeHandler $routeHandler + * + * @return bool + */ + public function getRunFrameworkServiceRouteAuthentication( \gcgov\framework\models\routeHandler $routeHandler ) : bool; +} diff --git a/src/models/appConfig.php b/src/models/appConfig.php index 075c343..d38b887 100644 --- a/src/models/appConfig.php +++ b/src/models/appConfig.php @@ -1,16 +1,26 @@ app = $unifiedConfig->app; + $this->email = $unifiedConfig->email; + $this->settings = $unifiedConfig->settings; + } + + /** + * v6 call sites serialize this object; the class it used to extend supplied that. + * + * @return array{app: app, email: email, settings: settings} + */ + public function jsonSerialize(): array { + return [ + 'app' => $this->app, + 'email' => $this->email, + 'settings' => $this->settings, + ]; } -} \ No newline at end of file + +} diff --git a/src/models/authUser.php b/src/models/authUser.php index a2e92c8..4fe6b7b 100644 --- a/src/models/authUser.php +++ b/src/models/authUser.php @@ -100,7 +100,7 @@ public function setFromJwtToken( array $tokenUser, array $tokenScopes ): self { $this->externalProvider = $tokenUser[ 'externalProvider' ] ?? ''; $this->name = $tokenUser[ 'name' ] ?? ''; $this->email = $tokenUser[ 'email' ] ?? ''; - $this->roles = $tokenScopes; + $this->roles = self::normalizeRoles( $tokenScopes ); return self::getInstance(); } @@ -117,12 +117,33 @@ public function setFromUser( \gcgov\framework\interfaces\auth\user $user ): self $this->name = $user->getName(); $this->username = $user->getUsername(); $this->email = $user->getEmail(); - $this->roles = $user->getRoles(); + $this->roles = self::normalizeRoles( $user->getRoles() ); return self::getInstance(); } + + /** + * Narrow roles to strings. + * + * $roles is documented string[] but nothing enforced it, and both sources are untyped: + * the token's `scope` claim is whatever JSON it carried, and the user model's roles + * field is whatever the collection holds — BSON deserialization passes scalars through + * untouched. A single non-string truthy element (roles: [true]) satisfied a loose + * in_array() against EVERY required role, so a token carrying one authorized every + * gated route. Narrowing here covers both setters and makes the strict comparisons in + * hasRole() and the auth guard meaningful. + * + * @param mixed[] $roles + * + * @return string[] + */ + private static function normalizeRoles( array $roles ): array { + return array_values( array_filter( $roles, static fn( $role ): bool => is_string( $role ) ) ); + } + + public function hasRole( string $role ): bool { - return in_array( $role, $this->roles ); + return in_array( $role, $this->roles, true ); } } diff --git a/src/models/config/app/settings.php b/src/models/config/app/settings.php index cd3732f..9c26877 100644 --- a/src/models/config/app/settings.php +++ b/src/models/config/app/settings.php @@ -6,8 +6,6 @@ class settings extends \andrewsauder\jsonDeserialize\jsonDeserialize { - public bool $useSession = false; - public bool $forceMfaForPasswordUsers = false; public function __construct() { diff --git a/src/models/config/environment/cronMonitor.php b/src/models/config/environment/cronMonitor.php new file mode 100644 index 0000000..ab81eff --- /dev/null +++ b/src/models/config/environment/cronMonitor.php @@ -0,0 +1,26 @@ +url )!==''; + } + +} diff --git a/src/models/config/environment/jwtAuth.php b/src/models/config/environment/jwtAuth.php index 065ed0c..e896580 100644 --- a/src/models/config/environment/jwtAuth.php +++ b/src/models/config/environment/jwtAuth.php @@ -2,18 +2,32 @@ namespace gcgov\framework\models\config\environment; -use gcgov\framework\exceptions\configException; - class jwtAuth extends \andrewsauder\jsonDeserialize\jsonDeserialize { + /** + * Token issuer. Leave empty to derive from the application's rootUrl — they are the + * same value in every deployment we have, and configuring both invites them to drift. + */ public string $tokenIssuedBy = ""; + /** Token audience. Leave empty to derive from the application's basePath. */ public string $tokenPermittedFor = ""; public string $redirectAfterLoginUrl = ""; public string $redirectAfterLogoutUrl = ""; + /** + * Directory holding the RSA signing keypairs and guids.json. + * + * Empty means the default `{root}/srv/jwtCertificates`, which is where + * `gf cert:generate-auth` writes them. Containers must point this at a + * provisioned, read-only location (e.g. /run/secrets/jwt): the keys are + * secrets, they are gitignored so they are never in a built image, and every + * replica has to sign with the same set. + */ + public string $keyPath = ""; + public function __construct() { } diff --git a/src/models/config/environment/logging.php b/src/models/config/environment/logging.php index 748fec4..d34d2ee 100644 --- a/src/models/config/environment/logging.php +++ b/src/models/config/environment/logging.php @@ -4,9 +4,39 @@ class logging extends \andrewsauder\jsonDeserialize\jsonDeserialize { - public bool $lifecycle = false; - public bool $renderer = false; + /** Write log records to stderr as JSON lines — the default, and what a container needs. */ + public const string DESTINATION_STDERR = 'stderr'; + + /** Write log records to {root}/logs/{channel}.log, as v6 did. */ + public const string DESTINATION_FILE = 'file'; + + /** Both of the above. */ + public const string DESTINATION_BOTH = 'both'; + + public bool $lifecycle = false; + + public bool $renderer = false; + + /** + * Where log records go: 'stderr' (default), 'file', or 'both'. + * + * stderr is the default because a container's filesystem does not survive a + * deploy — file logs would be per-replica and destroyed on every release. + * Applications still hosted on IIS set 'file'. + */ + public string $destination = self::DESTINATION_STDERR; public function __construct() { } + + + public function writesToStderr(): bool { + return $this->destination===self::DESTINATION_STDERR || $this->destination===self::DESTINATION_BOTH; + } + + + public function writesToFile(): bool { + return $this->destination===self::DESTINATION_FILE || $this->destination===self::DESTINATION_BOTH; + } + } diff --git a/src/models/config/environment/sqlDatabase.php b/src/models/config/environment/sqlDatabase.php index a2bbcf2..408ab84 100644 --- a/src/models/config/environment/sqlDatabase.php +++ b/src/models/config/environment/sqlDatabase.php @@ -17,6 +17,21 @@ class sqlDatabase extends \andrewsauder\jsonDeserialize\jsonDeserialize { public function __construct() { + $this->readAccount = new sqlDatabaseUser(); + $this->writeAccount = new sqlDatabaseUser(); } -} \ No newline at end of file + + protected function _afterJsonDeserialize(): void { + // jsonDeserialize may instantiate this class without invoking the constructor, + // leaving these typed-non-nullable properties uninitialized. Without this guard an + // entry that omits readAccount/writeAccount raises "must not be accessed before + // initialization" at the point of use rather than a configException at load. + foreach( [ 'readAccount', 'writeAccount' ] as $property ) { + if( !( new \ReflectionProperty( $this, $property ) )->isInitialized( $this ) ) { + $this->$property = new sqlDatabaseUser(); + } + } + } + +} diff --git a/src/models/config/services.php b/src/models/config/services.php new file mode 100644 index 0000000..a16c481 --- /dev/null +++ b/src/models/config/services.php @@ -0,0 +1,26 @@ +provider, self::PROVIDERS, true ) ) { + throw new environmentException( 'services.auth.provider must be one of "' . implode( '", "', self::PROVIDERS ) . '"' . ( $this->provider==='' ? ', and is missing' : ', not "' . $this->provider . '"' ) . '.' ); + } + + // A block for the provider that is not selected is configuration that would never + // be read. Saying so is the whole point of a fail-closed configuration: a setting + // that appears to do something and does nothing is the failure mode to prevent. + foreach( self::PROVIDERS as $provider ) { + if( $provider!==$this->provider && $this->$provider!==null ) { + throw new environmentException( 'services.auth.' . $provider . ' is configured but services.auth.provider is "' . $this->provider . '", so nothing would read it. Remove the "' . $provider . '" block or change the provider.' ); + } + } + + // The selected provider's block may be omitted entirely; a missing section + // hydrating to its defaults is the established rule for every other section. + if( $this->provider===self::PROVIDER_OAUTH && $this->oauth===null ) { + $this->oauth = new oauth(); + } + if( $this->provider===self::PROVIDER_MS_FRONT && $this->msFront===null ) { + $this->msFront = new msFront(); + } + } + + + public function isOauth(): bool { + return $this->provider===self::PROVIDER_OAUTH; + } + + + public function isMsFront(): bool { + return $this->provider===self::PROVIDER_MS_FRONT; + } + +} diff --git a/src/models/config/services/auth/msFront.php b/src/models/config/services/auth/msFront.php new file mode 100644 index 0000000..5ec11cb --- /dev/null +++ b/src/models/config/services/auth/msFront.php @@ -0,0 +1,13 @@ + + */ + public array $authorizeUrlParameters = []; + +} diff --git a/src/models/config/services/documentation.php b/src/models/config/services/documentation.php new file mode 100644 index 0000000..d63ef38 --- /dev/null +++ b/src/models/config/services/documentation.php @@ -0,0 +1,13 @@ +microsoft = new microsoft(); - $this->jwtAuth = new jwtAuth(); - $this->payjunction = new payjunction(); - $this->logging = new logging(); - } - - protected function _afterJsonDeserialize(): void { - // jsonDeserialize may instantiate this class without invoking the - // constructor, leaving typed-non-nullable properties uninitialized. - // Use reflection so we can ask the engine about init state without - // PHPStan narrowing the check away. - if( !( new \ReflectionProperty( $this, 'microsoft' ) )->isInitialized( $this ) ) { - $this->microsoft = new microsoft(); - } - if( !( new \ReflectionProperty( $this, 'jwtAuth' ) )->isInitialized( $this ) ) { - $this->jwtAuth = new jwtAuth(); - } - if( !( new \ReflectionProperty( $this, 'payjunction' ) )->isInitialized( $this ) ) { - $this->payjunction = new payjunction(); - } - if( !( new \ReflectionProperty( $this, 'logging' ) )->isInitialized( $this ) ) { - $this->logging = new logging(); - } - } - - public function getRootUrl(): string { - return rtrim( $this->rootUrl, '/ ' ); - } - - - public function getBaseUrl(): string { - return rtrim( $this->rootUrl, '/ ' ) . '/' . trim( $this->basePath, '/ ' ); - } - - - public function getBasePath(): string { - return '/' . trim( $this->basePath, '/ ' ); - } - - - public function isLocal(): bool { - return $this->type=='local'; - } - - - public function getDefaultSqlDatabase(): ?sqlDatabase { - foreach( $this->sqlDatabases as $sqlDatabase ) { - if( $sqlDatabase->default ) { - return $sqlDatabase; - } - } - return null; - } - - - public function getSqlDatabaseByName( string $name ): ?sqlDatabase { - foreach( $this->sqlDatabases as $sqlDatabase ) { - if( $sqlDatabase->name===$name ) { - return $sqlDatabase; - } - } - return null; - } - -} +/** + * @deprecated v7 — environmentConfig was merged into {@see unifiedConfig}. This + * autoloadable alias keeps v6 type references working (parameter/return + * type-hints, instanceof, static jsonDeserialize calls) until call + * sites migrate; unifiedConfig carries every former field and helper. + */ +\class_alias( unifiedConfig::class, __NAMESPACE__ . '\environmentConfig' ); diff --git a/src/models/route.php b/src/models/route.php index 39d124c..26a7913 100644 --- a/src/models/route.php +++ b/src/models/route.php @@ -45,7 +45,7 @@ class route { * @param string $class Fully qualified class name to initialize when this url is triggered. Ie: '\app\controllers\widget' * @param string $method Method inside the $class to call when this URL is triggered. Ie: 'getOne'. Method must have paramters that match the route pattern placeholders. In this example, getOne method must accept one parameter. Ie: getOne( string $_id ) * @param bool $authentication Whether authentication is required to access this route. Functionality to respond to this must be implemented in \app\router\authentication() - * @param array $requiredRoles If authentication is required, the roles required of the use to access this route. Functionality to respond to this must be implemented in \app\router\authentication(). If not using roles for a route or at all, just skip including this parameter. + * @param array $requiredRoles If authentication is required, the roles the user must hold to reach this route. Enforced by the framework in router::assertRequiredRoles() once the guard chain has run — not by \app\router::authentication(), which no longer has to implement anything for roles to take effect. If not using roles for a route or at all, just skip including this parameter. * @param bool $allowShortLivedUrlTokens Authentication token can be provided in url * @param string $description Optional human readable description of the route; surfaced by `gf cli:list` and shell completion */ diff --git a/src/models/unifiedConfig.php b/src/models/unifiedConfig.php new file mode 100644 index 0000000..7bf07b6 --- /dev/null +++ b/src/models/unifiedConfig.php @@ -0,0 +1,181 @@ +app = new app(); + $this->email = new email(); + $this->settings = new settings(); + $this->microsoft = new microsoft(); + $this->jwtAuth = new jwtAuth(); + $this->payjunction = new payjunction(); + $this->logging = new logging(); + $this->cronMonitor = new cronMonitor(); + $this->services = new services(); + } + + protected function _afterJsonDeserialize(): void { + // jsonDeserialize may instantiate this class without invoking the + // constructor, leaving typed-non-nullable properties uninitialized. + // Use reflection so we can ask the engine about init state without + // PHPStan narrowing the check away. + foreach( [ 'app' => app::class, 'email' => email::class, 'settings' => settings::class, 'microsoft' => microsoft::class, 'jwtAuth' => jwtAuth::class, 'payjunction' => payjunction::class, 'logging' => logging::class, 'cronMonitor' => cronMonitor::class, 'services' => services::class ] as $property => $class ) { + if( !( new \ReflectionProperty( $this, $property ) )->isInitialized( $this ) ) { + $this->$property = new $class(); + } + } + } + + public function getRootUrl(): string { + return rtrim( $this->rootUrl, '/ ' ); + } + + + public function getBaseUrl(): string { + return rtrim( rtrim( $this->rootUrl, '/ ' ) . '/' . trim( $this->basePath, '/ ' ), '/' ); + } + + + public function getBasePath(): string { + return '/' . trim( $this->basePath, '/ ' ); + } + + + /** + * The base path in the form a route pattern is built from: '' at the domain root, + * '/api' otherwise. + * + * {@see getBasePath()} cannot serve this purpose. It returns '/' at the domain root + * — correct for the token audience, which is its other use — and concatenating that + * with a leading-slash route yields '//user', which FastRoute registers and matches + * as that literal string. Every router prefixing a route uses this instead. + */ + public function getRoutePrefix(): string { + return rtrim( $this->getBasePath(), '/' ); + } + + + /** + * Where the JWT signing keypairs live: jwtAuth.keyPath when set, else + * {srvDir}/jwtCertificates. Always returned with a trailing slash. + * + * The srv directory is an argument because the two callers reach it differently — the + * request lifecycle through config::getSrvDir(), the gf CLI through appContext, which + * never boots \app. They previously resolved the location independently, so + * `gf cert:generate-auth` wrote keys to srv/jwtCertificates while jwtAuth looked in the + * configured keyPath and reported the very command that had just run as the remedy. + */ + public function getJwtKeyPath( string $srvDir ): string { + $configured = trim( $this->jwtAuth->keyPath ); + if( $configured!=='' ) { + return rtrim( str_replace( '\\', '/', $configured ), '/' ) . '/'; + } + + return rtrim( str_replace( '\\', '/', $srvDir ), '/' ) . '/jwtCertificates/'; + } + + + /** Token issuer, defaulting to the application's own root url. */ + public function getTokenIssuedBy(): string { + return $this->jwtAuth->tokenIssuedBy!=='' ? $this->jwtAuth->tokenIssuedBy : $this->getRootUrl(); + } + + + /** Token audience, defaulting to the application's own base path. */ + public function getTokenPermittedFor(): string { + return $this->jwtAuth->tokenPermittedFor!=='' ? $this->jwtAuth->tokenPermittedFor : $this->getBasePath(); + } + + + public function isLocal(): bool { + return $this->type=='local'; + } + + + public function getDefaultSqlDatabase(): ?sqlDatabase { + foreach( $this->sqlDatabases as $sqlDatabase ) { + if( $sqlDatabase->default ) { + return $sqlDatabase; + } + } + return null; + } + + + public function getSqlDatabaseByName( string $name ): ?sqlDatabase { + foreach( $this->sqlDatabases as $sqlDatabase ) { + if( $sqlDatabase->name===$name ) { + return $sqlDatabase; + } + } + return null; + } + +} diff --git a/src/renderer.php b/src/renderer.php index 9920ab9..75ada1f 100644 --- a/src/renderer.php +++ b/src/renderer.php @@ -82,7 +82,7 @@ private function getContentFromController( routeHandler $routeHandler ): control } catch( modelException $e ) { \error_log( $e ); - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::debug( 'Renderer', $e->getMessage(), $e->getTrace() ); } @@ -90,7 +90,7 @@ private function getContentFromController( routeHandler $routeHandler ): control } catch( controllerException $e ) { \error_log( $e ); - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::debug( 'Renderer', $e->getMessage(), $e->getTrace() ); } @@ -98,7 +98,7 @@ private function getContentFromController( routeHandler $routeHandler ): control } catch( \Exception|\Error|\ErrorException $e ) { \error_log( $e ); - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::error( 'Renderer', $e->getMessage(), [ $e ] ); } @@ -107,7 +107,7 @@ private function getContentFromController( routeHandler $routeHandler ): control } catch( \ReflectionException $e ) { error_log( $e ); - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::error( 'Renderer', $e->getMessage(), [ $e ] ); } @@ -128,7 +128,7 @@ private function processControllerDataResponse( \gcgov\framework\interfaces\_con } } else { - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::warning( 'Renderer', 'Cannot set content-type header or additional headers. Headers already sent in ' . $filename . ' on line ' . $lineNumber ); } } @@ -190,7 +190,7 @@ private function processControllerFileResponse( \gcgov\framework\interfaces\_con } } else { - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::warning( 'Renderer', 'Cannot set content-type header or additional headers. Headers already sent in ' . $fileBasename . ' on line ' . $lineNumber ); } } @@ -240,7 +240,7 @@ private function processControllerFileBase64EncodedContentResponse( \gcgov\frame } } else { - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::warning( 'Renderer', 'Cannot set content-type header or additional headers. Headers already sent in ' . $fileBasename . ' on line ' . $lineNumber ); } } diff --git a/src/router.php b/src/router.php index 9fd98bf..3506a81 100644 --- a/src/router.php +++ b/src/router.php @@ -4,44 +4,55 @@ use gcgov\framework\exceptions\routeException; use gcgov\framework\services\log; -use ReflectionClass; final class router { - private \gcgov\framework\interfaces\router $appRouter; + private \gcgov\framework\interfaces\appRouter $appRouter; /** @var \gcgov\framework\interfaces\router[] $serviceRouters */ private array $serviceRouters = []; /** - * @param string[] $serviceNamespaces + * Framework Services are declared in config.json's `services` section. Each is + * constructed here only if its block is present, and is handed its own typed + * configuration — there is no discovery step and no service configures itself from a + * singleton the application had to remember to tweak in \app\app::_before(). * - * @throws \gcgov\framework\exceptions\routeException + * @throws \gcgov\framework\exceptions\configException */ - public function __construct( array $serviceNamespaces ) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + public function __construct() { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- constructing framework\router' ); - log::debug( 'Framework Lifecycle', '-Router- check for routers in services' ); } - foreach($serviceNamespaces as $serviceNamespace) { - try { - $reflectionClassOfServiceRouter = new ReflectionClass( $serviceNamespace . '\router' ); - if(config::getEnvironmentConfig()->logging->lifecycle) { - log::debug( 'Framework Lifecycle', '-Router- instantiate ' . $serviceNamespace . '\router' ); - } - $serviceRouter = $reflectionClassOfServiceRouter->newInstance(); - if(!($serviceRouter instanceof \gcgov\framework\interfaces\router)) { - error_log($serviceNamespace.'\router must implement \gcgov\framework\interfaces\router if it wants to be used as a router by gcgov\framework'); - continue; - } - $this->serviceRouters[] = $serviceRouter; + + // The framework's own routes (health checks) come first and are not opt-in: a + // deploy pipeline cannot gate on an endpoint an application chose not to have. + $this->serviceRouters[] = new \gcgov\framework\services\health\router(); + + $services = config::getServices(); + + if( $services->auth!==null ) { + if(config::getLogging()->lifecycle) { + log::debug( 'Framework Lifecycle', '-Router- enable auth service (' . $services->auth->provider . ')' ); } - catch( \ReflectionException $e ) { - //service does not have a router, no problem + $this->serviceRouters[] = new \gcgov\framework\services\auth\router( $services->auth ); + } + + if( $services->userCrud!==null ) { + if(config::getLogging()->lifecycle) { + log::debug( 'Framework Lifecycle', '-Router- enable userCrud service' ); + } + $this->serviceRouters[] = new \gcgov\framework\services\userCrud\router(); + } + + if( $services->documentation!==null ) { + if(config::getLogging()->lifecycle) { + log::debug( 'Framework Lifecycle', '-Router- enable documentation service' ); } + $this->serviceRouters[] = new \gcgov\framework\services\documentation\router(); } - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- create \app\router' ); } $this->appRouter = new \app\router(); @@ -51,15 +62,19 @@ public function __construct( array $serviceNamespaces ) { /** * @return \gcgov\framework\models\routeHandler * @throws \gcgov\framework\exceptions\routeException + * @throws \gcgov\framework\exceptions\configException */ public function route(): \gcgov\framework\models\routeHandler { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- running framework\router route()' ); } //get all routes $routes = $this->getRoutes(); + // Refuse to serve routes that believe they are protected but are not. + self::assertAuthenticationIsProvided( $routes, config::getServices()->auth!==null, $this->appRouter->providesAuthentication() ); + //map routes to \FastRoute dispatcher $routeDispatcher = \FastRoute\simpleDispatcher( function( \FastRoute\RouteCollector $r ) use ( $routes ) { foreach( $routes as $route ) { @@ -67,7 +82,7 @@ public function route(): \gcgov\framework\models\routeHandler { } } ); - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- determine route' ); } $routeInfo = $routeDispatcher->dispatch( $this->getHttpMethod(), $this->getUri() ); @@ -79,7 +94,7 @@ public function route(): \gcgov\framework\models\routeHandler { // ... 405 Method Not Allowed throw new \gcgov\framework\exceptions\routeException ( 'Method Not Allowed', 405 ); case \FastRoute\Dispatcher::FOUND: - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- found matching route' ); } //build route handler to return to the framework renderer @@ -87,50 +102,52 @@ public function route(): \gcgov\framework\models\routeHandler { $routeHandler = $routeInfo[ 1 ]; $routeHandler->arguments = $routeInfo[ 2 ]; - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- running framework authentication' ); } if( !$routeHandler->authentication ) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- no authentication required for route' ); } return $routeHandler; } - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- run app\router authentication()' ); } $appAllowRoute = $this->appRouter->authentication( $routeHandler ); if( !$appAllowRoute ) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- app\router authentication() returned false; raising route exception' ); } throw new \gcgov\framework\exceptions\routeException ( 'Authentication failed', 401 ); } $runServiceRouting = true; - if(method_exists($this->appRouter, 'getRunFrameworkServiceRouteAuthentication')) { + if($this->appRouter instanceof \gcgov\framework\interfaces\router\skipsServiceAuthentication) { $runServiceRouting = $this->appRouter->getRunFrameworkServiceRouteAuthentication( $routeHandler ); } if($runServiceRouting) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- run service routers authentication()' ); } foreach($this->serviceRouters as $serviceRouter) { - if(config::getEnvironmentConfig()->logging->lifecycle) { - log::debug( 'Framework Lifecycle', '-Router- run framework\services\\' . get_class( $serviceRouter ) . '\router authentication()' ); + if(config::getLogging()->lifecycle) { + log::debug( 'Framework Lifecycle', '-Router- run ' . get_class( $serviceRouter ) . ' authentication()' ); } $serviceAllowRoute = $serviceRouter->authentication( $routeHandler ); if(!$serviceAllowRoute) { - if(config::getEnvironmentConfig()->logging->lifecycle) { - log::debug( 'Framework Lifecycle', '-Router- framework\services\\' . get_class( $serviceRouter ) . '\router authentication() returned false; raising route exception' ); + if(config::getLogging()->lifecycle) { + log::debug( 'Framework Lifecycle', '-Router- ' . get_class( $serviceRouter ) . ' authentication() returned false; raising route exception' ); } throw new \gcgov\framework\exceptions\routeException ( 'Authentication failed', 401 ); } } } - if(config::getEnvironmentConfig()->logging->lifecycle) { + self::assertRequiredRoles( $routeHandler ); + + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- return route handler to framework\framework' ); } //return rendered @@ -144,17 +161,117 @@ public function route(): \gcgov\framework\models\routeHandler { /** - * Build the full merged route table (service routes first, then app routes) without - * dispatching a request. Used by the gf CLI to enumerate routes. + * Enforce the route's declared requiredRoles. + * + * requiredRoles is declared on route and carried into routeHandler — framework-level + * models present on every route of every application — but the only code that ever read + * it was the guard inside the OPTIONAL auth service. Two supported configurations + * therefore declared roles that nothing checked, while looking protected in the route + * table, in `gf cli:list` and in review: + * + * · no services.auth block, with \app\router::providesAuthentication() returning true. + * The boot check is satisfied, and userCrud::authentication() returns true + * unconditionally — so the framework's own /user routes ran with User.Read and + * User.Write checked by nobody. + * · any route where skipsServiceAuthentication skipped the service guards, taking the + * one role check in the codebase with them. + * + * Enforcing here — after the app router and every service router have run — puts the + * check at the layer that declares the field, and means the answer no longer depends on + * which optional service happens to be enabled. The auth service's guard keeps doing + * what only it can do: validate the token and populate authUser. + * + * @throws \gcgov\framework\exceptions\routeException + */ + private static function assertRequiredRoles( \gcgov\framework\models\routeHandler $routeHandler ): void { + if( count( $routeHandler->requiredRoles )===0 ) { + return; + } + + $authUser = \gcgov\framework\services\request::getAuthUser(); + + // The route names roles but nothing established a user, so there is nothing to check + // them against. Fail closed: this is the case the boot check cannot see, because an + // \app\router::authentication() that returns true is indistinguishable from one that + // verified something. The detail goes to the log — it is a deployment + // misconfiguration, not something the caller can act on. + if( $authUser->userId==='' ) { + log::warning( 'Framework Lifecycle', '-Router- route "' . $routeHandler->class . '::' . $routeHandler->method . '" requires role(s) ' . implode( ', ', $routeHandler->requiredRoles ) . ' but no authenticated user was established. Enable services.auth, or have the authenticator populate the request-scoped authUser via request::getAuthUser()->setFromUser().' ); + + throw new routeException( 'Authentication failed', 401 ); + } + + foreach( $routeHandler->requiredRoles as $requiredRole ) { + if( !$authUser->hasRole( $requiredRole ) ) { + throw new routeException( 'User does not have the permission "' . $requiredRole . '" required to access this content', 403 ); + } + } + } + + + /** + * Routes that declare authentication:true are only actually guarded by an + * authentication service, or by an application that authenticates its own routes. + * With neither, \app\router::authentication() is the only guard left — and the + * scaffolded implementation of it returns true for everyone, so those routes would be + * open to the world while looking protected in the route table. + * + * Refusing to serve is the only safe reading: a configuration that cannot protect what + * it claims to protect is a broken configuration, not a permissive one. * - * @param string[] $serviceNamespaces Namespaces returned by \app\app::registerFrameworkServiceNamespaces() + * @param \gcgov\framework\models\route[] $routes + * + * @throws \gcgov\framework\exceptions\configException + */ + public static function assertAuthenticationIsProvided( array $routes, bool $authServiceEnabled, bool $appProvidesAuthentication ): void { + // Contradictory rather than dangerous, and independent of how the application + // authenticates: an unauthenticated route returns before the guard chain, so its + // roles can never be checked by anything. Warned rather than refused — such a route + // works today with its roles inert, and failing an application's boot over a + // declaration that never did anything is disproportionate. Behind the lifecycle + // flag because routes are rebuilt per request: unconditional, this was one + // identical log line per request for the life of the deployment. + if( config::getLogging()->lifecycle ) { + foreach( $routes as $route ) { + if( !$route->authentication && count( $route->requiredRoles )>0 ) { + log::warning( 'Framework Lifecycle', '-Router- route "' . $route->route . '" declares requiredRoles but authentication:false, so the roles are never checked. Set authentication:true, or drop the roles.' ); + } + } + } + + if( $authServiceEnabled || $appProvidesAuthentication ) { + return; + } + + $unguarded = []; + foreach( $routes as $route ) { + if( $route->authentication ) { + $unguarded[] = ( is_array( $route->httpMethod ) ? implode( '|', $route->httpMethod ) : $route->httpMethod ) . ' ' . $route->route; + } + } + + if( count( $unguarded )===0 ) { + return; + } + + throw new \gcgov\framework\exceptions\configException( count( $unguarded ) . ' route(s) require authentication but no authentication service is enabled: ' . implode( ', ', $unguarded ) . '. Enable one by adding a "services": { "auth": { "provider": "oauth" } } block to config.json, or — if \app\router authenticates these routes itself — have it implement \gcgov\framework\interfaces\appRouter::providesAuthentication() returning true.', 500 ); + } + + + /** + * Build the full merged route table (framework routes first, then enabled Framework + * Services, then the application) without dispatching a request. Used by the gf CLI to + * enumerate routes. + * + * Deliberately does not run assertAuthenticationIsProvided(): enumerating the routes of + * a misconfigured application is exactly when that listing is most useful. * * @return \gcgov\framework\models\route[] * @throws \gcgov\framework\exceptions\routeException - * @throws \gcgov\framework\exceptions\configException Missing/invalid app/config/environment.json + * @throws \gcgov\framework\exceptions\configException Missing/invalid {root}/config.json */ - public static function getMergedRoutes( array $serviceNamespaces ): array { - return ( new self( $serviceNamespaces ) )->getRoutes(); + public static function getMergedRoutes(): array { + return ( new self() )->getRoutes(); } @@ -162,23 +279,152 @@ public static function getMergedRoutes( array $serviceNamespaces ): array { * @return \gcgov\framework\models\route[] */ private function getRoutes(): array { - $routes = []; + $serviceRoutes = []; foreach($this->serviceRouters as $serviceRouter) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- get service routes' ); } - $serviceRoutes = $serviceRouter->getRoutes(); - $routes = array_merge( $routes, $serviceRoutes ); + $serviceRoutes = array_merge( $serviceRoutes, $serviceRouter->getRoutes() ); } - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- get app routes' ); } $appRoutes = $this->appRouter->getRoutes(); - $routes = array_merge( $routes, $appRoutes ); - return $routes; + // Where the application defines a route the framework already registers, the + // application wins and the framework's is dropped. + // + // FastRoute throws BadRouteException on a duplicate (method, pattern), and that + // exception is neither a routeException nor a configException — so a v6 application + // upgrading with its own /health did not lose /health, it lost EVERY route, with an + // empty 500 on every url. The health router's docblock already promised that such + // an application "keeps working"; nothing implemented it. + return array_merge( self::serviceRoutesNotOverridden( $serviceRoutes, $appRoutes ), $appRoutes ); + } + + + /** + * The service routes the application's own routes do NOT override. + * + * "Override" is judged the way FastRoute judges a duplicate — by the compiled shape of + * the pattern, never its spelling. user/{id} and user/{_id} are the same route to the + * dispatcher (a placeholder's name never reaches its regex), so comparing raw pattern + * strings kept both registered, and BadRouteException at dispatcher build took every + * url down — the exact outage this filter exists to prevent. A static application + * route inside a variable service route's shape (user/me under the service's + * user/{_id}) drops the service route for the same reason: service routes register + * first, and FastRoute rejects a static route shadowed by an earlier variable one. + * + * Pure and public so the collision rules are testable with synthetic routes. + * + * @param \gcgov\framework\models\route[] $serviceRoutes + * @param \gcgov\framework\models\route[] $appRoutes + * + * @return \gcgov\framework\models\route[] + */ + public static function serviceRoutesNotOverridden( array $serviceRoutes, array $appRoutes ): array { + $appKeys = []; + $appStaticPaths = []; + foreach( $appRoutes as $appRoute ) { + foreach( (array)$appRoute->httpMethod as $httpMethod ) { + $method = strtoupper( (string)$httpMethod ); + foreach( self::patternShapes( $appRoute->route ) as $shape ) { + $appKeys[ $method . ' ' . $shape[ 'signature' ] ] = true; + if( $shape[ 'regex' ]===null ) { + $appStaticPaths[ $method ][] = $shape[ 'signature' ]; + } + } + } + } + + $kept = []; + foreach( $serviceRoutes as $serviceRoute ) { + if( self::isOverriddenBy( $serviceRoute, $appKeys, $appStaticPaths ) ) { + if( config::getLogging()->lifecycle ) { + // Behind the lifecycle flag: routes are rebuilt per request, and an + // application using the override path deliberately would otherwise + // emit one identical notice per request for the life of the deploy. + log::notice( 'Framework Lifecycle', '-Router- \app\router defines "' . $serviceRoute->route . '"; the framework route of the same shape is not registered' ); + } + continue; + } + + $kept[] = $serviceRoute; + } + + return $kept; + } + + + /** + * @param array $appKeys 'METHOD signature' the app occupies + * @param array $appStaticPaths method => the app's static paths + */ + private static function isOverriddenBy( \gcgov\framework\models\route $serviceRoute, array $appKeys, array $appStaticPaths ): bool { + foreach( (array)$serviceRoute->httpMethod as $httpMethod ) { + $method = strtoupper( (string)$httpMethod ); + foreach( self::patternShapes( $serviceRoute->route ) as $shape ) { + if( isset( $appKeys[ $method . ' ' . $shape[ 'signature' ] ] ) ) { + return true; + } + if( $shape[ 'regex' ]!==null ) { + foreach( $appStaticPaths[ $method ] ?? [] as $staticPath ) { + if( preg_match( '~^' . $shape[ 'regex' ] . '$~', $staticPath )===1 ) { + return true; + } + } + } + } + } + + return false; + } + + + /** + * The shapes a FastRoute pattern occupies, one per optional-segment variant. + * + * `signature` keys the shape the way the dispatcher compiles it: literals verbatim, + * each placeholder reduced to {its-regex} — so user/{id} and user/{_id} share a + * signature while user/{id:\d+} has its own. `regex` is the anchored expression for a + * variant carrying placeholders (null for a purely static one), used by the + * static-shadowing check. A pattern the parser rejects falls back to its literal + * spelling: FastRoute reports the malformed pattern itself at dispatcher build. + * + * @return array{signature: string, regex: string|null}[] + */ + public static function patternShapes( string $pattern ): array { + try { + $variants = ( new \FastRoute\RouteParser\Std() )->parse( $pattern ); + } + catch( \FastRoute\BadRouteException ) { + return [ [ 'signature' => $pattern, 'regex' => null ] ]; + } + + $shapes = []; + foreach( $variants as $variant ) { + $signature = ''; + $regex = ''; + $variable = false; + foreach( $variant as $part ) { + if( is_array( $part ) ) { + // [ placeholder name, placeholder regex ] — the name never compiles. + $variable = true; + $signature .= '{' . (string)$part[ 1 ] . '}'; + $regex .= '(' . (string)$part[ 1 ] . ')'; + } + else { + $signature .= (string)$part; + $regex .= preg_quote( (string)$part, '~' ); + } + } + + $shapes[] = [ 'signature' => $signature, 'regex' => $variable ? $regex : null ]; + } + + return $shapes; } diff --git a/src/services/auth/controllers/auth.php b/src/services/auth/controllers/auth.php new file mode 100644 index 0000000..016b222 --- /dev/null +++ b/src/services/auth/controllers/auth.php @@ -0,0 +1,67 @@ + $jwtService->getJwksKeys() + ] ); + } + + + /** + * @OA\Get( + * path="/auth/fileToken", + * tags={"Auth"}, + * description="Exchange your access token for a very short lived one that may be passed as a URL parameter on routes which allow it" + * ) + * + * @return \gcgov\framework\models\controllerDataResponse + */ + public function fileToken(): controllerDataResponse { + $authUser = \gcgov\framework\services\request::getAuthUser(); + + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser, new \DateInterval( 'PT5S' ) ); + + return new controllerDataResponse( [ + 'accessToken' => $accessToken->toString() + ] ); + } + + + public static function _after(): void { + } + + + public static function _before(): void { + } + +} diff --git a/src/services/auth/guard.php b/src/services/auth/guard.php new file mode 100644 index 0000000..07c2deb --- /dev/null +++ b/src/services/auth/guard.php @@ -0,0 +1,89 @@ +validateAccessToken( $accessToken ); + if( !( $parsedToken instanceof \Lcobucci\JWT\UnencryptedToken ) ) { + throw new routeException( 'Token parsing failed', 401 ); + } + + $tokenData = $parsedToken->claims()->get( 'data' ); + $tokenScopes = (array)$parsedToken->claims()->get( 'scope' ); + + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromJwtToken( is_array( $tokenData ) ? $tokenData : [], $tokenScopes ); + } + catch( serviceException $e ) { + //JWT uses invalid kid/guid + throw new routeException( $e->getMessage(), 401, $e ); + } + catch( \Lcobucci\JWT\Encoding\CannotDecodeContent|\Lcobucci\JWT\Token\UnsupportedHeaderFound|\Lcobucci\JWT\Token\InvalidTokenStructure $e ) { + //JWT did not parse + throw new routeException( 'Token parsing failed', 401, $e ); + } + catch( \Lcobucci\JWT\Validation\RequiredConstraintsViolated $e ) { + //JWT parsed successfully but failed validation + $violationMessages = []; + foreach( $e->violations() as $violation ) { + $violationMessages[] = $violation->getMessage(); + } + throw new routeException( 'Token validation failed: ' . implode( ', ', $violationMessages ), 401, $e ); + } + + // requiredRoles is NOT checked here. It is declared on the framework's own route + // model and enforced by router::assertRequiredRoles() once the whole guard chain has + // run, so it holds for routes this service never sees — an application that + // authenticates itself, or one that opts out through skipsServiceAuthentication. + // This guard's job is the part only it can do: validate the token and establish the + // user the router then checks. + return true; + } + + + /** + * The Authorization header, or — only on routes that opt in — the fileAccessToken + * query parameter. A URL-borne token ends up in logs, referrers and browser history, + * which is why it is per-route opt-in and why the tokens minted for it expire in + * seconds. + * + * @throws \gcgov\framework\exceptions\routeException + */ + private static function readToken( \gcgov\framework\models\routeHandler $routeHandler ): string { + if( isset( $_SERVER[ 'HTTP_AUTHORIZATION' ] ) ) { + return (string)$_SERVER[ 'HTTP_AUTHORIZATION' ]; + } + + if( !$routeHandler->allowShortLivedUrlTokens || !isset( $_GET[ 'fileAccessToken' ] ) ) { + throw new routeException( 'Missing Authorization', 401 ); + } + + return (string)$_GET[ 'fileAccessToken' ]; + } + +} diff --git a/src/services/auth/providers/msFront/controllers/auth.php b/src/services/auth/providers/msFront/controllers/auth.php new file mode 100644 index 0000000..60926aa --- /dev/null +++ b/src/services/auth/providers/msFront/controllers/auth.php @@ -0,0 +1,106 @@ +clientId = config::getMicrosoft()->clientId; + $microsoftConfig->clientSecret = config::getMicrosoft()->clientSecret; + $microsoftConfig->tenant = config::getMicrosoft()->tenant; + $microsoftConfig->fromAddress = config::getMicrosoft()->fromAddress; + $microsoftAuthService = new \andrewsauder\microsoftServices\auth( $microsoftConfig ); // \gcgov\framework\services\microsoft\auth(); + $tokenInfo = $microsoftAuthService->verify(); + $user = $this->lookupUserMicrosoftTokenInfo( $tokenInfo ); + + //convert \app\models\user to authUser singleton + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromUser( $user ); + + //generate our custom jwt and return it to the user + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser ); + + //return data + $data = [ + 'accessToken' => $accessToken->toString() + ]; + + return new controllerDataResponse( $data ); + + } + + + + /** + * Processed after lifecycle is complete with this instance + */ + public static function _after(): void { + + } + + + /** + * Processed prior to __constructor() being called + */ + public static function _before(): void { + + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + private function lookupUserMicrosoftTokenInfo( \andrewsauder\microsoftServices\components\tokenInformation $tokenInfo ): \gcgov\framework\services\mongodb\models\auth\user { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + + //get user from database using Microsoft unique Id + try { + $authConfig = config::getServices()->auth; + + $user = $userClassName::getFromOauth( + email: $tokenInfo->email, + externalId: $tokenInfo->oid, + externalProvider: 'MicrosoftGraph', + firstName: $tokenInfo->name, + addIfNotExisting: !$authConfig->blockNewUsers, + rolesForNewUser: $authConfig->defaultNewUserRoles ); + } + catch( modelException $e ) { + throw new \gcgov\framework\exceptions\controllerException( 'The Microsoft user may need to be added to the user collection within the application. This Microsoft user could not be found in the app user list by external id and does not have a preferred username to lookup by email.', 404, $e ); + } + + try { + $updateResult = $userClassName::save( $user ); + } + catch( modelException $e ) { + //failed to save external id - no problem, we will try again next sign in + } + + return $user; + } + +} diff --git a/src/services/auth/providers/oauth/controllers/auth.php b/src/services/auth/providers/oauth/controllers/auth.php new file mode 100644 index 0000000..30ba65d --- /dev/null +++ b/src/services/auth/providers/oauth/controllers/auth.php @@ -0,0 +1,686 @@ + $baseUrl, + "authorization_endpoint" => $baseUrl . "/auth/authorize", + "token_endpoint" => $baseUrl . "/auth/authorize", + //"userinfo_endpoint" => "https://example.com/userinfo", + "jwks_uri" => $baseUrl . "/.well-known/jwks.json", + "end_session_endpoint" => $baseUrl . "/auth/out", + "scopes_supported" => [ + "login" + ], + "response_types_supported" => [ + "code", + "token" + ], + "token_endpoint_auth_methods_supported" => [ + "client_secret_post", + "private_key_jwt", + ], + + ]; + + //custom result for /.well-known/jwks.json + return new controllerDataResponse( $data ); + } + + + + /** + * @OA\Get( + * path="/auth/authorize", + * tags={"Auth"}, + * description="Direct access by end user - will send through to selected third party Oauth provider", + * @OA\Parameter( + * name="response_type", + * in="query", + * description="Only supported value is 'code'", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter( + * name="client_id", + * in="query", + * description="Must match app config guid", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter( + * name="scope", + * in="query", + * description="Must be oauth provider name string (ex. microsoft)", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Response( + * response="200", + * description="Successfully fetched", + * @OA\JsonContent( + * type="array", + * @OA\Items(ref="#/components/schemas/stdAuthResponse") + * ) + * ) + * ) + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function oauthGetAuthorize(): controllerDataResponse { + if( empty( $_GET[ 'response_type' ] ) || $_GET[ 'response_type' ]!='code' ) { + throw new controllerException( 'Invalid response type', 401 ); + } + if( empty( $_GET[ 'client_id' ] ) || $_GET[ 'client_id' ]!=config::getApp()->guid ) { + throw new controllerException( 'Invalid client id', 401 ); + } + if( empty( $_GET[ 'scope' ] ) ) { + throw new controllerException( 'Invalid scope', 401 ); + } + + if( session_status()!=PHP_SESSION_ACTIVE ) { + session_start(); + } + unset( $_SESSION[ 'auth_state' ] ); + if( !empty( $_GET[ 'state' ] ) ) { + // No urldecode(): PHP has already percent-decoded $_GET, so decoding again turned + // a state of a%252Bb into 'a+b' and %250A into a literal newline. + $_SESSION[ 'auth_state' ] = (string)$_GET[ 'state' ]; + } + + return $this->oauthHybridAuth( $_GET[ 'scope' ] ); + } + + + /** + * Handler for third party oauth provider (authorization_code), exchange + * refresh tokens (refresh_token), and exchange username/password + * (password). OpenAPI documentation for this endpoint is provided in the + * stdAuthResponse schema and the README; the inline @OA annotations were + * removed because the malformed nested structure broke phpDoc parsing. + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function oauthPostAuthorize(): controllerDataResponse { + $postData = \gcgov\framework\services\request::getPostData(); + + if( empty( $postData[ 'grant_type' ] ) ) { + throw new controllerException( 'Invalid grant type', 401 ); + } + if( empty( $postData[ 'client_id' ] ) || $postData[ 'client_id' ]!=config::getApp()->guid ) { + throw new controllerException( 'Invalid client id', 401 ); + } + + //route + if( $postData[ 'grant_type' ]=='password' ) { + return new controllerDataResponse( $this->password() ); + } + elseif( $postData[ 'grant_type' ]=='refresh_token' ) { + return new controllerDataResponse( $this->refresh_token() ); + } + elseif( $postData[ 'grant_type' ]=='authorization_code' ) { + return new controllerDataResponse( $this->authorization_code() ); + } + + throw new controllerException( 'Invalid grant type', 401 ); + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + private function password(): stdAuthResponse { + $postData = \gcgov\framework\services\request::getPostData(); + + if( empty( $postData[ 'scope' ] ) || $postData[ 'scope' ]!='login' ) { + throw new controllerException( 'Invalid scope', 401 ); + } + elseif( empty( $postData[ 'username' ] ) ) { + throw new controllerException( 'Username required', 401 ); + } + elseif( empty( $postData[ 'password' ] ) ) { + throw new controllerException( 'Password required', 401 ); + } + + //authenticate the username and password combo + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + /** @var \gcgov\framework\services\mongodb\models\auth\user $user */ + $user = $userClassName::verifyUsernamePassword( $postData[ 'username' ], $postData[ 'password' ] ); + } + catch( modelException $e ) { + throw new controllerException( 'Incorrect username or password', 401, $e ); + } + + $authUser = \gcgov\framework\services\request::getAuthUser(); + + //force configuration of MFA if required + if( $user->mfaRequired ) { + + //lock the user roles down but give them an authentication token so that they can verify their MFA + $user->roles = []; + $authUser->setFromUser( $user ); + + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser ); + + if( !$user->mfaConfigured ) { + return multifactor::configureMfaResponse( $user->_id, $accessToken ); + } + else { + return multifactor::requireMfaResponse( $accessToken, $user ); + } + } + + + //create token for valid user + return $this->createAccessTokenResponse( $user ); + } + + + /** + * @OA\Get( + * path="/auth/out", + * tags={"Auth"}, + * description="Sign out", + * @OA\Response( + * response="201", + * description="Successfully fetched", + * ) + * ) + * + * @return \gcgov\framework\models\controllerDataResponse + */ + public function out(): controllerDataResponse { + //unset the session variables + if( isset( $_SESSION ) ) { + foreach( $_SESSION as $key => $value ) { + unset( $_SESSION[ $key ] ); + } + } + + //delete the session cookie + $params = session_get_cookie_params(); + setcookie( session_name(), + '', + time() - 42000, + $params[ "path" ], + $params[ "domain" ], + $params[ "secure" ], + $params[ "httponly" ] ); + + //destroy the session + if( session_status()==PHP_SESSION_ACTIVE ) { + session_destroy(); + } + + //delete all other cookies + $past = time() - 3600; + foreach( $_COOKIE as $key => $value ) { + setcookie( $key, $value, $past, '/' ); + } + + //TODO: burn refresh token + + return new controllerDataResponse( [] ); + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + private function refresh_token(): stdAuthResponse { + $postData = \gcgov\framework\services\request::getPostData(); + + if( empty( $postData[ 'refresh_token' ] ) ) { + throw new controllerException( 'Invalid refresh token', 401 ); + } + + try { + \gcgov\framework\services\jwtAuth\models\userRefreshToken::removeOutdatedRefreshTokens(); + } + catch( modelException $e ) { + throw new controllerException( 'Failed to remove outdated refresh token', 500 ); + } + + $jwtValidationService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + + try { + $userId = $jwtValidationService->validateRefreshToken( $postData[ 'refresh_token' ] ); + } + catch( \Exception $e ) { + throw new controllerException( $e->getMessage(), 401, $e ); + } + + //invalidate the existing token because we will provide a new one with the response + try { + $jwtValidationService->deleteRefreshToken( $postData[ 'refresh_token' ] ); + } + catch( modelException $e ) { + throw new controllerException( 'Failed to remove existing refresh token', 500 ); + } + + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + /** @var \gcgov\framework\services\mongodb\models\auth\user $user */ + $user = $userClassName::getOne( $userId ); + } + catch( modelException $e ) { + throw new controllerException( 'Refresh token corrupted', 401 ); + } + + return $this->createAccessTokenResponse( $user ); + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + private function authorization_code(): stdAuthResponse { + $postData = \gcgov\framework\services\request::getPostData(); + + if( empty( $postData[ 'code' ] ) ) { + throw new controllerException( 'Invalid code', 401 ); + } + + //lookup auth code + try { + $userAuthorizationCode = \gcgov\framework\services\jwtAuth\models\userAuthorizationCode::getOne( $postData[ 'code' ] ); + } + catch( modelException $e ) { + throw new controllerException( 'Invalid code', 401 ); + } + + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + /** @var \gcgov\framework\interfaces\auth\user $user */ + $user = $userClassName::getOne( $userAuthorizationCode->userId ); + } + catch( modelException $e ) { + throw new controllerException( 'Authorization code corrupted', 401 ); + } + + return $this->createAccessTokenResponse( $user ); + } + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + public function oauthHybridAuth( string $provider = '' ): controllerDataResponse { + $provider = strtolower( $provider ); + + if( $provider=='google' ) { + $provider = "Google"; + } + elseif( $provider=='facebook' ) { + $provider = "Facebook"; + } + elseif( $provider=='microsoft' || $provider=='microsoftgraph' ) { + $provider = "MicrosoftGraph"; + + if( empty( config::getMicrosoft()->clientId ) ) { + throw new controllerException( 'Microsoft client id has not been defined in config.json > microsoft.clientId', 400 ); + } + if( empty( config::getMicrosoft()->clientSecret ) ) { + throw new controllerException( 'Microsoft client secret has not been defined in config.json > microsoft.clientSecret', 400 ); + } + if( empty( config::getMicrosoft()->tenant ) ) { + throw new controllerException( 'Microsoft tenant has not been defined in the app config file. /app/config/environment.json > microsoft.tenant', 400 ); + } + + } + + if( empty( $provider ) ) { + throw new controllerException( 'We do not currently support logging in with the service you provided', 400 ); + } + + $authConfig = config::getServices()->auth; + + $config = [ + //Location where to redirect users once they authenticate with a provider + 'callback' => config::getBaseUrl() . '/auth/hybridauth/' . $provider, + + //Providers specifics + 'providers' => [ + 'Google' => [ + 'enabled' => false, + 'keys' => [ + 'id' => '', + 'secret' => '' + ], + 'authorize_url_parameters' => $authConfig->oauth->authorizeUrlParameters + ], + 'Facebook' => [ + 'enabled' => false, + 'keys' => [ + 'id' => '', + 'secret' => '' + ], + 'authorize_url_parameters' => $authConfig->oauth->authorizeUrlParameters + ], + 'MicrosoftGraph' => [ + 'enabled' => true, + 'keys' => [ + 'id' => config::getMicrosoft()->clientId, + 'secret' => config::getMicrosoft()->clientSecret + ], + 'tenant' => config::getMicrosoft()->tenant, + 'scope' => 'openid offline_access profile email User.Read', + 'authorize_url_parameters' => $authConfig->oauth->authorizeUrlParameters + ], + ] + ]; + + try { + //Feed configuration array to Hybridauth + $hybridauth = new \Hybridauth\Hybridauth( $config ); + + //Then we can proceed and sign in with Twitter as an example. If you want to use a diffirent provider, + //simply replace 'Twitter' with 'Google' or 'Facebook'. + + //Attempt to authenticate users with a provider by name + $adapter = $hybridauth->authenticate( $provider ); + + //Returns a boolean of whether the user is connected with Twitter + $isConnected = $adapter->isConnected(); + + //Retrieve the user's profile + $oauthProfile = $adapter->getUserProfile(); + + //Disconnect the adapter + $adapter->disconnect(); + } + catch( \Exception $e ) { + log::error( 'auth', 'Hybridauth provider authentication failed', [ 'exception' => $e ] ); + $message = $e->getMessage(); + switch( $e->getCode() ) { + case 0 : + $message = "Unspecified error."; + break; + case 1 : + $message = "Hybridauth configuration error."; + break; + case 2 : + $message = "Provider not properly configured."; + break; + case 3 : + $message = "Unknown or disabled provider."; + break; + case 4 : + $message = "Missing provider application credentials."; + break; + case 5 : + $message = "The user has canceled the authentication or the provider refused the connection."; + break; + case 6 : + $message = "User profile request failed. Most likely the user is not connected to the provider and he should authenticate again."; + break; + case 7 : + $message = "User not connected to the provider."; + break; + case 8 : + $message = "Provider does not support this feature."; + break; + } + + return self::redirect( config::getJwtAuth()->redirectAfterLoginUrl . '?errorMessage=' . urlencode( $message ) ); + } + + if( empty( $oauthProfile->email ) ) { + throw new controllerException( $provider . ' did not provide us with your email address. That is a requirement to sign in.', 401 ); + } + + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + /** @var \gcgov\framework\interfaces\auth\user $user */ + $user = $userClassName::getFromOauth( + email: $oauthProfile->email, + externalId: $oauthProfile->identifier, + externalProvider: $provider, + firstName: $oauthProfile->firstName, + lastName: $oauthProfile->lastName, + addIfNotExisting: !$authConfig->blockNewUsers, + rolesForNewUser: $authConfig->defaultNewUserRoles ); + } + catch( modelException $e ) { + return self::redirect( config::getJwtAuth()->redirectAfterLoginUrl . '?errorMessage=' . urlencode( $e->getMessage() ) ); + } + + try { + $userIdRaw = $user->getId(); + $userIdObject = $userIdRaw instanceof \MongoDB\BSON\ObjectId ? $userIdRaw : new \MongoDB\BSON\ObjectId( (string) $userIdRaw ); + $authorizationCode = new \gcgov\framework\services\jwtAuth\models\userAuthorizationCode( $userIdObject, new \DateInterval( 'PT5M' ) ); + \gcgov\framework\services\jwtAuth\models\userAuthorizationCode::save( $authorizationCode ); + } + catch( modelException $e ) { + throw new controllerException( $provider . 'Server failed to generate an access code.', 500, $e ); + } + + $appendState = ''; + if( !empty( $_SESSION[ 'auth_state' ] ) ) { + // Encoded, like the code parameter beside it. The state is client-supplied, so a + // raw '&' or '=' in it split into extra query parameters in the redirect — which + // broke the client's CSRF state comparison, and let whoever chose the state + // append parameters of their own to the URL the browser is sent to. + $appendState = '&state=' . urlencode( (string)$_SESSION[ 'auth_state' ] ); + } + + if( session_status()==PHP_SESSION_ACTIVE ) { + session_destroy(); + } + + return self::redirect( config::getJwtAuth()->redirectAfterLoginUrl . '?code=' . urlencode( (string)$authorizationCode->_id ) . $appendState ); + } + + + /** + * The authenticated user's id as an ObjectId. + * + * `new ObjectId( $authUser->userId )` threw InvalidArgumentException when the token + * carried no data.userId claim, which surfaced as an opaque 500 rather than the 401 it + * actually is. + * + * @throws \gcgov\framework\exceptions\controllerException + */ + private static function authUserObjectId( \gcgov\framework\models\authUser $authUser ): \MongoDB\BSON\ObjectId { + try { + return new \MongoDB\BSON\ObjectId( $authUser->userId ); + } + catch( \Throwable $e ) { + throw new controllerException( 'The access token does not identify a user', 401, $e ); + } + } + + + /** + * A 302 as a controllerResponse. + * + * This path used to call header() then exit, which skipped controller::_after(), + * \app\renderer::_after() and \app\app::_after() on every OAuth sign-in — the success + * path included — so an application that releases resources or flushes state in those + * hooks never ran them on the most security-relevant request it serves. CLAUDE.md §4 + * allows exactly one exception to the never-exit rule, and it is not this file. + */ + private static function redirect( string $url ): controllerDataResponse { + $response = new controllerDataResponse( null, [ new \gcgov\framework\models\controllerResponseHeader( 'Location', $url ) ] ); + $response->setHttpStatus( 302 ); + + return $response; + } + + + public function createExternalAppToken( string $appName, \DateInterval $tokenExpiration, \MongoDB\BSON\ObjectId $_id, string $username, string $email, string $name, array $roles = [], string $password = '' ): controllerDataResponse { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + + if( $password=='' ) { + $password = uniqid(); + } + + $user = new $userClassName(); + $user->_id = $_id; + $user->username = $username; + $user->email = $email; + $user->name = $name; + $user->password = $password; + $user->roles = $roles; + $user::save( $user ); + + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromUser( $user ); + + $jwt = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $token = $jwt->createAccessToken( $authUser, $tokenExpiration ); + + // 0770, octal. This was written as the decimal literal 777, which is octal 1411: + // sticky bit set, owner r--, group --x, other --x. mkdir still returned true, so the + // guard below passed, and the write that follows then failed for any non-root + // process because the owner had neither write nor execute on its own directory. + if( !file_exists( config::getRootDir() . '/externalAppTokens/' ) ) { + $created = mkdir( config::getRootDir() . '/externalAppTokens/', 0770, true ); + if( !$created ) { + log::warning( 'auth', 'Directory "' . config::getRootDir() . '/externalAppTokens/" does not exist and could not be created automatically. Create directory to continue.' ); + throw new controllerException( 'Cannot create token because externalAppTokens directory does not exist' ); + } + } + + $tokenFilePath = config::getRootDir() . '/externalAppTokens/' . formatting::fileName( $appName ) . '.txt'; + + // Checked: an unchecked write here reported a token file that was never created, + // and the caller had no way to tell. + if( file_put_contents( $tokenFilePath, $token->toString() )===false ) { + log::error( 'auth', 'Failed writing external app token to "' . $tokenFilePath . '"' ); + throw new controllerException( 'Failed to write the external app token file', 500 ); + } + + return new controllerDataResponse( $tokenFilePath ); + } + + private function createAccessTokenResponse( \gcgov\framework\interfaces\auth\user $user ): stdAuthResponse { + //create token for valid user + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromUser( $user ); + + // One try, one catch. The outer catch here used to wrap the inner one and re-throw + // with the constructor arguments swapped — $e->getCode() as the message and + // $e->getMessage() as the int $code — so every failure on this path raised a + // TypeError instead of the 500 it meant to raise, and the renderer's + // controllerException branch never ran. + try { + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser ); + $refreshToken = $jwtService->createRefreshToken( $authUser ); + } + catch( \Exception $e ) { + throw new controllerException( 'Server failed to create authentication tokens', 500, $e ); + } + + return new stdAuthResponse( $accessToken, $refreshToken ); + } + + /** + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function verifyMfaSecret(): controllerDataResponse { + $verifyMfaSecretRequestJSON = file_get_contents( 'php://input' ); + try { + $verifyMfaSecretRequest = verifyMfaSecretRequest::jsonDeserialize( $verifyMfaSecretRequestJSON ); + } + catch( jsonDeserializeException $e ) { + throw new controllerException( 'Provided data is not in a valid format', 400, $e ); + } + + // Validated rather than passed straight through: userMultifactorId is nullable and + // client-supplied, while multifactor::verifyMfaSecret() declares it non-nullable, so + // a body omitting the field produced a TypeError and an opaque 500 where the + // deserialization guard above was meant to yield a 400. + if( $verifyMfaSecretRequest->userMultifactorId===null ) { + throw new controllerException( 'userMultifactorId is required', 400 ); + } + + $authUser = \gcgov\framework\services\request::getAuthUser(); + + multifactor::verifyMfaSecret( self::authUserObjectId( $authUser ), $verifyMfaSecretRequest->userMultifactorId, $verifyMfaSecretRequest->code ); + + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + return new controllerDataResponse( $this->createAccessTokenResponse( $userClassName::getOne($authUser->userId) ) ); + } + + /** + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function verifyMfaCode(): controllerDataResponse { + $verifyMfaCodeRequestJSON = file_get_contents( 'php://input' ); + try { + $verifyMfaCodeRequest = verifyMfaCodeRequest::jsonDeserialize( $verifyMfaCodeRequestJSON ); + } + catch( jsonDeserializeException $e ) { + throw new controllerException( 'Provided data is not in a valid format', 400, $e ); + } + + $authUser = \gcgov\framework\services\request::getAuthUser(); + + $valid = multifactor::isMfaCodeCorrect( self::authUserObjectId( $authUser ), $verifyMfaCodeRequest->code ); + if(!$valid) { + throw new controllerException('Invalid code', 500); + } + + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + return new controllerDataResponse( $this->createAccessTokenResponse( $userClassName::getOne($authUser->userId) ) ); + } + +} diff --git a/src/services/auth/providers/oauth/models/configureMfaResponse.php b/src/services/auth/providers/oauth/models/configureMfaResponse.php new file mode 100644 index 0000000..74f02fa --- /dev/null +++ b/src/services/auth/providers/oauth/models/configureMfaResponse.php @@ -0,0 +1,41 @@ +qrCodeDataUri = $qrCodeDataUri; + $this->secret = $userMultifactor->secret; + $this->userId = $userMultifactor->userId; + $this->userMultifactorId = $userMultifactor->_id; + $this->mfaRequired = true; + $this->mfaConfigured = false; + } + +} diff --git a/src/services/auth/providers/oauth/models/requireMfaResponse.php b/src/services/auth/providers/oauth/models/requireMfaResponse.php new file mode 100644 index 0000000..5851553 --- /dev/null +++ b/src/services/auth/providers/oauth/models/requireMfaResponse.php @@ -0,0 +1,25 @@ +mfaRequired = $user->getMfaRequired(); + $this->mfaConfigured = $user->getMfaConfigured(); + } + } + +} diff --git a/src/services/auth/providers/oauth/models/stdAuthResponse.php b/src/services/auth/providers/oauth/models/stdAuthResponse.php new file mode 100644 index 0000000..66888cc --- /dev/null +++ b/src/services/auth/providers/oauth/models/stdAuthResponse.php @@ -0,0 +1,37 @@ +token_type = $tokenType; + $this->expires_in = $accessToken->claims()->get( 'exp' )->getTimestamp() - $now->getTimestamp(); + $this->access_token = $accessToken->toString(); + } + + if( $refreshToken!==null ) { + $this->refresh_token = $refreshToken->toString(); + } + } + +} diff --git a/src/services/auth/providers/oauth/models/verifyMfaCodeRequest.php b/src/services/auth/providers/oauth/models/verifyMfaCodeRequest.php new file mode 100644 index 0000000..1b3edab --- /dev/null +++ b/src/services/auth/providers/oauth/models/verifyMfaCodeRequest.php @@ -0,0 +1,18 @@ +code = $code; + } + +} diff --git a/src/services/auth/providers/oauth/models/verifyMfaSecretRequest.php b/src/services/auth/providers/oauth/models/verifyMfaSecretRequest.php new file mode 100644 index 0000000..601fb08 --- /dev/null +++ b/src/services/auth/providers/oauth/models/verifyMfaSecretRequest.php @@ -0,0 +1,22 @@ +code = $code; + $this->userMultifactorId = $userMultifactorId; + } + +} diff --git a/src/services/auth/providers/oauth/services/multifactor.php b/src/services/auth/providers/oauth/services/multifactor.php new file mode 100644 index 0000000..72281b7 --- /dev/null +++ b/src/services/auth/providers/oauth/services/multifactor.php @@ -0,0 +1,171 @@ + renders. + */ + private static function qrProvider(): \RobThree\Auth\Providers\Qr\BaconQrCodeProvider { + return new \RobThree\Auth\Providers\Qr\BaconQrCodeProvider( format: 'svg' ); + } + + + public static function requireMfaResponse( ?\Lcobucci\JWT\Token\Plain $accessToken, \gcgov\framework\interfaces\auth\user $user ): requireMfaResponse { + return new requireMfaResponse( $accessToken, $user ); + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + public static function configureMfaResponse( \MongoDB\BSON\ObjectId $userId, ?\Lcobucci\JWT\Token\Plain $accessToken = null ): configureMfaResponse { + try { + $tfa = new \RobThree\Auth\TwoFactorAuth( self::qrProvider() ); + $secret = $tfa->createSecret(); + } + catch( TwoFactorAuthException $e ) { + throw new controllerException( 'Failed to generate MFA secret', 500 ); + } + + try { + $qrCodeDataUri = $tfa->getQRCodeImageAsDataUri( config::getApp()->title, $secret, 500 ); + } + catch( TwoFactorAuthException $e ) { + throw new controllerException( 'Failed to generate QR code for MFA secret', 500 ); + } + + try { + $oldMultifactorConfigurations = userMultifactor::getAll( [ 'userId' => $userId ] ); + userMultifactor::deleteMany( $oldMultifactorConfigurations ); + + //create new multifactor attempt + $userMultifactor = new userMultifactor( $userId ); + $userMultifactor->secret = $secret; + userMultifactor::save( $userMultifactor ); + } + catch( modelException $e ) { + log::error( 'auth', 'Failed to save MFA secret', [ 'exception' => $e ] ); + throw new controllerException( 'Failed to save MFA secret', 500 ); + } + + return new configureMfaResponse( $accessToken, $userMultifactor, $qrCodeDataUri ); + } + + + /** + * @param \MongoDB\BSON\ObjectId $userId + * @param \MongoDB\BSON\ObjectId $userMultifactorId + * @param string $code + * + * @return \gcgov\framework\interfaces\auth\user + * @throws \gcgov\framework\exceptions\controllerException + */ + public static function verifyMfaSecret( \MongoDB\BSON\ObjectId $userId, \MongoDB\BSON\ObjectId $userMultifactorId, string $code ): \gcgov\framework\interfaces\auth\user { + + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + $user = $userClassName::getOne( $userId ); + $userMultifactor = userMultifactor::getOneBy( [ '_id' => $userMultifactorId, 'userId' => $user->_id ] ); + } + catch( modelDocumentNotFoundException|modelException $e ) { + throw new controllerException( 'Identifier not found for user', 404 ); + } + + try { + $tfa = new \RobThree\Auth\TwoFactorAuth( self::qrProvider() ); + $timeslice = $userMultifactor->timeslice; + $result = $tfa->verifyCode( $userMultifactor->secret, $code, 1, null, $timeslice ); + + } + catch( TwoFactorAuthException $e ) { + throw new controllerException( 'Not able to check MFA code', 500, $e ); + } + + if( !$result ) { + throw new controllerException( 'Incorrect code provided', 400 ); + } + + try { + $userMultifactor->timeslice = $timeslice; + $userMultifactor->verified = true; + $userMultifactor->verifiedAt = new \DateTimeImmutable(); + userMultifactor::save( $userMultifactor ); + + $user->password = ''; + $user->mfaConfigured = true; + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + $userClassName::save( $user ); + + return $user; + } + catch( \Exception $e ) { + throw new controllerException( 'Failed to save MFA configuration', 500, $e ); + } + + } + + + /** + * @param \MongoDB\BSON\ObjectId $userId + * @param string $code + * + * @return bool + * @throws \gcgov\framework\exceptions\controllerException + */ + public static function isMfaCodeCorrect( \MongoDB\BSON\ObjectId $userId, string $code ): bool { + + try { + $userMultifactor = userMultifactor::getOneBy( [ 'userId' => new \MongoDB\BSON\ObjectId( $userId ) ] ); + } + catch( modelDocumentNotFoundException|modelException $e ) { + throw new controllerException( 'Identifier not found for user', 404 ); + } + + try { + $tfa = new \RobThree\Auth\TwoFactorAuth( self::qrProvider() ); + $timeslice = null; + $result = $tfa->verifyCode( $userMultifactor->secret, $code, 1, null, $timeslice ); + } + catch( TwoFactorAuthException $e ) { + throw new controllerException( 'Not able to check MFA code', 500, $e ); + } + + if( !$result ) { + throw new controllerException( 'Incorrect code provided', 400 ); + } + + //replay attack prevention + if( $timeslice===null || $timeslice<=$userMultifactor->timeslice ) { + throw new controllerException( 'This code has already been used', 500 ); + } + + //save the updated timeslice for future verification + try { + $userMultifactor->timeslice = $timeslice; + userMultifactor::save( $userMultifactor ); + } + catch( \Exception $e ) { + log::error( 'auth', 'Failed to save MFA timeslice', [ 'exception' => $e ] ); + } + + return true; + } + +} diff --git a/src/services/auth/router.php b/src/services/auth/router.php new file mode 100644 index 0000000..621b883 --- /dev/null +++ b/src/services/auth/router.php @@ -0,0 +1,60 @@ +config->isOauth() ) { + return array_merge( $routes, [ + new route( 'GET', $basePath . '/.well-known/openid-configuration', self::OAUTH, 'openId', false ), + new route( 'POST', $basePath . '/auth/authorize', self::OAUTH, 'oauthPostAuthorize', false ), + new route( 'GET', $basePath . '/auth/authorize', self::OAUTH, 'oauthGetAuthorize', false ), + new route( 'GET', $basePath . '/auth/hybridauth/{provider}', self::OAUTH, 'oauthHybridAuth', false ), + new route( 'GET', $basePath . '/auth/out', self::OAUTH, 'out', true ), + new route( 'POST', $basePath . '/auth/verifyMfaSecret', self::OAUTH, 'verifyMfaSecret', true ), + new route( 'POST', $basePath . '/auth/verifyMfaCode', self::OAUTH, 'verifyMfaCode', true ), + ] ); + } + + return array_merge( $routes, [ + new route( 'GET', $basePath . '/auth/microsoft', self::MS_FRONT, 'microsoft', false ), + ] ); + } + + + /** + * @throws \gcgov\framework\exceptions\routeException + */ + public function authentication( \gcgov\framework\models\routeHandler $routeHandler ): bool { + return guard::authenticate( $routeHandler ); + } + +} diff --git a/src/services/cronMonitor/cronMonitor.php b/src/services/cronMonitor/cronMonitor.php new file mode 100644 index 0000000..05d7c3f --- /dev/null +++ b/src/services/cronMonitor/cronMonitor.php @@ -0,0 +1,69 @@ +jobId = $jobId; + + // An empty url disables reporting, as the config docblock and CLAUDE.md §8 both + // say. Without this guard the documented off switch did nothing: the constructor + // built a client with an empty base_uri and fired a request at a hostless relative + // URI, and because the rejection only surfaces in end()'s wait(), every cron run on + // a default-configured application blocked on one doomed request and then issued a + // second. The constructor is also the one place in this fail-safe class with no + // try/catch, so its contract only held from the second line on. + if( !config::getCronMonitor()->isConfigured() ) { + return; + } + + $this->client = new \GuzzleHttp\Client( [ 'base_uri' => config::getCronMonitor()->url ] ); + // Fired asynchronously so the start ping overlaps the job rather than delaying it. + $this->jobPromise = $this->client->requestAsync( 'GET', 'jobHistory/start/' . $this->jobId ); + } + + public function end(): void { + if( $this->client===null || $this->jobPromise===null ) { + return; + } + + $runId = ''; + //make sure the job started and get the run id from it's response + try { + $response = $this->jobPromise->wait(); + $parsedResponse = json_decode( (string) $response->getBody(), false, 512, JSON_THROW_ON_ERROR ); + if( is_object( $parsedResponse ) && isset( $parsedResponse->data ) ) { + $runId = (string) $parsedResponse->data; + } + } + catch( \JsonException|\Exception $e ) { + } + + //end the job regardless of whether we got a successful start or not + try { + $this->client->request( 'GET', 'jobHistory/end/'.$this->jobId.'/' . $runId ); + } + catch( \Exception|GuzzleException $e ) { + } + } + +} diff --git a/src/services/documentation/controllers/documentation.php b/src/services/documentation/controllers/documentation.php new file mode 100644 index 0000000..e4a8baf --- /dev/null +++ b/src/services/documentation/controllers/documentation.php @@ -0,0 +1,133 @@ +getScanDirectories(); + $excludeFilesDirectories = $this->getExcludeDirectoriesFiles(); + $finder = new \OpenApi\SourceFinder( $scanDirectories, $excludeFilesDirectories, '*.php' ); + $openapi = ( new \OpenApi\Generator() )->generate( $finder ); + header( 'Content-Type: text/x-yaml' ); + echo $openapi->toYaml(); + die(); + } + + + /** + * The application's own source, plus the framework's. + * + * The framework directory is derived from this file rather than guessed at + * vendor/gcgov/framework, so it is correct whether the framework is installed from + * Packagist or symlinked from a path repository during development. Under a path + * repository the old hardcoded vendor path did not exist and was silently dropped by + * the file_exists filter below, so nothing of the framework was documented in + * development. + * + * Scanning the framework's src/services now also picks up the Framework Services' + * own annotations. As separate packages they were simply never in this list, so + * their endpoints never reached the document. + * + * @return string[] + */ + private function getScanDirectories(): array { + $frameworkSrc = dirname( __DIR__, 3 ); + + $directoriesToScan = [ + config::getAppDir(), + $frameworkSrc . '/controllers', + $frameworkSrc . '/exceptions', + $frameworkSrc . '/models', + $frameworkSrc . '/services' + ]; + + foreach( $directoriesToScan as $i => $directory ) { + if( !file_exists( $directory ) ) { + unset( $directoriesToScan[ $i ] ); + } + } + + return array_values( $directoriesToScan ); + } + + + /** + * An application that defines its own user model replaces the framework's, so the + * framework's must not also appear in the document as a second schema of the same name. + * + * @return string[] + * @throws \gcgov\framework\exceptions\configException + */ + private function getExcludeDirectoriesFiles(): array { + $frameworkSrc = dirname( __DIR__, 3 ); + + $exclusions = []; + + // No blanket {root}/vendor exclusion. Under a normal Composer install the framework + // itself lives at {root}/vendor/gcgov/framework, so excluding the whole tree was a + // prefix match over every framework path just added to the scan list — the + // Framework Service annotations this class exists to publish were added and then + // immediately dropped again, and only a symlinked development checkout behaved as + // documented. The scan list is explicit (see getScanDirectories()), so nothing under + // vendor is reached except the framework, which is what we want reached. + + // A Framework Service that is not enabled contributes no routes, so documenting its + // annotations would advertise endpoints that 404. + $services = config::getServices(); + if( $services->auth===null ) { + $exclusions[] = $frameworkSrc . '/services/auth'; + } + else { + $exclusions[] = $frameworkSrc . '/services/auth/providers/' . ( $services->auth->isOauth() ? 'msFront' : 'oauth' ); + } + if( $services->userCrud===null ) { + $exclusions[] = $frameworkSrc . '/services/userCrud'; + } + if( $services->documentation===null ) { + $exclusions[] = $frameworkSrc . '/services/documentation'; + } + + if( class_exists( '\app\models\user' ) ) { + $exclusions[] = $frameworkSrc . '/services/mongodb/models/auth/user.php'; + } + + if( class_exists( '\app\models\authUser' ) ) { + $exclusions[] = $frameworkSrc . '/models/authUser.php'; + } + + return $exclusions; + } + + + public function routes(): controllerDataResponse { + $routes = []; + return new controllerDataResponse( $routes ); + } + + + /** + * Processed after lifecycle is complete with this instance + */ + public static function _after(): void { + } + + + /** + * Processed prior to __constructor() being called + */ + public static function _before(): void { + } + +} diff --git a/src/services/documentation/router.php b/src/services/documentation/router.php new file mode 100644 index 0000000..5a9a4fc --- /dev/null +++ b/src/services/documentation/router.php @@ -0,0 +1,28 @@ + variable name => is a secret + * @throws \gcgov\framework\services\environment\environmentException + */ + public static function references( string $rootDir ): array { + $configFile = self::configFilePath( $rootDir ); + if( !file_exists( $configFile ) ) { + throw new environmentException( 'Missing config file: ' . $configFile ); + } + + // The references themselves do not need resolving, but the caller asks whether each + // one is SET — and on a developer machine the answers live in {root}/.env. Without + // this, `gf env --list` reported every variable MISSING while `gf env` on the same + // machine resolved the same config.json successfully. + dotEnvLoader::loadOnce( $rootDir ); + + $decoded = json_decode( (string)file_get_contents( $configFile ), false ); + if( !$decoded instanceof \stdClass ) { + throw new environmentException( 'Failed to parse ' . $configFile . ': the file is not a valid JSON object.' ); + } + + return envVarResolver::collectReferences( $decoded, $configFile ); + } + + + /** + * @return \stdClass|string Decoded object, or the raw string when it does not decode to an object. + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function readAndDecode( string $rootDir, string $configFile ): \stdClass|string { + if( !file_exists( $configFile ) ) { + throw new environmentException( 'Missing config file: ' . $configFile ); + } + + dotEnvLoader::loadOnce( $rootDir ); + + $json = (string)file_get_contents( $configFile ); + $decoded = json_decode( $json, false ); + + return $decoded instanceof \stdClass ? $decoded : $json; + } + + + /** + * @template T of \andrewsauder\jsonDeserialize\jsonDeserialize + * + * @param class-string $class + * @param \stdClass|string $data + * + * @return T + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function hydrate( string $class, \stdClass|string $data, string $source ): object { + try { + return $class::jsonDeserialize( $data ); + } + catch( \andrewsauder\jsonDeserialize\exceptions\jsonDeserializeException $e ) { + throw new environmentException( 'Failed to parse ' . $source . ': ' . $e->getMessage(), 0, $e ); + } + } + +} diff --git a/src/services/environment/dotEnvLoader.php b/src/services/environment/dotEnvLoader.php new file mode 100644 index 0000000..a30ad5a --- /dev/null +++ b/src/services/environment/dotEnvLoader.php @@ -0,0 +1,74 @@ + .env.local > .env. + * The real container/process environment always wins — Symfony's Dotenv never + * overrides variables that are already present in the environment. `usePutenv()` + * is enabled so that call sites reading through `getenv()` (e.g. `GF_PHP` in the + * gf CLI) also observe values loaded from .env files. Either file may exist on + * its own — a project keeping only machine-local values in `.env.local` loads + * exactly like one with only `.env`. + * + * There is deliberately no APP_ENV cascade: an Environment IS the variable set the + * process is given, so environment selection is simply which variables the process + * environment (or .env) supplies. Nothing is activated, copied, or selected by name. + */ +final class dotEnvLoader { + + /** Root directories already processed, so loading is a no-op on repeat calls. */ + private static array $loadedRoots = []; + + + /** + * Load {root}/.env and/or {root}/.env.local when present. No-op when neither + * exists or when this root has already been loaded in the current process. + * + * @throws \gcgov\framework\services\environment\environmentException When a present file has invalid syntax + */ + public static function loadOnce( string $rootDir ): void { + $rootDir = rtrim( str_replace( '\\', '/', $rootDir ), '/' ); + + if( isset( self::$loadedRoots[ $rootDir ] ) ) { + return; + } + self::$loadedRoots[ $rootDir ] = true; + + // load() applies files left to right with later files overriding earlier ones, + // never overriding real environment variables that are already set. + $files = array_values( array_filter( [ $rootDir . '/.env', $rootDir . '/.env.local' ], 'file_exists' ) ); + if( count( $files )===0 ) { + // Nothing to load; still marked as processed so we don't re-stat every call. + return; + } + + $dotenv = new Dotenv(); + $dotenv->usePutenv(); + + try { + $dotenv->load( ...$files ); + } + catch( \Symfony\Component\Dotenv\Exception\FormatException $e ) { + throw new environmentException( 'Invalid syntax in environment file (' . implode( ', ', $files ) . '): ' . $e->getMessage(), 0, $e ); + } + } + + + /** + * Reset the idempotency cache. Intended for test isolation only. + * + * @internal + */ + public static function resetForTesting(): void { + self::$loadedRoots = []; + } + +} diff --git a/src/services/environment/envVarResolver.php b/src/services/environment/envVarResolver.php new file mode 100644 index 0000000..dbcf4a9 --- /dev/null +++ b/src/services/environment/envVarResolver.php @@ -0,0 +1,474 @@ + variable name => is a secret, ordered by first appearance + * @throws \gcgov\framework\services\environment\environmentException On a malformed reference + */ + public static function collectReferences( \stdClass $decoded, string $sourceDescription ): array { + $references = []; + self::walkStrings( $decoded, function( string $value ) use ( &$references, $sourceDescription ): void { + if( !str_contains( $value, '%env(' ) ) { + return; + } + preg_match_all( '/%env\(([^)]+)\)%/', $value, $matches ); + foreach( $matches[ 1 ] as $expression ) { + [ $varName, $processors ] = self::parseExpression( $expression, $sourceDescription ); + $isSecret = in_array( 'secret', $processors, true ); + // A name referenced both ways is a secret: the stricter reading wins. + $references[ $varName ] = ( $references[ $varName ] ?? false ) || $isSecret; + } + } ); + + return $references; + } + + + /** + * Recursively resolve string leaves within the decoded tree. + * + * @param mixed $node + * @param string $sourceDescription + * + * @return mixed + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function resolveNode( mixed $node, string $sourceDescription ): mixed { + if( $node instanceof \stdClass ) { + foreach( get_object_vars( $node ) as $key => $value ) { + $node->$key = self::resolveNode( $value, $sourceDescription ); + } + + return $node; + } + + if( is_array( $node ) ) { + return array_map( static fn( $value ) => self::resolveNode( $value, $sourceDescription ), $node ); + } + + if( is_string( $node ) ) { + return self::resolveString( $node, $sourceDescription ); + } + + return $node; + } + + + /** + * Visit every string leaf of a decoded tree without modifying it. + * + * @param mixed $node + * @param callable(string):void $visitor + */ + private static function walkStrings( mixed $node, callable $visitor ): void { + if( $node instanceof \stdClass ) { + foreach( get_object_vars( $node ) as $value ) { + self::walkStrings( $value, $visitor ); + } + + return; + } + + if( is_array( $node ) ) { + foreach( $node as $value ) { + self::walkStrings( $value, $visitor ); + } + + return; + } + + if( is_string( $node ) ) { + $visitor( $node ); + } + } + + + /** + * Resolve `%env(...)%` occurrences in a single string leaf. + * + * @return mixed Typed value when the whole string is one reference; a string otherwise. + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function resolveString( string $value, string $sourceDescription ): mixed { + if( !str_contains( $value, '%env(' ) ) { + return $value; + } + + // Whole-string reference → typed result. + if( preg_match( '/^%env\(([^)]+)\)%$/', $value, $matches )===1 ) { + return self::resolveExpression( $matches[ 1 ], $sourceDescription ); + } + + // Embedded reference(s) → string substitution. + $result = preg_replace_callback( '/%env\(([^)]+)\)%/', static function( array $matches ) use ( $sourceDescription ): string { + $resolved = self::resolveExpression( $matches[ 1 ], $sourceDescription ); + if( is_bool( $resolved ) ) { + return $resolved ? 'true' : 'false'; + } + if( is_scalar( $resolved ) ) { + return (string)$resolved; + } + throw new environmentException( 'Cannot embed non-scalar environment value for %env(' . $matches[ 1 ] . ')% inside a larger string in ' . $sourceDescription . '. Reference it as the whole value ("%env(...)%") instead.' ); + }, $value ) ?? $value; + + // Fail loud instead of silently shipping an unresolved reference: a leftover + // '%env(' means an unterminated reference or a literal '%env(' in a config + // value — neither is supported. + if( str_contains( $result, '%env(' ) ) { + throw new environmentException( 'Unresolvable %env(...) reference in ' . $sourceDescription . ': "' . $value . '". A reference ends at the first ")", and a config value cannot contain the literal text "%env(".' ); + } + + return $result; + } + + + /** + * Split one `%env(...)%` expression (the text between the parentheses) into its + * variable name and its processor chain, outermost first. + * + * @return array{0: string, 1: string[]} + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function parseExpression( string $expression, string $sourceDescription ): array { + $segments = explode( ':', $expression ); + $varName = (string)array_pop( $segments ); + + if( preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $varName )!==1 ) { + throw new environmentException( 'Invalid environment variable reference "%env(' . $expression . ')%" in ' . $sourceDescription . ': "' . $varName . '" is not a valid variable name.' ); + } + + foreach( $segments as $index => $processor ) { + if( !in_array( $processor, self::PROCESSORS, true ) ) { + throw new environmentException( 'Unknown environment processor "' . $processor . '" in "%env(' . $expression . ')%" (' . $sourceDescription . '). Supported: ' . implode( ', ', self::PROCESSORS ) . '.' ); + } + if( $processor==='secret' && $index!==count( $segments ) - 1 ) { + throw new environmentException( '"secret" must be the innermost processor in "%env(' . $expression . ')%" (' . $sourceDescription . '), i.e. immediately before the variable name — it selects where the value is read from, so nothing can come between it and the variable.' ); + } + } + + return [ $varName, $segments ]; + } + + + /** + * Resolve one `%env(...)%` expression. + * + * @return mixed + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function resolveExpression( string $expression, string $sourceDescription ): mixed { + [ $varName, $processors ] = self::parseExpression( $expression, $sourceDescription ); + + $useSecretLookup = false; + if( count( $processors )>0 && end( $processors )==='secret' ) { + array_pop( $processors ); + $useSecretLookup = true; + } + + $value = $useSecretLookup + ? self::lookupSecret( $varName, $expression, $sourceDescription ) + : self::lookupEnv( $varName ); + + if( $value===null ) { + if( self::isBlockedName( $varName ) ) { + throw new environmentException( '"' . $varName . '" is a reserved CGI meta-variable name and is never resolved from the environment (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Rename the configuration variable.' ); + } + throw new environmentException( 'Required environment variable "' . $varName . '" is not set' . ( $useSecretLookup ? ' (and neither is "' . $varName . self::SECRET_FILE_SUFFIX . '")' : '' ) . ' (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Set it in the process environment, a provisioned secret file, or a .env file.' ); + } + + // Apply processors right-to-left (inner → outer). + foreach( array_reverse( $processors ) as $processor ) { + $value = self::applyProcessor( $processor, $value, $expression, $sourceDescription ); + } + + return $value; + } + + + /** + * The `secret` lookup: prefer the file named by `{NAME}_FILE`, else the plain variable. + * A `_FILE` variable that is set but unreadable is an error, never a fall-back — see the + * class docblock. + * + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function lookupSecret( string $varName, string $expression, string $sourceDescription ): ?string { + $fileVarName = $varName . self::SECRET_FILE_SUFFIX; + $path = self::lookupEnv( $fileVarName ); + + if( $path===null ) { + return self::lookupEnv( $varName ); + } + + if( !is_file( $path ) || !is_readable( $path ) ) { + throw new environmentException( 'Secret file for "%env(' . $expression . ')%" in ' . $sourceDescription . ' does not exist or is not readable: "' . $path . '" (from ' . $fileVarName . '). The secret is not falling back to ' . $varName . ' — fix the mount or unset ' . $fileVarName . '.' ); + } + + $contents = file_get_contents( $path ); + if( $contents===false ) { + throw new environmentException( 'Failed reading the secret file for "%env(' . $expression . ')%" in ' . $sourceDescription . ': "' . $path . '" (from ' . $fileVarName . ').' ); + } + + // Provisioned secret files conventionally end in a newline; a credential never + // legitimately has surrounding whitespace. + return trim( $contents ); + } + + + /** + * @param mixed $value + * + * @return mixed + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function applyProcessor( string $processor, mixed $value, string $expression, string $sourceDescription ): mixed { + switch( $processor ) { + case 'bool': + // Fails closed, like "int" immediately below. The fallback used to be + // `?? (bool)$value`, which turned every unrecognised value — "flase", + // "disabled", "off ", "2" — into TRUE with no error: a typo in + // AUTH_BLOCK_NEW_USERS or LOGGING_LIFECYCLE resolved silently to the wrong + // setting and `gf env` reported success. ADR 0001 removed the default: + // processor to keep exactly this from happening one processor over. + $bool = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); + if( $bool===null ) { + throw new environmentException( 'Cannot apply "bool" to value "' . (string)$value . '" for "%env(' . $expression . ')%" in ' . $sourceDescription . '. Use one of: 1/0, true/false, yes/no, on/off.' ); + } + + return $bool; + + case 'int': + if( !is_numeric( trim( (string)$value ) ) ) { + throw new environmentException( 'Cannot apply "int" to non-numeric value for "%env(' . $expression . ')%" in ' . $sourceDescription . '.' ); + } + + return (int)$value; + + case 'trim': + return trim( (string)$value ); + + case 'file': + $path = (string)$value; + if( !is_file( $path ) || !is_readable( $path ) ) { + throw new environmentException( 'Cannot apply "file" for "%env(' . $expression . ')%" in ' . $sourceDescription . ': file "' . $path . '" does not exist or is not readable.' ); + } + $contents = file_get_contents( $path ); + if( $contents===false ) { + throw new environmentException( 'Cannot apply "file" for "%env(' . $expression . ')%" in ' . $sourceDescription . ': failed reading "' . $path . '".' ); + } + + return $contents; + + case 'json': + $decoded = json_decode( (string)$value, false ); + if( json_last_error()!==JSON_ERROR_NONE ) { + throw new environmentException( 'Cannot apply "json" for "%env(' . $expression . ')%" in ' . $sourceDescription . ': ' . json_last_error_msg() . '.' ); + } + + return $decoded; + + default: + // parseExpression() has already rejected unknown processors, and `secret` + // is consumed before the chain is applied. + throw new environmentException( 'Environment processor "' . $processor . '" cannot be applied to a value in "%env(' . $expression . ')%" (' . $sourceDescription . ').' ); + } + } + + + /** Request-derived under web SAPIs — never satisfiable from the ambient environment. */ + private static function isBlockedName( string $name ): bool { + if( in_array( $name, self::BLOCKED_NAMES, true ) ) { + return true; + } + foreach( self::BLOCKED_NAME_PREFIXES as $prefix ) { + if( str_starts_with( $name, $prefix ) ) { + return true; + } + } + + return false; + } + + + /** + * Look up an environment variable value. + * Precedence: $_ENV → $_SERVER → getenv(). The blocked-name guard applies to ALL + * three sources by name (not per source): under CGI/FastCGI SAPIs request headers + * reach the real process environment (getenv) and — with `variables_order=E` — + * $_ENV, so filtering only $_SERVER would be bypassable. + * + * A variable set to the empty string is reported as unset. Every reference is + * required, so "" is never a meaningful configured value — treating it as one + * would let a blank line in a .env satisfy a required secret. + */ + private static function lookupEnv( string $name ): ?string { + if( self::isBlockedName( $name ) ) { + return null; + } + + $value = null; + + if( array_key_exists( $name, $_ENV ) ) { + $value = (string)$_ENV[ $name ]; + } + elseif( array_key_exists( $name, $_SERVER ) && is_scalar( $_SERVER[ $name ] ) ) { + $value = (string)$_SERVER[ $name ]; + } + else { + $fromGetenv = getenv( $name ); + if( $fromGetenv!==false ) { + $value = $fromGetenv; + } + } + + return ( $value===null || $value==='' ) ? null : $value; + } + + + /** + * Whether a reference is satisfied by the current environment, by either the plain name + * or its {NAME}_FILE companion. + * + * Shared with `gf env --list` so the diagnostic and the resolver cannot disagree about + * what "set" means. The command reimplemented this inline and got two things wrong that + * lookupEnv() already handles: it skipped the blocked-CGI-name rule, and its + * `?? ... ?: ''` chain collapsed a legitimate "0" to '' — reporting MISSING for every + * variable holding the value that %env(bool:...)% false is written as. + */ + public static function isSatisfied( string $name ): bool { + return self::lookupEnv( $name )!==null || self::lookupEnv( $name . self::SECRET_FILE_SUFFIX )!==null; + } + + + /** + * Whether a name is reserved: a CGI meta-variable name this resolver never satisfies + * from the ambient environment (see the class docblock). Public so `gf env` can say + * "reserved — rename it" instead of reporting an unsatisfiable variable as MISSING + * and writing a dead line into .env for the developer to fill in forever. + */ + public static function isReservedName( string $name ): bool { + return self::isBlockedName( $name ); + } + +} diff --git a/src/services/environment/environmentException.php b/src/services/environment/environmentException.php new file mode 100644 index 0000000..d3dd71b --- /dev/null +++ b/src/services/environment/environmentException.php @@ -0,0 +1,18 @@ + 'ok', + 'version' => self::version(), + ] ); + } + + + /** + * Readiness. Pings every configured Mongo database. Backs the deploy gate and the + * reverse proxy's load-balancing decision. Returns 503 when a dependency is down, so + * a deploy that cannot reach its database fails loudly instead of reporting success. + */ + public function ready(): controllerDataResponse { + $checks = []; + $healthy = true; + + try { + $mongoDatabases = config::getMongoDatabases(); + $authEnabled = config::getServices()->auth!==null; + $jwtKeyPath = $authEnabled ? config::getJwtKeyPath() : ''; + } + catch( \Throwable $e ) { + // Logged, not returned — see pingMongo(). A configException message carries the + // config file path and the name of the unresolved environment variable. + log::error( 'health', 'Readiness could not read configuration', [ 'exception' => $e ] ); + $mongoDatabases = []; + $authEnabled = false; + $jwtKeyPath = ''; + $checks[ 'config' ] = 'failed'; + $healthy = false; + } + + // pingMongo() never throws — see its contract — so the loop needs no guard. + foreach( $mongoDatabases as $mongoDatabase ) { + $status = self::pingMongo( $mongoDatabase ); + $checks[ 'mongo:' . $mongoDatabase->database ] = $status; + $healthy = $healthy && $status==='ok'; + } + + // When the auth service is enabled, usable signing keys are a dependency like the + // database. Nothing else checks them before traffic arrives: sign-in is the first + // thing that constructs jwtAuth, so a missing or empty key mount (APP_JWT_KEY_PATH + // pointing at an unprovisioned directory) passed every health gate — deploy green, + // proxy green — and surfaced only as a configException on the first production + // sign-in. + if( $authEnabled ) { + $status = self::jwtKeysStatus( $jwtKeyPath ); + $checks[ 'jwtKeys' ] = $status; + $healthy = $healthy && $status==='ok'; + } + + $response = new controllerDataResponse( [ + 'status' => $healthy ? 'ok' : 'unavailable', + 'version' => self::version(), + 'checks' => $checks, + ] ); + if( !$healthy ) { + $response->setHttpStatus( 503 ); + } + + return $response; + } + + + /** + * The deployed release, for confirming that a deploy actually landed — half the + * reason this endpoint exists. Written into the image at build time. + */ + private static function version(): string { + $version = getenv( 'APP_VERSION' ); + + return is_string( $version ) && $version!=='' ? $version : 'unknown'; + } + + + /** + * @return string 'ok' or 'failed' — never a thrown exception, and never the reason. + * + * The reason goes to the log. These routes are registered unauthenticated on every + * application, and a driver failure message names internal hostnames, ports and + * replica-set topology — a probe of a public /health/ready would otherwise map the + * inside of a Zone that is not reachable from outside it. + * + * Built as its own short-timeout client rather than through mdb, which takes its + * timeouts from the application's own clientParams. A configured clientParams still + * wins, so a database that genuinely needs longer can say so. + */ + private static function pingMongo( mongoDatabase $mongoDatabase ): string { + try { + $client = new \MongoDB\Client( $mongoDatabase->uri, array_merge( [ + 'serverSelectionTimeoutMS' => self::PROBE_TIMEOUT_MS, + 'connectTimeoutMS' => self::PROBE_TIMEOUT_MS, + 'socketTimeoutMS' => self::PROBE_TIMEOUT_MS, + ], $mongoDatabase->clientParams ) ); + $client->{$mongoDatabase->database}->command( [ 'ping' => 1 ] ); + + return 'ok'; + } + catch( \Throwable $e ) { + log::warning( 'health', 'Readiness ping failed for database "' . $mongoDatabase->database . '"', [ 'exception' => $e ] ); + + return 'failed'; + } + } + + + /** + * @return string 'ok' or 'failed' — never a thrown exception, and never the path: + * like pingMongo(), the reason goes to the log rather than to a route that is + * registered unauthenticated on every application. + * + * Mirrors jwtAuth's own key discovery: guids.json naming at least one guid whose + * private and public pem files both exist beside it. + */ + private static function jwtKeysStatus( string $keyPath ): string { + $guidsFile = $keyPath . 'guids.json'; + if( file_exists( $guidsFile ) ) { + $guids = json_decode( (string)file_get_contents( $guidsFile ) ); + foreach( is_array( $guids ) ? $guids : [] as $guid ) { + if( !is_string( $guid ) ) { + continue; + } + if( file_exists( $keyPath . 'private-' . $guid . '.pem' ) && file_exists( $keyPath . 'public-' . $guid . '.pem' ) ) { + return 'ok'; + } + } + } + + log::warning( 'health', 'Readiness found no usable JWT signing keys in the configured key directory. Generate them with `vendor/bin/gf cert:generate-auth`, or point jwtAuth.keyPath (APP_JWT_KEY_PATH) at the provisioned directory.' ); + + return 'failed'; + } + +} diff --git a/src/services/health/router.php b/src/services/health/router.php new file mode 100644 index 0000000..6eab433 --- /dev/null +++ b/src/services/health/router.php @@ -0,0 +1,43 @@ +keyPath = config::getSrvDir().'/jwtCertificates/'; - } - else { - $this->keyPath = dirname( __FILE__ ) . '/jwtCertificates/'; + // Signing keys: jwtAuth.keyPath when configured, else {root}/srv/jwtCertificates. + // A container points keyPath at a provisioned read-only mount — the keys are + // secrets and are never baked into an image. + $this->keyPath = config::getJwtKeyPath(); + if( !is_dir( $this->keyPath ) ) { + throw new configException( 'JWT key directory does not exist: ' . $this->keyPath . '. Generate keys with `vendor/bin/gf cert:generate-auth`, or point "jwtAuth.keyPath" in config.json at the directory they are provisioned to.' ); } - //env config - $envConfig = config::getEnvironmentConfig(); - if( !isset( $envConfig->jwtAuth ) || empty( $envConfig->jwtAuth->tokenIssuedBy ) || empty( $envConfig->jwtAuth->tokenPermittedFor ) ) { - throw new configException( 'Missing "auth" section of /app/config/environment.json' ); + // Issuer and audience default to the application's own rootUrl / basePath. + $this->issuedBy = config::getTokenIssuedBy(); + $this->permittedFor = config::getTokenPermittedFor(); + if( $this->issuedBy==='' || $this->permittedFor==='' ) { + throw new configException( 'Cannot determine the JWT issuer and audience: set "rootUrl" and "basePath" in config.json, or set "jwtAuth.tokenIssuedBy" and "jwtAuth.tokenPermittedFor" explicitly.' ); } - $this->issuedBy = $envConfig->jwtAuth->tokenIssuedBy; - $this->permittedFor = $envConfig->jwtAuth->tokenPermittedFor; //guid config if( !file_exists( $this->keyPath . 'guids.json' ) ) { @@ -99,12 +98,12 @@ private function init( string $guid ): void { private function getPrivateKeyPath(): string { - return $this->keyPath . '/private-' . $this->guid . '.pem'; + return $this->keyPath . 'private-' . $this->guid . '.pem'; } private function getPublicKeyPath(): string { - return $this->keyPath . '/public-' . $this->guid . '.pem'; + return $this->keyPath . 'public-' . $this->guid . '.pem'; } @@ -355,7 +354,7 @@ public function getJwksKeys() { $jwksKeys = []; foreach( $this->guids as $guid ) { - $pub_key = openssl_pkey_get_public( file_get_contents( $this->keyPath . '/public-' . $guid . '.pem' ) ); + $pub_key = openssl_pkey_get_public( file_get_contents( $this->keyPath . 'public-' . $guid . '.pem' ) ); $keyData = openssl_pkey_get_details( $pub_key ); $jwksKeys[] = [ 'alg' => 'RS512', diff --git a/src/services/log.php b/src/services/log.php index e280636..09d1138 100644 --- a/src/services/log.php +++ b/src/services/log.php @@ -2,6 +2,7 @@ namespace gcgov\framework\services; use Monolog\Logger; +use Monolog\Formatter\JsonFormatter; use Monolog\Handler\StreamHandler; final class log { @@ -65,7 +66,7 @@ private static function getLogger( string $channel = '' ): Logger { if( empty( $channel ) ) { try { - $channel = \gcgov\framework\config::getAppConfig()->app->title; + $channel = \gcgov\framework\config::getApp()->title; if( $channel === '' ) { $channel = 'app'; } @@ -75,13 +76,52 @@ private static function getLogger( string $channel = '' ): Logger { } } - $handlers = [ - new StreamHandler( \gcgov\framework\config::getRootDir() . '/logs/' . $channel . '.log' ) - ]; - - self::$loggers[ $channel ] = new Logger( $channel, $handlers ); + self::$loggers[ $channel ] = new Logger( $channel, self::buildHandlers( $channel ) ); return self::$loggers[ $channel ]; } + + /** + * Handlers for the configured destination. + * + * stderr is the default and what a container needs: its filesystem does not survive + * a deploy, so file logs would be per-replica and destroyed on every release. Records + * go out as JSON lines so a collector can query them. Applications still hosted on + * IIS set logging.destination to "file". + * + * Configuration may itself be unreadable when something fails early in the lifecycle, + * and a logger that throws while reporting an error is worse than a misplaced log — + * so an unreadable config falls back to stderr. + * + * @return \Monolog\Handler\HandlerInterface[] + */ + private static function buildHandlers( string $channel ): array { + try { + $logging = \gcgov\framework\config::getLogging(); + } + catch( \gcgov\framework\exceptions\configException ) { + return [ self::stderrHandler() ]; + } + + $handlers = []; + if( $logging->writesToStderr() ) { + $handlers[] = self::stderrHandler(); + } + if( $logging->writesToFile() ) { + $handlers[] = new StreamHandler( \gcgov\framework\config::getRootDir() . '/logs/' . $channel . '.log' ); + } + + // An unrecognised destination still has to log somewhere. + return count( $handlers )>0 ? $handlers : [ self::stderrHandler() ]; + } + + + private static function stderrHandler(): StreamHandler { + $handler = new StreamHandler( 'php://stderr' ); + $handler->setFormatter( new JsonFormatter() ); + + return $handler; + } + } \ No newline at end of file diff --git a/src/services/microsoft/auth.php b/src/services/microsoft/auth.php index 1ae095b..a5789d7 100644 --- a/src/services/microsoft/auth.php +++ b/src/services/microsoft/auth.php @@ -15,7 +15,7 @@ class auth { public function __construct() { - $this->provider = new \TheNetworg\OAuth2\Client\Provider\Azure( (array) config::getEnvironmentConfig()->microsoft ); + $this->provider = new \TheNetworg\OAuth2\Client\Provider\Azure( (array) config::getMicrosoft() ); } @@ -86,11 +86,11 @@ public function getApplicationAccessToken() : string { //get application access token try { $guzzle = new \GuzzleHttp\Client(); - $url = 'https://login.microsoftonline.com/' . config::getEnvironmentConfig()->microsoft->tenant . '/oauth2/token?api-version=1.0'; + $url = 'https://login.microsoftonline.com/' . config::getMicrosoft()->tenant . '/oauth2/token?api-version=1.0'; $token = json_decode( $guzzle->post( $url, [ 'form_params' => [ - 'client_id' => config::getEnvironmentConfig()->microsoft->clientId, - 'client_secret' => config::getEnvironmentConfig()->microsoft->clientSecret, + 'client_id' => config::getMicrosoft()->clientId, + 'client_secret' => config::getMicrosoft()->clientSecret, 'resource' => 'https://graph.microsoft.com/', 'grant_type' => 'client_credentials', ], diff --git a/src/services/microsoft/files.php b/src/services/microsoft/files.php index 813aaed..332bfb2 100644 --- a/src/services/microsoft/files.php +++ b/src/services/microsoft/files.php @@ -69,7 +69,7 @@ public function getFile( array $microsoftPathParts ): \Microsoft\Graph\Model\Dri $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "GET", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/root:/' . $this->rootBasePath . implode( '/', $microsoftPathParts ) ) + $driveItem = $graph->createRequest( "GET", "/drives/" . config::getMicrosoft()->driveId . '/root:/' . $this->rootBasePath . implode( '/', $microsoftPathParts ) ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); return $driveItem; @@ -99,7 +99,7 @@ public function getFileById( string $itemId ): \Microsoft\Graph\Model\DriveItem $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "GET", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/items/' . $itemId ) + $driveItem = $graph->createRequest( "GET", "/drives/" . config::getMicrosoft()->driveId . '/items/' . $itemId ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); return $driveItem; @@ -126,7 +126,7 @@ public function moveItem( string $itemIdToMove, string $newParentDirItemId ) : \ $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . "/items/" . $itemIdToMove ) + $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getMicrosoft()->driveId . "/items/" . $itemIdToMove ) ->attachBody( [ 'parentReference'=>[ 'id'=>$newParentDirItemId ]] ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); @@ -154,7 +154,7 @@ public function renameItem( string $itemIdToRename, string $newName ) : \Microso $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . "/items/" . $itemIdToRename ) + $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getMicrosoft()->driveId . "/items/" . $itemIdToRename ) ->attachBody( [ 'name'=>$newName ] ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); @@ -189,8 +189,8 @@ public function downloadFile( string $itemId, string $pathToTempSave ): string { $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "GET", '/drives/' . config::getEnvironmentConfig()->microsoft->driveId . '/items/' . $itemId )->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); - $graph->createRequest( "GET", '/drives/' . config::getEnvironmentConfig()->microsoft->driveId . '/items/' . $itemId . '/content' )->download( $pathToTempSave . '/' . $itemId . '/' . $driveItem->getName() ); + $driveItem = $graph->createRequest( "GET", '/drives/' . config::getMicrosoft()->driveId . '/items/' . $itemId )->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); + $graph->createRequest( "GET", '/drives/' . config::getMicrosoft()->driveId . '/items/' . $itemId . '/content' )->download( $pathToTempSave . '/' . $itemId . '/' . $driveItem->getName() ); return $pathToTempSave . '/' . $itemId . '/' . $driveItem->getName(); } @@ -226,7 +226,7 @@ public function upload( string $serverFullFilePath, string $fileName, array $upl $graph = new \Microsoft\Graph\Graph(); $graph->setAccessToken( $accessToken ); - $fileEndpoint = "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/root:/' . $this->rootBasePath . implode( '/', $uploadPathParts ) . '/' . $fileName; + $fileEndpoint = "/drives/" . config::getMicrosoft()->driveId . '/root:/' . $this->rootBasePath . implode( '/', $uploadPathParts ) . '/' . $fileName; $fileSize = filesize( $serverFullFilePath ); @@ -240,7 +240,7 @@ public function upload( string $serverFullFilePath, string $fileName, array $upl if(!empty($fileDescription)) { try { - $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/items/' . $driveItem->getId() ) + $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getMicrosoft()->driveId . '/items/' . $driveItem->getId() ) ->attachBody( [ "description" => $fileDescription ] ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); } @@ -327,7 +327,7 @@ public function delete( string $itemId ) { $graph = new \Microsoft\Graph\Graph(); $graph->setAccessToken( $accessToken ); - $itemEndpoint = "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . "/items/" . $itemId; + $itemEndpoint = "/drives/" . config::getMicrosoft()->driveId . "/items/" . $itemId; try { $deleteRequest = $graph->createRequest( "DELETE", $itemEndpoint )->execute(); @@ -373,7 +373,7 @@ private function getMicrosoftDriveItems( $accessToken, string $path ): array { //get all the project folders /** @var \Microsoft\Graph\Model\DriveItem[] $driveItems */ - $driveItems = $graph->createRequest( "GET", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/root:/' . $this->rootBasePath . $path . ":/children" ) + $driveItems = $graph->createRequest( "GET", "/drives/" . config::getMicrosoft()->driveId . '/root:/' . $this->rootBasePath . $path . ":/children" ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); foreach( $driveItems as $i => $driveItem ) { diff --git a/src/services/microsoft/mail.php b/src/services/microsoft/mail.php index 8035b0b..939ce4d 100644 --- a/src/services/microsoft/mail.php +++ b/src/services/microsoft/mail.php @@ -65,7 +65,7 @@ public function send( string|array $to, string $subject, string $content, string } if( $from == '' ) { - $from = config::getEnvironmentConfig()->microsoft->fromAddress; + $from = config::getMicrosoft()->fromAddress; } $mailBody = [ diff --git a/src/services/mongodb/dispatcher.php b/src/services/mongodb/dispatcher.php index 13f1330..619d3ab 100644 --- a/src/services/mongodb/dispatcher.php +++ b/src/services/mongodb/dispatcher.php @@ -397,7 +397,7 @@ public static function _insertEmbedded( object $objectToInsert, ?\MongoDB\Driver * @throws \gcgov\framework\exceptions\modelException */ protected static function _runMongoActions( array $mongoActions, string $logChannel, ?\MongoDB\Driver\Session $mongoDbSession = null ): array { - $logging = config::getEnvironmentConfig()->type=='local'; + $logging = config::isLocal(); $sessionParent = false; @@ -435,7 +435,7 @@ protected static function _runMongoActions( array $mongoActions, string $logChan $updateInsertDeleteResults[] = new updateDeleteResult( $result ); //create index files in local environment - if( \gcgov\framework\config::getEnvironmentConfig()->isLocal() ) { + if( \gcgov\framework\config::isLocal() ) { foreach( $queries as $operations ) { foreach( $operations as $operationType => $filterUpdateOptions ) { $index = []; @@ -471,7 +471,7 @@ protected static function _runMongoActions( array $mongoActions, string $logChan } } - if( \gcgov\framework\config::getEnvironmentConfig()->isLocal() ) { + if( \gcgov\framework\config::isLocal() ) { if( count( self::$_indexesToCreate )>0 ) { $filename = \gcgov\framework\config::getTempDir() . '/create-indexes-' . microtime() . '.js'; diff --git a/src/services/mongodb/models/_meta.php b/src/services/mongodb/models/_meta.php index 184baa9..3803dd5 100644 --- a/src/services/mongodb/models/_meta.php +++ b/src/services/mongodb/models/_meta.php @@ -77,8 +77,8 @@ public function jsonSerialize(): array { ]; //TODO: make sure this is using the actual database it's being used for - if( isset( config::getEnvironmentConfig()->mongoDatabases[ 0 ] ) ) { - $mdbConfig = config::getEnvironmentConfig()->mongoDatabases[ 0 ]; + if( isset( config::getMongoDatabases()[ 0 ] ) ) { + $mdbConfig = config::getMongoDatabases()[ 0 ]; if( $mdbConfig->include_metaLabels ) { $export[ 'labels' ] = $this->labels; } diff --git a/src/services/mongodb/models/auth/user.php b/src/services/mongodb/models/auth/user.php index c7c8d9f..8ad9295 100644 --- a/src/services/mongodb/models/auth/user.php +++ b/src/services/mongodb/models/auth/user.php @@ -31,7 +31,7 @@ class user public function __construct() { parent::__construct(); $this->_id = new \MongoDB\BSON\ObjectId(); - if( config::getAppConfig()->settings->forceMfaForPasswordUsers ) { + if( config::getSettings()->forceMfaForPasswordUsers ) { $this->mfaRequired = true; } } @@ -170,7 +170,7 @@ public function getId(): \MongoDB\BSON\ObjectId { protected function _beforeBsonSerialize(): void { - if( config::getAppConfig()->settings->forceMfaForPasswordUsers ) { + if( config::getSettings()->forceMfaForPasswordUsers ) { $this->mfaRequired = true; } @@ -196,7 +196,7 @@ protected function _beforeBsonSerialize(): void { protected function _afterBsonUnserialize( $rawBsonData ): void { - if( config::getAppConfig()->settings->forceMfaForPasswordUsers ) { + if( config::getSettings()->forceMfaForPasswordUsers ) { $this->mfaRequired = true; } } diff --git a/src/services/mongodb/tools/log.php b/src/services/mongodb/tools/log.php index f3ba85a..055a66d 100644 --- a/src/services/mongodb/tools/log.php +++ b/src/services/mongodb/tools/log.php @@ -83,12 +83,12 @@ public static function emergency( string $channel, string $message, array $conte private static function isMongoLoggingEnabled(): bool { try { - $envConfig = config::getEnvironmentConfig(); + $mongoDatabases = config::getMongoDatabases(); } catch( \gcgov\framework\exceptions\configException $e ) { return false; } - return isset( $envConfig->mongoDatabases[ 0 ] ) && $envConfig->mongoDatabases[ 0 ]->logging; + return isset( $mongoDatabases[ 0 ] ) && $mongoDatabases[ 0 ]->logging; } @@ -102,7 +102,7 @@ private static function getLogger( string $channel = '' ): Logger { if( $channel === '' ) { try { - $channel = \gcgov\framework\config::getAppConfig()->app->title; + $channel = \gcgov\framework\config::getApp()->title; if( $channel === '' ) { $channel = 'app'; } diff --git a/src/services/mongodb/tools/mdb.php b/src/services/mongodb/tools/mdb.php index 83f1d0c..b8df99d 100644 --- a/src/services/mongodb/tools/mdb.php +++ b/src/services/mongodb/tools/mdb.php @@ -118,9 +118,7 @@ private function addEncryptionDriverOptions( mongoDatabase $connector, string $c * @throws \gcgov\framework\exceptions\modelException */ public function getConnector( string $database = '' ): mongoDatabase { - $environmentConfig = config::getEnvironmentConfig(); - - foreach( $environmentConfig->mongoDatabases as $mongoDatabase ) { + foreach( config::getMongoDatabases() as $mongoDatabase ) { if( $mongoDatabase->default && $database==='' ) { return $mongoDatabase; } diff --git a/src/services/pdodb/pdodb.php b/src/services/pdodb/pdodb.php index 28b9f78..d8b006a 100644 --- a/src/services/pdodb/pdodb.php +++ b/src/services/pdodb/pdodb.php @@ -16,16 +16,16 @@ class pdodb extends PDO { * @throws \PDOException */ public function __construct( bool $readOnly=true, string $databaseName='' ) { - $envConfig = config::getEnvironmentConfig(); + $sqlDatabases = config::getSqlDatabases(); - if(count($envConfig->sqlDatabases)===0) { + if(count($sqlDatabases)===0) { throw new \PDOException('No database connectors are defined in the app environment config'); } //find the matching database /** @var ?\gcgov\framework\models\config\environment\sqlDatabase $useSqlDatabase */ $useSqlDatabase = null; - foreach($envConfig->sqlDatabases as $sqlDatabase) { + foreach($sqlDatabases as $sqlDatabase) { if($databaseName==='' && $sqlDatabase->default) { $useSqlDatabase = $sqlDatabase; break; diff --git a/src/services/userCrud/controllers/user.php b/src/services/userCrud/controllers/user.php new file mode 100644 index 0000000..6a4b2bd --- /dev/null +++ b/src/services/userCrud/controllers/user.php @@ -0,0 +1,234 @@ + [ + 'name' => -1 + ] + ]; + $limit = $_GET[ 'limit' ] ?? 10; + $page = $_GET[ 'page' ] ?? 1; + + + try { + $userClassName = request::getUserClassFqdn(); + $users = $userClassName::getPagedResponse($limit, $page, $filter, $options ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + + return new controllerPagedDataResponse( $users ); + + } + + + /** + * @OA\Get( + * path="/user/{_id}", + * tags={"User", "Auth"}, + * description="Fetch an user object", + * @OA\Parameter(in="path", name="_id", required=true, @OA\Schema(type="string")), + * @OA\Response( + * response="200", + * description="Successfully fetched", + * @OA\JsonContent(ref="#/components/schemas/user") + * ) + * ) + * + * @param string $_id + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function getOne( string $_id ): controllerDataResponse { + + $userClassName = request::getUserClassFqdn(); + if( $_id==='new' ) { + $user = new $userClassName(); + } + else { + try { + $user = $userClassName::getOne( $_id ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + } + + return new controllerDataResponse( $user ); + } + + + /** + * @OA\Post( + * path="/user/{_id}", + * tags={"User", "Auth"}, + * description="Create or update an user object", + * @OA\Parameter(in="path", name="_id", required=true, @OA\Schema(type="string")), + * @OA\RequestBody( + * description="user object to save", + * required=true, + * @OA\JsonContent(ref="#/components/schemas/user") + * ), + * @OA\Response( + * response="200", + * description="Successfully saved", + * @OA\JsonContent(ref="#/components/schemas/user") + * ) + * ) + * + * @param string $_id + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function save( string $_id ): controllerDataResponse { + + $userClassName = request::getUserClassFqdn(); + $userJSON = file_get_contents( 'php://input' ); + try { + $user = $userClassName::jsonDeserialize( $userJSON ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + + // The URL decides which document is written — not the body. This method used to + // ignore $_id entirely, so a caller holding User.Write could POST to their own + // /user/{_id} with a body naming any other account and overwrite it, roles + // included. The URL is also the only part a reverse proxy, an audit log or a + // route-level policy can see, so it has to be the part that binds. + // + // Compared as strings rather than coerced to a type: getId() is declared + // string|int|ObjectId, and an application may substitute its own user model. + if( $_id==='new' ) { + // A create must create. Without this, POST /user/new carrying an existing + // account's _id would overwrite that account — the same hole by another route. + // The fresh identity is ASSIGNED rather than unset(): the model's $_id is a + // typed ObjectId that save() reads unconditionally, so an unset property is a + // fatal uninitialized-property Error on the framework's own create endpoint. + $user->_id = new \MongoDB\BSON\ObjectId(); + } + elseif( !isset( $user->_id ) || (string)$user->_id==='' ) { + throw new controllerException( 'The request body must carry the _id it is being saved to', 400 ); + } + elseif( (string)$user->_id!==$_id ) { + throw new controllerException( 'The _id in the request body does not match the _id in the url', 400 ); + } + + try { + $userClassName::save( $user ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + + return new controllerDataResponse( $user ); + } + + + /** + * @OA\Delete( + * path="/user/{_id}", + * tags={"User", "Auth"}, + * @OA\Parameter(in="path", name="_id", required=true, @OA\Schema(type="string")), + * @OA\Response( + * response="204", + * description="Successfully deleted" + * ) + * ) + * + * @param string $_id + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function delete( string $_id ): controllerDataResponse { + $userClassName = request::getUserClassFqdn(); + try { + $deleteResult = $userClassName::delete( $_id ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + + if( $deleteResult->getDeletedCount()===0 ) { + throw new controllerException( 'user not found', 404 ); + } + + $response = new controllerDataResponse(); + $response->setHttpStatus( 204 ); + return $response; + + } + +} diff --git a/src/services/userCrud/router.php b/src/services/userCrud/router.php new file mode 100644 index 0000000..0368776 --- /dev/null +++ b/src/services/userCrud/router.php @@ -0,0 +1,43 @@ + */ + public static array $records = []; + + /** @var \Exception|null */ + public static ?\Exception $nextException = null; + + /** @var int */ + public static int $deleteAffectedCount = 1; + + public string $_id = ''; + public string $name = ''; + public string $email = ''; + public string $username = ''; + /** @var string[] */ + public array $roles = []; + + public static function reset(): void { + self::$records = []; + self::$nextException = null; + self::$deleteAffectedCount = 1; + } + + /** + * @param int $limit + * @param int $page + * @param array $filter + * @param array $options + */ + public static function getPagedResponse( int $limit, int $page, array $filter, array $options ): FakeDbGetResult { + self::throwIfQueued(); + $result = new FakeDbGetResult(); + $result->setData( array_values( self::$records ) ); + $result->setLimit( $limit ); + $result->setPage( $page ); + $result->setTotalDocumentCount( count( self::$records ) ); + return $result; + } + + public static function getOne( \MongoDB\BSON\ObjectId|string|int $_id ): self { + self::throwIfQueued(); + $key = (string) $_id; + if ( !isset( self::$records[ $key ] ) ) { + throw new \gcgov\framework\exceptions\modelException( 'not found', 404 ); + } + return self::$records[ $key ]; + } + + public static function save( object &$object ): mixed { + self::throwIfQueued(); + if ( !( $object instanceof self ) ) { + throw new \InvalidArgumentException( 'Expected ' . self::class ); + } + // Mirrors the real factory: save() reads $object->_id unconditionally to build its + // update filter, so an unset typed property is an Error here exactly as it is in + // production. A fresh identity comes from the caller — save() never mints one. + self::$records[ (string) $object->_id ] = $object; + return $object; + } + + public static function delete( string $_id ): FakeDeleteResult { + self::throwIfQueued(); + $count = self::$deleteAffectedCount; + if ( isset( self::$records[ $_id ] ) ) { + unset( self::$records[ $_id ] ); + } + return new FakeDeleteResult( $count ); + } + + public static function jsonDeserialize( string $json ): self { + $data = json_decode( $json, true ) ?: []; + $instance = new self(); + $instance->_id = (string) ( $data[ '_id' ] ?? '' ); + $instance->name = (string) ( $data[ 'name' ] ?? '' ); + return $instance; + } + + private static function throwIfQueued(): void { + if ( self::$nextException !== null ) { + $e = self::$nextException; + self::$nextException = null; + throw $e; + } + } + + public function getId(): string|int|\MongoDB\BSON\ObjectId { return $this->_id; } + public function getName(): string { return $this->name; } + public function getUsername(): string { return $this->username; } + public function getPassword(): string { return ''; } + public function getOauthId(): string { return ''; } + public function getOauthProvider(): string { return ''; } + public function getEmail(): string { return $this->email; } + public function getRoles(): array { return $this->roles; } + public function getActive(): bool { return true; } + public function getMfaRequired(): bool { return false; } + public function getMfaConfigured(): bool { return false; } + public static function getFromOauth( string $email, string $externalId, string $externalProvider, ?string $firstName = '', ?string $lastName = '', bool $addIfNotExisting = false, array $rolesForNewUser=[] ): self { throw new \BadMethodCallException(); } + public static function verifyUsernamePassword( string $username, string $password ): self { throw new \BadMethodCallException(); } + public static function getOneByExternalId( string $externalId ): self { throw new \BadMethodCallException(); } + public static function getOneByEmail( string $email ): self { throw new \BadMethodCallException(); } + +} + +class FakeDeleteResult { + public function __construct( private int $deletedCount ) {} + public function getDeletedCount(): int { + return $this->deletedCount; + } +} + +class FakeDbGetResult implements \gcgov\framework\interfaces\dbGetResult { + /** @var array */ + private array $data = []; + private int $limit = 10; + private int $page = 1; + private int $totalDocumentCount = 0; + + public function setData( array $data ): void { $this->data = $data; } + public function setLimit( int $limit ): void { $this->limit = max( 1, $limit ); } + public function setPage( int $page ): void { $this->page = max( 1, $page ); } + public function getData(): array { return $this->data; } + public function getLimit(): int { return $this->limit; } + public function getSkip(): int { return ( $this->page - 1 ) * $this->limit; } + public function getCount(): int { return count( $this->data ); } + public function getPage(): int { return $this->page; } + public function getTotalDocumentCount(): int { return $this->totalDocumentCount; } + public function setTotalDocumentCount( int $totalDocumentCount ): void { $this->totalDocumentCount = $totalDocumentCount; } + public function getTotalPageCount(): int { + return (int) ceil( $this->totalDocumentCount / $this->limit ); + } +} diff --git a/tests/Support/capturesFrameworkLog.php b/tests/Support/capturesFrameworkLog.php new file mode 100644 index 0000000..a661678 --- /dev/null +++ b/tests/Support/capturesFrameworkLog.php @@ -0,0 +1,76 @@ + channel => the logger that was cached before */ + private array $capturedFrameworkLogChannels = []; + + + /** + * Route one channel's records into a TestHandler for the rest of this test. + * + * The channel is the first argument to log::warning() and friends at the call site + * under test — 'health', 'Framework Lifecycle', and so on. + */ + protected function captureLog( string $channel ): TestHandler { + $loggers = new \ReflectionProperty( log::class, 'loggers' ); + /** @var array $current */ + $current = $loggers->getValue(); + + if( !array_key_exists( $channel, $this->capturedFrameworkLogChannels ) ) { + $this->capturedFrameworkLogChannels[ $channel ] = $current[ $channel ] ?? null; + } + + $handler = new TestHandler(); + $current[ $channel ] = new Logger( $channel, [ $handler ] ); + $loggers->setValue( null, $current ); + + return $handler; + } + + + #[After] + protected function restoreCapturedFrameworkLog(): void { + if( count( $this->capturedFrameworkLogChannels )===0 ) { + return; + } + + $loggers = new \ReflectionProperty( log::class, 'loggers' ); + /** @var array $current */ + $current = $loggers->getValue(); + + foreach( $this->capturedFrameworkLogChannels as $channel => $previous ) { + if( $previous===null ) { + unset( $current[ $channel ] ); + } + else { + $current[ $channel ] = $previous; + } + } + $loggers->setValue( null, $current ); + + $this->capturedFrameworkLogChannels = []; + } + +} diff --git a/tests/Support/seedsFrameworkConfig.php b/tests/Support/seedsFrameworkConfig.php new file mode 100644 index 0000000..984fd16 --- /dev/null +++ b/tests/Support/seedsFrameworkConfig.php @@ -0,0 +1,68 @@ +isInitialized() ? $property->getValue() : null; + self::$seedsFrameworkConfigCaptured = true; + } + + $config = new unifiedConfig(); + if( $mutate!==null ) { + $mutate( $config ); + } + $property->setValue( null, $config ); + + return $config; + } + + + /** Put back the configuration that was installed before this test ran. */ + protected function restoreConfig(): void { + if( !self::$seedsFrameworkConfigCaptured ) { + return; + } + + ( new \ReflectionProperty( config::class, 'unifiedConfig' ) )->setValue( null, self::$seedsFrameworkConfigOriginal ); + self::$seedsFrameworkConfigCaptured = false; + self::$seedsFrameworkConfigOriginal = null; + } + + + protected function tearDown(): void { + $this->restoreConfig(); + parent::tearDown(); + } + +} diff --git a/tests/Unit/Cli/AppContextTest.php b/tests/Unit/Cli/AppContextTest.php index cbda727..e5b3f41 100644 --- a/tests/Unit/Cli/AppContextTest.php +++ b/tests/Unit/Cli/AppContextTest.php @@ -90,40 +90,80 @@ public function testDirectoryAccessors(): void { $this->assertNotNull( $context ); $root = str_replace( '\\', '/', $this->tempRootDir ); $this->assertSame( $root . '/app', $context->getAppDir() ); - $this->assertSame( $root . '/app/config', $context->getConfigDir() ); $this->assertSame( $root . '/srv', $context->getSrvDir() ); $this->assertSame( $root . '/vendor/autoload.php', $context->getVendorAutoloadPath() ); } - public function testLoadEnvironmentConfigParsesVariantFile(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', json_encode( [ + public function testLoadConfigParsesActiveFile(): void { + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => 'mongodb://u:p@h:27017/widgets' ] ], ] ) ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - $environmentConfig = $context->loadEnvironmentConfig( 'prod' ); + $environmentConfig = $context->loadConfig(); $this->assertSame( 'prod', $environmentConfig->type ); $this->assertCount( 1, $environmentConfig->mongoDatabases ); $this->assertSame( 'widgets', $environmentConfig->mongoDatabases[0]->database ); } - public function testLoadEnvironmentConfigThrowsWhenMissing(): void { + public function testLoadConfigThrowsWhenMissing(): void { $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); $this->expectException( cliException::class ); - $context->loadEnvironmentConfig(); + $context->loadConfig(); } - public function testGetEnvironmentVariantsListsVariantFiles(): void { - touch( $this->tempRootDir . '/app/config/environment-local.json' ); - touch( $this->tempRootDir . '/app/config/environment-prod.json' ); - touch( $this->tempRootDir . '/app/config/environment.json' ); + + public function testLoadConfigResolvesEnvVars(): void { + $_ENV[ 'TEST_MONGO_URI' ] = 'mongodb://resolved:27017/widgets'; + putenv( 'TEST_MONGO_URI=mongodb://resolved:27017/widgets' ); + try { + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ + 'type' => 'prod', + 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MONGO_URI)%' ] ], + ] ) ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + $environmentConfig = $context->loadConfig(); + $this->assertSame( 'mongodb://resolved:27017/widgets', $environmentConfig->mongoDatabases[ 0 ]->uri ); + } + finally { + unset( $_ENV[ 'TEST_MONGO_URI' ] ); + putenv( 'TEST_MONGO_URI' ); + } + } + + + public function testLoadConfigThrowsCliExceptionWhenEnvVarMissing(): void { + unset( $_ENV[ 'TEST_MISSING_URI' ] ); + putenv( 'TEST_MISSING_URI' ); + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ + 'type' => 'prod', + 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MISSING_URI)%' ] ], + ] ) ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - $this->assertSame( [ 'local', 'prod' ], $context->getEnvironmentVariants() ); + $this->expectException( cliException::class ); + $context->loadConfig(); } + + + + + + + + + + + + + + + + private function deleteDirectory( string $directory ): void { if( !is_dir( $directory ) ) { return; diff --git a/tests/Unit/Cli/ApplicationTest.php b/tests/Unit/Cli/ApplicationTest.php index d1c90ca..59239cd 100644 --- a/tests/Unit/Cli/ApplicationTest.php +++ b/tests/Unit/Cli/ApplicationTest.php @@ -14,7 +14,7 @@ final class ApplicationTest extends TestCase { public function testAllBuiltInCommandsAreRegistered(): void { $application = new application(); - foreach( [ 'cli', 'cli:list', 'cert:generate-auth', 'db:restore', 'db:run', 'env', 'setup', 'deploy', 'completion:powershell', 'completion' ] as $commandName ) { + foreach( [ 'cli', 'cli:list', 'cert:generate-auth', 'db:run', 'env', 'init', 'migrate', 'completion:powershell', 'completion' ] as $commandName ) { $this->assertTrue( $application->has( $commandName ), 'missing command: ' . $commandName ); } } @@ -22,7 +22,7 @@ public function testAllBuiltInCommandsAreRegistered(): void { public function testNormalizeArgvJoinsSpaceSeparatedCommandNames(): void { $application = new application(); - $this->assertSame( [ 'gf', 'db:restore', '--from=prod' ], $application->normalizeArgv( [ 'gf', 'db', 'restore', '--from=prod' ] ) ); + $this->assertSame( [ 'gf', 'db:run', 'db/seed.js' ], $application->normalizeArgv( [ 'gf', 'db', 'run', 'db/seed.js' ] ) ); $this->assertSame( [ 'gf', 'cert:generate-auth' ], $application->normalizeArgv( [ 'gf', 'cert', 'generate-auth' ] ) ); } diff --git a/tests/Unit/Cli/CommandsTest.php b/tests/Unit/Cli/CommandsTest.php index bca048b..103c6ff 100644 --- a/tests/Unit/Cli/CommandsTest.php +++ b/tests/Unit/Cli/CommandsTest.php @@ -10,7 +10,6 @@ use gcgov\framework\cli\commands\cliListCommand; use gcgov\framework\cli\commands\completionPowershellCommand; use gcgov\framework\cli\commands\envCommand; -use gcgov\framework\cli\commands\setupCommand; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; @@ -19,7 +18,6 @@ #[CoversClass(certGenerateAuthCommand::class)] #[CoversClass(completionPowershellCommand::class)] #[CoversClass(envCommand::class)] -#[CoversClass(setupCommand::class)] final class CommandsTest extends TestCase { private string $tempRootDir = ''; @@ -58,16 +56,10 @@ public function testCliListShowsCliRoutesWithDescriptions(): void { $this->assertStringNotContainsString( '/widget', $display ); } - public function testEnvCommandCopiesVariantFiles(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-local.json', '{"type":"local"}' ); - $commandTester = new CommandTester( new envCommand() ); - $exitCode = $commandTester->execute( [ 'environment' => 'local' ] ); - $this->assertSame( 0, $exitCode ); - $this->assertSame( '{"type":"local"}', file_get_contents( $this->tempRootDir . '/app/config/environment.json' ) ); - $this->assertStringContainsString( 'copied', $commandTester->getDisplay() ); - } + + public function testCertGenerateAuthCreatesKeypairsAndGuidsJson(): void { if( !extension_loaded( 'openssl' ) ) { @@ -85,6 +77,10 @@ public function testCertGenerateAuthCreatesKeypairsAndGuidsJson(): void { $this->assertCount( 2, $guids ); foreach( $guids as $guid ) { + // Provisioning lowercases every secret filename it writes to the host, so the + // GUID inside the filename — and its guids.json spelling — must already be + // lowercase, or jwtAuth looks up a file that does not exist there. + $this->assertSame( strtolower( (string)$guid ), $guid, 'key GUIDs must be lowercase' ); $this->assertFileExists( $certificateDir . '/private-' . $guid . '.pem' ); $this->assertFileExists( $certificateDir . '/public-' . $guid . '.pem' ); $publicKey = openssl_pkey_get_public( (string)file_get_contents( $certificateDir . '/public-' . $guid . '.pem' ) ); @@ -96,6 +92,64 @@ public function testCertGenerateAuthCreatesKeypairsAndGuidsJson(): void { $this->assertFileExists( $certificateDir . '/.gitignore', 'gitignore is copied from the jwtAuth service directory' ); } + /** A configured jwtAuth.keyPath wins, and a relative one anchors to the app root. */ + public function testCertGenerateAuthHonorsAConfiguredKeyPath(): void { + if( !extension_loaded( 'openssl' ) ) { + $this->markTestSkipped( 'ext-openssl not loaded' ); + } + + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ 'jwtAuth' => [ 'keyPath' => 'srv/authKeys' ] ] ) ); + + $commandTester = new CommandTester( new certGenerateAuthCommand() ); + $exitCode = $commandTester->execute( [ '--count' => '1', '--yes' => true ] ); + + $this->assertSame( 0, $exitCode ); + $this->assertFileExists( $this->tempRootDir . '/srv/authKeys/guids.json' ); + } + + + /** + * `gf init` runs this command on a scaffold whose .env is still empty, so a failure + * to resolve UNRELATED references (MONGO_URI among them) must not block key + * generation — it needs only jwtAuth.keyPath. + */ + public function testCertGenerateAuthSucceedsWhenOnlyOtherReferencesAreUnresolved(): void { + if( !extension_loaded( 'openssl' ) ) { + $this->markTestSkipped( 'ext-openssl not loaded' ); + } + + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ + 'type' => '%env(GF_TEST_CERT_ABSENT)%', + 'jwtAuth' => [ 'keyPath' => 'srv/authKeys' ], + ] ) ); + + $commandTester = new CommandTester( new certGenerateAuthCommand() ); + $exitCode = $commandTester->execute( [ '--count' => '1', '--yes' => true ] ); + + $this->assertSame( 0, $exitCode ); + $this->assertFileExists( $this->tempRootDir . '/srv/authKeys/guids.json', 'the configured keyPath must still be honored' ); + } + + + /** Only when the keyPath reference ITSELF has no value does the default apply — loudly. */ + public function testCertGenerateAuthFallsBackToTheDefaultWhenTheKeyPathReferenceIsUnset(): void { + if( !extension_loaded( 'openssl' ) ) { + $this->markTestSkipped( 'ext-openssl not loaded' ); + } + + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ + 'jwtAuth' => [ 'keyPath' => '%env(GF_TEST_CERT_KEYPATH_ABSENT)%' ], + ] ) ); + + $commandTester = new CommandTester( new certGenerateAuthCommand() ); + $exitCode = $commandTester->execute( [ '--count' => '1', '--yes' => true ] ); + + $this->assertSame( 0, $exitCode ); + $this->assertFileExists( $this->tempRootDir . '/srv/jwtCertificates/guids.json' ); + $this->assertStringContainsString( 'GF_TEST_CERT_KEYPATH_ABSENT', $commandTester->getDisplay(), 'the fallback must name the unresolved variable' ); + } + + public function testCertGenerateAuthRegenerationReplacesOldKeys(): void { if( !extension_loaded( 'openssl' ) ) { $this->markTestSkipped( 'ext-openssl not loaded' ); @@ -125,34 +179,7 @@ public function testCompletionPowershellPrintsBridgeWithApiVersion(): void { $this->assertStringContainsString( '-a' . \Symfony\Component\Console\Command\CompleteCommand::COMPLETION_API_VERSION, $display ); } - public function testSetupRefusesNonInteractiveMode(): void { - $commandTester = new CommandTester( new setupCommand() ); - $this->expectException( \gcgov\framework\cli\cliException::class ); - $commandTester->execute( [], [ 'interactive' => false ] ); - } - - public function testSetupBuildReplacementTableDerivesUrlTokens(): void { - $setupCommand = new setupCommand(); - - $replacements = $setupCommand->buildReplacementTable( [ - 'app_title' => 'Widget API', - 'app_base_path' => 'api', - 'prod_app_base_path' => '/api/', - 'app_root_url' => 'https://local.example.gov/', - 'prod_app_absolute_path' => 'E:\Web\api\\', - ], '/var/www/widget' ); - - $this->assertSame( 'Widget API', $replacements[ '{app_title}' ] ); - $this->assertSame( '/api/', $replacements[ '{app_base_path}' ] ); - $this->assertSame( 'api/', $replacements[ '{app_relative_url}' ] ); - $this->assertSame( '/api/', $replacements[ '{prod_app_base_path}' ] ); - $this->assertSame( 'api/', $replacements[ '{prod_app_relative_url}' ] ); - $this->assertSame( 'https://local.example.gov', $replacements[ '{app_root_url}' ] ); - $this->assertSame( 'E:\Web\api', $replacements[ '{prod_app_absolute_path}' ] ); - $this->assertSame( '/var/www/widget', $replacements[ '{app_absolute_path}' ] ); - $this->assertNotSame( '', $replacements[ '{app_guid}' ] ); - } public function testDynamicRouteCompletionSuggestsCliRoutes(): void { $suggestions = \gcgov\framework\cli\commands\cliCommand::suggestCliRoutes( \Symfony\Component\Console\Completion\CompletionInput::fromTokens( [ 'gf', 'cli', '' ], 2 ) ); diff --git a/tests/Unit/Cli/DbRestoreCommandTest.php b/tests/Unit/Cli/DbRestoreCommandTest.php deleted file mode 100644 index 4166f9d..0000000 --- a/tests/Unit/Cli/DbRestoreCommandTest.php +++ /dev/null @@ -1,94 +0,0 @@ -database = $database; - $mongoDatabase->uri = $uri; - $mongoDatabase->default = $default; - - return $mongoDatabase; - } - - public function testPairDatabasesMatchesByName(): void { - $source = [ $this->makeDatabase( 'widgets', 'mongodb://prod/widgets' ), $this->makeDatabase( 'audit', 'mongodb://prod/audit' ) ]; - $target = [ $this->makeDatabase( 'audit', 'mongodb://local/audit' ), $this->makeDatabase( 'widgets', 'mongodb://local/widgets' ) ]; - - $pairs = dbRestoreCommand::pairDatabases( $source, $target ); - - $this->assertCount( 2, $pairs[ 'matched' ] ); - $this->assertSame( [], $pairs[ 'unmatched' ] ); - $this->assertSame( 'mongodb://local/widgets', $pairs[ 'matched' ][0][1]->uri ); - } - - public function testPairDatabasesFallsBackToDefaults(): void { - $source = [ $this->makeDatabase( 'appProd', 'mongodb://prod/appProd', true ) ]; - $target = [ $this->makeDatabase( 'appLocal', 'mongodb://local/appLocal', true ) ]; - - $pairs = dbRestoreCommand::pairDatabases( $source, $target ); - - $this->assertCount( 1, $pairs[ 'matched' ] ); - $this->assertSame( 'appLocal', $pairs[ 'matched' ][0][1]->database ); - } - - public function testPairDatabasesReportsUnmatched(): void { - $source = [ $this->makeDatabase( 'reports', 'mongodb://prod/reports' ) ]; - $target = [ $this->makeDatabase( 'widgets', 'mongodb://local/widgets' ) ]; - - $pairs = dbRestoreCommand::pairDatabases( $source, $target ); - - $this->assertSame( [], $pairs[ 'matched' ] ); - $this->assertSame( [ 'reports' ], $pairs[ 'unmatched' ] ); - } - - public function testPairDatabasesHonorsDbFilter(): void { - $source = [ $this->makeDatabase( 'widgets', 'u' ), $this->makeDatabase( 'audit', 'u' ) ]; - $target = [ $this->makeDatabase( 'widgets', 'u' ), $this->makeDatabase( 'audit', 'u' ) ]; - - $pairs = dbRestoreCommand::pairDatabases( $source, $target, [ 'audit' ] ); - - $this->assertCount( 1, $pairs[ 'matched' ] ); - $this->assertSame( 'audit', $pairs[ 'matched' ][0][0]->database ); - } - - public function testBuildDumpCommand(): void { - $sourceDb = $this->makeDatabase( 'widgets', 'mongodb://u:p@prod:27017/widgets' ); - - $this->assertSame( - [ '/usr/bin/mongodump', '--uri=mongodb://u:p@prod:27017/widgets', '--db=widgets', '--out=/tmp/dump' ], - dbRestoreCommand::buildDumpCommand( '/usr/bin/mongodump', $sourceDb, '/tmp/dump' ) - ); - } - - public function testBuildRestoreCommandSameNameHasNoNsRemap(): void { - $sourceDb = $this->makeDatabase( 'widgets', 'mongodb://prod/widgets' ); - $targetDb = $this->makeDatabase( 'widgets', 'mongodb://local/widgets' ); - - $this->assertSame( - [ '/usr/bin/mongorestore', '--uri=mongodb://local/widgets', '--drop', '/tmp/dump/widgets' ], - dbRestoreCommand::buildRestoreCommand( '/usr/bin/mongorestore', $sourceDb, $targetDb, '/tmp/dump' ) - ); - } - - public function testBuildRestoreCommandRemapsDifferingNames(): void { - $sourceDb = $this->makeDatabase( 'appProd', 'mongodb://prod/appProd' ); - $targetDb = $this->makeDatabase( 'appLocal', 'mongodb://local/appLocal' ); - - $this->assertSame( - [ '/usr/bin/mongorestore', '--uri=mongodb://local/appLocal', '--drop', '--nsFrom=appProd.*', '--nsTo=appLocal.*', '/tmp/dump/appProd' ], - dbRestoreCommand::buildRestoreCommand( '/usr/bin/mongorestore', $sourceDb, $targetDb, '/tmp/dump' ) - ); - } - -} diff --git a/tests/Unit/Cli/EnvCommandTest.php b/tests/Unit/Cli/EnvCommandTest.php new file mode 100644 index 0000000..8b51e87 --- /dev/null +++ b/tests/Unit/Cli/EnvCommandTest.php @@ -0,0 +1,129 @@ +renderEnvFile( [ 'APP_TYPE' => false, 'APP_ROOT_URL' => false ] ); + + self::assertStringContainsString( "APP_TYPE=\n", $rendered ); + self::assertStringContainsString( "APP_ROOT_URL=\n", $rendered ); + } + + + /** + * A secret gets its `_FILE` companion shown as a comment, because production supplies + * it that way and a developer reading the file should see that the option exists. + */ + public function testSecretsAreGroupedAndShowTheFileAlternative(): void { + $rendered = ( new envCommand() )->renderEnvFile( [ 'APP_TYPE' => false, 'MONGO_URI' => true ] ); + + self::assertStringContainsString( "MONGO_URI=\n", $rendered ); + self::assertStringContainsString( '# MONGO_URI_FILE=/run/secrets//mongo_uri', $rendered ); + self::assertStringContainsString( 'never commit', strtolower( $rendered ) ); + + // Plain variables come first, secrets in their own labelled block after. + self::assertLessThan( strpos( $rendered, 'MONGO_URI=' ), strpos( $rendered, 'APP_TYPE=' ) ); + } + + + public function testRenderedEnvSaysEveryVariableIsRequired(): void { + $rendered = ( new envCommand() )->renderEnvFile( [ 'APP_TYPE' => false ] ); + + self::assertStringContainsString( 'REQUIRED', $rendered ); + self::assertStringContainsString( 'empty string counts as unset', $rendered ); + } + + + public function testEmptyReferenceSetStillProducesAUsableFile(): void { + $rendered = ( new envCommand() )->renderEnvFile( [] ); + + self::assertStringStartsWith( '#', $rendered ); + self::assertStringEndsWith( "\n", $rendered ); + } + + + /** + * `gf env --init` promises in its own help text that it "leaves anything already in the + * file alone". It used to write the skeleton over the top of the file whenever --force + * was given — which is exactly what a developer does after the already-exists refusal — + * destroying every filled-in value and every variable config.json knows nothing about. + */ + public function testDeclaredNamesFindsExistingAssignments(): void { + $declared = self::declaredNames( <<<'ENV' + # a comment + APP_TYPE=local + MONGO_URI='mongodb://localhost' + export EXPORTED=1 + # MONGO_URI_FILE=/run/secrets//mongo_uri + COMPOSE_PORT=8080 + ENV ); + + self::assertArrayHasKey( 'APP_TYPE', $declared ); + self::assertArrayHasKey( 'MONGO_URI', $declared ); + self::assertArrayHasKey( 'EXPORTED', $declared, 'an export prefix is still a declaration' ); + self::assertArrayHasKey( 'COMPOSE_PORT', $declared, 'variables config.json never mentions still count' ); + } + + + public function testDeclaredNamesIgnoresCommentedHints(): void { + $declared = self::declaredNames( "# MONGO_URI_FILE=/run/secrets//mongo_uri +#APP_TYPE=local +" ); + + self::assertArrayNotHasKey( 'MONGO_URI_FILE', $declared, 'a commented hint is guidance, not a declaration' ); + self::assertArrayNotHasKey( 'APP_TYPE', $declared ); + } + + + /** + * Declared-ness is judged by the same symfony/dotenv parser the runtime loads the + * file with: a quoted value spans lines, so a NAME= at line start inside one is part + * of the VALUE, not a declaration. The hand-rolled regex this replaced counted it, + * told --init the variable was covered, and the app then failed at startup on a + * reference the tool had just reported as declared. + */ + public function testDeclaredNamesUsesTheRuntimeParserForMultiLineValues(): void { + $declared = self::declaredNames( "GREETING=\"first line\nMONGO_URI=not-a-declaration\"\nAPP_TYPE=local\n" ); + + self::assertArrayHasKey( 'GREETING', $declared ); + self::assertArrayHasKey( 'APP_TYPE', $declared ); + self::assertArrayNotHasKey( 'MONGO_URI', $declared, 'text inside a quoted value is not a declaration' ); + } + + + public function testDeclaredNamesRejectsAFileTheRuntimeCannotParse(): void { + $this->expectException( \gcgov\framework\cli\cliException::class ); + self::declaredNames( "SPACED = value\n" ); + } + + + /** + * A reserved CGI meta-variable name can never be resolved, so a live `NAME=` line + * would be filled in and still report MISSING forever — guidance is written instead. + */ + public function testReservedNamesAreWrittenAsGuidanceNotDeadAssignments(): void { + $rendered = ( new envCommand() )->renderEnvFile( [ 'SERVER_API_TOKEN' => false, 'APP_TYPE' => false ] ); + + self::assertStringNotContainsString( "\nSERVER_API_TOKEN=", $rendered ); + self::assertStringContainsString( 'reserved', $rendered ); + self::assertStringContainsString( "APP_TYPE=\n", $rendered, 'ordinary names still get live lines' ); + } + + + /** Reflection, because the parser is a private detail of the command. */ + private static function declaredNames( string $env ): array { + $method = new \ReflectionMethod( envCommand::class, 'declaredNames' ); + + return $method->invoke( null, $env, '.env' ); + } + +} diff --git a/tests/Unit/Cli/EnvironmentFilesTest.php b/tests/Unit/Cli/EnvironmentFilesTest.php deleted file mode 100644 index d0a1173..0000000 --- a/tests/Unit/Cli/EnvironmentFilesTest.php +++ /dev/null @@ -1,79 +0,0 @@ -tempRootDir = sys_get_temp_dir() . '/gcgov-envfiles-test-' . uniqid(); - mkdir( $this->tempRootDir . '/app/config', 0777, true ); - mkdir( $this->tempRootDir . '/www', 0777, true ); - } - - protected function tearDown(): void { - $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $this->tempRootDir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); - foreach( $iterator as $file ) { - $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); - } - rmdir( $this->tempRootDir ); - } - - public function testAppliesAllThreeVariantFiles(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-local.json', '{"type":"local"}' ); - file_put_contents( $this->tempRootDir . '/composer-local.json', '{"name":"local"}' ); - file_put_contents( $this->tempRootDir . '/www/web-local.config', '' ); - - $results = environmentFiles::apply( $this->tempRootDir, 'local' ); - - $this->assertCount( 3, $results ); - $this->assertSame( '{"type":"local"}', file_get_contents( $this->tempRootDir . '/app/config/environment.json' ) ); - $this->assertSame( '{"name":"local"}', file_get_contents( $this->tempRootDir . '/composer.json' ) ); - $this->assertSame( '', file_get_contents( $this->tempRootDir . '/www/web.config' ) ); - } - - public function testMissingVariantsAreSkippedGracefully(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', '{"type":"prod"}' ); - - $results = environmentFiles::apply( $this->tempRootDir, 'prod' ); - - $statuses = array_column( $results, 'status' ); - $this->assertSame( 'copied', $statuses[0] ); - $this->assertStringStartsWith( 'skipped', $statuses[1] ); - $this->assertStringStartsWith( 'skipped', $statuses[2] ); - $this->assertFileDoesNotExist( $this->tempRootDir . '/composer.json' ); - } - - public function testThrowsWhenNoVariantFileExists(): void { - $this->expectException( cliException::class ); - environmentFiles::apply( $this->tempRootDir, 'staging' ); - } - - public function testDryRunDoesNotWrite(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-local.json', '{"type":"local"}' ); - - $results = environmentFiles::apply( $this->tempRootDir, 'local', true ); - - $this->assertSame( 'would copy', $results[0][ 'status' ] ); - $this->assertFileDoesNotExist( $this->tempRootDir . '/app/config/environment.json' ); - } - - public function testExistingCanonicalFilesAreOverwritten(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', '{"type":"prod"}' ); - file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"local"}' ); - - environmentFiles::apply( $this->tempRootDir, 'prod' ); - - $this->assertSame( '{"type":"prod"}', file_get_contents( $this->tempRootDir . '/app/config/environment.json' ) ); - } - -} diff --git a/tests/Unit/Cli/GfBinSmokeTest.php b/tests/Unit/Cli/GfBinSmokeTest.php index 790660d..3d7a7a5 100644 --- a/tests/Unit/Cli/GfBinSmokeTest.php +++ b/tests/Unit/Cli/GfBinSmokeTest.php @@ -27,7 +27,7 @@ public function testGfListRunsOutsideAnApplication(): void { $this->assertSame( 0, $process->getExitCode(), $process->getOutput() . $process->getErrorOutput() ); $this->assertStringContainsString( 'cli:list', $process->getOutput() ); - $this->assertStringContainsString( 'db:restore', $process->getOutput() ); + $this->assertStringContainsString( 'db:run', $process->getOutput() ); } public function testGfSpaceSeparatedCommandNameResolves(): void { @@ -36,11 +36,11 @@ public function testGfSpaceSeparatedCommandNameResolves(): void { $this->markTestSkipped( 'framework vendor/ not installed' ); } - $process = new Process( [ PHP_BINARY, $frameworkRoot . '/bin/gf', 'db', 'restore', '--help', '--no-ansi' ], $frameworkRoot ); + $process = new Process( [ PHP_BINARY, $frameworkRoot . '/bin/gf', 'db', 'run', '--help', '--no-ansi' ], $frameworkRoot ); $process->run(); $this->assertSame( 0, $process->getExitCode(), $process->getOutput() . $process->getErrorOutput() ); - $this->assertStringContainsString( 'db:restore', $process->getOutput() ); + $this->assertStringContainsString( 'db:run', $process->getOutput() ); } } diff --git a/tests/Unit/Cli/InitCommandTest.php b/tests/Unit/Cli/InitCommandTest.php new file mode 100644 index 0000000..10bedd6 --- /dev/null +++ b/tests/Unit/Cli/InitCommandTest.php @@ -0,0 +1,183 @@ +tempRootDir = sys_get_temp_dir() . '/gcgov-init-test-' . uniqid(); + mkdir( $this->tempRootDir . '/vendor', 0777, true ); + mkdir( $this->tempRootDir . '/app', 0777, true ); + touch( $this->tempRootDir . '/vendor/autoload.php' ); + touch( $this->tempRootDir . '/app/app.php' ); + touch( $this->tempRootDir . '/composer.json' ); + file_put_contents( $this->tempRootDir . '/config.json', '{"app":{"title":"","guid":""},"type":"%env(APP_TYPE)%","mongoDatabases":[{"default":true,"database":"%env(MONGO_DATABASE)%","uri":"%env(secret:MONGO_URI)%"}]}' ); + + \gcgov\framework\cli\appContext::$composerAutoloadPath = $this->tempRootDir . '/vendor/autoload.php'; + } + + + protected function tearDown(): void { + \gcgov\framework\cli\appContext::$composerAutoloadPath = null; + $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $this->tempRootDir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); + foreach( $iterator as $file ) { + $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $this->tempRootDir ); + } + + + /** + * The documented bootstrap starts with `cp .env.example .env`, so by the time init runs + * the file already exists and carries the docker compose variables. init used to see it + * and skip the step entirely, which left the application's own variables — every one of + * them required — undeclared, and `gf env` then failed on the first. + */ + public function testInitAppendsToAnExistingEnvRatherThanSkippingIt(): void { + file_put_contents( $this->tempRootDir . '/.env', "# compose\nHTTP_PORT=8080\n" ); + + $this->runInit(); + + $env = (string)file_get_contents( $this->tempRootDir . '/.env' ); + self::assertStringContainsString( 'HTTP_PORT=8080', $env, 'the compose half must survive' ); + self::assertMatchesRegularExpression( '/^APP_TYPE=/m', $env ); + self::assertMatchesRegularExpression( '/^MONGO_DATABASE=/m', $env ); + self::assertMatchesRegularExpression( '/^MONGO_URI=/m', $env ); + } + + + public function testInitWritesTheEnvFileWhenThereIsNone(): void { + $this->runInit(); + + $env = (string)file_get_contents( $this->tempRootDir . '/.env' ); + self::assertMatchesRegularExpression( '/^APP_TYPE=/m', $env ); + } + + + /** A value already filled in is never rewritten. */ + public function testInitLeavesFilledInValuesAlone(): void { + file_put_contents( $this->tempRootDir . '/.env', "APP_TYPE=local\n" ); + + $this->runInit(); + + $env = (string)file_get_contents( $this->tempRootDir . '/.env' ); + self::assertStringContainsString( 'APP_TYPE=local', $env ); + self::assertSame( 1, preg_match_all( '/^APP_TYPE=/m', $env ), 'the variable must not be declared twice' ); + } + + + /** + * Driven through a real Application because the .env step is `env --init` run as a + * sub-command, which a bare CommandTester cannot resolve. + */ + private function runInit(): void { + $application = new \gcgov\framework\cli\application(); + $application->setAutoExit( false ); + $exitCode = $application->run( + new \Symfony\Component\Console\Input\ArrayInput( [ 'command' => 'init', '--title' => 'Test API', '--skip-keys' => true, '--skip-chrome' => true ] ), + new \Symfony\Component\Console\Output\NullOutput() + ); + + self::assertSame( 0, $exitCode ); + } + + + public function testEmptyServiceBlocksSurviveTheRewrite(): void { + $original = '{"app":{"title":"","guid":""},"services":{"userCrud":{},"documentation":{}},"appDictionary":{}}'; + + $rewritten = initCommand::applyIdentity( $original, 'Timesheet API', 'abc-123' )[ 'json' ]; + + self::assertSame( '{}', $this->encodedFragment( $rewritten, [ 'services', 'userCrud' ] ) ); + self::assertSame( '{}', $this->encodedFragment( $rewritten, [ 'services', 'documentation' ] ) ); + self::assertSame( '{}', $this->encodedFragment( $rewritten, [ 'appDictionary' ] ) ); + } + + + public function testRewrittenConfigStillHydratesItsServices(): void { + $original = '{"app":{"title":"","guid":""},"services":{"userCrud":{},"documentation":{}}}'; + + $rewritten = initCommand::applyIdentity( $original, 'Timesheet API', 'abc-123' )[ 'json' ]; + $config = \gcgov\framework\models\unifiedConfig::jsonDeserialize( $rewritten ); + + self::assertNotNull( $config->services->userCrud, 'an empty block must still enable the service' ); + self::assertNotNull( $config->services->documentation ); + } + + + public function testTitleAndGuidAreStamped(): void { + $identity = initCommand::applyIdentity( '{"app":{"title":"","guid":""}}', 'Timesheet API', 'abc-123' ); + $decoded = json_decode( $identity[ 'json' ], false ); + + self::assertSame( 'Timesheet API', $decoded->app->title ); + self::assertSame( 'abc-123', $decoded->app->guid ); + self::assertSame( 'Timesheet API', $identity[ 'title' ] ); + self::assertFalse( $identity[ 'guidKept' ] ); + } + + + /** The guid is the OAuth client_id: reminting it would invalidate every registered client. */ + public function testExistingGuidIsKeptWhenNoneIsSupplied(): void { + $identity = initCommand::applyIdentity( '{"app":{"title":"Old","guid":"keep-me"}}', 'New Title', '' ); + $decoded = json_decode( $identity[ 'json' ], false ); + + self::assertSame( 'keep-me', $decoded->app->guid ); + self::assertSame( 'New Title', $decoded->app->title ); + self::assertTrue( $identity[ 'guidKept' ] ); + } + + + public function testAGuidIsMintedWhenThereIsNone(): void { + $identity = initCommand::applyIdentity( '{"app":{"title":"","guid":""}}', 'API', '' ); + + self::assertNotSame( '', $identity[ 'guid' ] ); + self::assertFalse( $identity[ 'guidKept' ] ); + } + + + public function testUnrelatedSectionsAreLeftIntact(): void { + $original = '{"app":{"title":"","guid":""},"mongoDatabases":[{"default":true,"database":"appdb"}],"type":"local"}'; + + $decoded = json_decode( initCommand::applyIdentity( $original, 'API', 'g' )[ 'json' ], false ); + + self::assertSame( 'local', $decoded->type ); + self::assertSame( 'appdb', $decoded->mongoDatabases[ 0 ]->database ); + } + + + public function testNonObjectJsonIsRejected(): void { + $this->expectException( \gcgov\framework\cli\cliException::class ); + + initCommand::applyIdentity( '[1,2,3]', 'API', 'g' ); + } + + + /** @param string[] $path */ + private function encodedFragment( string $json, array $path ): string { + $value = json_decode( $json, false ); + foreach( $path as $key ) { + self::assertObjectHasProperty( $key, $value, 'expected ' . implode( '.', $path ) . ' to survive' ); + $value = $value->{$key}; + } + + return (string)json_encode( $value ); + } + +} diff --git a/tests/Unit/Cli/MigrateCommandTest.php b/tests/Unit/Cli/MigrateCommandTest.php new file mode 100644 index 0000000..d236e30 --- /dev/null +++ b/tests/Unit/Cli/MigrateCommandTest.php @@ -0,0 +1,223 @@ +, 1: array} */ + private function v6Fixture(): array { + $appJson = [ + 'app' => [ 'title' => 'Permits API', 'guid' => 'f1f2f3' ], + 'email' => [ 'fromAddress' => 'noreply@example.gov', 'fromName' => 'Permits' ], + 'settings' => [ 'useSession' => false ], + ]; + + $environmentJson = [ + 'type' => 'prod', + 'serverName' => 'permits.example.gov', + 'rootUrl' => 'https://permits.example.gov', + 'basePath' => '/api/', + 'baseUrl' => 'https://permits.example.gov/api/', + 'cookieUrl' => 'https://permits.example.gov', + 'phpPath' => 'E:\\php', + 'mongoDatabases' => [ + [ 'default' => true, 'database' => 'permits', 'uri' => 'mongodb+srv://user:hunter2@cluster/' ], + ], + 'microsoft' => [ 'clientId' => 'abc', 'clientSecret' => 'super-secret', 'tenant' => 'contoso' ], + 'jwtAuth' => [ 'tokenIssuedBy' => 'https://permits.example.gov', 'redirectAfterLoginUrl' => 'https://permits.example.gov/app/in' ], + ]; + + return [ $appJson, $environmentJson ]; + } + + + public function testAppJsonSectionsMergeIntoTheUnifiedConfig(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + self::assertSame( 'Permits API', $plan[ 'config' ][ 'app' ][ 'title' ] ); + self::assertSame( 'f1f2f3', $plan[ 'config' ][ 'app' ][ 'guid' ] ); + self::assertSame( 'noreply@example.gov', $plan[ 'config' ][ 'email' ][ 'fromAddress' ] ); + self::assertFalse( $plan[ 'config' ][ 'settings' ][ 'useSession' ] ); + } + + + public function testRemovedKeysAreDroppedAndReported(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + foreach( [ 'serverName', 'cookieUrl', 'phpPath', 'baseUrl' ] as $removed ) { + self::assertArrayNotHasKey( $removed, $plan[ 'config' ] ); + self::assertNotEmpty( + array_filter( $plan[ 'warnings' ], fn( string $warning ): bool => str_contains( $warning, '"' . $removed . '"' ) ), + $removed . ' should be reported, not silently dropped' + ); + } + } + + + /** The point of the whole exercise: credentials leave the committed file. */ + public function testCredentialsBecomeSecretReferencesAndTheValuesMoveToTheEnv(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + self::assertSame( '%env(secret:MONGO_URI)%', $plan[ 'config' ][ 'mongoDatabases' ][ 0 ][ 'uri' ] ); + self::assertSame( '%env(MONGO_DATABASE)%', $plan[ 'config' ][ 'mongoDatabases' ][ 0 ][ 'database' ] ); + self::assertSame( '%env(secret:MICROSOFT_CLIENT_SECRET)%', $plan[ 'config' ][ 'microsoft' ][ 'clientSecret' ] ); + + self::assertSame( 'mongodb+srv://user:hunter2@cluster/', $plan[ 'env' ][ 'MONGO_URI' ] ); + self::assertSame( 'permits', $plan[ 'env' ][ 'MONGO_DATABASE' ] ); + self::assertSame( 'super-secret', $plan[ 'env' ][ 'MICROSOFT_CLIENT_SECRET' ] ); + + self::assertTrue( $plan[ 'secrets' ][ 'MONGO_URI' ] ); + self::assertTrue( $plan[ 'secrets' ][ 'MICROSOFT_CLIENT_SECRET' ] ); + self::assertFalse( $plan[ 'secrets' ][ 'MONGO_DATABASE' ] ); + } + + + public function testNonSecretIdentityValuesBecomePlainReferences(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + self::assertSame( '%env(APP_TYPE)%', $plan[ 'config' ][ 'type' ] ); + self::assertSame( '%env(APP_ROOT_URL)%', $plan[ 'config' ][ 'rootUrl' ] ); + self::assertSame( '%env(APP_BASE_PATH)%', $plan[ 'config' ][ 'basePath' ] ); + self::assertSame( 'prod', $plan[ 'env' ][ 'APP_TYPE' ] ); + self::assertSame( '/api/', $plan[ 'env' ][ 'APP_BASE_PATH' ] ); + } + + + public function testEmptyValuesAreLeftAloneRatherThanBecomingRequiredReferences(): void { + $plan = migrateCommand::plan( [], [ 'type' => 'local', 'microsoft' => [ 'clientSecret' => '' ] ] ); + + self::assertSame( '', $plan[ 'config' ][ 'microsoft' ][ 'clientSecret' ] ); + self::assertArrayNotHasKey( 'MICROSOFT_CLIENT_SECRET', $plan[ 'env' ] ); + } + + + public function testSecondAndSubsequentDatabasesGetDistinctVariables(): void { + $plan = migrateCommand::plan( [], [ + 'mongoDatabases' => [ + [ 'database' => 'primary', 'uri' => 'mongodb://one' ], + [ 'database' => 'archive', 'uri' => 'mongodb://two' ], + ], + ] ); + + self::assertSame( '%env(secret:MONGO_URI)%', $plan[ 'config' ][ 'mongoDatabases' ][ 0 ][ 'uri' ] ); + self::assertSame( '%env(secret:MONGO_URI_2)%', $plan[ 'config' ][ 'mongoDatabases' ][ 1 ][ 'uri' ] ); + self::assertSame( 'mongodb://one', $plan[ 'env' ][ 'MONGO_URI' ] ); + self::assertSame( 'mongodb://two', $plan[ 'env' ][ 'MONGO_URI_2' ] ); + } + + + /** + * An IIS application that upgrades must not silently stop writing its log files — + * changing destination is a decision for whoever containerises it. + */ + public function testLoggingDestinationIsPinnedToFileToPreserveV6Behaviour(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + self::assertSame( 'file', $plan[ 'config' ][ 'logging' ][ 'destination' ] ); + self::assertNotEmpty( array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'logging.destination' ) ) ); + } + + + public function testSqlDatabasesAreReportedRatherThanGuessedAt(): void { + $plan = migrateCommand::plan( [], [ 'sqlDatabases' => [ [ 'name' => 'legacy', 'dsn' => 'pgsql:host=db' ] ] ] ); + + self::assertSame( [ [ 'name' => 'legacy', 'dsn' => 'pgsql:host=db' ] ], $plan[ 'config' ][ 'sqlDatabases' ] ); + self::assertNotEmpty( array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'sqlDatabases' ) ) ); + } + + + public function testMissingGuidIsReportedBecauseOauthUsesItAsTheClientId(): void { + $plan = migrateCommand::plan( [ 'app' => [ 'title' => 'No Guid' ] ], [ 'type' => 'local' ] ); + + self::assertNotEmpty( array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'app.guid' ) ) ); + } + + + public function testReferenceRendering(): void { + self::assertSame( '%env(FOO)%', migrateCommand::reference( 'FOO', false ) ); + self::assertSame( '%env(secret:FOO)%', migrateCommand::reference( 'FOO', true ) ); + } + + + public function testDeadFilesPresentFindsBothFixedPathsAndEnvironmentVariants(): void { + $root = sys_get_temp_dir() . '/gcgov-migrate-test-' . uniqid(); + mkdir( $root . '/app/config', 0777, true ); + mkdir( $root . '/www', 0777, true ); + touch( $root . '/app/config/app.json' ); + touch( $root . '/app/config/environment.json' ); + touch( $root . '/app/config/environment-prod.json' ); + touch( $root . '/www/web-prod.config' ); + + $present = migrateCommand::deadFilesPresent( $root ); + + self::assertContains( 'app/config/app.json', $present ); + self::assertContains( 'app/config/environment.json', $present ); + self::assertContains( 'app/config/environment-prod.json', $present ); + self::assertContains( 'www/web-prod.config', $present ); + self::assertNotContains( 'update-production.ps1', $present, 'only files that actually exist' ); + + foreach( [ $root . '/app/config/app.json', $root . '/app/config/environment.json', $root . '/app/config/environment-prod.json', $root . '/www/web-prod.config' ] as $file ) { + unlink( $file ); + } + rmdir( $root . '/app/config' ); + rmdir( $root . '/app' ); + rmdir( $root . '/www' ); + rmdir( $root ); + } + + + /** + * The values gf migrate writes are the credentials lifted out of the v6 config. Written + * bare they were silently corrupted: symfony/dotenv interpolates $VAR in an unquoted + * value, treats a whitespace-preceded # as a comment, and rejects an embedded quote — + * and because every %env() reference is required, the damage surfaced later as a + * wrong-credential auth failure rather than a startup error. + */ + #[DataProvider('hostileEnvValues')] + public function testEnvValuesAreQuotedSoDotenvReadsThemBackUnchanged( string $value, string $description ): void { + $encoded = migrateCommand::encodeEnvValue( $value ); + $parsed = ( new \Symfony\Component\Dotenv\Dotenv() )->parse( 'SECRET=' . $encoded . "\n", '.env' ); + + self::assertSame( $value, $parsed[ 'SECRET' ], $description ); + } + + + /** @return array */ + public static function hostileEnvValues(): array { + return [ + 'plain' => [ 'simpleValue', 'an ordinary value is unaffected' ], + 'dollar signs' => [ 'pa$$w0rd', '$ would otherwise be interpolated away' ], + 'braced variable' => [ 'a${HOME}b', '${...} would otherwise be expanded' ], + 'hash' => [ 'mongodb://u:p#1@host/db', '# would otherwise start a comment' ], + 'space' => [ 'two words', 'an unquoted space is a format error' ], + 'single quote' => [ "it's", 'the quote that terminates the quoting we add' ], + 'double quote' => [ 'say "hi"', 'the other quote character' ], + 'backslash' => [ 'back\\slash', 'a backslash must not escape anything' ], + 'realistic uri' => [ 'mongodb+srv://user:P@ss#w0rd$x@cluster.example.net/db?retryWrites=true', 'the shape gf migrate actually writes' ], + ]; + } + +} diff --git a/tests/Unit/Cli/MigrateServicesTest.php b/tests/Unit/Cli/MigrateServicesTest.php new file mode 100644 index 0000000..1d51311 --- /dev/null +++ b/tests/Unit/Cli/MigrateServicesTest.php @@ -0,0 +1,152 @@ +setBlockNewUsers( false, constants::DEFAULT_ROLES ); + //$oauthConfig = \gcgov\framework\services\authoauth\oauthConfig::getInstance(); + //$oauthConfig->setBlockNewUsers( false, constants::DEFAULT_ROLES ); + return [ + '\gcgov\framework\services\documentation', + '\gcgov\framework\services\cronMonitor', + '\gcgov\framework\services\usercrud', + //'\gcgov\framework\services\authmsfront', + '\gcgov\framework\services\authoauth', + ]; + } + } + PHP; + + public function testCommentedOutRegistrationsAreNotDetected(): void { + $detected = migrateCommand::detectServices( self::TEMPLATE_APP_PHP ); + + $this->assertContains( 'documentation', $detected[ 'services' ] ); + $this->assertContains( 'userCrud', $detected[ 'services' ] ); + $this->assertContains( 'cronMonitor', $detected[ 'services' ] ); + $this->assertContains( 'auth:oauth', $detected[ 'services' ] ); + $this->assertNotContains( 'auth:msFront', $detected[ 'services' ], 'the msFront entry is commented out' ); + } + + + public function testCommentedOutSingletonCallsAreNotDetected(): void { + $detected = migrateCommand::detectServices( self::TEMPLATE_APP_PHP ); + + $this->assertSame( [], $detected[ 'singletons' ] ); + } + + + public function testLiveSingletonCallsAreDetected(): void { + $source = 'setBlockNewUsers( false, [ "Widget.Read" ] ); + $c->setAuthorizeUrlParameters( [ "prompt" => "consent" ] ); + } }'; + + $detected = migrateCommand::detectServices( $source ); + + $this->assertContains( 'setBlockNewUsers', $detected[ 'singletons' ] ); + $this->assertContains( 'setAuthorizeUrlParameters', $detected[ 'singletons' ] ); + } + + + public function testDetectedServicesBecomeAConfigSection(): void { + $detected = migrateCommand::detectServices( self::TEMPLATE_APP_PHP ); + $plan = migrateCommand::plan( [], [], $detected ); + + $services = $plan[ 'config' ][ 'services' ]; + $this->assertSame( [ 'provider' => 'oauth' ], $services[ 'auth' ] ); + $this->assertEquals( new \stdClass(), $services[ 'userCrud' ] ); + $this->assertEquals( new \stdClass(), $services[ 'documentation' ] ); + // cronMonitor is not a Framework Service any more, so it gets no services entry + $this->assertArrayNotHasKey( 'cronMonitor', $services ); + } + + + /** An empty services block must not appear at all rather than appear empty. */ + public function testNoDetectedServicesWritesNoServicesSection(): void { + $plan = migrateCommand::plan( [], [], [ 'services' => [], 'singletons' => [] ] ); + + $this->assertArrayNotHasKey( 'services', $plan[ 'config' ] ); + } + + + public function testRegisteringBothAuthServicesKeepsOneAndWarns(): void { + $plan = migrateCommand::plan( [], [], [ 'services' => [ 'auth:oauth', 'auth:msFront' ], 'singletons' => [] ] ); + + $this->assertSame( [ 'provider' => 'oauth' ], $plan[ 'config' ][ 'services' ][ 'auth' ] ); + $this->assertNotEmpty( array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'Both authentication services were registered' ) ) ); + } + + + public function testCronMonitorUrlMovesOutOfAppDictionary(): void { + $plan = migrateCommand::plan( [], [ 'appDictionary' => [ 'cronMonitorUrl' => 'https://monitor.local/', 'other' => 'kept' ] ] ); + + $this->assertSame( [ 'url' => 'https://monitor.local/' ], $plan[ 'config' ][ 'cronMonitor' ] ); + $this->assertSame( [ 'other' => 'kept' ], $plan[ 'config' ][ 'appDictionary' ] ); + } + + + public function testSingletonCallsBecomeWarningsNamingTheirReplacement(): void { + $plan = migrateCommand::plan( [], [], [ 'services' => [], 'singletons' => [ 'setBlockNewUsers' ] ] ); + + $matching = array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'setBlockNewUsers' ) ); + $this->assertNotEmpty( $matching ); + $this->assertStringContainsString( 'services.auth.blockNewUsers', implode( ' ', $matching ) ); + } + + + public function testServiceRequiresAreRemovedFromComposerJson(): void { + $result = migrateCommand::removeServiceRequires( [ + 'require' => [ + 'php' => '>=8.4', + 'gcgov/framework' => '^7.0', + 'gcgov/framework-service-documentation' => '^1.1', + 'gcgov/framework-service-user-crud' => '^1.1', + 'gcgov/framework-service-auth-oauth-server' => '^2.1', + ], + 'require-dev' => [ 'phpunit/phpunit' => '^11.5' ], + ] ); + + $this->assertSame( [ 'php' => '>=8.4', 'gcgov/framework' => '^7.0' ], $result[ 'json' ][ 'require' ] ); + $this->assertSame( [ 'phpunit/phpunit' => '^11.5' ], $result[ 'json' ][ 'require-dev' ] ); + $this->assertCount( 3, $result[ 'removed' ] ); + } + + + public function testComposerJsonWithoutServicePackagesIsUnchanged(): void { + $input = [ 'require' => [ 'php' => '>=8.4', 'gcgov/framework' => '^7.0' ] ]; + $result = migrateCommand::removeServiceRequires( $input ); + + $this->assertSame( $input, $result[ 'json' ] ); + $this->assertSame( [], $result[ 'removed' ] ); + } + +} diff --git a/tests/Unit/Cli/RouteCatalogTest.php b/tests/Unit/Cli/RouteCatalogTest.php index cdc970b..1020352 100644 --- a/tests/Unit/Cli/RouteCatalogTest.php +++ b/tests/Unit/Cli/RouteCatalogTest.php @@ -34,9 +34,54 @@ protected function tearDown(): void { rmdir( $this->tempRootDir ); } - public function testGetMergedRoutesReturnsAppRoutes(): void { - $routes = router::getMergedRoutes( [] ); - $this->assertCount( 3, $routes ); + public function testGetMergedRoutesReturnsFrameworkAndAppRoutes(): void { + $routes = router::getMergedRoutes(); + + // 3 stub app routes + the framework's own two health routes, which every + // application gets whether it asked for them or not. + $this->assertCount( 5, $routes ); + } + + + public function testHealthRoutesAreContributedFirstAndUnauthenticated(): void { + $routes = router::getMergedRoutes(); + + $healthRoutes = array_values( array_filter( $routes, fn( $route ) => str_contains( $route->route, '/health' ) ) ); + $this->assertCount( 2, $healthRoutes ); + + $paths = array_map( fn( $route ) => $route->route, $healthRoutes ); + $this->assertSame( [ '/api/health', '/api/health/ready' ], $paths ); + + foreach( $healthRoutes as $healthRoute ) { + $this->assertFalse( $healthRoute->authentication, 'a prober holds no token' ); + } + + // Contributed before anything else, so an application defining its own /health + // collides at boot rather than shadowing the deploy gate. + $this->assertSame( '/api/health', $routes[ 0 ]->route ); + } + + + /** + * The CLI reads the same `services` section the HTTP router does. Before this, the + * CLI asked \app\app for the namespaces without running _before(), so a service + * configured there was invisible to gf — the two paths could disagree. + */ + public function testEnabledServicesAppearInTheCliRouteCatalog(): void { + $original = ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->getValue(); + try { + $config = \gcgov\framework\models\unifiedConfig::jsonDeserialize( json_decode( '{"basePath":"api","services":{"userCrud":{},"documentation":{}}}', false ) ); + ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->setValue( null, $config ); + + $paths = array_map( fn( $route ) => $route->route, router::getMergedRoutes() ); + + $this->assertContains( '/api/user', $paths ); + $this->assertContains( '/api/documentation.yaml', $paths ); + $this->assertContains( '/api/health', $paths, 'health is never opt-in' ); + } + finally { + ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->setValue( null, $original ); + } } public function testGetCliRoutesFiltersToCliMethodOnly(): void { diff --git a/tests/Unit/Cli/RunRouteScriptTest.php b/tests/Unit/Cli/RunRouteScriptTest.php index 50ae5be..1e3bd0e 100644 --- a/tests/Unit/Cli/RunRouteScriptTest.php +++ b/tests/Unit/Cli/RunRouteScriptTest.php @@ -59,8 +59,14 @@ public function testRequiredIniFlagsRestoreArgumentsWhenPhpIniDisablesThem(): vo $output = $process->getErrorOutput() . $process->getOutput(); - // Arguments were readable: the script got past its argument checks and tried to - // require the (deliberately missing) autoloader. + // Arguments were readable: the script got past its argument checks and reached the + // (deliberately missing) autoloader. + // + // The script reports that itself rather than letting require's fatal stand in for it. + // Whether that fatal reaches us depends on the host php.ini — with display_errors Off + // and error_log naming a file, which is an ordinary server configuration, the child + // prints nothing at all and this assertion had nothing to match. + $this->assertSame( 2, $process->getExitCode() ); $this->assertStringNotContainsString( 'register_argc_argv', $output ); $this->assertStringNotContainsString( 'usage: php run-route.php', $output ); $this->assertStringContainsString( 'nonexistent-autoload.php', $output ); diff --git a/tests/Unit/Cli/TokenReplacerTest.php b/tests/Unit/Cli/TokenReplacerTest.php deleted file mode 100644 index 1d7a082..0000000 --- a/tests/Unit/Cli/TokenReplacerTest.php +++ /dev/null @@ -1,96 +0,0 @@ -tempRootDir = sys_get_temp_dir() . '/gcgov-tokenreplacer-test-' . uniqid(); - mkdir( $this->tempRootDir . '/app/config', 0777, true ); - mkdir( $this->tempRootDir . '/vendor/some/package', 0777, true ); - mkdir( $this->tempRootDir . '/srv', 0777, true ); - } - - protected function tearDown(): void { - $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $this->tempRootDir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); - foreach( $iterator as $file ) { - $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); - } - rmdir( $this->tempRootDir ); - } - - public function testReplacesTokensInEligibleExtensions(): void { - file_put_contents( $this->tempRootDir . '/app/config/app.json', '{"title":"{app_title}"}' ); - file_put_contents( $this->tempRootDir . '/setup.php', 'tempRootDir . '/readme.md', 'title: {app_title}' ); - - $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_title}' => 'Widget API' ] ); - - $this->assertCount( 2, $modified ); - $this->assertSame( '{"title":"Widget API"}', file_get_contents( $this->tempRootDir . '/app/config/app.json' ) ); - $this->assertSame( 'tempRootDir . '/setup.php' ) ); - // .md is not in the eligible extension list - $this->assertSame( 'title: {app_title}', file_get_contents( $this->tempRootDir . '/readme.md' ) ); - } - - public function testVendorIsExcluded(): void { - file_put_contents( $this->tempRootDir . '/vendor/some/package/file.json', '{"a":"{app_title}"}' ); - file_put_contents( $this->tempRootDir . '/web.config', '{app_title}' ); - - $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_title}' => 'X' ] ); - - $this->assertSame( [ str_replace( '\\', '/', $this->tempRootDir ) . '/web.config' ], $modified ); - $this->assertStringContainsString( '{app_title}', (string)file_get_contents( $this->tempRootDir . '/vendor/some/package/file.json' ) ); - } - - public function testSrvPhpIniFilesAreReplaced(): void { - // regression: the scaffold's per-environment php.ini files live under srv/ - // (srv/app.local-cli/php.ini etc.) and MUST receive token replacement - mkdir( $this->tempRootDir . '/srv/app.local-cli', 0777, true ); - file_put_contents( $this->tempRootDir . '/srv/app.local-cli/php.ini', 'xdebug.output_dir ="{app_absolute_path}\srv\profile"' . "\n" . 'guid={app_guid}' ); - - $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_absolute_path}' => 'E:\Web\api', '{app_guid}' => 'abc-123' ] ); - - $this->assertSame( [ str_replace( '\\', '/', $this->tempRootDir ) . '/srv/app.local-cli/php.ini' ], $modified ); - $contents = (string)file_get_contents( $this->tempRootDir . '/srv/app.local-cli/php.ini' ); - $this->assertStringContainsString( 'xdebug.output_dir ="E:\Web\api\srv\profile"', $contents ); - $this->assertStringContainsString( 'guid=abc-123', $contents ); - } - - public function testBackslashesAreEscapedInJsonFilesOnly(): void { - file_put_contents( $this->tempRootDir . '/a.json', '{"path":"{app_absolute_path}"}' ); - file_put_contents( $this->tempRootDir . '/a.ini', 'path={app_absolute_path}' ); - - tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_absolute_path}' => 'E:\Web\api' ] ); - - $this->assertSame( '{"path":"E:\\\\Web\\\\api"}', file_get_contents( $this->tempRootDir . '/a.json' ) ); - $this->assertSame( 'path=E:\Web\api', file_get_contents( $this->tempRootDir . '/a.ini' ) ); - } - - public function testEmptyValuesAreSkipped(): void { - file_put_contents( $this->tempRootDir . '/a.json', '{"title":"{app_title}"}' ); - - $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_title}' => '' ] ); - - $this->assertSame( [], $modified ); - $this->assertSame( '{"title":"{app_title}"}', file_get_contents( $this->tempRootDir . '/a.json' ) ); - } - - public function testFormatRelativeUrl(): void { - $this->assertSame( '/api/', tokenReplacer::formatRelativeUrl( 'api' ) ); - $this->assertSame( '/api/', tokenReplacer::formatRelativeUrl( '/api/' ) ); - $this->assertSame( 'api/', tokenReplacer::formatRelativeUrl( 'api', true, false ) ); - $this->assertSame( '/', tokenReplacer::formatRelativeUrl( '/' ) ); - $this->assertSame( '/a/b/', tokenReplacer::formatRelativeUrl( 'a\b' ) ); - } - -} diff --git a/tests/Unit/Cli/UserCreateCommandTest.php b/tests/Unit/Cli/UserCreateCommandTest.php new file mode 100644 index 0000000..11cd270 --- /dev/null +++ b/tests/Unit/Cli/UserCreateCommandTest.php @@ -0,0 +1,157 @@ +newUser(); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertSame( 'dev@example.test', $user->email ); + self::assertSame( 'dev@example.test', $user->username, 'verifyUsernamePassword() matches on username first, so a user created without one could never sign in' ); + } + + + public function testAnExplicitUsernameWins(): void { + $user = $this->newUser(); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'username' => 'dev' ] ); + + self::assertSame( 'dev', $user->username ); + } + + + /** + * The model hashes in _beforeBsonSerialize(). Hashing here too would store a hash of a + * hash, and no password would ever verify — the failure would look like a wrong password. + */ + public function testThePasswordIsStoredAsPlaintextForTheModelToHash(): void { + $user = $this->newUser(); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'password' => 'correct horse' ] ); + + self::assertSame( 'correct horse', $user->password ); + } + + + /** + * An omitted password on a --force update must leave the stored one alone: the model + * unsets an empty password rather than writing it, so `--force --roles=…` is a safe way + * to add a role. + */ + public function testAnOmittedPasswordLeavesTheExistingOneAlone(): void { + $user = $this->newUser(); + $user->password = 'already-hashed'; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'password' => '' ] ); + + self::assertSame( 'already-hashed', $user->password ); + } + + + public function testOmittedRolesAreLeftAloneButAnEmptyListClearsThem(): void { + $user = $this->newUser(); + $user->roles = [ 'User.Read' ]; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'roles' => null ] ); + self::assertSame( [ 'User.Read' ], $user->roles, 'a --roles that was never passed must not silently strip a user\'s roles' ); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'roles' => [] ] ); + self::assertSame( [], $user->roles ); + } + + + public function testNameIsOnlyWrittenWhenSupplied(): void { + $user = $this->newUser(); + $user->name = 'Existing Name'; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertSame( 'Existing Name', $user->name ); + } + + + /** + * factory::save() reads the typed $_id unconditionally to build its update filter, so an + * uninitialized property is a fatal Error rather than an insert — the same fault that + * broke POST /user/new. + */ + public function testAnIdIsAssignedWhenTheModelHasNotSetOne(): void { + $user = $this->newUser(); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertTrue( isset( $user->_id ) ); + } + + + public function testAnExistingIdIsKept(): void { + $user = $this->newUser(); + $user->_id = 'keep-me'; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertSame( 'keep-me', $user->_id ); + } + + + public function testTheUserIsActivated(): void { + $user = $this->newUser(); + $user->active = false; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertTrue( $user->active ); + } + + + public function testRolesAreSplitTrimmedAndDeduplicated(): void { + self::assertSame( [ 'User.Read', 'User.Write' ], userCreateCommand::parseRoles( ' User.Read , User.Write ' ) ); + self::assertSame( [ 'User.Read' ], userCreateCommand::parseRoles( 'User.Read,User.Read' ) ); + } + + + /** A trailing comma is a typo, not a role named "". */ + public function testEmptyRoleEntriesAreDropped(): void { + self::assertSame( [ 'User.Read' ], userCreateCommand::parseRoles( 'User.Read,,' ) ); + self::assertSame( [], userCreateCommand::parseRoles( '' ) ); + } + + + /** + * Stands in for the user model's public properties (the userTrait ones), which is all + * applyTo() touches. Untyped so a test can leave $_id unset the way a typed ObjectId is. + */ + private function newUser(): object { + return new class { + public $_id; + public string $email = ''; + public string $username = ''; + public string $name = ''; + public string $password = ''; + /** @var string[] */ + public array $roles = []; + public bool $active = true; + }; + } + +} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 5eb296c..6030121 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use gcgov\framework\config; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; #[CoversClass(config::class)] final class ConfigTest extends TestCase { @@ -15,7 +15,13 @@ final class ConfigTest extends TestCase { private string $tempRootDir = ''; protected function setUp(): void { - $this->tempRootDir = sys_get_temp_dir() . '/gcgov-config-test-' . uniqid(); + // Forward slashes, because that is the only shape config ever holds: setAppDir() + // normalises the separators it reflects out of \app\app, and the gf CLI reaches the + // same field through appContext::normalize(). Injecting sys_get_temp_dir() raw would + // put a backslash root into a private static that cannot hold one at runtime, and the + // accessors that normalise — getConfigFilePath(), getJwtKeyPath() — would then + // disagree with the fixture on Windows and nowhere else. + $this->tempRootDir = str_replace( '\\', '/', sys_get_temp_dir() ) . '/gcgov-config-test-' . uniqid(); mkdir( $this->tempRootDir . '/app/config', 0777, true ); $rootProp = new \ReflectionProperty( config::class, 'rootDir' ); @@ -32,8 +38,8 @@ public function testGetModelsDirAppendsModels(): void { $this->assertSame( $this->tempRootDir . '/app/models/', config::getModelsDir() ); } - public function testGetConfigDirAppendsConfig(): void { - $this->assertSame( $this->tempRootDir . '/app/config/', config::getConfigDir() ); + public function testGetConfigFilePathIsRootConfigJson(): void { + $this->assertSame( $this->tempRootDir . '/config.json', config::getConfigFilePath() ); } public function testGetServicesDirAppendsServices(): void { @@ -52,18 +58,141 @@ public function testGetTempDirIsRootSrvTmpTmp(): void { $this->assertSame( $this->tempRootDir . '/srv/tmp/tmp', config::getTempDir() ); } - public function testEnvironmentConfigCanBeInjectedAndReadBack(): void { - $env = new environmentConfig(); - $env->basePath = 'custom'; + public function testUnifiedConfigIsExposedThroughStaticAccessors(): void { + $unified = new unifiedConfig(); + $unified->type = 'local'; + $unified->basePath = 'custom'; + $unified->rootUrl = 'https://example.gov/'; + $unified->app->title = 'Widget API'; + $unified->appDictionary = [ 'key' => 'value' ]; + + $prop = new \ReflectionProperty( config::class, 'unifiedConfig' ); + $prop->setValue( null, $unified ); + + $this->assertSame( '/custom', config::getBasePath() ); + $this->assertSame( 'https://example.gov', config::getRootUrl() ); + $this->assertSame( 'https://example.gov/custom', config::getBaseUrl() ); + $this->assertTrue( config::isLocal() ); + $this->assertSame( 'Widget API', config::getApp()->title ); + $this->assertSame( [ 'key' => 'value' ], config::getAppDictionary() ); + $this->assertSame( $unified->logging, config::getLogging() ); + $this->assertSame( $unified->email, config::getEmail() ); + $this->assertSame( $unified->settings, config::getSettings() ); + } + + public function testDeprecatedPassThroughsPreserveV6CallPatterns(): void { + $unified = new unifiedConfig(); + $unified->type = 'prod'; + $unified->basePath = '/api/'; + $unified->settings->forceMfaForPasswordUsers = true; + $unified->app->title = 'Widget API'; + + $prop = new \ReflectionProperty( config::class, 'unifiedConfig' ); + $prop->setValue( null, $unified ); + + // v6 environmentConfig call patterns — the shim returns the unified object + $this->assertSame( $unified, config::getEnvironmentConfig() ); + $this->assertSame( '/api', config::getEnvironmentConfig()->getBasePath() ); + $this->assertFalse( config::getEnvironmentConfig()->isLocal() ); + $this->assertSame( [], config::getEnvironmentConfig()->mongoDatabases ); + + // v6 appConfig call patterns — the shim returns a v6-shaped VIEW (app/email/settings) + $appConfig = config::getAppConfig(); + $this->assertInstanceOf( \gcgov\framework\models\appConfig::class, $appConfig ); + $this->assertTrue( $appConfig->settings->forceMfaForPasswordUsers ); + $this->assertSame( 'Widget API', $appConfig->app->title ); + $this->assertSame( '', $appConfig->email->SMTPUsername ); + $this->assertSame( $unified->settings, $appConfig->settings, 'view shares the live section objects' ); + + // The view must NOT expose environment-side secrets (mongo/microsoft/payjunction/jwtAuth) + $viewProperties = array_keys( get_object_vars( $appConfig ) ); + $this->assertSame( [ 'app', 'email', 'settings' ], $viewProperties ); + } - $prop = new \ReflectionProperty( config::class, 'environmentConfig' ); - $prop->setValue( null, $env ); - $this->assertSame( $env, config::getEnvironmentConfig() ); + public function testEnvironmentConfigClassAliasResolvesToUnifiedConfig(): void { + // v6 type references (\gcgov\framework\models\environmentConfig) must still autoload. + $this->assertTrue( class_exists( \gcgov\framework\models\environmentConfig::class ) ); + $this->assertSame( unifiedConfig::class, ( new \ReflectionClass( \gcgov\framework\models\environmentConfig::class ) )->getName() ); + $this->assertInstanceOf( \gcgov\framework\models\environmentConfig::class, new unifiedConfig() ); } public function testIsFinalClass(): void { $this->assertTrue( ( new \ReflectionClass( config::class ) )->isFinal() ); } + + /** + * The keys are gitignored, so they are never in a built image and must be provisioned + * to a path outside the application tree. Before v7 the path was hard-coded. + */ + public function testJwtKeyPathDefaultsToSrvButHonoursTheConfiguredPath(): void { + $unified = config::getEnvironmentConfig(); + $original = $unified->jwtAuth->keyPath; + + try { + $unified->jwtAuth->keyPath = ''; + $this->assertSame( config::getSrvDir() . 'jwtCertificates/', config::getJwtKeyPath() ); + + $unified->jwtAuth->keyPath = '/run/secrets/jwt'; + $this->assertSame( '/run/secrets/jwt/', config::getJwtKeyPath(), 'always returned with a trailing slash' ); + + $unified->jwtAuth->keyPath = '/run/secrets/jwt/'; + $this->assertSame( '/run/secrets/jwt/', config::getJwtKeyPath(), 'a configured trailing slash is not doubled' ); + } + finally { + $unified->jwtAuth->keyPath = $original; + } + } + + + /** Configuring issuer and audience separately from rootUrl/basePath only invites drift. */ + public function testJwtIssuerAndAudienceDeriveFromTheApplicationUrlWhenNotSet(): void { + $unified = config::getEnvironmentConfig(); + $originalIssuer = $unified->jwtAuth->tokenIssuedBy; + $originalAudience = $unified->jwtAuth->tokenPermittedFor; + + try { + $unified->jwtAuth->tokenIssuedBy = ''; + $unified->jwtAuth->tokenPermittedFor = ''; + $this->assertSame( config::getRootUrl(), config::getTokenIssuedBy() ); + $this->assertSame( config::getBasePath(), config::getTokenPermittedFor() ); + + $unified->jwtAuth->tokenIssuedBy = 'https://explicit.example.gov'; + $this->assertSame( 'https://explicit.example.gov', config::getTokenIssuedBy() ); + } + finally { + $unified->jwtAuth->tokenIssuedBy = $originalIssuer; + $unified->jwtAuth->tokenPermittedFor = $originalAudience; + } + } + + + /** @return iterable */ + public static function removedAccessorProvider(): iterable { + yield 'getServerName' => [ 'getServerName' ]; + yield 'getCookieUrl' => [ 'getCookieUrl' ]; + yield 'getPhpPath' => [ 'getPhpPath' ]; + } + + + /** + * Confirmed unread by the framework and by all five framework services before removal. + * + */ + #[\PHPUnit\Framework\Attributes\DataProvider('removedAccessorProvider')] + public function testAccessorsForUnreadConfigValuesAreGone( string $accessor ): void { + $this->assertFalse( method_exists( config::class, $accessor ) ); + } + + + public function testUnreadConfigPropertiesAreGoneFromTheModel(): void { + $properties = array_keys( get_object_vars( new unifiedConfig() ) ); + + foreach( [ 'serverName', 'cookieUrl', 'phpPath' ] as $removed ) { + $this->assertNotContains( $removed, $properties ); + } + $this->assertContains( 'app', $properties, 'app.guid stays — the oauth server uses it as the client_id' ); + } + } diff --git a/tests/Unit/FrameworkStructuralTest.php b/tests/Unit/FrameworkStructuralTest.php index 76aaf3d..5b73705 100644 --- a/tests/Unit/FrameworkStructuralTest.php +++ b/tests/Unit/FrameworkStructuralTest.php @@ -36,13 +36,21 @@ public function testFrameworkRunAppReturnsString(): void { $this->assertSame( 'string', (string) $method->getReturnType() ); } - public function testRouterConstructorTakesServiceNamespaces(): void { + /** + * Which Framework Services run is read from config.json, not passed in from + * \app\app — so the router needs nothing from its caller. + */ + public function testRouterConstructorTakesNoArguments(): void { $ctor = ( new \ReflectionClass( router::class ) )->getConstructor(); $this->assertNotNull( $ctor ); - $params = $ctor->getParameters(); - $this->assertCount( 1, $params ); - $this->assertSame( 'serviceNamespaces', $params[0]->getName() ); - $this->assertSame( 'array', (string) $params[0]->getType() ); + $this->assertCount( 0, $ctor->getParameters() ); + } + + + public function testGetMergedRoutesTakesNoArguments(): void { + $method = new \ReflectionMethod( router::class, 'getMergedRoutes' ); + $this->assertCount( 0, $method->getParameters() ); + $this->assertTrue( $method->isStatic() ); } public function testRouterRouteReturnsRouteHandler(): void { diff --git a/tests/Unit/Interfaces/InterfacesTest.php b/tests/Unit/Interfaces/InterfacesTest.php index 8dab9b7..46c8260 100644 --- a/tests/Unit/Interfaces/InterfacesTest.php +++ b/tests/Unit/Interfaces/InterfacesTest.php @@ -30,8 +30,10 @@ public function testInterfaceDeclaresExpectedMethods( string $interface, array $ public static function interfaceMethodMatrix(): array { return [ - 'app' => [ interfaces\app::class, [ 'registerFrameworkServiceNamespaces' ] ], + 'app' => [ interfaces\app::class, [ '_before', '_after' ] ], 'router' => [ interfaces\router::class, [ 'getRoutes', 'authentication' ] ], + 'appRouter' => [ interfaces\appRouter::class, [ 'getRoutes', 'authentication', '_before', '_after', 'providesAuthentication' ] ], + 'skipsServiceAuthentication' => [ interfaces\router\skipsServiceAuthentication::class, [ 'getRunFrameworkServiceRouteAuthentication' ] ], 'controller' => [ interfaces\controller::class, [] ], 'render' => [ interfaces\render::class, [ 'processModelException', @@ -68,4 +70,31 @@ public function testSingletonIsAbstractClassNotInterface(): void { $this->assertTrue( $reflection->hasMethod( 'getInstance' ) ); } + + /** + * Framework Services are declared in config.json's `services` section, not returned + * from the application class. + */ + public function testAppInterfaceNoLongerRegistersServiceNamespaces(): void { + $reflection = new \ReflectionClass( interfaces\app::class ); + $this->assertFalse( $reflection->hasMethod( 'registerFrameworkServiceNamespaces' ) ); + } + + + /** + * Only \app\router's lifecycle hooks are ever invoked, so requiring them of every + * router described a contract the framework did not honour. They live on appRouter. + */ + public function testServiceRouterInterfaceCarriesNoLifecycleHooks(): void { + $reflection = new \ReflectionClass( interfaces\router::class ); + $this->assertFalse( $reflection->hasMethod( '_before' ) ); + $this->assertFalse( $reflection->hasMethod( '_after' ) ); + $this->assertTrue( $reflection->isInterface() ); + } + + + public function testAppRouterExtendsRouter(): void { + $this->assertTrue( is_subclass_of( interfaces\appRouter::class, interfaces\router::class ) ); + } + } diff --git a/tests/Unit/LifecycleExceptionTest.php b/tests/Unit/LifecycleExceptionTest.php new file mode 100644 index 0000000..3310f40 --- /dev/null +++ b/tests/Unit/LifecycleExceptionTest.php @@ -0,0 +1,102 @@ +routingTryBlock( $source ); + + self::assertStringContainsString( 'catch( routeException $e )', $routingBlock ); + self::assertStringContainsString( 'catch( \Throwable $e )', $routingBlock, 'configException, BadRouteException and TypeError all reach here' ); + } + + + /** The lifecycle hooks after routing must still run once a config failure is caught. */ + public function testLifecycleContinuesAfterTheRoutingCatch(): void { + $source = (string)file_get_contents( __DIR__ . '/../../src/framework.php' ); + + $afterCatch = substr( $source, strpos( $source, 'catch( \Throwable $e )' ) ?: 0 ); + + self::assertStringContainsString( '\app\router::_after();', $afterCatch ); + self::assertStringContainsString( '\app\renderer::_before();', $afterCatch ); + self::assertStringContainsString( '\app\renderer::_after();', $afterCatch ); + self::assertStringContainsString( '\app\app::_after();', $afterCatch ); + } + + + /** + * A config failure message names route patterns, the config file path and unresolved + * environment variables, so it is logged rather than returned to the caller. + */ + public function testCaughtConfigFailureIsLoggedAndGenericised(): void { + $source = (string)file_get_contents( __DIR__ . '/../../src/framework.php' ); + + $routingBlock = $this->routingTryBlock( $source ); + + self::assertStringContainsString( 'log::critical', $routingBlock ); + // Single-quoted on purpose: the literal text being asserted absent contains + // `$e->getMessage()`, which double quotes would interpolate at runtime. + self::assertStringNotContainsString( 'routeException( $e->getMessage()', $routingBlock ); + self::assertMatchesRegularExpression( '/new routeException\(\s*\'[^\']+\',\s*500/', $routingBlock ); + } + + + private function routingTryBlock( string $source ): string { + $start = strpos( $source, 'new \gcgov\framework\router()' ); + $end = strpos( $source, '\app\router::_after();' ); + self::assertIsInt( $start ); + self::assertIsInt( $end ); + + return substr( $source, $start, $end - $start ); + } + +} diff --git a/tests/Unit/Models/AuthUserRolesTest.php b/tests/Unit/Models/AuthUserRolesTest.php new file mode 100644 index 0000000..6905643 --- /dev/null +++ b/tests/Unit/Models/AuthUserRolesTest.php @@ -0,0 +1,103 @@ +setFromJwtToken( [], [] ); + } + + + public function testStringRolesSurviveUnchanged(): void { + $authUser = authUser::getInstance()->setFromJwtToken( [], [ 'User.Read', 'User.Write' ] ); + + $this->assertSame( [ 'User.Read', 'User.Write' ], $authUser->roles ); + $this->assertTrue( $authUser->hasRole( 'User.Read' ) ); + $this->assertFalse( $authUser->hasRole( 'User.Delete' ) ); + } + + + #[DataProvider('nonStringScopeClaims')] + public function testNonStringScopeElementsGrantNothing( array $scope, string $description ): void { + $authUser = authUser::getInstance()->setFromJwtToken( [], $scope ); + + $this->assertFalse( $authUser->hasRole( 'User.Write' ), $description ); + $this->assertFalse( $authUser->hasRole( 'Anything.At.All' ), $description ); + foreach( $authUser->roles as $role ) { + $this->assertIsString( $role, 'roles must be string[] as documented' ); + } + } + + + /** @return array, string}> */ + public static function nonStringScopeClaims(): array { + return [ + 'boolean true' => [ [ true ], 'true == "User.Write" under a loose comparison' ], + 'integer one' => [ [ 1 ], 'a truthy int must not stand in for a role name' ], + 'float' => [ [ 1.0 ], 'a truthy float must not stand in for a role name' ], + 'nested array' => [ [ [ 'User.Write' ] ], 'a nested array is not a role' ], + 'object' => [ [ new \stdClass() ], 'an object is not a role' ], + 'null' => [ [ null ], 'null is not a role' ], + ]; + } + + + public function testMixedScopeKeepsOnlyTheRealRoles(): void { + $authUser = authUser::getInstance()->setFromJwtToken( [], [ 'User.Read', true, 'User.Write', 1 ] ); + + $this->assertSame( [ 'User.Read', 'User.Write' ], $authUser->roles ); + $this->assertTrue( $authUser->hasRole( 'User.Read' ) ); + $this->assertTrue( $authUser->hasRole( 'User.Write' ) ); + $this->assertFalse( $authUser->hasRole( 'User.Delete' ) ); + } + + + /** + * The same narrowing has to apply to roles arriving from the user model, not only the + * token. + * + * In a separate process because the stub defines \app\models\user — and a process + * that holds that class answers request::getUserClassFqdn() differently for every + * later test (RequestTest asserts the framework default). Same isolation, and same + * reason, as UserControllerTest. + */ + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] + public function testRolesFromTheUserModelAreNarrowedToo(): void { + require_once __DIR__ . '/../../Stubs/FakeUserModel.php'; + + $user = new \app\models\user(); + $user->_id = '507f1f77bcf86cd799439011'; + $user->name = 'Test User'; + $user->email = 'test@example.gov'; + // A roles array written out of band — a migration, an admin tool, a direct DB write. + $user->roles = [ 'User.Read', true ]; + + $authUser = authUser::getInstance()->setFromUser( $user ); + + $this->assertSame( [ 'User.Read' ], $authUser->roles ); + $this->assertFalse( $authUser->hasRole( 'User.Write' ) ); + } + +} diff --git a/tests/Unit/Models/Config/AppConfigModelsTest.php b/tests/Unit/Models/Config/AppConfigModelsTest.php index c7d3f0d..e10bbfa 100644 --- a/tests/Unit/Models/Config/AppConfigModelsTest.php +++ b/tests/Unit/Models/Config/AppConfigModelsTest.php @@ -6,20 +6,20 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use gcgov\framework\models\appConfig; +use gcgov\framework\models\unifiedConfig; use gcgov\framework\models\config\app\app; use gcgov\framework\models\config\app\email; use gcgov\framework\models\config\app\settings; -#[CoversClass(appConfig::class)] +#[CoversClass(unifiedConfig::class)] #[CoversClass(app::class)] #[CoversClass(email::class)] #[CoversClass(settings::class)] final class AppConfigModelsTest extends TestCase { - public function testAppConfigInstantiates(): void { - $config = new appConfig(); - $this->assertInstanceOf( appConfig::class, $config ); + public function testUnifiedConfigInstantiates(): void { + $config = new unifiedConfig(); + $this->assertInstanceOf( unifiedConfig::class, $config ); } public function testAppHasTitleAndGuid(): void { @@ -48,12 +48,20 @@ public function testEmailDefaults(): void { public function testSettingsDefaults(): void { $settings = new settings(); - $this->assertFalse( $settings->useSession ); $this->assertFalse( $settings->forceMfaForPasswordUsers ); } + + /** + * useSession was declared, documented, and read by nothing — not the framework, not a + * Framework Service, not any application. It was removed rather than carried forward. + */ + public function testUseSessionIsGone(): void { + $this->assertFalse( property_exists( settings::class, 'useSession' ) ); + } + public function testAllConfigsExtendJsonDeserialize(): void { - foreach ( [ appConfig::class, app::class, email::class, settings::class ] as $class ) { + foreach ( [ unifiedConfig::class, app::class, email::class, settings::class ] as $class ) { $this->assertTrue( is_subclass_of( $class, \andrewsauder\jsonDeserialize\jsonDeserialize::class ) ); } } diff --git a/tests/Unit/Models/Config/ServicesConfigTest.php b/tests/Unit/Models/Config/ServicesConfigTest.php new file mode 100644 index 0000000..e082df4 --- /dev/null +++ b/tests/Unit/Models/Config/ServicesConfigTest.php @@ -0,0 +1,143 @@ +assertInstanceOf( services::class, $config->services ); + $this->assertNull( $config->services->auth ); + $this->assertNull( $config->services->userCrud ); + $this->assertNull( $config->services->documentation ); + } + + + public function testEmptyServicesSectionLeavesEveryServiceOff(): void { + $config = self::hydrate( '{"services":{}}' ); + + $this->assertNull( $config->services->auth ); + $this->assertNull( $config->services->userCrud ); + $this->assertNull( $config->services->documentation ); + } + + + public function testAnEmptyBlockEnablesTheService(): void { + $config = self::hydrate( '{"services":{"userCrud":{}}}' ); + + $this->assertInstanceOf( userCrud::class, $config->services->userCrud ); + $this->assertNull( $config->services->documentation ); + $this->assertNull( $config->services->auth ); + } + + + public function testSeveralServicesEnableIndependently(): void { + $config = self::hydrate( '{"services":{"userCrud":{},"documentation":{}}}' ); + + $this->assertInstanceOf( userCrud::class, $config->services->userCrud ); + $this->assertInstanceOf( documentation::class, $config->services->documentation ); + $this->assertNull( $config->services->auth ); + } + + + public function testAuthSettingsHydrate(): void { + $config = self::hydrate( '{"services":{"auth":{"provider":"oauth","blockNewUsers":false,"defaultNewUserRoles":["Widget.Read"],"oauth":{"authorizeUrlParameters":{"prompt":"consent"}}}}}' ); + + $auth = $config->services->auth; + $this->assertInstanceOf( auth::class, $auth ); + $this->assertTrue( $auth->isOauth() ); + $this->assertFalse( $auth->blockNewUsers ); + $this->assertSame( [ 'Widget.Read' ], $auth->defaultNewUserRoles ); + $this->assertSame( [ 'prompt' => 'consent' ], $auth->oauth->authorizeUrlParameters ); + $this->assertNull( $auth->msFront ); + } + + + public function testBlockNewUsersDefaultsToBlocking(): void { + $config = self::hydrate( '{"services":{"auth":{"provider":"msFront"}}}' ); + + $this->assertTrue( $config->services->auth->blockNewUsers ); + } + + + /** A missing block for the selected provider is the established missing-section rule. */ + public function testSelectedProviderBlockMayBeOmitted(): void { + $config = self::hydrate( '{"services":{"auth":{"provider":"oauth"}}}' ); + + $this->assertInstanceOf( \gcgov\framework\models\config\services\auth\oauth::class, $config->services->auth->oauth ); + $this->assertSame( [], $config->services->auth->oauth->authorizeUrlParameters ); + $this->assertNull( $config->services->auth->msFront ); + } + + + public function testMsFrontProviderSelects(): void { + $config = self::hydrate( '{"services":{"auth":{"provider":"msFront"}}}' ); + + $this->assertTrue( $config->services->auth->isMsFront() ); + $this->assertFalse( $config->services->auth->isOauth() ); + $this->assertInstanceOf( \gcgov\framework\models\config\services\auth\msFront::class, $config->services->auth->msFront ); + } + + + /** Configuration that nothing would read is an error, not a silent no-op. */ + public function testBlockForTheUnselectedProviderIsRejected(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessage( 'services.auth.msFront is configured but services.auth.provider is "oauth"' ); + + self::hydrate( '{"services":{"auth":{"provider":"oauth","msFront":{}}}}' ); + } + + + public function testBlockForTheUnselectedProviderIsRejectedTheOtherWayRound(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessage( 'services.auth.oauth is configured but services.auth.provider is "msFront"' ); + + self::hydrate( '{"services":{"auth":{"provider":"msFront","oauth":{}}}}' ); + } + + + public function testUnknownProviderIsRejected(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessage( 'not "typo"' ); + + self::hydrate( '{"services":{"auth":{"provider":"typo"}}}' ); + } + + + public function testMissingProviderIsRejected(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessage( 'is missing' ); + + self::hydrate( '{"services":{"auth":{"blockNewUsers":false}}}' ); + } + + + public function testProviderConstantsAreTheLegalValues(): void { + $this->assertSame( [ 'oauth', 'msFront' ], auth::PROVIDERS ); + } + +} diff --git a/tests/Unit/Models/EnvironmentConfigTest.php b/tests/Unit/Models/UnifiedConfigTest.php similarity index 83% rename from tests/Unit/Models/EnvironmentConfigTest.php rename to tests/Unit/Models/UnifiedConfigTest.php index 672e21d..abe85f3 100644 --- a/tests/Unit/Models/EnvironmentConfigTest.php +++ b/tests/Unit/Models/UnifiedConfigTest.php @@ -6,14 +6,14 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; use gcgov\framework\models\config\environment\sqlDatabase; -#[CoversClass(environmentConfig::class)] -final class EnvironmentConfigTest extends TestCase { +#[CoversClass(unifiedConfig::class)] +final class UnifiedConfigTest extends TestCase { public function testConstructorInitializesNestedConfigs(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $this->assertInstanceOf( \gcgov\framework\models\config\environment\microsoft::class, $config->microsoft @@ -33,38 +33,38 @@ public function testConstructorInitializesNestedConfigs(): void { } public function testGetRootUrlTrimsTrailingSlashesAndSpaces(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->rootUrl = 'https://example.com/ '; $this->assertSame( 'https://example.com', $config->getRootUrl() ); } public function testGetBaseUrlCombinesRootAndBasePath(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->rootUrl = 'https://example.com/'; $config->basePath = '/api/v1/'; $this->assertSame( 'https://example.com/api/v1', $config->getBaseUrl() ); } public function testGetBasePathReturnsLeadingSlashTrimmedValue(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->basePath = 'api/v1 '; $this->assertSame( '/api/v1', $config->getBasePath() ); } public function testIsLocalReturnsTrueWhenTypeIsLocal(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->type = 'local'; $this->assertTrue( $config->isLocal() ); } public function testIsLocalReturnsFalseForOtherEnvironments(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->type = 'production'; $this->assertFalse( $config->isLocal() ); } public function testGetDefaultSqlDatabaseReturnsTheOneMarkedDefault(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $db1 = new sqlDatabase(); $db1->name = 'db1'; $db1->default = false; @@ -77,7 +77,7 @@ public function testGetDefaultSqlDatabaseReturnsTheOneMarkedDefault(): void { } public function testGetDefaultSqlDatabaseReturnsNullWhenNoneDefault(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $db = new sqlDatabase(); $db->default = false; $config->sqlDatabases = [ $db ]; @@ -85,7 +85,7 @@ public function testGetDefaultSqlDatabaseReturnsNullWhenNoneDefault(): void { } public function testGetSqlDatabaseByNameMatches(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $db = new sqlDatabase(); $db->name = 'primary'; $config->sqlDatabases = [ $db ]; @@ -95,7 +95,7 @@ public function testGetSqlDatabaseByNameMatches(): void { } public function testAppDictionaryIsArrayByDefault(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $this->assertSame( [], $config->appDictionary ); } diff --git a/tests/Unit/RequiredRolesTest.php b/tests/Unit/RequiredRolesTest.php new file mode 100644 index 0000000..f5096a6 --- /dev/null +++ b/tests/Unit/RequiredRolesTest.php @@ -0,0 +1,151 @@ +setFromJwtToken( [], [] ); + } + + + /** A route that declares no roles must not start requiring authentication. */ + public function testRouteWithoutRolesPassesEvenWithNoUser(): void { + $this->expectNotToPerformAssertions(); + + $this->assertRoles( $this->handler( [] ) ); + } + + + /** + * The regression the change exists for: roles declared, nobody authenticated. This + * previously sailed through — the only role check in the codebase was never reached. + */ + public function testRolesDeclaredWithNoUserEstablishedIs401(): void { + $log = $this->captureLog( 'Framework Lifecycle' ); + + // try/catch rather than expectException: the refusal and the diagnostic that goes + // with it are one behaviour. A 401 whose cause is unlogged sends an application + // developer hunting through the guard chain for a route that is simply unguarded. + try { + $this->assertRoles( $this->handler( [ 'User.Read' ] ) ); + $this->fail( 'a role-gated route with no authenticated user must be refused' ); + } + catch( routeException $e ) { + $this->assertSame( 401, $e->getCode() ); + } + + $this->assertTrue( $log->hasWarningThatContains( 'User.Read' ), 'the log must name the role that was required' ); + $this->assertTrue( $log->hasWarningThatContains( 'services.auth' ), 'and what to do about it' ); + } + + + public function testUserHoldingEveryRequiredRolePasses(): void { + $this->authenticate( [ 'User.Read', 'User.Write' ] ); + + $this->expectNotToPerformAssertions(); + + $this->assertRoles( $this->handler( [ 'User.Read', 'User.Write' ] ) ); + } + + + public function testUserMissingARoleIs403(): void { + $this->authenticate( [ 'User.Read' ] ); + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 403 ); + $this->expectExceptionMessage( 'User does not have the permission "User.Write" required to access this content' ); + + $this->assertRoles( $this->handler( [ 'User.Read', 'User.Write' ] ) ); + } + + + /** Every required role must be held — a subset is not enough. */ + public function testHoldingASubsetIs403(): void { + $this->authenticate( [ 'User.Read', 'Widget.Read' ] ); + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 403 ); + + $this->assertRoles( $this->handler( [ 'User.Read', 'User.Write', 'Widget.Read' ] ) ); + } + + + public function testUnrelatedRolesDoNotSatisfyTheRequirement(): void { + $this->authenticate( [ 'Widget.Read', 'Widget.Write' ] ); + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 403 ); + + $this->assertRoles( $this->handler( [ 'User.Read' ] ) ); + } + + + /** + * The narrowing in authUser::normalizeRoles() has to hold through this call path too: a + * non-string truthy element in the token's scope claim satisfied a loose comparison + * against every required role. + */ + #[DataProvider('nonStringScopes')] + public function testNonStringScopeElementsDoNotSatisfyARole( array $scope ): void { + $this->authenticate( $scope ); + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 403 ); + + $this->assertRoles( $this->handler( [ 'User.Write' ] ) ); + } + + + /** @return array}> */ + public static function nonStringScopes(): array { + return [ + 'boolean true' => [ [ true ] ], + 'integer one' => [ [ 1 ] ], + 'float' => [ [ 1.0 ] ], + ]; + } + + + /** Establish a user the way the auth guard does, so roles have something to check against. */ + private function authenticate( array $roles ): void { + authUser::getInstance()->setFromJwtToken( [ 'userId' => '507f1f77bcf86cd799439011' ], $roles ); + } + + + private function handler( array $requiredRoles ): routeHandler { + return new routeHandler( '\app\controllers\widget', 'getOne', true, $requiredRoles ); + } + + + /** @throws routeException */ + private function assertRoles( routeHandler $routeHandler ): void { + ( new \ReflectionMethod( router::class, 'assertRequiredRoles' ) )->invoke( null, $routeHandler ); + } + +} diff --git a/tests/Unit/RouteOverrideTest.php b/tests/Unit/RouteOverrideTest.php new file mode 100644 index 0000000..2b1322e --- /dev/null +++ b/tests/Unit/RouteOverrideTest.php @@ -0,0 +1,144 @@ +routeKeys( new route( [ 'GET', 'CLI' ], '/cli/report', '\app\controllers\report', 'run' ) ); + + self::assertSame( [ 'GET /cli/report', 'CLI /cli/report' ], $keys ); + } + + + public function testRouteKeysNormaliseTheMethodCase(): void { + $keys = $this->routeKeys( new route( 'get', '/widget', '\app\controllers\widget', 'getAll' ) ); + + self::assertSame( [ 'GET /widget' ], $keys ); + } + + + /** + * A collision is per (method, pattern): an application POSTing to a path the framework + * only serves with GET is not a collision at all. + */ + public function testDifferentMethodsOnTheSamePathDoNotCollide(): void { + $framework = $this->routeKeys( new route( 'GET', '/api/health', '\gcgov\framework\services\health\controllers\health', 'live' ) ); + $app = $this->routeKeys( new route( 'POST', '/api/health', '\app\controllers\status', 'record' ) ); + + self::assertSame( [], array_intersect( $framework, $app ) ); + } + + + public function testSameMethodAndPathCollide(): void { + $framework = $this->routeKeys( new route( 'GET', '/api/health', '\gcgov\framework\services\health\controllers\health', 'live' ) ); + $app = $this->routeKeys( new route( 'GET', '/api/health', '\app\controllers\status', 'health' ) ); + + self::assertSame( [ 'GET /api/health' ], array_values( array_intersect( $framework, $app ) ) ); + } + + + /** + * FastRoute compiles a placeholder's regex, never its name, so user/{id} and + * user/{_id} are the SAME route to the dispatcher — and must collide here, or both + * register and BadRouteException takes every url down: the exact whole-surface outage + * the override mechanism exists to prevent, for a v6 app whose placeholder is merely + * spelled differently from the framework's. + */ + public function testPlaceholderSpellingDoesNotDefeatTheCollision(): void { + $framework = $this->routeKeys( new route( 'GET', '/api/user/{_id}', '\gcgov\framework\services\userCrud\controllers\user', 'getOne' ) ); + $app = $this->routeKeys( new route( 'GET', '/api/user/{userId}', '\app\controllers\user', 'getOne' ) ); + + self::assertSame( $framework, $app ); + } + + + /** A custom placeholder regex compiles differently, so it is a different shape. */ + public function testACustomPlaceholderRegexIsADifferentShape(): void { + $plain = $this->routeKeys( new route( 'GET', '/api/user/{id}', '\app\controllers\user', 'getOne' ) ); + $constrained = $this->routeKeys( new route( 'GET', '/api/user/{id:\d+}', '\app\controllers\user', 'getOne' ) ); + + self::assertSame( [], array_intersect( $plain, $constrained ) ); + } + + + public function testOptionalSegmentsOccupyOneSlotPerVariant(): void { + $keys = $this->routeKeys( new route( 'GET', '/api/widget[/{id}]', '\app\controllers\widget', 'get' ) ); + + self::assertCount( 2, $keys ); + } + + + /** + * A static application route inside a variable service route's shape is also fatal: + * service routes register first, and FastRoute rejects a static route shadowed by an + * earlier variable one. The application's path must win there too. + */ + public function testAStaticAppRouteOverridesTheVariableServiceRouteThatWouldShadowIt(): void { + $service = [ new route( 'GET', '/api/user/{_id}', '\gcgov\framework\services\userCrud\controllers\user', 'getOne' ) ]; + $app = [ new route( 'GET', '/api/user/me', '\app\controllers\user', 'me' ) ]; + + self::assertSame( [], router::serviceRoutesNotOverridden( $service, $app ) ); + } + + + public function testAnUnrelatedStaticAppRouteDropsNothing(): void { + $service = [ new route( 'GET', '/api/user/{_id}', '\gcgov\framework\services\userCrud\controllers\user', 'getOne' ) ]; + $app = [ new route( 'GET', '/api/widget/me', '\app\controllers\widget', 'me' ) ]; + + self::assertSame( $service, router::serviceRoutesNotOverridden( $service, $app ) ); + } + + + /** + * The merged table must never contain a duplicate (method, pattern) — that is exactly + * what FastRoute rejects, and rejecting it takes the whole application down. + */ + public function testMergedRoutesContainNoDuplicateMethodAndPattern(): void { + $seen = []; + foreach( router::getMergedRoutes() as $mergedRoute ) { + foreach( $this->routeKeys( $mergedRoute ) as $key ) { + self::assertArrayNotHasKey( $key, $seen, 'duplicate route "' . $key . '" would make FastRoute reject every route' ); + $seen[ $key ] = true; + } + } + + self::assertNotSame( [], $seen ); + } + + + /** + * The (method, shape) slots a route occupies, built on the router's public + * patternShapes() — the same signatures the override filter compares. + * + * @return string[] + */ + private function routeKeys( route $route ): array { + $keys = []; + foreach( (array)$route->httpMethod as $httpMethod ) { + foreach( router::patternShapes( $route->route ) as $shape ) { + $keys[] = strtoupper( (string)$httpMethod ) . ' ' . $shape[ 'signature' ]; + } + } + + return $keys; + } + +} diff --git a/tests/Unit/RoutePrefixTest.php b/tests/Unit/RoutePrefixTest.php new file mode 100644 index 0000000..dda1c92 --- /dev/null +++ b/tests/Unit/RoutePrefixTest.php @@ -0,0 +1,141 @@ +seedConfig( static fn( unifiedConfig $c ) => $c->basePath = '' ); + + $this->assertSame( '/', config::getBasePath(), 'getBasePath() keeps its "/" for the token audience' ); + $this->assertSame( '', config::getRoutePrefix(), 'a route prefix must contribute nothing at the domain root' ); + } + + + public function testRoutePrefixIsNormalisedBasePathOtherwise(): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = 'api/v1' ); + + $this->assertSame( '/api/v1', config::getRoutePrefix() ); + } + + + #[DataProvider('untidyBasePaths')] + public function testRoutePrefixToleratesUntidyConfiguredValues( string $configured, string $expected ): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = $configured ); + + $this->assertSame( $expected, config::getRoutePrefix() ); + } + + + /** @return array */ + public static function untidyBasePaths(): array { + return [ + 'leading slash' => [ '/api', '/api' ], + 'trailing slash' => [ 'api/', '/api' ], + 'both' => [ '/api/', '/api' ], + 'whitespace' => [ ' api ', '/api' ], + 'only a slash' => [ '/', '' ], + 'only spaces' => [ ' ', '' ], + ]; + } + + + /** + * The regression itself: no framework route may contain '//' at the domain root. + */ + public function testNoFrameworkRouteDoublesItsSlashesAtDomainRoot(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->basePath = ''; + $c->rootUrl = 'https://example.gov'; + } ); + + $routers = [ + 'health' => new \gcgov\framework\services\health\router(), + 'userCrud' => new \gcgov\framework\services\userCrud\router(), + 'documentation' => new \gcgov\framework\services\documentation\router(), + 'auth' => new \gcgov\framework\services\auth\router( $this->oauthAuthConfig() ), + ]; + + foreach( $routers as $name => $router ) { + foreach( $router->getRoutes() as $route ) { + $this->assertStringStartsWith( '/', $route->route, $name . ' route must be rooted' ); + $this->assertStringNotContainsString( '//', $route->route, $name . ' route "' . $route->route . '" would never match a request' ); + } + } + } + + + public function testFrameworkRoutesCarryTheBasePathWhenThereIsOne(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->basePath = 'api'; + $c->rootUrl = 'https://example.gov'; + } ); + + $routes = ( new \gcgov\framework\services\userCrud\router() )->getRoutes(); + + $this->assertSame( '/api/user', $routes[ 0 ]->route ); + } + + + /** + * getBaseUrl() has the same shape of defect: it is concatenated with '/auth/authorize' + * to build the advertised openid-configuration and the OAuth callback, so a trailing + * slash at the domain root produced 'https://host//auth/hybridauth/...' — which fails + * redirect-URI matching at the provider. + */ + public function testBaseUrlHasNoTrailingSlashAtDomainRoot(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->basePath = ''; + $c->rootUrl = 'https://example.gov'; + } ); + + $this->assertSame( 'https://example.gov', config::getBaseUrl() ); + $this->assertStringNotContainsString( '//auth', config::getBaseUrl() . '/auth/authorize' ); + } + + + public function testBaseUrlStillJoinsRootUrlAndBasePath(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->basePath = 'api/v1'; + $c->rootUrl = 'https://example.gov/'; + } ); + + $this->assertSame( 'https://example.gov/api/v1', config::getBaseUrl() ); + } + + + private function oauthAuthConfig(): \gcgov\framework\models\config\services\auth { + $auth = new \gcgov\framework\models\config\services\auth(); + $auth->provider = \gcgov\framework\models\config\services\auth::PROVIDER_OAUTH; + + return $auth; + } + +} diff --git a/tests/Unit/RouterAuthenticationGuaranteeTest.php b/tests/Unit/RouterAuthenticationGuaranteeTest.php new file mode 100644 index 0000000..da9f277 --- /dev/null +++ b/tests/Unit/RouterAuthenticationGuaranteeTest.php @@ -0,0 +1,100 @@ +expectException( configException::class ); + $this->expectExceptionMessage( '2 route(s) require authentication but no authentication service is enabled' ); + + router::assertAuthenticationIsProvided( self::routes( true ), false, false ); + } + + + public function testTheMessageNamesTheOffendingRoutes(): void { + try { + router::assertAuthenticationIsProvided( self::routes( true ), false, false ); + $this->fail( 'expected a configException' ); + } + catch( configException $e ) { + $this->assertStringContainsString( 'POST /widget/{_id}', $e->getMessage() ); + $this->assertStringContainsString( 'GET|POST /secret', $e->getMessage() ); + // and tells the reader both ways out + $this->assertStringContainsString( '"provider": "oauth"', $e->getMessage() ); + $this->assertStringContainsString( 'providesAuthentication()', $e->getMessage() ); + } + } + + + public function testAnEnabledAuthServiceSatisfiesTheCheck(): void { + router::assertAuthenticationIsProvided( self::routes( true ), true, false ); + $this->expectNotToPerformAssertions(); + } + + + public function testAnApplicationThatGuardsItsOwnRoutesSatisfiesTheCheck(): void { + router::assertAuthenticationIsProvided( self::routes( true ), false, true ); + $this->expectNotToPerformAssertions(); + } + + + /** No authenticated routes means nothing to fail closed about. */ + public function testNoAuthenticatedRoutesNeedsNoAuthService(): void { + router::assertAuthenticationIsProvided( self::routes( false ), false, false ); + $this->expectNotToPerformAssertions(); + } + + + public function testEmptyRouteTableIsFine(): void { + router::assertAuthenticationIsProvided( [], false, false ); + $this->expectNotToPerformAssertions(); + } + + + /** + * requiredRoles on an unauthenticated route is contradictory — the route returns before + * the guard chain, so the roles can never be checked. It is warned, not refused: the + * declaration was already inert, so failing an application's boot over it would break + * something that works rather than protect anything. + */ + public function testRolesOnAnUnauthenticatedRouteWarnRatherThanRefuse(): void { + $this->expectNotToPerformAssertions(); + + router::assertAuthenticationIsProvided( + [ new route( 'GET', '/widget', '\app\controllers\widget', 'getAll', false, [ 'Widget.Read' ] ) ], + false, + false + ); + } + +} diff --git a/tests/Unit/RouterServiceActivationTest.php b/tests/Unit/RouterServiceActivationTest.php new file mode 100644 index 0000000..ff8b144 --- /dev/null +++ b/tests/Unit/RouterServiceActivationTest.php @@ -0,0 +1,137 @@ +getValue(); + } + + + protected function tearDown(): void { + ( new \ReflectionProperty( config::class, 'unifiedConfig' ) )->setValue( null, self::$original ); + } + + + private static function useServices( string $servicesJson ): void { + $config = unifiedConfig::jsonDeserialize( json_decode( '{"type":"local","rootUrl":"http://test.local","basePath":"api","services":' . $servicesJson . '}', false ) ); + ( new \ReflectionProperty( config::class, 'unifiedConfig' ) )->setValue( null, $config ); + } + + + /** @return string[] */ + private static function routePaths(): array { + $paths = []; + foreach( router::getMergedRoutes() as $route ) { + $paths[] = $route->route; + } + + return $paths; + } + + + public function testNoServicesGivesOnlyHealthAndAppRoutes(): void { + self::useServices( '{}' ); + $paths = self::routePaths(); + + $this->assertContains( '/api/health', $paths ); + $this->assertContains( '/api/health/ready', $paths ); + $this->assertContains( '/widget', $paths ); + $this->assertNotContains( '/api/user', $paths ); + $this->assertNotContains( '/api/documentation.yaml', $paths ); + } + + + public function testUserCrudBlockAddsItsRoutes(): void { + self::useServices( '{"userCrud":{}}' ); + $paths = self::routePaths(); + + $this->assertContains( '/api/user', $paths ); + $this->assertContains( '/api/user/{_id}', $paths ); + $this->assertNotContains( '/api/documentation.yaml', $paths ); + } + + + public function testDocumentationBlockAddsItsRoute(): void { + self::useServices( '{"documentation":{}}' ); + + $this->assertContains( '/api/documentation.yaml', self::routePaths() ); + } + + + /** + * The provider decides which token-acquisition routes exist. The shared ones — + * jwks and fileToken — are present either way. + */ + public function testOauthProviderContributesTheOauthRoutes(): void { + self::useServices( '{"auth":{"provider":"oauth"}}' ); + $paths = self::routePaths(); + + $this->assertContains( '/api/.well-known/jwks.json', $paths ); + $this->assertContains( '/api/auth/fileToken', $paths ); + $this->assertContains( '/api/auth/authorize', $paths ); + $this->assertContains( '/api/auth/hybridauth/{provider}', $paths ); + $this->assertContains( '/api/auth/verifyMfaCode', $paths ); + $this->assertContains( '/api/.well-known/openid-configuration', $paths ); + $this->assertNotContains( '/api/auth/microsoft', $paths ); + } + + + public function testMsFrontProviderContributesOnlyTheExchangeRoute(): void { + self::useServices( '{"auth":{"provider":"msFront"}}' ); + $paths = self::routePaths(); + + $this->assertContains( '/api/.well-known/jwks.json', $paths ); + $this->assertContains( '/api/auth/fileToken', $paths ); + $this->assertContains( '/api/auth/microsoft', $paths ); + $this->assertNotContains( '/api/auth/authorize', $paths ); + $this->assertNotContains( '/api/auth/verifyMfaCode', $paths ); + } + + + /** Every route the enabled providers register must point at a real method. */ + public function testEveryRouteResolvesToAnExistingControllerMethod(): void { + foreach( [ '{"auth":{"provider":"oauth"},"userCrud":{},"documentation":{}}', '{"auth":{"provider":"msFront"}}' ] as $services ) { + self::useServices( $services ); + foreach( router::getMergedRoutes() as $route ) { + if( !str_starts_with( ltrim( $route->class, '\\' ), 'gcgov\\framework\\services' ) ) { + continue; + } + $this->assertTrue( class_exists( $route->class ), 'missing controller ' . $route->class ); + $this->assertTrue( method_exists( $route->class, $route->method ), $route->class . '::' . $route->method . '() does not exist' ); + } + } + } + + + public function testEnablingEverythingProducesNoDuplicateRoutes(): void { + self::useServices( '{"auth":{"provider":"oauth"},"userCrud":{},"documentation":{}}' ); + + $seen = []; + foreach( router::getMergedRoutes() as $route ) { + foreach( (array)$route->httpMethod as $method ) { + $key = $method . ' ' . $route->route; + $this->assertNotContains( $key, $seen, 'duplicate route ' . $key . ' would be a FastRoute boot failure' ); + $seen[] = $key; + } + } + } + +} diff --git a/tests/Unit/Services/Auth/Controllers/AuthControllerTest.php b/tests/Unit/Services/Auth/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..f82bb3f --- /dev/null +++ b/tests/Unit/Services/Auth/Controllers/AuthControllerTest.php @@ -0,0 +1,62 @@ +assertContains( controller::class, class_implements( auth::class ) ?: [] ); + } + + + public function testConstructorRequiresNoArguments(): void { + $constructor = ( new \ReflectionClass( auth::class ) )->getConstructor(); + $this->assertNotNull( $constructor ); + $this->assertSame( 0, $constructor->getNumberOfRequiredParameters() ); + $this->assertInstanceOf( auth::class, new auth() ); + } + + + public function testJwksReturnsControllerDataResponseType(): void { + $this->assertSame( controllerDataResponse::class, (string)( new \ReflectionMethod( auth::class, 'jwks' ) )->getReturnType() ); + } + + + public function testFileTokenReturnsControllerDataResponseType(): void { + $this->assertSame( controllerDataResponse::class, (string)( new \ReflectionMethod( auth::class, 'fileToken' ) )->getReturnType() ); + } + + + public function testLifecycleHooksReturnVoid(): void { + auth::_before(); + auth::_after(); + $reflection = new \ReflectionClass( auth::class ); + $this->assertSame( 'void', (string)$reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string)$reflection->getMethod( '_after' )->getReturnType() ); + } + + + /** Neither provider should keep a copy of the shared endpoints. */ + public function testProvidersDoNotRedeclareTheSharedEndpoints(): void { + foreach( [ \gcgov\framework\services\auth\providers\oauth\controllers\auth::class, + \gcgov\framework\services\auth\providers\msFront\controllers\auth::class ] as $provider ) { + $this->assertFalse( method_exists( $provider, 'jwks' ), $provider . ' still declares jwks()' ); + $this->assertFalse( method_exists( $provider, 'fileToken' ), $provider . ' still declares fileToken()' ); + } + } + +} diff --git a/tests/Unit/Services/Auth/GuardTest.php b/tests/Unit/Services/Auth/GuardTest.php new file mode 100644 index 0000000..530d652 --- /dev/null +++ b/tests/Unit/Services/Auth/GuardTest.php @@ -0,0 +1,129 @@ + */ + private array $server = []; + /** @var array */ + private array $get = []; + + protected function setUp(): void { + $this->server = $_SERVER; + $this->get = $_GET; + unset( $_SERVER[ 'HTTP_AUTHORIZATION' ], $_GET[ 'fileAccessToken' ] ); + } + + + protected function tearDown(): void { + $_SERVER = $this->server; + $_GET = $this->get; + } + + + public function testMissingAuthorizationHeaderIs401(): void { + $this->expectException( routeException::class ); + $this->expectExceptionCode( 401 ); + $this->expectExceptionMessage( 'Missing Authorization' ); + + guard::authenticate( $this->routeHandler() ); + } + + + /** + * A token in a URL ends up in access logs, Referer headers and browser history, so a + * route has to opt in before one is accepted. Without the opt-in the query parameter is + * ignored entirely — not merely rejected later. + */ + public function testFileAccessTokenIsIgnoredOnRoutesThatDoNotOptIn(): void { + $_GET[ 'fileAccessToken' ] = 'a.b.c'; + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 401 ); + $this->expectExceptionMessage( 'Missing Authorization' ); + + guard::authenticate( $this->routeHandler( allowShortLivedUrlTokens: false ) ); + } + + + /** + * With the opt-in the token is read, so the guard gets past readToken() and fails + * somewhere later instead — never with 'Missing Authorization'. + */ + public function testFileAccessTokenIsReadOnRoutesThatOptIn(): void { + $_GET[ 'fileAccessToken' ] = 'not-a-real-token'; + + try { + guard::authenticate( $this->routeHandler( allowShortLivedUrlTokens: true ) ); + $this->fail( 'an unparseable token must not authenticate' ); + } + catch( \Throwable $e ) { + $this->assertStringNotContainsString( 'Missing Authorization', $e->getMessage() ); + } + } + + + public function testAuthorizationHeaderIsPreferredOverTheQueryParameter(): void { + $_SERVER[ 'HTTP_AUTHORIZATION' ] = 'Bearer not-a-real-token'; + $_GET[ 'fileAccessToken' ] = 'also-not-real'; + + try { + guard::authenticate( $this->routeHandler( allowShortLivedUrlTokens: true ) ); + $this->fail( 'an unparseable token must not authenticate' ); + } + catch( \Throwable $e ) { + $this->assertStringNotContainsString( 'Missing Authorization', $e->getMessage() ); + } + } + + + private function routeHandler( bool $allowShortLivedUrlTokens = false ): routeHandler { + return new routeHandler( '\app\controllers\widget', 'getOne', true, [ 'Widget.Read' ], $allowShortLivedUrlTokens ); + } + + + /** + * Authorization is not this class's job. A second copy of the role loop here would + * reintroduce exactly the split that made requiredRoles enforceable on one path and not + * the other. + */ + public function testGuardDoesNotCheckRequiredRolesItself(): void { + $source = (string)file_get_contents( __DIR__ . '/../../../../src/services/auth/guard.php' ); + + self::assertStringNotContainsString( 'requiredRoles as $requiredRole', $source, 'the role loop belongs to router::assertRequiredRoles()' ); + self::assertStringNotContainsString( 'required to access this content', $source, 'the 403 belongs to router::assertRequiredRoles()' ); + } + + + /** Establishing the user is this class's job, and the router's check depends on it. */ + public function testGuardPopulatesTheRequestScopedAuthUser(): void { + $source = (string)file_get_contents( __DIR__ . '/../../../../src/services/auth/guard.php' ); + + self::assertStringContainsString( 'request::getAuthUser()', $source ); + self::assertStringContainsString( 'setFromJwtToken', $source ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/MsFront/Controllers/AuthControllerTest.php b/tests/Unit/Services/Auth/Providers/MsFront/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..c8eb71c --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/MsFront/Controllers/AuthControllerTest.php @@ -0,0 +1,53 @@ +assertContains( + \gcgov\framework\interfaces\controller::class, + class_implements( auth::class ) ?: [] + ); + } + + public function testConstructorRequiresNoArguments(): void { + $reflection = new \ReflectionClass( auth::class ); + $constructor = $reflection->getConstructor(); + $this->assertNotNull( $constructor ); + $this->assertSame( 0, $constructor->getNumberOfRequiredParameters() ); + $this->assertInstanceOf( auth::class, new auth() ); + } + + + public function testMicrosoftMethodReturnsControllerDataResponseType(): void { + $reflection = new \ReflectionMethod( auth::class, 'microsoft' ); + $this->assertSame( + \gcgov\framework\models\controllerDataResponse::class, + (string) $reflection->getReturnType() + ); + } + + + public function testLookupUserMicrosoftTokenInfoIsPrivate(): void { + $reflection = new \ReflectionMethod( auth::class, 'lookupUserMicrosoftTokenInfo' ); + $this->assertTrue( $reflection->isPrivate() ); + } + + public function testLifecycleHooksReturnVoid(): void { + auth::_before(); + auth::_after(); + + $reflection = new \ReflectionClass( auth::class ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_after' )->getReturnType() ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Controllers/AuthControllerTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..59f722a --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Controllers/AuthControllerTest.php @@ -0,0 +1,71 @@ +assertContains( + \gcgov\framework\interfaces\controller::class, + class_implements( auth::class ) ?: [] + ); + } + + public function testConstructorRequiresNoArguments(): void { + $reflection = new \ReflectionClass( auth::class ); + $constructor = $reflection->getConstructor(); + $this->assertNotNull( $constructor ); + $this->assertSame( 0, $constructor->getNumberOfRequiredParameters() ); + $this->assertInstanceOf( auth::class, new auth() ); + } + + + public function testOpenidReturnsControllerDataResponseType(): void { + $reflection = new \ReflectionMethod( auth::class, 'openId' ); + $this->assertSame( + \gcgov\framework\models\controllerDataResponse::class, + (string) $reflection->getReturnType() + ); + } + + + public function testOauthPostAuthorizeMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'oauthPostAuthorize' ) ); + } + + public function testOauthGetAuthorizeMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'oauthGetAuthorize' ) ); + } + + public function testOauthHybridAuthMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'oauthHybridAuth' ) ); + } + + public function testVerifyMfaSecretMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'verifyMfaSecret' ) ); + } + + public function testVerifyMfaCodeMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'verifyMfaCode' ) ); + } + + public function testOutMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'out' ) ); + } + + public function testLifecycleHooksReturnVoid(): void { + auth::_before(); + auth::_after(); + $reflection = new \ReflectionClass( auth::class ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_after' )->getReturnType() ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/ConfigureMfaResponseTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/ConfigureMfaResponseTest.php new file mode 100644 index 0000000..e9d6f3c --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/ConfigureMfaResponseTest.php @@ -0,0 +1,63 @@ +assertTrue( is_subclass_of( configureMfaResponse::class, stdAuthResponse::class ) ); + } + + public function testConstructorPopulatesAllFieldsFromUserMultifactor(): void { + $userId = new ObjectId(); + $mf = new userMultifactor( $userId ); + $mf->secret = 'SECRETXYZ'; + + $token = $this->buildToken(); + $response = new configureMfaResponse( $token, $mf, 'data:image/png;base64,abc' ); + + $this->assertSame( 'data:image/png;base64,abc', $response->qrCodeDataUri ); + $this->assertSame( 'SECRETXYZ', $response->secret ); + $this->assertSame( (string) $userId, (string) $response->userId ); + $this->assertSame( (string) $mf->_id, (string) $response->userMultifactorId ); + $this->assertTrue( $response->mfaRequired ); + $this->assertFalse( $response->mfaConfigured ); + $this->assertSame( $token->toString(), $response->access_token ); + } + + public function testQrCodeDataUriDefaultsToEmptyString(): void { + $mf = new userMultifactor( new ObjectId() ); + $response = new configureMfaResponse( null, $mf ); + $this->assertSame( '', $response->qrCodeDataUri ); + } + + public function testNullAccessTokenLeavesAccessTokenEmpty(): void { + $mf = new userMultifactor( new ObjectId() ); + $response = new configureMfaResponse( null, $mf, 'x' ); + $this->assertSame( '', $response->access_token ); + $this->assertSame( 0, $response->expires_in ); + } + + private function buildToken(): Plain { + $exp = ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ); + return new Plain( + new DataSet( [ 'typ' => 'JWT', 'alg' => 'none' ], 'header' ), + new DataSet( [ 'exp' => $exp ], 'payload' ), + new Signature( '', '' ) + ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/RequireMfaResponseTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/RequireMfaResponseTest.php new file mode 100644 index 0000000..30521ff --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/RequireMfaResponseTest.php @@ -0,0 +1,93 @@ +assertTrue( is_subclass_of( requireMfaResponse::class, stdAuthResponse::class ) ); + } + + public function testDefaultsWithNoArguments(): void { + $response = new requireMfaResponse(); + $this->assertTrue( $response->mfaRequired ); + $this->assertTrue( $response->mfaConfigured ); + $this->assertSame( '', $response->access_token ); + } + + public function testAccessTokenWithoutUserPreservesDefaultFlags(): void { + $token = $this->buildToken(); + $response = new requireMfaResponse( $token ); + + $this->assertSame( $token->toString(), $response->access_token ); + $this->assertTrue( $response->mfaRequired ); + $this->assertTrue( $response->mfaConfigured ); + } + + public function testUserDictatesFlagValues(): void { + $user = $this->buildUser( false, false ); + $response = new requireMfaResponse( null, $user ); + + $this->assertFalse( $response->mfaRequired ); + $this->assertFalse( $response->mfaConfigured ); + } + + public function testUserAndAccessTokenCombineCorrectly(): void { + $token = $this->buildToken(); + $user = $this->buildUser( true, false ); + $response = new requireMfaResponse( $token, $user ); + + $this->assertSame( $token->toString(), $response->access_token ); + $this->assertTrue( $response->mfaRequired ); + $this->assertFalse( $response->mfaConfigured ); + } + + private function buildToken(): Plain { + $exp = ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ); + return new Plain( + new DataSet( [ 'typ' => 'JWT', 'alg' => 'none' ], 'header' ), + new DataSet( [ 'exp' => $exp ], 'payload' ), + new Signature( '', '' ) + ); + } + + private function buildUser( bool $mfaRequired, bool $mfaConfigured ): \gcgov\framework\interfaces\auth\user { + return new class( $mfaRequired, $mfaConfigured ) implements \gcgov\framework\interfaces\auth\user { + public bool $mfaRequired; + public bool $mfaConfigured; + public function __construct( bool $mfaRequired, bool $mfaConfigured ) { + $this->mfaRequired = $mfaRequired; + $this->mfaConfigured = $mfaConfigured; + } + public function getId(): string|int|\MongoDB\BSON\ObjectId { return new \MongoDB\BSON\ObjectId(); } + public function getName(): string { return ''; } + public function getUsername(): string { return ''; } + public function getPassword(): string { return ''; } + public function getOauthId(): string { return ''; } + public function getOauthProvider(): string { return ''; } + public function getEmail(): string { return ''; } + public function getRoles(): array { return []; } + public function getActive(): bool { return true; } + public function getMfaRequired(): bool { return $this->mfaRequired; } + public function getMfaConfigured(): bool { return $this->mfaConfigured; } + public static function getFromOauth( string $email, string $externalId, string $externalProvider, ?string $firstName = '', ?string $lastName = '', bool $addIfNotExisting = false, array $rolesForNewUser=[] ): self { throw new \BadMethodCallException(); } + public static function verifyUsernamePassword( string $username, string $password ): self { throw new \BadMethodCallException(); } + public static function getOneByExternalId( string $externalId ): self { throw new \BadMethodCallException(); } + public static function getOneByEmail( string $email ): self { throw new \BadMethodCallException(); } + public static function getOne( \MongoDB\BSON\ObjectId|string|int $_id ): self { throw new \BadMethodCallException(); } + public static function save( object &$object ): mixed { return null; } + }; + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/StdAuthResponseTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/StdAuthResponseTest.php new file mode 100644 index 0000000..79dc51e --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/StdAuthResponseTest.php @@ -0,0 +1,68 @@ +assertSame( 'Bearer', $response->token_type ); + $this->assertSame( 0, $response->expires_in ); + $this->assertSame( '', $response->access_token ); + $this->assertSame( '', $response->refresh_token ); + } + + public function testAccessTokenPopulatesExpiryAndString(): void { + $expiresAt = ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ); + $accessToken = $this->buildToken( [ 'exp' => $expiresAt ] ); + + $response = new stdAuthResponse( $accessToken ); + $this->assertEqualsWithDelta( 3600, $response->expires_in, 5 ); + $this->assertSame( $accessToken->toString(), $response->access_token ); + $this->assertSame( 'Bearer', $response->token_type ); + $this->assertSame( '', $response->refresh_token ); + } + + public function testCustomTokenTypeIsRespected(): void { + $accessToken = $this->buildToken( [ 'exp' => ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT5M' ) ) ] ); + $response = new stdAuthResponse( $accessToken, null, 'MAC' ); + $this->assertSame( 'MAC', $response->token_type ); + } + + public function testRefreshTokenPopulatesRefreshString(): void { + $accessToken = $this->buildToken( [ 'exp' => ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ) ] ); + $refreshToken = $this->buildToken( [ 'exp' => ( new \DateTimeImmutable() )->add( new \DateInterval( 'P30D' ) ) ] ); + + $response = new stdAuthResponse( $accessToken, $refreshToken ); + $this->assertSame( $refreshToken->toString(), $response->refresh_token ); + } + + public function testRefreshTokenAlonePopulatesOnlyRefreshString(): void { + $refreshToken = $this->buildToken( [ 'exp' => ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ) ] ); + $response = new stdAuthResponse( null, $refreshToken ); + $this->assertSame( '', $response->access_token ); + $this->assertSame( $refreshToken->toString(), $response->refresh_token ); + $this->assertSame( 'Bearer', $response->token_type ); + } + + /** + * @param array $claims + */ + private function buildToken( array $claims ): Plain { + $headers = new DataSet( [ 'typ' => 'JWT', 'alg' => 'none' ], 'eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0' ); + $payload = new DataSet( $claims, 'eyJjbGFpbXMiOiJpbnNpZGUifQ' ); + $signature = new Signature( '', '' ); + return new Plain( $headers, $payload, $signature ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaCodeRequestTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaCodeRequestTest.php new file mode 100644 index 0000000..e247089 --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaCodeRequestTest.php @@ -0,0 +1,39 @@ +assertSame( '', $request->code ); + } + + public function testConstructorAssignsCode(): void { + $request = new verifyMfaCodeRequest( '123456' ); + $this->assertSame( '123456', $request->code ); + } + + public function testCodeIsPublicallyMutable(): void { + $request = new verifyMfaCodeRequest(); + $request->code = '987654'; + $this->assertSame( '987654', $request->code ); + } + + public function testExtendsJsonDeserialize(): void { + $this->assertTrue( + is_subclass_of( + verifyMfaCodeRequest::class, + \andrewsauder\jsonDeserialize\jsonDeserialize::class + ) + ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaSecretRequestTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaSecretRequestTest.php new file mode 100644 index 0000000..9738d31 --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaSecretRequestTest.php @@ -0,0 +1,42 @@ +assertSame( '', $request->code ); + $this->assertNull( $request->userMultifactorId ); + } + + public function testConstructorAssignsCodeAndObjectId(): void { + $id = new ObjectId(); + $request = new verifyMfaSecretRequest( 'abc', $id ); + $this->assertSame( 'abc', $request->code ); + $this->assertSame( (string) $id, (string) $request->userMultifactorId ); + } + + public function testConstructorWithCodeOnlyLeavesIdNull(): void { + $request = new verifyMfaSecretRequest( '12345' ); + $this->assertNull( $request->userMultifactorId ); + } + + public function testExtendsJsonDeserialize(): void { + $this->assertTrue( + is_subclass_of( + verifyMfaSecretRequest::class, + \andrewsauder\jsonDeserialize\jsonDeserialize::class + ) + ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Services/MultifactorTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Services/MultifactorTest.php new file mode 100644 index 0000000..09d52ee --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Services/MultifactorTest.php @@ -0,0 +1,103 @@ +buildUser( true, false ); + $response = multifactor::requireMfaResponse( null, $user ); + + $this->assertInstanceOf( requireMfaResponse::class, $response ); + $this->assertTrue( $response->mfaRequired ); + $this->assertFalse( $response->mfaConfigured ); + } + + public function testRequireMfaResponseIncludesAccessTokenString(): void { + $user = $this->buildUser( true, true ); + $token = $this->buildToken(); + $response = multifactor::requireMfaResponse( $token, $user ); + + $this->assertSame( $token->toString(), $response->access_token ); + $this->assertTrue( $response->mfaRequired ); + $this->assertTrue( $response->mfaConfigured ); + } + + public function testRequireMfaResponseStaticAndReturnsModelType(): void { + $method = new \ReflectionMethod( multifactor::class, 'requireMfaResponse' ); + $this->assertTrue( $method->isStatic() ); + $this->assertTrue( $method->isPublic() ); + $this->assertSame( requireMfaResponse::class, (string) $method->getReturnType() ); + } + + public function testConfigureMfaResponseIsStaticWithMongoIdArgument(): void { + $method = new \ReflectionMethod( multifactor::class, 'configureMfaResponse' ); + $this->assertTrue( $method->isStatic() ); + + $params = $method->getParameters(); + $this->assertSame( 'userId', $params[0]->getName() ); + $this->assertSame( \MongoDB\BSON\ObjectId::class, (string) $params[0]->getType() ); + $this->assertTrue( $params[1]->allowsNull() ); + } + + public function testVerifyMfaSecretIsStaticAndReturnsUserInterface(): void { + $method = new \ReflectionMethod( multifactor::class, 'verifyMfaSecret' ); + $this->assertTrue( $method->isStatic() ); + $this->assertSame( \gcgov\framework\interfaces\auth\user::class, (string) $method->getReturnType() ); + } + + public function testIsMfaCodeCorrectIsStaticAndReturnsBool(): void { + $method = new \ReflectionMethod( multifactor::class, 'isMfaCodeCorrect' ); + $this->assertTrue( $method->isStatic() ); + $this->assertSame( 'bool', (string) $method->getReturnType() ); + } + + private function buildToken(): Plain { + $exp = ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ); + return new Plain( + new DataSet( [ 'typ' => 'JWT', 'alg' => 'none' ], 'h' ), + new DataSet( [ 'exp' => $exp ], 'p' ), + new Signature( '', '' ) + ); + } + + private function buildUser( bool $mfaRequired, bool $mfaConfigured ): \gcgov\framework\interfaces\auth\user { + return new class( $mfaRequired, $mfaConfigured ) implements \gcgov\framework\interfaces\auth\user { + public bool $mfaRequired; + public bool $mfaConfigured; + public function __construct( bool $mfaRequired, bool $mfaConfigured ) { + $this->mfaRequired = $mfaRequired; + $this->mfaConfigured = $mfaConfigured; + } + public function getId(): string|int|\MongoDB\BSON\ObjectId { return new \MongoDB\BSON\ObjectId(); } + public function getName(): string { return ''; } + public function getUsername(): string { return ''; } + public function getPassword(): string { return ''; } + public function getOauthId(): string { return ''; } + public function getOauthProvider(): string { return ''; } + public function getEmail(): string { return ''; } + public function getRoles(): array { return []; } + public function getActive(): bool { return true; } + public function getMfaRequired(): bool { return $this->mfaRequired; } + public function getMfaConfigured(): bool { return $this->mfaConfigured; } + public static function getFromOauth( string $email, string $externalId, string $externalProvider, ?string $firstName = '', ?string $lastName = '', bool $addIfNotExisting = false, array $rolesForNewUser=[] ): self { throw new \BadMethodCallException(); } + public static function verifyUsernamePassword( string $username, string $password ): self { throw new \BadMethodCallException(); } + public static function getOneByExternalId( string $externalId ): self { throw new \BadMethodCallException(); } + public static function getOneByEmail( string $email ): self { throw new \BadMethodCallException(); } + public static function getOne( \MongoDB\BSON\ObjectId|string|int $_id ): self { throw new \BadMethodCallException(); } + public static function save( object &$object ): mixed { return null; } + }; + } + +} diff --git a/tests/Unit/Services/Auth/RouterTest.php b/tests/Unit/Services/Auth/RouterTest.php new file mode 100644 index 0000000..7c4a4e2 --- /dev/null +++ b/tests/Unit/Services/Auth/RouterTest.php @@ -0,0 +1,165 @@ + $provider ] ) ); + } + + + protected function setUp(): void { + unset( $_SERVER[ 'HTTP_AUTHORIZATION' ], $_GET[ 'fileAccessToken' ] ); + } + + + private static function findRoute( router $router, string $method ): route { + foreach( $router->getRoutes() as $route ) { + if( $route->method===$method ) { + return $route; + } + } + throw new \LogicException( 'No route with method=' . $method ); + } + + + public function testRouterImplementsTheFrameworkRouterInterface(): void { + $this->assertContains( \gcgov\framework\interfaces\router::class, class_implements( router::class ) ?: [] ); + } + + + public function testOauthProviderRegistersNineRoutes(): void { + $routes = self::router( 'oauth' )->getRoutes(); + $this->assertCount( 9, $routes ); + foreach( $routes as $route ) { + $this->assertInstanceOf( route::class, $route ); + } + } + + + public function testMsFrontProviderRegistersThreeRoutes(): void { + $this->assertCount( 3, self::router( 'msFront' )->getRoutes() ); + } + + + /** jwks and fileToken are the same endpoints whichever provider is selected. */ + public function testSharedRoutesArePresentForBothProviders(): void { + foreach( [ 'oauth', 'msFront' ] as $provider ) { + $router = self::router( $provider ); + + $jwks = self::findRoute( $router, 'jwks' ); + $this->assertSame( 'GET', $jwks->httpMethod ); + $this->assertSame( '/api/.well-known/jwks.json', $jwks->route ); + $this->assertFalse( $jwks->authentication ); + + $fileToken = self::findRoute( $router, 'fileToken' ); + $this->assertSame( '/api/auth/fileToken', $fileToken->route ); + $this->assertTrue( $fileToken->authentication ); + } + } + + + public function testOpenidConfigurationRouteIsPublic(): void { + $route = self::findRoute( self::router( 'oauth' ), 'openId' ); + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/.well-known/openid-configuration', $route->route ); + $this->assertFalse( $route->authentication ); + } + + + public function testAuthorizeRoutesShareOnePathAcrossTwoMethods(): void { + $router = self::router( 'oauth' ); + + $post = self::findRoute( $router, 'oauthPostAuthorize' ); + $this->assertSame( 'POST', $post->httpMethod ); + $this->assertSame( '/api/auth/authorize', $post->route ); + $this->assertFalse( $post->authentication ); + + $get = self::findRoute( $router, 'oauthGetAuthorize' ); + $this->assertSame( 'GET', $get->httpMethod ); + $this->assertSame( '/api/auth/authorize', $get->route ); + } + + + public function testHybridAuthRouteHasProviderPlaceholder(): void { + $this->assertSame( '/api/auth/hybridauth/{provider}', self::findRoute( self::router( 'oauth' ), 'oauthHybridAuth' )->route ); + } + + + public function testMfaRoutesArePostAndAuthenticated(): void { + foreach( [ 'verifyMfaSecret', 'verifyMfaCode' ] as $method ) { + $route = self::findRoute( self::router( 'oauth' ), $method ); + $this->assertSame( 'POST', $route->httpMethod ); + $this->assertTrue( $route->authentication ); + } + } + + + public function testMicrosoftExchangeRouteIsPublic(): void { + $route = self::findRoute( self::router( 'msFront' ), 'microsoft' ); + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/auth/microsoft', $route->route ); + $this->assertFalse( $route->authentication ); + } + + + public function testMfaRoutesAreAbsentUnderMsFront(): void { + $methods = array_map( fn( route $r ): string => $r->method, self::router( 'msFront' )->getRoutes() ); + $this->assertNotContains( 'verifyMfaCode', $methods ); + $this->assertNotContains( 'oauthPostAuthorize', $methods ); + } + + + public function testMissingAuthorizationHeaderIs401( ): void { + foreach( [ 'oauth', 'msFront' ] as $provider ) { + try { + self::router( $provider )->authentication( new routeHandler( '\some\controller', 'm' ) ); + $this->fail( 'Expected routeException for ' . $provider ); + } + catch( routeException $e ) { + $this->assertSame( 401, $e->getCode() ); + $this->assertSame( 'Missing Authorization', $e->getMessage() ); + } + } + } + + + public function testShortLivedUrlTokensAllowedButNoTokenSuppliedIs401(): void { + $handler = new routeHandler( '\some\controller', 'm' ); + $handler->allowShortLivedUrlTokens = true; + + try { + self::router( 'oauth' )->authentication( $handler ); + $this->fail( 'Expected routeException' ); + } + catch( routeException $e ) { + $this->assertSame( 401, $e->getCode() ); + } + } + + + public function testMalformedJwtIsRejected(): void { + $_SERVER[ 'HTTP_AUTHORIZATION' ] = 'not.a.valid.jwt'; + + $this->expectException( \Throwable::class ); + self::router( 'oauth' )->authentication( new routeHandler( '\some\controller', 'm' ) ); + } + +} diff --git a/tests/Unit/Services/Chrome/ChromeServiceTest.php b/tests/Unit/Services/Chrome/ChromeServiceTest.php index 05f4990..3f6b805 100644 --- a/tests/Unit/Services/Chrome/ChromeServiceTest.php +++ b/tests/Unit/Services/Chrome/ChromeServiceTest.php @@ -21,7 +21,7 @@ protected function setUp(): void { mkdir( $this->tempSrvDir, 0777, true ); // point config::getSrvDir() at the fixture (same technique tests/bootstrap.php - // uses to seed environmentConfig) + // uses to seed unifiedConfig) $srvDirProperty = new \ReflectionProperty( config::class, 'srvDir' ); $srvDirProperty->setValue( null, $this->tempSrvDir . '/' ); } diff --git a/tests/Unit/Services/CronMonitor/CronMonitorTest.php b/tests/Unit/Services/CronMonitor/CronMonitorTest.php new file mode 100644 index 0000000..ff5b9ae --- /dev/null +++ b/tests/Unit/Services/CronMonitor/CronMonitorTest.php @@ -0,0 +1,118 @@ +> */ + private array $requestHistory; + + protected function setUp(): void { + $this->requestHistory = []; + $this->primeFrameworkConfig(); + } + + + /** The monitor url moved out of the untyped appDictionary into its own section. */ + private function primeFrameworkConfig(): void { + $config = ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->getValue(); + $config->cronMonitor->url = 'http://monitor.test/'; + } + + public function testClassExposesEndMethodReturningVoid(): void { + $reflection = new \ReflectionClass( cronMonitor::class ); + $this->assertTrue( $reflection->hasMethod( 'end' ) ); + $this->assertSame( 'void', (string) $reflection->getMethod( 'end' )->getReturnType() ); + } + + public function testConstructorStoresJobIdAndFiresStartRequest(): void { + $monitor = $this->buildMonitorWithMockedTransport( 'job-001', [ + new Response( 200, [], json_encode( [ 'data' => 'run-xyz' ] ) ), + new Response( 200, [], '' ), + ] ); + + $this->assertSame( 'job-001', $this->reflectProperty( $monitor, 'jobId' ) ); + $this->assertInstanceOf( PromiseInterface::class, $this->reflectProperty( $monitor, 'jobPromise' ) ); + + $monitor->end(); + $this->assertCount( 2, $this->requestHistory ); + $this->assertSame( 'http://monitor.test/jobHistory/start/job-001', (string) $this->requestHistory[0][ 'request' ]->getUri() ); + $this->assertSame( 'http://monitor.test/jobHistory/end/job-001/run-xyz', (string) $this->requestHistory[1][ 'request' ]->getUri() ); + } + + public function testEndDefaultsRunIdToEmptyWhenStartResponseIsInvalidJson(): void { + $monitor = $this->buildMonitorWithMockedTransport( 'job-002', [ + new Response( 200, [], 'not-json' ), + new Response( 200, [], '' ), + ] ); + $monitor->end(); + + $this->assertCount( 2, $this->requestHistory ); + $this->assertSame( 'http://monitor.test/jobHistory/end/job-002/', (string) $this->requestHistory[1][ 'request' ]->getUri() ); + } + + public function testEndSwallowsExceptionsFromFinalRequest(): void { + $monitor = $this->buildMonitorWithMockedTransport( 'job-004', [ + new Response( 200, [], json_encode( [ 'data' => 'run-abc' ] ) ), + new \RuntimeException( 'network is down' ), + ] ); + + $monitor->end(); + $this->assertCount( 2, $this->requestHistory ); + } + + public function testEndSwallowsStartPromiseExceptionAndStillFiresEndRequest(): void { + // The start-promise wait() is now wrapped in the same try/catch as + // the JSON-decoding step, so an exception from the start request + // is swallowed and the end request still fires with an empty runId. + $monitor = $this->buildMonitorWithMockedTransport( 'job-005', [ + new \RuntimeException( 'start failed' ), + new Response( 200, [], '' ), + ] ); + + $monitor->end(); + $this->assertCount( 2, $this->requestHistory ); + $this->assertSame( 'http://monitor.test/jobHistory/end/job-005/', (string) $this->requestHistory[1][ 'request' ]->getUri() ); + } + + /** + * @param list<\Throwable|Response> $queue + */ + private function buildMonitorWithMockedTransport( string $jobId, array $queue ): cronMonitor { + $monitor = ( new \ReflectionClass( cronMonitor::class ) )->newInstanceWithoutConstructor(); + + $mock = new MockHandler( $queue ); + $stack = HandlerStack::create( $mock ); + $stack->push( Middleware::history( $this->requestHistory ) ); + $client = new Client( [ 'base_uri' => 'http://monitor.test/', 'handler' => $stack ] ); + + $jobIdProp = new \ReflectionProperty( cronMonitor::class, 'jobId' ); + $jobIdProp->setValue( $monitor, $jobId ); + $clientProp = new \ReflectionProperty( cronMonitor::class, 'client' ); + $clientProp->setValue( $monitor, $client ); + $promiseProp = new \ReflectionProperty( cronMonitor::class, 'jobPromise' ); + $promiseProp->setValue( $monitor, $client->requestAsync( 'GET', 'jobHistory/start/' . $jobId ) ); + + return $monitor; + } + + private function reflectProperty( cronMonitor $monitor, string $name ): mixed { + $prop = new \ReflectionProperty( cronMonitor::class, $name ); + return $prop->getValue( $monitor ); + } + + +} diff --git a/tests/Unit/Services/Documentation/Controllers/DocumentationControllerTest.php b/tests/Unit/Services/Documentation/Controllers/DocumentationControllerTest.php new file mode 100644 index 0000000..de496a9 --- /dev/null +++ b/tests/Unit/Services/Documentation/Controllers/DocumentationControllerTest.php @@ -0,0 +1,82 @@ +primeFrameworkAppDir(); + } + + public function testControllerImplementsFrameworkControllerInterface(): void { + $this->assertContains( + \gcgov\framework\interfaces\controller::class, + class_implements( documentation::class ) ?: [] + ); + } + + public function testConstructorAcceptsNoArguments(): void { + $reflection = new \ReflectionClass( documentation::class ); + $this->assertSame( 0, $reflection->getConstructor()?->getNumberOfRequiredParameters() ); + $this->assertInstanceOf( documentation::class, new documentation() ); + } + + public function testRoutesReturnsEmptyControllerDataResponse(): void { + $response = ( new documentation() )->routes(); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + } + + public function testYamlMethodIsMarkedNoReturn(): void { + $reflection = new \ReflectionMethod( documentation::class, 'yaml' ); + $attributes = $reflection->getAttributes(); + $names = array_map( fn( \ReflectionAttribute $a ) => $a->getName(), $attributes ); + $this->assertContains( \JetBrains\PhpStorm\NoReturn::class, $names ); + } + + public function testGetScanDirectoriesReturnsExistingDirectories(): void { + $controller = new documentation(); + $method = new \ReflectionMethod( $controller, 'getScanDirectories' ); + $directories = $method->invoke( $controller ); + + $this->assertIsArray( $directories ); + foreach ( $directories as $dir ) { + $this->assertIsString( $dir ); + $this->assertDirectoryExists( $dir ); + } + } + + public function testGetExcludeDirectoriesFilesReturnsArray(): void { + $controller = new documentation(); + $method = new \ReflectionMethod( $controller, 'getExcludeDirectoriesFiles' ); + $exclusions = $method->invoke( $controller ); + + $this->assertIsArray( $exclusions ); + } + + public function testLifecycleHooksReturnVoid(): void { + documentation::_before(); + documentation::_after(); + + $reflection = new \ReflectionClass( documentation::class ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_after' )->getReturnType() ); + } + + private function primeFrameworkAppDir(): void { + // config::getAppDir() reflects on \app\app to derive the directory. + // Use a temp dir for the stub so getScanDirectories can verify any + // real-on-disk paths return false (and thus get filtered). + if ( !class_exists( '\app\app' ) ) { + eval( 'namespace app; class app {}' ); + } + } + +} diff --git a/tests/Unit/Services/Documentation/RouterTest.php b/tests/Unit/Services/Documentation/RouterTest.php new file mode 100644 index 0000000..25c0e46 --- /dev/null +++ b/tests/Unit/Services/Documentation/RouterTest.php @@ -0,0 +1,75 @@ +seedConfig( static fn( \gcgov\framework\models\unifiedConfig $c ) => $c->basePath = 'api/v1' ); + } + + + + + public function testRouterImplementsFrameworkRouterInterface(): void { + $this->assertContains( + \gcgov\framework\interfaces\router::class, + class_implements( router::class ) ?: [] + ); + } + + public function testGetRoutesReturnsSingleDocumentationYamlRoute(): void { + $routes = ( new router() )->getRoutes(); + $this->assertCount( 1, $routes ); + $this->assertInstanceOf( route::class, $routes[0] ); + } + + public function testRouteIsGetMethodAtConfiguredBasePath(): void { + $routes = ( new router() )->getRoutes(); + /** @var route $route */ + $route = $routes[0]; + + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/v1/documentation.yaml', $route->route ); + } + + public function testRouteTargetsDocumentationControllerYamlMethod(): void { + $routes = ( new router() )->getRoutes(); + /** @var route $route */ + $route = $routes[0]; + + $this->assertSame( '\gcgov\framework\services\documentation\controllers\documentation', $route->class ); + $this->assertSame( 'yaml', $route->method ); + } + + public function testRouteIsUnauthenticated(): void { + $routes = ( new router() )->getRoutes(); + /** @var route $route */ + $route = $routes[0]; + + $this->assertFalse( $route->authentication ); + } + + public function testAuthenticationAlwaysReturnsTrue(): void { + $routeHandler = $this->createStub( \gcgov\framework\models\routeHandler::class ); + $this->assertTrue( ( new router() )->authentication( $routeHandler ) ); + } + + +} diff --git a/tests/Unit/Services/Environment/ConfigLoaderTest.php b/tests/Unit/Services/Environment/ConfigLoaderTest.php new file mode 100644 index 0000000..1369d17 --- /dev/null +++ b/tests/Unit/Services/Environment/ConfigLoaderTest.php @@ -0,0 +1,95 @@ + */ + private array $envSnapshot = []; + + /** @var array */ + private array $serverSnapshot = []; + + private string $tempDir = ''; + + + protected function setUp(): void { + $this->envSnapshot = $_ENV; + $this->serverSnapshot = $_SERVER; + // Forward slashes: configFilePath() normalises separators, so a raw sys_get_temp_dir() + // makes the fixture disagree with it on Windows and nowhere else. + $this->tempDir = str_replace( '\\', '/', sys_get_temp_dir() ) . '/gcgov-configloader-test-' . uniqid(); + mkdir( $this->tempDir, 0777, true ); + dotEnvLoader::resetForTesting(); + } + + + protected function tearDown(): void { + foreach( array_keys( $_ENV ) as $key ) { + if( !array_key_exists( $key, $this->envSnapshot ) ) { + putenv( $key ); + } + } + $_ENV = $this->envSnapshot; + $_SERVER = $this->serverSnapshot; + dotEnvLoader::resetForTesting(); + $this->deleteDirectory( $this->tempDir ); + } + + + private function writeConfig( array $config ): void { + file_put_contents( $this->tempDir . '/config.json', json_encode( $config ) ); + } + + + public function testConfigFilePathIsRootConfigJson(): void { + $this->assertSame( $this->tempDir . '/config.json', configLoader::configFilePath( $this->tempDir ) ); + $this->assertSame( 'config.json', configLoader::FILE_NAME ); + + // Stated rather than left to the platform: the assertion above cannot exercise the + // normalisation on a system whose temp path has no backslashes to normalise. + $this->assertSame( 'C:/app/config.json', configLoader::configFilePath( 'C:\\app' ) ); + $this->assertSame( 'C:/app/config.json', configLoader::configFilePath( 'C:\\app\\' ) ); + } + + + + + public function testLoadThrowsWhenConfigMissing(): void { + $this->expectException( environmentException::class ); + configLoader::load( $this->tempDir ); + } + + + + + + + + + + + + + private function deleteDirectory( string $directory ): void { + if( !is_dir( $directory ) ) { + return; + } + $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); + foreach( $iterator as $file ) { + $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $directory ); + } + +} diff --git a/tests/Unit/Services/Environment/DotEnvLoaderTest.php b/tests/Unit/Services/Environment/DotEnvLoaderTest.php new file mode 100644 index 0000000..e11440a --- /dev/null +++ b/tests/Unit/Services/Environment/DotEnvLoaderTest.php @@ -0,0 +1,122 @@ + */ + private array $envSnapshot = []; + + /** @var array */ + private array $serverSnapshot = []; + + private string $tempDir = ''; + + /** @var string[] */ + private array $introducedKeys = [ 'DOTENV_TEST_A', 'DOTENV_TEST_B', 'DOTENV_TEST_REAL' ]; + + + protected function setUp(): void { + $this->envSnapshot = $_ENV; + $this->serverSnapshot = $_SERVER; + $this->tempDir = sys_get_temp_dir() . '/gcgov-dotenv-test-' . uniqid(); + mkdir( $this->tempDir, 0777, true ); + dotEnvLoader::resetForTesting(); + foreach( $this->introducedKeys as $key ) { + putenv( $key ); + unset( $_ENV[ $key ], $_SERVER[ $key ] ); + } + } + + + protected function tearDown(): void { + foreach( $this->introducedKeys as $key ) { + putenv( $key ); + unset( $_ENV[ $key ], $_SERVER[ $key ] ); + } + $_ENV = $this->envSnapshot; + $_SERVER = $this->serverSnapshot; + dotEnvLoader::resetForTesting(); + + $this->deleteDirectory( $this->tempDir ); + } + + + public function testLoadsEnvFile(): void { + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_A=from_env\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'from_env', $_ENV[ 'DOTENV_TEST_A' ] ?? null ); + $this->assertSame( 'from_env', getenv( 'DOTENV_TEST_A' ) ); + } + + + public function testRealEnvironmentWins(): void { + putenv( 'DOTENV_TEST_REAL=real_value' ); + $_ENV[ 'DOTENV_TEST_REAL' ] = 'real_value'; + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_REAL=dotenv_value\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'real_value', $_ENV[ 'DOTENV_TEST_REAL' ] ); + } + + + public function testLocalOverridesBaseEnv(): void { + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_B=base\n" ); + file_put_contents( $this->tempDir . '/.env.local', "DOTENV_TEST_B=local\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'local', $_ENV[ 'DOTENV_TEST_B' ] ); + } + + + public function testIsIdempotent(): void { + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_A=first\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + // Rewriting the file and reloading must NOT change the already-loaded value. + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_A=second\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'first', $_ENV[ 'DOTENV_TEST_A' ] ); + } + + + public function testNoOpWhenAbsent(): void { + // No .env file present — must not throw and must not set anything. + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertArrayNotHasKey( 'DOTENV_TEST_A', $_ENV ); + } + + + public function testLoadsEnvLocalWhenEnvAbsent(): void { + // A project keeping only machine-local values in .env.local (no committed .env) + // must still load — the documented precedence lists both files. + file_put_contents( $this->tempDir . '/.env.local', "DOTENV_TEST_A=only_local\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'only_local', $_ENV[ 'DOTENV_TEST_A' ] ?? null ); + $this->assertSame( 'only_local', getenv( 'DOTENV_TEST_A' ) ); + } + + + public function testMalformedEnvFileThrowsEnvironmentException(): void { + file_put_contents( $this->tempDir . '/.env', "NOT A VALID LINE ===\n" ); + $this->expectException( \gcgov\framework\services\environment\environmentException::class ); + dotEnvLoader::loadOnce( $this->tempDir ); + } + + + private function deleteDirectory( string $directory ): void { + if( !is_dir( $directory ) ) { + return; + } + $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); + foreach( $iterator as $file ) { + $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $directory ); + } + +} diff --git a/tests/Unit/Services/Environment/EnvVarResolverTest.php b/tests/Unit/Services/Environment/EnvVarResolverTest.php new file mode 100644 index 0000000..89a0ca8 --- /dev/null +++ b/tests/Unit/Services/Environment/EnvVarResolverTest.php @@ -0,0 +1,322 @@ + */ + private array $originalEnv = []; + + + protected function tearDown(): void { + foreach( array_keys( $this->originalEnv ) as $name ) { + unset( $_ENV[ $name ], $_SERVER[ $name ] ); + putenv( $name ); + } + $this->originalEnv = []; + + parent::tearDown(); + } + + + private function setEnv( string $name, string $value ): void { + $this->originalEnv[ $name ] = getenv( $name ); + $_ENV[ $name ] = $value; + putenv( $name . '=' . $value ); + } + + + /** Track a name so tearDown cleans it, without setting it. */ + private function trackEnv( string $name ): void { + $this->originalEnv[ $name ] = getenv( $name ); + } + + + private function resolve( string $json ): \stdClass { + $resolved = envVarResolver::resolveJson( $json, 'test config' ); + self::assertInstanceOf( \stdClass::class, $resolved ); + + return $resolved; + } + + + // --- opting in ------------------------------------------------------------- + + public function testConfigWithoutAnyReferenceIsReturnedByteForByte(): void { + $json = '{ "not json at all'; + self::assertSame( $json, envVarResolver::resolveJson( $json, 'test config' ) ); + } + + + public function testMalformedJsonContainingAReferenceIsHandedBackForTheCallerToReport(): void { + $json = '{ "uri": "%env(ANYTHING)%"'; + self::assertSame( $json, envVarResolver::resolveJson( $json, 'test config' ) ); + } + + + // --- required references --------------------------------------------------- + + public function testWholeStringReferenceIsReplacedWithTheValue(): void { + $this->setEnv( 'GF_TEST_URI', 'mongodb://db:27017' ); + + self::assertSame( 'mongodb://db:27017', $this->resolve( '{"uri":"%env(GF_TEST_URI)%"}' )->uri ); + } + + + public function testEmbeddedReferenceIsSubstitutedAsAString(): void { + $this->setEnv( 'GF_TEST_HOST', 'example.org' ); + + self::assertSame( 'https://example.org/api', $this->resolve( '{"url":"https://%env(GF_TEST_HOST)%/api"}' )->url ); + } + + + public function testMissingVariableThrowsNamingIt(): void { + $this->trackEnv( 'GF_TEST_ABSENT' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/GF_TEST_ABSENT/' ); + $this->resolve( '{"uri":"%env(GF_TEST_ABSENT)%"}' ); + } + + + /** + * The behaviour that makes a copied .env full of blank placeholders fail loudly + * instead of silently configuring an application with empty credentials. + */ + public function testVariableSetToTheEmptyStringCountsAsUnset(): void { + $this->setEnv( 'GF_TEST_BLANK', '' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/GF_TEST_BLANK/' ); + $this->resolve( '{"secret":"%env(GF_TEST_BLANK)%"}' ); + } + + + public function testDefaultProcessorNoLongerExists(): void { + $this->trackEnv( 'GF_TEST_ABSENT' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/Unknown environment processor "default"/' ); + $this->resolve( '{"type":"%env(default:local:GF_TEST_ABSENT)%"}' ); + } + + + /** @return iterable */ + public static function removedProcessorProvider(): iterable { + yield 'not' => [ 'not' ]; + yield 'float' => [ 'float' ]; + yield 'base64' => [ 'base64' ]; + yield 'string' => [ 'string' ]; + } + + + #[DataProvider('removedProcessorProvider')] + public function testRemovedProcessorsAreRejected( string $processor ): void { + $this->setEnv( 'GF_TEST_VALUE', '1' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/Unknown environment processor "' . $processor . '"/' ); + $this->resolve( '{"v":"%env(' . $processor . ':GF_TEST_VALUE)%"}' ); + } + + + // --- surviving processors -------------------------------------------------- + + public function testIntProcessorProducesATypedInt(): void { + $this->setEnv( 'GF_TEST_PORT', '587' ); + + self::assertSame( 587, $this->resolve( '{"port":"%env(int:GF_TEST_PORT)%"}' )->port ); + } + + + public function testBoolProcessorProducesATypedBool(): void { + $this->setEnv( 'GF_TEST_FLAG', 'true' ); + + self::assertTrue( $this->resolve( '{"flag":"%env(bool:GF_TEST_FLAG)%"}' )->flag ); + } + + + /** + * ADR 0001: the bool processor fails closed. It used to fall back to `(bool)$value`, + * which turned every unrecognised value — "flase", "disabled", "2" — into TRUE with + * no error, so a typo'd AUTH_BLOCK_NEW_USERS resolved silently to the wrong setting + * while `gf env` reported success. + */ + public function testBoolProcessorRejectsAnUnrecognisedValue(): void { + $this->setEnv( 'GF_TEST_FLAG', 'flase' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/bool/' ); + $this->resolve( '{"flag":"%env(bool:GF_TEST_FLAG)%"}' ); + } + + + public function testIsSatisfiedAcceptsThePlainOrTheFileName(): void { + $this->trackEnv( 'GF_TEST_SAT' ); + self::assertFalse( envVarResolver::isSatisfied( 'GF_TEST_SAT' ) ); + + $this->setEnv( 'GF_TEST_SAT' . envVarResolver::SECRET_FILE_SUFFIX, '/run/secrets/app/sat' ); + self::assertTrue( envVarResolver::isSatisfied( 'GF_TEST_SAT' ) ); + } + + + /** A reserved CGI name is never satisfiable, even when genuinely set. */ + public function testIsSatisfiedIsFalseForAReservedNameEvenWhenSet(): void { + $this->setEnv( 'SERVER_API_TOKEN', 'value' ); + + self::assertFalse( envVarResolver::isSatisfied( 'SERVER_API_TOKEN' ) ); + } + + + public function testIsReservedNameMatchesPrefixesAndExactNames(): void { + self::assertTrue( envVarResolver::isReservedName( 'SERVER_API_TOKEN' ) ); + self::assertTrue( envVarResolver::isReservedName( 'CONTENT_TYPE' ) ); + self::assertFalse( envVarResolver::isReservedName( 'MONGO_URI' ) ); + } + + + public function testProcessorsApplyRightToLeft(): void { + $path = tempnam( sys_get_temp_dir(), 'gf' ); + self::assertIsString( $path ); + file_put_contents( $path, " 42\n" ); + $this->setEnv( 'GF_TEST_FILE', $path ); + + // int(trim(file(env))) — innermost first. + self::assertSame( 42, $this->resolve( '{"n":"%env(int:trim:file:GF_TEST_FILE)%"}' )->n ); + + unlink( $path ); + } + + + public function testUnknownProcessorIsRejected(): void { + $this->setEnv( 'GF_TEST_VALUE', 'x' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/Unknown environment processor "rot13"/' ); + $this->resolve( '{"v":"%env(rot13:GF_TEST_VALUE)%"}' ); + } + + + // --- the secret lookup ----------------------------------------------------- + + public function testSecretFallsBackToThePlainVariableWhenNoFileIsNamed(): void { + $this->setEnv( 'GF_TEST_MONGO', 'mongodb://localhost' ); + $this->trackEnv( 'GF_TEST_MONGO_FILE' ); + + self::assertSame( 'mongodb://localhost', $this->resolve( '{"uri":"%env(secret:GF_TEST_MONGO)%"}' )->uri ); + } + + + public function testSecretPrefersTheFileAndTrimsIt(): void { + $path = tempnam( sys_get_temp_dir(), 'gf' ); + self::assertIsString( $path ); + file_put_contents( $path, "mongodb://from-file\n" ); + + $this->setEnv( 'GF_TEST_MONGO', 'mongodb://from-environment' ); + $this->setEnv( 'GF_TEST_MONGO_FILE', $path ); + + self::assertSame( 'mongodb://from-file', $this->resolve( '{"uri":"%env(secret:GF_TEST_MONGO)%"}' )->uri ); + + unlink( $path ); + } + + + /** + * The rule that keeps a failed secret mount from silently resolving to whatever + * stale value happens to be in the environment. + */ + public function testSecretFileThatIsMissingIsAnErrorAndNeverFallsBack(): void { + $this->setEnv( 'GF_TEST_MONGO', 'mongodb://from-environment' ); + $this->setEnv( 'GF_TEST_MONGO_FILE', '/nonexistent/secret/mongo_uri' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/not falling back to GF_TEST_MONGO/' ); + $this->resolve( '{"uri":"%env(secret:GF_TEST_MONGO)%"}' ); + } + + + public function testSecretReportsBothNamesWhenNeitherIsSet(): void { + $this->trackEnv( 'GF_TEST_MONGO' ); + $this->trackEnv( 'GF_TEST_MONGO_FILE' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/GF_TEST_MONGO_FILE/' ); + $this->resolve( '{"uri":"%env(secret:GF_TEST_MONGO)%"}' ); + } + + + public function testSecretMustBeTheInnermostProcessor(): void { + $this->setEnv( 'GF_TEST_MONGO', 'x' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/"secret" must be the innermost processor/' ); + $this->resolve( '{"uri":"%env(secret:trim:GF_TEST_MONGO)%"}' ); + } + + + // --- request-data injection guard ----------------------------------------- + + public function testReservedCgiNamesAreNeverResolvedFromTheEnvironment(): void { + $_SERVER[ 'HTTP_X_INJECTED' ] = 'attacker-controlled'; + + try { + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/reserved CGI meta-variable/' ); + $this->resolve( '{"v":"%env(HTTP_X_INJECTED)%"}' ); + } + finally { + unset( $_SERVER[ 'HTTP_X_INJECTED' ] ); + } + } + + + // --- malformed syntax ------------------------------------------------------ + + public function testUnterminatedReferenceIsAnErrorRatherThanShippedVerbatim(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/Unresolvable %env/' ); + $this->resolve( '{"v":"prefix %env(GF_TEST_UNTERMINATED"}' ); + } + + + public function testInvalidVariableNameIsRejected(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/is not a valid variable name/' ); + $this->resolve( '{"v":"%env(trim:not a name)%"}' ); + } + + + // --- reference enumeration ------------------------------------------------- + + public function testCollectReferencesFindsEveryVariableWithoutResolvingThem(): void { + $decoded = json_decode( '{ + "type": "%env(APP_TYPE)%", + "nested": { "uri": "%env(secret:MONGO_URI)%" }, + "list": [ { "url": "https://%env(APP_HOST)%/api" } ], + "literal": "no reference here" + }', false ); + self::assertInstanceOf( \stdClass::class, $decoded ); + + $references = envVarResolver::collectReferences( $decoded, 'test config' ); + + self::assertSame( [ 'APP_TYPE' => false, 'MONGO_URI' => true, 'APP_HOST' => false ], $references ); + } + + + public function testCollectReferencesTreatsANameUsedBothWaysAsASecret(): void { + $decoded = json_decode( '{"a":"%env(MONGO_URI)%","b":"%env(secret:MONGO_URI)%"}', false ); + self::assertInstanceOf( \stdClass::class, $decoded ); + + self::assertSame( [ 'MONGO_URI' => true ], envVarResolver::collectReferences( $decoded, 'test config' ) ); + } + +} diff --git a/tests/Unit/Services/Health/HealthControllerTest.php b/tests/Unit/Services/Health/HealthControllerTest.php new file mode 100644 index 0000000..5b27d9f --- /dev/null +++ b/tests/Unit/Services/Health/HealthControllerTest.php @@ -0,0 +1,174 @@ +seedConfig(); + + $response = ( new health() )->live(); + $data = $response->getData(); + + $this->assertSame( 200, $response->getHttpStatus() ); + $this->assertSame( 'ok', $data[ 'status' ] ); + $this->assertArrayNotHasKey( 'checks', $data, 'liveness must not touch dependencies' ); + } + + + public function testVersionFallsBackWhenAppVersionIsUnset(): void { + $this->seedConfig(); + + $this->assertSame( 'unknown', ( new health() )->live()->getData()[ 'version' ] ); + } + + + public function testVersionReportsTheDeployedRelease(): void { + $this->seedConfig(); + putenv( 'APP_VERSION=1.4.2' ); + + $this->assertSame( '1.4.2', ( new health() )->live()->getData()[ 'version' ] ); + } + + + public function testReadinessIsOkWithNoConfiguredDatabases(): void { + $this->seedConfig(); + + $response = ( new health() )->ready(); + + $this->assertSame( 200, $response->getHttpStatus() ); + $this->assertSame( 'ok', $response->getData()[ 'status' ] ); + $this->assertSame( [], $response->getData()[ 'checks' ] ); + } + + + /** + * An unreachable database must produce 503 — and must not put the driver's message, + * which names internal hostnames, ports and replica-set topology, into the body of an + * unauthenticated endpoint. + */ + public function testUnreachableDatabaseIs503AndDisclosesNothing(): void { + $log = $this->captureLog( 'health' ); + $this->seedConfig( static function( unifiedConfig $c ): void { + $database = new mongoDatabase(); + $database->default = true; + $database->database = 'appdb'; + // Unroutable by RFC 5737, so this fails without depending on a live server. + $database->uri = 'mongodb://192.0.2.1:27017'; + $database->clientParams = [ 'serverSelectionTimeoutMS' => 50, 'connectTimeoutMS' => 50 ]; + $c->mongoDatabases = [ $database ]; + } ); + + $response = ( new health() )->ready(); + $data = $response->getData(); + + $this->assertSame( 503, $response->getHttpStatus() ); + $this->assertSame( 'unavailable', $data[ 'status' ] ); + $this->assertSame( 'failed', $data[ 'checks' ][ 'mongo:appdb' ] ); + + $serialized = json_encode( $data ); + $this->assertIsString( $serialized ); + $this->assertStringNotContainsString( '192.0.2.1', $serialized, 'the probe must not publish the database host' ); + $this->assertStringNotContainsString( '27017', $serialized, 'the probe must not publish the database port' ); + + // The detail the response withholds has to go somewhere, or the operator has a 503 + // and nothing to act on. It belongs in the log, which is not public. + $this->assertTrue( $log->hasWarningThatContains( 'appdb' ), 'the failing database must be named in the log' ); + } + + + /** + * When the auth service is enabled, usable signing keys are a readiness dependency: + * a missing or empty key mount used to pass every health gate — deploy green, proxy + * green — and surface only as a configException on the first production sign-in. + */ + public function testMissingJwtKeysFailReadinessWhenAuthIsEnabled(): void { + $log = $this->captureLog( 'health' ); + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->services->auth = new \gcgov\framework\models\config\services\auth(); + $c->jwtAuth->keyPath = sys_get_temp_dir() . '/gcgov-health-missing-keys-' . uniqid(); + } ); + + $response = ( new health() )->ready(); + + $this->assertSame( 503, $response->getHttpStatus() ); + $this->assertSame( 'failed', $response->getData()[ 'checks' ][ 'jwtKeys' ] ); + $this->assertTrue( $log->hasWarningThatContains( 'cert:generate-auth' ), 'the log must name the command that fixes it' ); + } + + + public function testProvisionedJwtKeysPassReadinessWhenAuthIsEnabled(): void { + $keyDir = sys_get_temp_dir() . '/gcgov-health-keys-' . uniqid(); + mkdir( $keyDir, 0777, true ); + file_put_contents( $keyDir . '/guids.json', json_encode( [ 'abc' ] ) ); + file_put_contents( $keyDir . '/private-abc.pem', 'pem' ); + file_put_contents( $keyDir . '/public-abc.pem', 'pem' ); + + try { + $this->seedConfig( static function( unifiedConfig $c ) use ( $keyDir ): void { + $c->services->auth = new \gcgov\framework\models\config\services\auth(); + $c->jwtAuth->keyPath = $keyDir; + } ); + + $response = ( new health() )->ready(); + + $this->assertSame( 200, $response->getHttpStatus() ); + $this->assertSame( 'ok', $response->getData()[ 'checks' ][ 'jwtKeys' ] ); + } + finally { + unlink( $keyDir . '/guids.json' ); + unlink( $keyDir . '/private-abc.pem' ); + unlink( $keyDir . '/public-abc.pem' ); + rmdir( $keyDir ); + } + } + + + /** Readiness without the auth service must not demand keys nothing will read. */ + public function testJwtKeysAreNotCheckedWhenAuthIsDisabled(): void { + $this->seedConfig(); + + $response = ( new health() )->ready(); + + $this->assertSame( 200, $response->getHttpStatus() ); + $this->assertArrayNotHasKey( 'jwtKeys', $response->getData()[ 'checks' ] ); + } + + + /** + * The probe carries its own short timeouts so an unreachable database cannot park a + * worker for the driver's 30s default — with a probe every few seconds that exhausts + * the pool and 502s real traffic. + */ + public function testProbeTimeoutIsSizedToAProbeInterval(): void { + $timeout = ( new \ReflectionClassConstant( health::class, 'PROBE_TIMEOUT_MS' ) )->getValue(); + + $this->assertLessThanOrEqual( 5000, $timeout, 'a readiness probe must fail fast' ); + $this->assertGreaterThan( 0, $timeout ); + } + +} diff --git a/tests/Unit/Services/Health/RouterTest.php b/tests/Unit/Services/Health/RouterTest.php new file mode 100644 index 0000000..6fa3f54 --- /dev/null +++ b/tests/Unit/Services/Health/RouterTest.php @@ -0,0 +1,80 @@ +assertContains( + \gcgov\framework\interfaces\router::class, + class_implements( router::class ) ?: [] + ); + } + + + /** + * The router interface deliberately declares no lifecycle hooks — only \app\router's + * are ever invoked — so a service router must not carry hooks that will never fire. + */ + public function testRouterDeclaresNoLifecycleHooks(): void { + $this->assertFalse( method_exists( router::class, '_before' ) ); + $this->assertFalse( method_exists( router::class, '_after' ) ); + } + + + public function testBothProbesAreRegisteredUnauthenticated(): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = 'api' ); + + $routes = ( new router() )->getRoutes(); + + $this->assertCount( 2, $routes ); + foreach( $routes as $route ) { + $this->assertInstanceOf( route::class, $route ); + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertFalse( $route->authentication, 'a probe an orchestrator calls cannot require a token' ); + $this->assertSame( [], $route->requiredRoles ); + $this->assertNotSame( '', $route->description, 'probes appear in gf cli:list and shell completion' ); + } + } + + + public function testLivenessAndReadinessArePathsUnderTheBasePath(): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = 'api' ); + + $routes = ( new router() )->getRoutes(); + + $this->assertSame( '/api/health', $routes[ 0 ]->route ); + $this->assertSame( 'live', $routes[ 0 ]->method ); + $this->assertSame( '/api/health/ready', $routes[ 1 ]->route ); + $this->assertSame( 'ready', $routes[ 1 ]->method ); + } + + + public function testProbePathsAreSingleSlashedAtDomainRoot(): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = '' ); + + $routes = ( new router() )->getRoutes(); + + $this->assertSame( '/health', $routes[ 0 ]->route ); + $this->assertSame( '/health/ready', $routes[ 1 ]->route ); + } + + + public function testAuthenticationAllowsTheProbes(): void { + $routeHandler = $this->createStub( \gcgov\framework\models\routeHandler::class ); + $this->assertTrue( ( new router() )->authentication( $routeHandler ) ); + } + +} diff --git a/tests/Unit/Services/LogTest.php b/tests/Unit/Services/LogTest.php index fdaa205..ab39fa8 100644 --- a/tests/Unit/Services/LogTest.php +++ b/tests/Unit/Services/LogTest.php @@ -6,7 +6,10 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use gcgov\framework\models\config\environment\logging; use gcgov\framework\services\log; +use Monolog\Formatter\JsonFormatter; +use Monolog\Handler\StreamHandler; #[CoversClass(log::class)] final class LogTest extends TestCase { @@ -28,6 +31,28 @@ protected function setUp(): void { $rootDir = dirname( $this->logsDir ); $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'rootDir' ); $prop->setValue( null, $rootDir ); + + $this->setDestination( logging::DESTINATION_FILE ); + } + + + /** The seeded unifiedConfig is shared across tests; put it back. */ + protected function tearDown(): void { + $this->setDestination( logging::DESTINATION_STDERR ); + + parent::tearDown(); + } + + + private function setDestination( string $destination ): void { + $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); + $config = $prop->getValue(); + if( $config instanceof \gcgov\framework\models\unifiedConfig ) { + $config->logging->destination = $destination; + } + + $loggers = new \ReflectionProperty( log::class, 'loggers' ); + $loggers->setValue( null, [] ); } public function testDebugLogWritesToChannelFile(): void { @@ -71,4 +96,57 @@ public function testRepeatedCallsReuseSameLoggerInstance(): void { $this->assertArrayHasKey( 'reuse-channel', $loggers ); } + + /** + * The v7 default. A container's filesystem does not survive a deploy, so file logs + * would be per-replica and destroyed on every release. + * + * Asserted against the handlers rather than by logging: this test used to call + * log::error() and then check only that no FILE appeared, which is true of a great many + * broken implementations and said nothing at all about JSON lines. What it did reliably + * do was print a record to the console on every green run. + */ + public function testStderrIsTheDefaultDestinationAndEmitsJsonLines(): void { + $this->setDestination( logging::DESTINATION_STDERR ); + + $handlers = self::buildHandlers( 'stderr-channel' ); + + $this->assertCount( 1, $handlers, 'the stderr destination adds no file handler' ); + $this->assertInstanceOf( StreamHandler::class, $handlers[ 0 ] ); + $this->assertSame( 'php://stderr', $handlers[ 0 ]->getUrl() ); + $this->assertInstanceOf( JsonFormatter::class, $handlers[ 0 ]->getFormatter(), 'a collector has to be able to query the records' ); + + $this->assertSame( logging::DESTINATION_STDERR, ( new logging() )->destination ); + $this->assertTrue( ( new logging() )->writesToStderr() ); + $this->assertFalse( ( new logging() )->writesToFile() ); + } + + + /** "both" is the stderr handler plus the file handler, not one or the other. */ + public function testBothDestinationAddsTheFileHandlerAlongsideStderr(): void { + $this->setDestination( logging::DESTINATION_BOTH ); + + $handlers = self::buildHandlers( 'both-channel' ); + $urls = array_map( static fn( StreamHandler $h ): ?string => $h->getUrl(), $handlers ); + + $this->assertCount( 2, $handlers ); + $this->assertContains( 'php://stderr', $urls ); + $this->assertContains( $this->logsDir . '/both-channel.log', $urls ); + } + + + /** + * Monolog opens a stream lazily, so building the handlers writes nothing and creates + * no file — which is what lets these two tests assert the wiring without emitting a + * record. + * + * @return \Monolog\Handler\StreamHandler[] + */ + private static function buildHandlers( string $channel ): array { + /** @var \Monolog\Handler\StreamHandler[] $handlers */ + $handlers = ( new \ReflectionMethod( log::class, 'buildHandlers' ) )->invoke( null, $channel ); + + return $handlers; + } + } diff --git a/tests/Unit/Services/MongoDB/Tools/MongoLogTest.php b/tests/Unit/Services/MongoDB/Tools/MongoLogTest.php index e4c66ee..6228b9b 100644 --- a/tests/Unit/Services/MongoDB/Tools/MongoLogTest.php +++ b/tests/Unit/Services/MongoDB/Tools/MongoLogTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use gcgov\framework\services\mongodb\tools\log; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; use gcgov\framework\models\config\environment\mongoDatabase; #[CoversClass(log::class)] @@ -61,9 +61,9 @@ public function testEmptyMongoDatabasesIsAGracefulNoOp(): void { // than trying to access the first entry. This used to raise a PHP // warning under PHP 8.4; the guard added during the level-5 cleanup // preserves silent no-op semantics. - $env = new environmentConfig(); + $env = new unifiedConfig(); $env->mongoDatabases = []; - $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'environmentConfig' ); + $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); $prop->setValue( null, $env ); log::debug( 'empty-mongo', 'msg' ); @@ -71,11 +71,11 @@ public function testEmptyMongoDatabasesIsAGracefulNoOp(): void { } private function primeEnvWithMongoLogging( bool $enabled ): void { - $env = new environmentConfig(); + $env = new unifiedConfig(); $db = new mongoDatabase(); $db->logging = $enabled; $env->mongoDatabases = [ $db ]; - $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'environmentConfig' ); + $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); $prop->setValue( null, $env ); } diff --git a/tests/Unit/Services/PdoDb/PdodbTest.php b/tests/Unit/Services/PdoDb/PdodbTest.php index 38754c0..64de644 100644 --- a/tests/Unit/Services/PdoDb/PdodbTest.php +++ b/tests/Unit/Services/PdoDb/PdodbTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use gcgov\framework\services\pdodb\pdodb; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; use gcgov\framework\models\config\environment\sqlDatabase; use gcgov\framework\models\config\environment\sqlDatabaseUser; @@ -88,9 +88,9 @@ private function makeSqlDatabase( string $dsn, string $name, bool $default ): sq * @param list $databases */ private function primeEnvWith( array $databases ): void { - $env = new environmentConfig(); + $env = new unifiedConfig(); $env->sqlDatabases = $databases; - $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'environmentConfig' ); + $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); $prop->setValue( null, $env ); } diff --git a/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php b/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php new file mode 100644 index 0000000..01f03c0 --- /dev/null +++ b/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php @@ -0,0 +1,281 @@ +assertContains( + \gcgov\framework\interfaces\controller::class, + class_implements( user::class ) ?: [] + ); + } + + public function testGetAllReturnsPagedDataResponse(): void { + $response = ( new user() )->getAll(); + $this->assertInstanceOf( controllerPagedDataResponse::class, $response ); + } + + public function testGetAllReturnsRecordsFromUserModel(): void { + $record = new FakeUser(); + $record->_id = 'u1'; + $record->name = 'Alice'; + FakeUser::$records[ 'u1' ] = $record; + + $response = ( new user() )->getAll(); + $this->assertInstanceOf( controllerPagedDataResponse::class, $response ); + } + + public function testGetAllWrapsModelExceptionInControllerException(): void { + FakeUser::$nextException = new modelException( 'boom', 500 ); + + $this->expectException( controllerException::class ); + $this->expectExceptionMessage( 'boom' ); + ( new user() )->getAll(); + } + + public function testGetOneReturnsExistingUser(): void { + $record = new FakeUser(); + $record->_id = 'u42'; + $record->name = 'Bob'; + FakeUser::$records[ 'u42' ] = $record; + + $response = ( new user() )->getOne( 'u42' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + } + + public function testGetOneWithNewSentinelInstantiatesEmptyUser(): void { + $response = ( new user() )->getOne( 'new' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + } + + public function testGetOneWrapsModelExceptionInControllerException(): void { + $this->expectException( controllerException::class ); + ( new user() )->getOne( 'does-not-exist' ); + } + + public function testSaveDeserializesPayloadAndPersists(): void { + $payload = json_encode( [ '_id' => 'u99', 'name' => 'Carol' ] ); + $this->withPhpInput( $payload, function() { + $response = ( new user() )->save( 'u99' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + $this->assertArrayHasKey( 'u99', FakeUser::$records ); + $this->assertSame( 'Carol', FakeUser::$records[ 'u99' ]->name ); + } ); + } + + /** + * The URL decides which document is written. This method used to ignore its $_id + * entirely, so a caller holding User.Write could POST to their own /user/{_id} with a + * body naming any other account and overwrite it — roles included. + */ + public function testSaveRejectsABodyTargetingADifferentUser(): void { + $victim = new FakeUser(); + $victim->_id = 'victim'; + $victim->name = 'Victim'; + FakeUser::$records[ 'victim' ] = $victim; + + $this->withPhpInput( json_encode( [ '_id' => 'victim', 'name' => 'Owned' ] ), function() { + try { + ( new user() )->save( 'attacker' ); + $this->fail( 'the body must not be able to retarget the write' ); + } + catch( controllerException $e ) { + $this->assertSame( 400, $e->getCode() ); + } + } ); + + $this->assertSame( 'Victim', FakeUser::$records[ 'victim' ]->name, 'the other account must be untouched' ); + } + + + public function testSaveRejectsABodyWithNoId(): void { + $this->withPhpInput( json_encode( [ 'name' => 'Nameless' ] ), function() { + $this->expectException( controllerException::class ); + $this->expectExceptionCode( 400 ); + ( new user() )->save( 'u1' ); + } ); + } + + + /** POST /user/new must create, not overwrite whatever _id the body happens to carry. */ + public function testSaveToNewDoesNotOverwriteAnExistingAccount(): void { + $victim = new FakeUser(); + $victim->_id = 'victim'; + $victim->name = 'Victim'; + FakeUser::$records[ 'victim' ] = $victim; + + $this->withPhpInput( json_encode( [ '_id' => 'victim', 'name' => 'Owned' ] ), function() { + ( new user() )->save( 'new' ); + } ); + + $this->assertSame( 'Victim', FakeUser::$records[ 'victim' ]->name, 'the named account must be untouched' ); + } + + + /** + * The fresh identity must be ASSIGNED, not unset: the real model's $_id is a typed + * ObjectId that factory::save() reads unconditionally, so an unset _id made every + * POST /user/new a fatal uninitialized-property Error — the framework's own create + * endpoint could never succeed. The stub's save() mirrors that unconditional read. + */ + public function testSaveToNewAssignsAFreshIdAndPersistsTheNewAccount(): void { + $this->withPhpInput( json_encode( [ '_id' => 'victim', 'name' => 'Newcomer' ] ), function() { + $response = ( new user() )->save( 'new' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + } ); + + $created = array_filter( FakeUser::$records, static fn( FakeUser $record ): bool => $record->name==='Newcomer' ); + $this->assertCount( 1, $created, 'the create must persist the new account' ); + $createdId = (string) array_key_first( $created ); + $this->assertNotSame( 'victim', $createdId, 'the body id must not survive a create' ); + $this->assertNotSame( '', $createdId, 'save() must receive an initialized _id' ); + } + + + public function testSaveWrapsModelExceptionInControllerException(): void { + FakeUser::$nextException = new modelException( 'validation failure', 422 ); + + $this->withPhpInput( json_encode( [ '_id' => 'u1' ] ), function() { + $this->expectException( controllerException::class ); + $this->expectExceptionCode( 422 ); + ( new user() )->save( 'u1' ); + } ); + } + + public function testDeleteReturnsNoContentWhenSucceeded(): void { + $record = new FakeUser(); + $record->_id = 'u88'; + FakeUser::$records[ 'u88' ] = $record; + FakeUser::$deleteAffectedCount = 1; + + $response = ( new user() )->delete( 'u88' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + $this->assertSame( 204, $response->getHttpStatus() ); + } + + public function testDeleteThrows404WhenNoRecordsAffected(): void { + FakeUser::$deleteAffectedCount = 0; + + $this->expectException( controllerException::class ); + $this->expectExceptionCode( 404 ); + ( new user() )->delete( 'missing' ); + } + + public function testDeleteWrapsModelExceptionInControllerException(): void { + FakeUser::$nextException = new modelException( 'db error', 500 ); + + $this->expectException( controllerException::class ); + $this->expectExceptionMessage( 'db error' ); + ( new user() )->delete( 'u1' ); + } + + public function testLifecycleHooksReturnVoid(): void { + user::_before(); + user::_after(); + + $reflection = new \ReflectionClass( user::class ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_after' )->getReturnType() ); + } + + public function testConstructorAcceptsNoArguments(): void { + $reflection = new \ReflectionClass( user::class ); + $constructor = $reflection->getConstructor(); + $this->assertNotNull( $constructor ); + $this->assertSame( 0, $constructor->getNumberOfRequiredParameters() ); + } + + private function withPhpInput( string $body, callable $work ): void { + // php://input is read-only, so we hijack via a stream wrapper. + stream_wrapper_unregister( 'php' ); + stream_wrapper_register( 'php', PhpInputStreamWrapper::class ); + PhpInputStreamWrapper::$content = $body; + try { + $work(); + } + finally { + stream_wrapper_restore( 'php' ); + } + } + +} + +final class PhpInputStreamWrapper { + public static string $content = ''; + public $context; + private int $position = 0; + + public function stream_open( string $path, string $mode, int $options, ?string &$opened_path ): bool { + $this->position = 0; + return true; + } + + public function stream_read( int $count ): string { + $ret = substr( self::$content, $this->position, $count ); + $this->position += strlen( $ret ); + return $ret; + } + + public function stream_eof(): bool { + return $this->position >= strlen( self::$content ); + } + + public function stream_stat(): array { + return [ 'size' => strlen( self::$content ) ]; + } + + public function stream_close(): void {} + + public function stream_seek( int $offset, int $whence = SEEK_SET ): bool { + switch ( $whence ) { + case SEEK_SET: $this->position = $offset; break; + case SEEK_CUR: $this->position += $offset; break; + case SEEK_END: $this->position = strlen( self::$content ) + $offset; break; + } + return true; + } + + public function stream_tell(): int { + return $this->position; + } + + public function url_stat( string $path, int $flags ): array|false { + return [ 'size' => strlen( self::$content ) ]; + } +} diff --git a/tests/Unit/Services/UserCrud/RouterTest.php b/tests/Unit/Services/UserCrud/RouterTest.php new file mode 100644 index 0000000..b5c34a3 --- /dev/null +++ b/tests/Unit/Services/UserCrud/RouterTest.php @@ -0,0 +1,99 @@ +seedConfig( static fn( \gcgov\framework\models\unifiedConfig $c ) => $c->basePath = 'api' ); + } + + + + + public function testRouterImplementsFrameworkRouterInterface(): void { + $this->assertContains( + \gcgov\framework\interfaces\router::class, + class_implements( router::class ) ?: [] + ); + } + + public function testGetRoutesReturnsFourCrudRoutes(): void { + $routes = ( new router() )->getRoutes(); + $this->assertCount( 4, $routes ); + foreach ( $routes as $route ) { + $this->assertInstanceOf( route::class, $route ); + } + } + + public function testGetAllRouteIsGetAtBasePathUser(): void { + $route = $this->routes()[0]; + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/user', $route->route ); + $this->assertSame( 'getAll', $route->method ); + $this->assertTrue( $route->authentication ); + $this->assertSame( [ 'User.Read' ], $route->requiredRoles ); + } + + public function testGetOneRouteIsGetWithIdParameter(): void { + $route = $this->routes()[1]; + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/user/{_id}', $route->route ); + $this->assertSame( 'getOne', $route->method ); + $this->assertTrue( $route->authentication ); + $this->assertSame( [ 'User.Read' ], $route->requiredRoles ); + } + + public function testSaveRouteIsPostAndRequiresWritePermission(): void { + $route = $this->routes()[2]; + $this->assertSame( 'POST', $route->httpMethod ); + $this->assertSame( '/api/user/{_id}', $route->route ); + $this->assertSame( 'save', $route->method ); + $this->assertSame( [ 'User.Read', 'User.Write' ], $route->requiredRoles ); + } + + public function testDeleteRouteIsDeleteAndRequiresWritePermission(): void { + $route = $this->routes()[3]; + $this->assertSame( 'DELETE', $route->httpMethod ); + $this->assertSame( '/api/user/{_id}', $route->route ); + $this->assertSame( 'delete', $route->method ); + $this->assertSame( [ 'User.Read', 'User.Write' ], $route->requiredRoles ); + } + + public function testAllRoutesTargetUserController(): void { + $expected = [ + 'gcgov\framework\services\userCrud\controllers\user', + '\gcgov\framework\services\userCrud\controllers\user', + ]; + foreach ( $this->routes() as $route ) { + $this->assertContains( $route->class, $expected ); + } + } + + public function testAuthenticationContractReturnsTrue(): void { + $routeHandler = $this->createStub( \gcgov\framework\models\routeHandler::class ); + $this->assertTrue( ( new router() )->authentication( $routeHandler ) ); + } + + + /** @return list */ + private function routes(): array { + return ( new router() )->getRoutes(); + } + +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 341bfba..5f533b6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,20 +11,28 @@ require __DIR__ . '/Shims/MongoDBShims.php'; } +// Shared test helpers. tests/ is not PSR-4 autoloaded, so they are required here. +require __DIR__ . '/Support/seedsFrameworkConfig.php'; +require __DIR__ . '/Support/capturesFrameworkLog.php'; + // Several framework call sites reflect on \app\app to derive directories. -// Stub the class so tests that touch config::getAppDir() can boot. The gf CLI -// additionally calls registerFrameworkServiceNamespaces() during route enumeration. +// Stub the class so tests that touch config::getAppDir() can boot. if ( !class_exists( '\app\app' ) ) { - eval( 'namespace app; class app { public static function _before(): void {} public static function _after(): void {} public function registerFrameworkServiceNamespaces(): array { return []; } }' ); + eval( 'namespace app; class app { public static function _before(): void {} public static function _after(): void {} }' ); } // Stub \app\router with fixture routes so the gf CLI route catalog can be -// exercised (router::getMergedRoutes() instantiates \app\router). +// exercised (router::getMergedRoutes() instantiates \app\router). None of the +// fixture routes require authentication, so the framework's no-auth-service +// check is satisfied without the stub claiming to authenticate anything. if ( !class_exists( '\app\router' ) ) { eval( 'namespace app; - class router implements \gcgov\framework\interfaces\router { + class router implements \gcgov\framework\interfaces\appRouter { public static function _before(): void {} public static function _after(): void {} + public function providesAuthentication(): bool { + return false; + } public function getRoutes(): array { return [ new \gcgov\framework\models\route( "GET", "/widget", "\\\\app\\\\controllers\\\\widget", "getAll" ), @@ -38,12 +46,11 @@ public function authentication( \gcgov\framework\models\routeHandler $routeHandl }' ); } -// Seed environmentConfig so config::getEnvironmentConfig() doesn't try to +// Seed unifiedConfig so config accessors don't try to // load a JSON file from disk. -$envConfig = new \gcgov\framework\models\environmentConfig(); +$envConfig = new \gcgov\framework\models\unifiedConfig(); $envConfig->basePath = 'api'; -$envConfig->serverName = 'test.local'; $envConfig->rootUrl = 'http://test.local'; $envConfig->type = 'local'; -$prop = new \ReflectionProperty( \gcgov\framework\config::class, 'environmentConfig' ); +$prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); $prop->setValue( null, $envConfig );