diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 7d579a30..f8783755 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -48,6 +48,15 @@ OPENDOX_DATABASE_URL=postgresql://opendox_runtime:change-me-local-only-too@postg # `opendox-runtime runtime migrate` alone; the served application never receives it. OPENDOX_MIGRATION_DATABASE_URL=postgresql://opendox:change-me-local-only@postgres:5432/opendox +# The install shape: `hosted` or `local`, and this package is HOSTED — a +# broker, its pinned issuer and this file's own database. Unset means hosted +# too, which is the point: a hosted install that forgets its issuer REFUSES +# rather than falling into the local single-user mode (plan 034 T070; #1144 +# 13.4, 13.5). `local` is the one-user install `opendox generate-and-open +# --local` starts on a laptop, with no broker and loopback only; it refuses the +# broker settings below, so it is never selected here. +OPENDOX_INSTALL_MODE=hosted + # REQUIRED. The Keycloak broker's issuer, pinned (RULING Q2): a token from any # other issuer is refused rather than trusted. OPENDOX_OIDC_ISSUER=https://keycloak.example/realms/opendox diff --git a/src/opendox/cli.py b/src/opendox/cli.py index 994111dc..c7923abd 100644 --- a/src/opendox/cli.py +++ b/src/opendox/cli.py @@ -83,6 +83,11 @@ # `opendox.corpus_adapter` besides the stdlib. from opendox import corpus_adapter # noqa: E402 from opendox.runtime import local_git_adapter # noqa: E402 +# THE INSTALL SHAPE (plan 034 T070; #1144 13.4-13.6): `generate-and-open` +# resolves `--local` against `OPENDOX_INSTALL_MODE` here, before it generates +# or serves anything. Stdlib-only, like `local_git_adapter` above, which +# already imports it, so this adds no reach and no import weight. +from opendox.runtime import config as runtime_config # noqa: E402 from opendox.boundary import ( # noqa: E402 BoundaryViolation, HumanGate, OutputBoundary, ) @@ -409,11 +414,57 @@ def _validate(written: Path, args: argparse.Namespace, *, return 0 +def _resolve_install_shape(args: argparse.Namespace, + env=None) -> str: + """The install shape this run serves as, or `ConfigurationError` naming why. + + `--local` and `OPENDOX_INSTALL_MODE` are resolved by + `runtime_config.install_mode`, the one reading of the selector, which + refuses the two disagreeing (plan 034 T070's fail-closed reading) and + defaults to HOSTED (#1144 13.4, 13.5). Then each shape asks what it needs: + + * LOCAL binds loopback only, with no opt-in: a non-loopback `--host` is + refused naming the rule (13.4), and so is anything a local install + cannot be (`refuse_what_a_local_install_cannot_be`: a broker setting + beside it, or a non-loopback `OPENDOX_BIND_HOST`). It needs no broker. + Its datastore is 13.1's, and arrives with T072. + * HOSTED, set or by default, refuses with no issuer, NAMING THE ISSUER + (13.5), and then loads the runtime's whole configuration, because the + serving process is the one whose settings are the install's (13.4a; + R1Q16 (i)). Otherwise unchanged (13.6). + + Asked before anything is scanned, minted or bound, so a refused run leaves + nothing behind and exits at once rather than starting a server that a + bound would have to kill (F13.1's `test "$rc" -ne 124`). + """ + env = os.environ if env is None else env + mode = runtime_config.install_mode( + env, local_flag=bool(getattr(args, "local", False))) + if mode == runtime_config.INSTALL_MODE_LOCAL: + runtime_config.refuse_a_non_loopback_local_bind("--host", args.host) + runtime_config.refuse_what_a_local_install_cannot_be(env) + else: + runtime_config.require_the_hosted_issuer(env) + runtime_config.load_settings(env) + return mode + + def cmd_generate_and_open(args: argparse.Namespace, *, opener=webbrowser.open) -> int: """Regenerate the snapshot from the working tree into a run dir, start the local server, print the URL (ALWAYS), and open the browser. `--no-open` suppresses the browser; `--no-serve` returns after printing the URL without - blocking (used by tests). `opener` is injectable for testing.""" + blocking (used by tests). `opener` is injectable for testing. + + THE INSTALL SHAPE IS RESOLVED FIRST (plan 034 T070): `--local`, or + `OPENDOX_INSTALL_MODE=local`, selects the local single-user install, and + with neither the install is hosted — see `_resolve_install_shape`. A + refusal there is printed on stderr and the command exits 1, before any + other work.""" + try: + args.install_mode = _resolve_install_shape(args) + except runtime_config.ConfigurationError as exc: + print(f"generate-and-open refused: {exc}", file=sys.stderr) + return 1 # Ahead of minting the run dir, so a refused root leaves not even an empty # temp directory behind. `_generate_and_write` is still the guard that MATTERS # (it is the one no caller can skip); these are the same checks, earlier. @@ -968,6 +1019,17 @@ def build_parser(*, subcommand_extensions: tuple = ()) -> argparse.ArgumentParse gao.add_argument("--actor", default=None, help="human identity for loopback gate actions " "(default: the checkout's git user.name)") + # THE INSTALL SHAPE'S FLAG (plan 034 T070; R1Q15 (b), as T007 batch H's + # 13.4 addendum reads): the documented command is + # `opendox generate-and-open --local …`. The same selection as + # `OPENDOX_INSTALL_MODE=local`; with neither the install is hosted, and the + # flag beside `OPENDOX_INSTALL_MODE=hosted` is refused. + gao.add_argument(runtime_config.LOCAL_FLAG, action="store_true", + dest="local", + help="the LOCAL single-user install: no identity broker, " + "loopback only (the same selection as " + "OPENDOX_INSTALL_MODE=local; with neither, the " + "install is hosted and needs its broker's issuer)") gao.add_argument("--host", default=serve_mod.DEFAULT_HOST, help="bind host (default: 127.0.0.1, loopback only)") gao.add_argument("--port", type=int, default=0, help="bind port (default: ephemeral)") diff --git a/src/opendox/runtime/cli.py b/src/opendox/runtime/cli.py index 42921498..a69bfd41 100644 --- a/src/opendox/runtime/cli.py +++ b/src/opendox/runtime/cli.py @@ -90,6 +90,8 @@ from opendox.runtime import identity, migrations from opendox.runtime.config import ( + INSTALL_MODE_LOCAL, + LOCAL_FLAG, SECRET_NAMES, SETTINGS, ConfigurationError, @@ -341,6 +343,11 @@ def _redacted_settings(settings: RuntimeSettings) -> dict[str, Any]: values = { "OPENDOX_DATABASE_URL": settings.database_url, "OPENDOX_MIGRATION_DATABASE_URL": settings.migration_database_url, + # THE INSTALL SHAPE this process loaded (plan 034 T070): a name and + # never a credential, and the first thing an operator reading `status` + # needs to know, because it decides whether the broker lines below + # mean anything at all. + "OPENDOX_INSTALL_MODE": settings.install_mode, # THE BROKER URLS ARE REDACTED HERE TOO. `load_settings` refuses # userinfo in the issuer and in an explicit JWKS URL — but this report # prints a DERIVED value, and a settings object can also be built by @@ -565,10 +572,31 @@ def cmd_migrate(args: argparse.Namespace) -> int: def cmd_serve(args: argparse.Namespace) -> int: - """Run the API. The pool is opened by the application's lifespan.""" + """Run the API. The pool is opened by the application's lifespan. + + NOT IN A LOCAL INSTALL (plan 034 T070; a holder reading on + openxFactory#656 that Brett may overrule). Every `/api/v1` route verifies + a token the BROKER signed (`oidc.build_verifier`), and the local mode has + no broker (#1144 13.4), so there is no identity this API could serve + with: started anyway, it would either refuse every request or, worse, + stand a local principal up that no task text defines. A local install is + served by `opendox generate-and-open --local`, and in release 1 its + document surface reads nothing from the store (R1Q16 (ii)). Refused + BEFORE anything is imported or bound, as evidence like every refusal. + """ settings = _settings_or_refusal(args) if isinstance(settings, int): return settings + if settings.install_mode == INSTALL_MODE_LOCAL: + return _emit({"verb": "serve", "refusal": "local-mode-has-no-broker", + "message": "the runtime API authenticates every request " + "with a token its identity broker signed, " + "and a LOCAL install has no broker, so this " + "API has no identity to serve with. A local " + "install is served by `opendox " + f"generate-and-open {LOCAL_FLAG}`; the " + "runtime API is a HOSTED install's surface " + "(13.4)"}, ok=False) try: import uvicorn @@ -636,6 +664,19 @@ def cmd_serve(args: argparse.Namespace) -> int: "exiting normally"}, ok=True) +def _report_the_local_broker(report: dict[str, Any]) -> None: + """What `status` says of a LOCAL install's broker, on every path. + + Its broker is NOT CONFIGURED (plan 034 T070; #1144 13.4): a statement + about the install's configuration rather than a probe's result, so there + is no discovery URL to report and nothing counts against `ok`. `status` + returns from two places, and both write it here, so the two answers + cannot drift apart (Copilot review of openDox-code#67). + """ + report["broker_keys"] = "not configured (local mode)" + report["broker_discovery"] = None + + def cmd_status(args: argparse.Namespace) -> int: """Report, never change: configuration, the schema pin, the ledger, the broker. @@ -665,10 +706,18 @@ def cmd_status(args: argparse.Namespace) -> int: try: from opendox.runtime.db import Database - except ImportError as exc: # pragma: no cover - the extra is absent + except ImportError as exc: report["runtime_extra"] = f"absent: {_safe_message(exc)}" report["database"] = "not probed" - report["broker_keys"] = "not probed" + # A LOCAL INSTALL'S BROKER IS NOT CONFIGURED WHETHER OR NOT THE EXTRA + # IS PRESENT (plan 034 T070; Copilot review of openDox-code#67). That + # answer comes from its configuration, not from a probe, so this early + # return gives the same one the full report gives below. A hosted + # install's broker was never probed, and says so, as before. + if settings.install_mode == INSTALL_MODE_LOCAL: + _report_the_local_broker(report) + else: + report["broker_keys"] = "not probed" return _emit(report, ok=False) report["runtime_extra"] = "present" @@ -744,6 +793,14 @@ def cmd_status(args: argparse.Namespace) -> int: f"unreachable: {type(exc).__name__}: {_safe_message(exc)}") ok = False + # A LOCAL INSTALL HAS NO BROKER TO PROBE (plan 034 T070; #1144 13.4), + # and that is its configuration rather than a fault: reported by name, and + # NOT counted against `ok`, so a healthy local install's `status` exits 0 + # — F13.1 runs it under `set -e`, and a verdict of "unhealthy" for a + # broker the install was never meant to have would be false. + if settings.install_mode == INSTALL_MODE_LOCAL: + _report_the_local_broker(report) + return _emit(report, ok=ok) try: from opendox.runtime.oidc import build_verifier diff --git a/src/opendox/runtime/config.py b/src/opendox/runtime/config.py index 5c755006..ad8c518d 100644 --- a/src/opendox/runtime/config.py +++ b/src/opendox/runtime/config.py @@ -85,10 +85,23 @@ class Setting: "the PRIVILEGED DSN ordered-SQL migrations are applied with, used by " "`opendox runtime migrate` alone and never by the served application", ), + # THE INSTALL SHAPE, READ BESIDE THE ISSUER IT DECIDES ABOUT (plan 034 + # T070; #1144 13.4, 13.5). Not `required`: its default is the SAFE value, + # and "unset" is the case 13.4 names as the one that must be safe. + Setting( + PREFIX + "INSTALL_MODE", "hosted", False, False, + "the install shape: `hosted` (the default — the broker, the pinned " + "issuer and an operator's database, exactly as before) or `local` " + "(one user, no broker, loopback only). `generate-and-open --local` " + "makes the same selection; the two may not disagree, and with " + "neither the install is hosted, so a hosted install with no issuer " + "refuses rather than falling into local mode (13.4, 13.5)", + ), Setting( PREFIX + "OIDC_ISSUER", None, True, False, "the Keycloak broker's issuer, pinned: a token from any other issuer " - "is refused rather than trusted (RULING Q2)", + "is refused rather than trusted (RULING Q2). Required by a HOSTED " + "install; a LOCAL install has no broker and refuses one given here", ), Setting( PREFIX + "OIDC_AUDIENCE", None, True, False, @@ -188,10 +201,29 @@ class RuntimeSettings: docker-compose.yaml`'s `opendox` service and `docs/runtime.md` § 3 never supply it, and `load_migration_settings` is the loader that actually requires one (unaffected by this: it already refused to load without one). + + `install_mode` is `INSTALL_MODE_HOSTED` or `INSTALL_MODE_LOCAL` (plan 034 + T070; #1144 13.4), whichever loader built the object. + + THE BROKER FIELDS DEPEND ON THE LOADER, and what follows holds for + `load_settings` only (Copilot review of openDox-code#67): + * From `load_settings`, a LOCAL install has no broker. Its + `oidc_issuer` and `oidc_audience` are EMPTY and its `oidc_jwks_url` + is `None`, never a placeholder that looks like an endpoint, and + `jwks_url()` and `discovery_url()` answer the empty string rather + than a path glued onto nothing. A HOSTED one always carries a real + issuer, because `load_settings` refuses one without it. + * From `load_migration_settings`, in EITHER shape, the issuer and + audience are `MIGRATION_SENTINEL_ISSUER` and + `MIGRATION_SENTINEL_AUDIENCE`. A migration run reaches no broker at + all, and anything that tried to with those values would fail naming + them. So neither statement above applies to it. `install_mode` there + records the shape the run belongs to, and nothing else. """ database_url: str migration_database_url: str | None + install_mode: str oidc_issuer: str oidc_audience: str oidc_jwks_url: str | None @@ -213,6 +245,7 @@ def __repr__(self) -> str: "RuntimeSettings(database_url=, " "migration_database_url=" f"{'' if self.migration_database_url else 'None'}, " + f"install_mode={self.install_mode!r}, " # REDACTED TOO, and not because `load_settings` allows userinfo # here — it refuses it. A `RuntimeSettings` built by hand, in a # test or by a future caller, does not go through that door, and @@ -241,13 +274,25 @@ def jwks_url(self) -> str: it saves one variable in the common case; setting it explicitly is what a broker behind a rewriting proxy needs, which is why the variable exists at all rather than the URL always being computed. + + EMPTY FOR A LOCAL INSTALL, which has no issuer to derive one from + (plan 034 T070): the derivation would otherwise answer the bare path + `/protocol/openid-connect/certs`, which `status` would print as if it + were a configured endpoint. """ if self.oidc_jwks_url: return self.oidc_jwks_url + if not self.oidc_issuer: + return "" return self.oidc_issuer.rstrip("/") + "/protocol/openid-connect/certs" def discovery_url(self) -> str: - """The issuer's discovery document, for `opendox runtime status`.""" + """The issuer's discovery document, for `opendox runtime status`. + + Empty for a local install, for the reason `jwks_url` gives. + """ + if not self.oidc_issuer: + return "" return self.oidc_issuer.rstrip("/") + "/.well-known/openid-configuration" @@ -1488,7 +1533,171 @@ def _refuse_two_dsns_that_select_different_schemas( "carries a password)") -def load_settings(env: Mapping[str, str] | None = None) -> RuntimeSettings: +#: THE TWO INSTALL SHAPES (plan 034 T070; #1144 13.4). One selector decides +#: the whole shape at once — the identity mode here, and the datastore source +#: 13.1 adds — because requirements 12 and 13 both describe "the standalone +#: install" and one deliberate choice should decide both. +INSTALL_MODE_HOSTED = "hosted" +INSTALL_MODE_LOCAL = "local" +INSTALL_MODES: tuple[str, ...] = (INSTALL_MODE_LOCAL, INSTALL_MODE_HOSTED) + +#: The flag that makes the same selection as `OPENDOX_INSTALL_MODE=local`, +#: spelled ONCE: `opendox.cli` declares `generate-and-open`'s option with this +#: constant, and every refusal below names it with the same one (R1Q15 (b), as +#: T007 batch H's 13.4 addendum reads). +LOCAL_FLAG = "--local" + +#: LOOPBACK, AS THE DOCUMENT SERVER ALREADY JUDGES IT. `serve.py` makes its +#: `session` capability conditional on a bind in exactly this set +#: (`serve.LOOPBACK_HOSTS`), and 13.4 asks the local mode to "make the same +#: judgement at the mode's own boundary" — so it is the same set, not +#: `_is_loopback` above: that one reads `127.0.0.0/8` as loopback, and a local +#: install bound to `127.0.0.2` would then pass here while the server it starts +#: treats that very bind as off-loopback. This module cannot import `serve` +#: (the import weight in `opendox/runtime/__init__.py`), so the set is spelled +#: here and `tests_runtime/test_install_mode.py` holds it equal to serve's. +LOCAL_BIND_HOSTS: frozenset[str] = frozenset({"127.0.0.1", "::1", "localhost"}) + +#: THE SETTINGS ONLY A HOSTED INSTALL READS. Given beside the local mode, each +#: is REFUSED, by name (a holder reading on openxFactory#656, plan 034 T070, +#: the same fail-closed reading as the disagreeing flag and setting): an issuer +#: next to `local` says a broker was meant, and honouring `local` over it would +#: silently drop the authentication the operator configured. T072 adds the two +#: DSNs, which the local install supplies itself (13.1). +HOSTED_ONLY_SETTINGS: tuple[str, ...] = ( + PREFIX + "OIDC_ISSUER", + PREFIX + "OIDC_AUDIENCE", + PREFIX + "OIDC_JWKS_URL", +) + + +def install_mode(env: Mapping[str, str] | None = None, *, + local_flag: bool = False) -> str: + """`INSTALL_MODE_LOCAL` or `INSTALL_MODE_HOSTED`, or a refusal naming why. + + THE DEFAULT IS HOSTED, and it is the default because it is the safe one + (#1144 13.4: "It is UNSET, not `local`, that must be safe"): an install + that sets nothing is hosted, and a hosted install with no issuer refuses + (13.5), so single-user operation is never reached by forgetting to + configure something. A BLANK value is unset, the reading `_optional` gives + every other setting. + + `local_flag` is `generate-and-open --local` (R1Q15 (b)). It selects local + exactly as `OPENDOX_INSTALL_MODE=local` does, and the two may not + DISAGREE: `--local` beside `OPENDOX_INSTALL_MODE=hosted` is refused naming + both, so no explicit selection is silently overridden by the other. No + answer on #656 rules that pair; the refusal is plan 034's fail-closed + reading (Principle VII, T070), recorded for Brett in + `evidence/analyze-round-2.md` and NOT written into #1144. + + AN UNRECOGNISED VALUE IS REFUSED, matched case-sensitively (a holder + reading on #656, T070): `Local` or `single-user` is not a spelling of + either shape, and guessing which one was meant is the one thing a + selector whose default is a safety property must not do. + """ + env = os.environ if env is None else env + setting = PREFIX + "INSTALL_MODE" + raw = env.get(setting, "").strip() + if raw and raw not in INSTALL_MODES: + raise ConfigurationError( + f"{setting} is {raw!r}, which is neither `local` nor `hosted` (the " + "two values are matched exactly, case included). Unset means " + "hosted; a single-user install selects `local` explicitly, with " + f"{setting}=local or `generate-and-open {LOCAL_FLAG}`") + if local_flag and raw == INSTALL_MODE_HOSTED: + raise ConfigurationError( + f"{LOCAL_FLAG} selects the LOCAL install and {setting}=hosted " + "selects the HOSTED one. Both are explicit selections and they " + "disagree, so neither is allowed to override the other: drop the " + f"flag for a hosted install, or unset {setting} (or set it to " + "`local`) for a local one") + if local_flag: + return INSTALL_MODE_LOCAL + return raw or INSTALL_MODE_HOSTED + + +def refuse_a_non_loopback_local_bind(name: str, host: str) -> None: + """A LOCAL install binds loopback only, with NO opt-in (#1144 13.4). + + `name` is what set the address — `--host` on `generate-and-open`, or + `OPENDOX_BIND_HOST` for the runtime's own listener — so the refusal names + the thing the operator actually typed. The local mode has no broker, so a + local install other machines can reach is an unauthenticated multi-user + service wearing the word "local"; an install that must be reachable from + another machine is a HOSTED install, with a broker. + """ + if host in LOCAL_BIND_HOSTS: + return + raise ConfigurationError( + f"{name} {host!r} is not a loopback address, and a LOCAL install binds " + f"LOOPBACK ONLY ({', '.join(sorted(LOCAL_BIND_HOSTS))}). The local " + "mode has no identity broker, so a local install another machine can " + "reach would be an unauthenticated multi-user service. There is no " + "opt-in: an install that must be reachable from another machine is a " + "HOSTED install, with a broker (13.4)") + + +def refuse_what_a_local_install_cannot_be(env: Mapping[str, str]) -> None: + """The two refusals a LOCAL install makes of its own environment. + + Every hosted-only setting given beside it (`HOSTED_ONLY_SETTINGS`), and a + non-loopback `OPENDOX_BIND_HOST`, the runtime's own listener. One function, + because `load_settings` and `generate-and-open --local` both ask it and the + two must not come to disagree about what a local install is. + """ + _refuse_hosted_only_settings(env) + refuse_a_non_loopback_local_bind( + PREFIX + "BIND_HOST", + _optional(env, _by_name(PREFIX + "BIND_HOST")) or "127.0.0.1") + + +def _refuse_hosted_only_settings(env: Mapping[str, str]) -> None: + """Every setting in `HOSTED_ONLY_SETTINGS` given beside `local`, named.""" + given = [name for name in HOSTED_ONLY_SETTINGS + if env.get(name, "").strip()] + if given: + raise ConfigurationError( + f"{' and '.join(given)} {'are' if len(given) > 1 else 'is'} set, " + "and this is a LOCAL install, which has no broker and reads " + f"{'none of them' if len(given) > 1 else 'none'}. A broker " + "setting beside the local mode says a HOSTED install was meant, " + "and honouring `local` over it would silently drop that " + f"authentication: unset {'them' if len(given) > 1 else 'it'} for " + f"a local install, or drop {LOCAL_FLAG} / " + f"{PREFIX}INSTALL_MODE=local for a hosted one (the values are not " + "repeated here)") + + +def require_the_hosted_issuer(env: Mapping[str, str] | None = None) -> None: + """A HOSTED install with no issuer refuses, NAMING THE ISSUER (#1144 13.5). + + `load_settings` asks for the served DSN before it asks for the issuer, so + a hosted `generate-and-open` run with NOTHING configured would otherwise + be refused naming `OPENDOX_DATABASE_URL` — true, and not the refusal 13.5 + and plan 034's requirement-13 scenario ask for, which is the one that + tells an operator this install is HOSTED and how to select the other one. + So the document server's entry point asks this first, and `load_settings` + keeps its own order for every verb that already relies on it (13.6: the + hosted mode is otherwise unchanged). + """ + env = os.environ if env is None else env + issuer = PREFIX + "OIDC_ISSUER" + if env.get(issuer, "").strip(): + return + selected = (f"{PREFIX}INSTALL_MODE=hosted" + if env.get(PREFIX + "INSTALL_MODE", "").strip() + else f"{PREFIX}INSTALL_MODE is unset, and unset means hosted") + raise ConfigurationError( + f"{issuer} is required and is not set, and this install is HOSTED " + f"({selected}). A hosted install authenticates through the broker " + "whose issuer this names, and it does NOT fall back to single-user " + "operation without one (13.5). A single-user install selects the " + f"local mode explicitly: `generate-and-open {LOCAL_FLAG}` or " + f"{PREFIX}INSTALL_MODE=local") + + +def load_settings(env: Mapping[str, str] | None = None, *, + local_flag: bool = False) -> RuntimeSettings: """Resolve :class:`RuntimeSettings` from `env` (default `os.environ`). Refuses with :class:`ConfigurationError` naming the variable — never with a @@ -1512,9 +1721,22 @@ def load_settings(env: Mapping[str, str] | None = None) -> RuntimeSettings: checks below still apply: a non-PostgreSQL migration DSN is refused (13.2), and the two being the exact same value is refused (13.3) — optional does not mean unchecked. + + THE INSTALL MODE FIRST (plan 034 T070; #1144 13.4-13.6). `local_flag` is + `generate-and-open --local`, resolved against `OPENDOX_INSTALL_MODE` by + `install_mode`, which refuses the two disagreeing. A HOSTED install — the + default — is exactly what this function has always loaded, in the same + order, with the issuer and audience required (13.6). A LOCAL install needs + no broker: its issuer, audience and key-set URL are empty, and any of the + three GIVEN beside it is refused (`HOSTED_ONLY_SETTINGS`); and its own + listener, `OPENDOX_BIND_HOST`, must be loopback, with no opt-in. """ env = os.environ if env is None else env + mode = install_mode(env, local_flag=local_flag) + local = mode == INSTALL_MODE_LOCAL + if local: + refuse_what_a_local_install_cannot_be(env) algorithms = _algorithms(env) served = _require(env, _by_name(PREFIX + "DATABASE_URL")) migration = _optional(env, _by_name(PREFIX + "MIGRATION_DATABASE_URL")) @@ -1538,18 +1760,23 @@ def load_settings(env: Mapping[str, str] | None = None) -> RuntimeSettings: # make. _refuse_the_same_dsn_in_both_settings(served, migration) + bind_host = _optional(env, _by_name(PREFIX + "BIND_HOST")) or "127.0.0.1" + return RuntimeSettings( database_url=served, migration_database_url=migration, - oidc_issuer=_broker_url(env, _by_name(PREFIX + "OIDC_ISSUER"), - required=True, is_a_base_url=True) or "", - oidc_audience=_require(env, _by_name(PREFIX + "OIDC_AUDIENCE")), - oidc_jwks_url=_broker_url(env, _by_name(PREFIX + "OIDC_JWKS_URL"), - required=False), + install_mode=mode, + oidc_issuer="" if local else _broker_url( + env, _by_name(PREFIX + "OIDC_ISSUER"), + required=True, is_a_base_url=True) or "", + oidc_audience="" if local else _require( + env, _by_name(PREFIX + "OIDC_AUDIENCE")), + oidc_jwks_url=None if local else _broker_url( + env, _by_name(PREFIX + "OIDC_JWKS_URL"), required=False), oidc_algorithms=algorithms, oidc_jwks_ttl_seconds=_positive_int(env, _by_name(PREFIX + "OIDC_JWKS_TTL_SECONDS")), oidc_leeway_seconds=_positive_int(env, _by_name(PREFIX + "OIDC_LEEWAY_SECONDS")), - bind_host=_optional(env, _by_name(PREFIX + "BIND_HOST")) or "127.0.0.1", + bind_host=bind_host, bind_port=_positive_int(env, _by_name(PREFIX + "BIND_PORT")), runtime_pg_role=_role_name(env), served_schema=_served_schema(env), @@ -1584,6 +1811,16 @@ def load_migration_settings(env: Mapping[str, str] | None = None) -> RuntimeSett not, because those are the served runtime and must have the real thing. """ env = os.environ if env is None else env + # THE ONE READING OF THE SELECTOR, AND OF WHAT A LOCAL INSTALL CANNOT BE + # (plan 034 T070; Copilot review of openDox-code#67). A migration run is + # part of the same install as the served one, so `runtime migrate` and + # `runtime reset` refuse what `load_settings` and `generate-and-open` + # refuse beside `local` — a broker setting, or a non-loopback + # `OPENDOX_BIND_HOST` — rather than accepting it in the one loader that + # never reads it. + mode = install_mode(env) + if mode == INSTALL_MODE_LOCAL: + refuse_what_a_local_install_cannot_be(env) dsn = env.get(PREFIX + "MIGRATION_DATABASE_URL", "").strip() if not dsn: raise ConfigurationError( @@ -1601,6 +1838,10 @@ def load_migration_settings(env: Mapping[str, str] | None = None) -> RuntimeSett return RuntimeSettings( database_url=dsn, migration_database_url=dsn, + # READ ABOVE, SO AN UNRECOGNISED VALUE IS REFUSED HERE TOO (plan 034 + # T070). It changes nothing else a migration run does; the broker + # fields below are sentinels in either shape. + install_mode=mode, oidc_issuer=MIGRATION_SENTINEL_ISSUER, oidc_audience=MIGRATION_SENTINEL_AUDIENCE, oidc_jwks_url=None, diff --git a/tests/test_doxbench_entrypoint.py b/tests/test_doxbench_entrypoint.py index 0eac4803..2488fdf0 100644 --- a/tests/test_doxbench_entrypoint.py +++ b/tests/test_doxbench_entrypoint.py @@ -72,6 +72,7 @@ from opendox import doxbench_install as install_mod from opendox import doxbench_model from opendox import serve as serve_mod +from opendox.runtime import config as runtime_config def _handler_class(httpd): @@ -163,8 +164,18 @@ def _capture(*args, **kwargs): checkout = tmp_path / "checkout" checkout.mkdir() session_root = tmp_path / "model-sessions" + # THE LOCAL INSTALL, SELECTED EXPLICITLY (plan 034 T070; #1144 13.4, as + # T007 batch H's addendum reads). With neither `--local` nor + # `OPENDOX_INSTALL_MODE=local` the install is HOSTED, and a hosted install + # with no issuer refuses (13.5) before `build_server` is ever reached. This + # fixture drives the single-user entrypoint a student runs, so it says so, + # and it scrubs every runtime setting first: a broker setting inherited + # from the shell would be refused beside the local mode, by design. + for name in runtime_config.SETTING_NAMES: + monkeypatch.delenv(name, raising=False) args = cli_mod.build_parser().parse_args([ "generate-and-open", + runtime_config.LOCAL_FLAG, "--repo-root", str(checkout), "--repository", "fixture-repo", "--source-revision", PINNED_REVISION, diff --git a/tests/test_install_mode_entrypoint.py b/tests/test_install_mode_entrypoint.py new file mode 100644 index 00000000..9c4c6203 --- /dev/null +++ b/tests/test_install_mode_entrypoint.py @@ -0,0 +1,216 @@ +"""`generate-and-open`'s install shape: F13.1's refusals, run the way F13.1 +runs them (plan 034 T070; #1144 13.4, 13.5, 13.6). + +F13.1's refusal probes take the SAME `generate-and-open` path its local probe +takes, under `timeout 30`, and each must exit nonzero, must not be the bound's +124, and must name its rule on stderr. These cases run that path in a child +process for the same reason: a regression that silently STARTED a server +would block in-process forever, where a child is killed by the bound and the +case fails on `TimeoutExpired` instead of hanging the suite. The child is +`python -m opendox.cli`, which `cli.py`'s `__main__` block hands to the +package's `main()`, so it is the same `main()` the `opendox` console script +runs. + +Each probe gives a well-formed `--repo-root` (a fresh git repository) and every +other setting its case needs, so the install shape is the only fault; and the +install shape is resolved BEFORE the repo root is scanned, so the refusal is +the install's, whatever the tree holds. No child reaches a database, a broker +or a socket. + +The disagreeing flag and setting are plan 034's fail-closed reading (T070), +not a line of #1144; the rest is #1144's. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from opendox import cli as cli_mod +from opendox import serve as serve_mod +from opendox.runtime import config as runtime_config + +SRC = Path(__file__).resolve().parents[1] / "src" +PREFIX = runtime_config.PREFIX +MODE = PREFIX + "INSTALL_MODE" + +#: F13.1's hosted probes' environment: every setting a hosted install needs +#: EXCEPT the issuer, so the issuer is the only fault. +HOSTED_WITHOUT_ISSUER = { + PREFIX + "DATABASE_URL": "postgresql://serve@127.0.0.1:1/opendox", + PREFIX + "MIGRATION_DATABASE_URL": "postgresql://migrate@127.0.0.1:1/opendox", + PREFIX + "OIDC_AUDIENCE": "fixture", +} + + +@pytest.fixture() +def corpus(tmp_path: Path) -> Path: + """A fresh repository, as F13.1's preamble makes one. + + Made APART FROM the user's own git configuration (`GIT_CONFIG_GLOBAL`, + `GIT_CONFIG_NOSYSTEM`), as `tests/test_checkout_head.py` makes its + repositories, so a global signing rule or hook cannot fail the setup + before the install-mode probe it exists for ever runs (Copilot review of + this PR). + """ + root = tmp_path / "plain-documents" + root.mkdir() + (root / "note.md").write_text("# A note\n\nPlain text.\n", encoding="utf-8") + env = {**os.environ, + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid", + "GIT_COMMITTER_NAME": "fixture", + "GIT_COMMITTER_EMAIL": "fixture@example.invalid"} + for argv in (["git", "init", "-q"], ["git", "add", "-A"], + ["git", "commit", "-qm", "fixture"]): + subprocess.run(argv, cwd=root, env=env, check=True) + return root + + +def _probe(corpus: Path, tmp_path: Path, *extra: str, + env: dict[str, str] | None = None) -> tuple[int, str]: + """One bounded `generate-and-open` child: `(returncode, stderr)`.""" + child_env = {name: value for name, value in os.environ.items() + if name not in runtime_config.SETTING_NAMES} + child_env.update(env or {}) + child_env["PYTHONPATH"] = os.pathsep.join( + [str(SRC), child_env.get("PYTHONPATH", "")]).rstrip(os.pathsep) + try: + done = subprocess.run( + [sys.executable, "-m", "opendox.cli", "generate-and-open", + "--repo-root", str(corpus), "--repository", "fixture", + "--run-dir", str(tmp_path / "run"), "--no-open", *extra], + env=child_env, capture_output=True, text=True, timeout=30) + except subprocess.TimeoutExpired as exc: # F13.1's rc 124 + raise AssertionError( + "generate-and-open did not refuse: it was still running when the " + "30-second bound killed it, which is a server that STARTED") from exc + return done.returncode, done.stderr + + +def test_local_mode_refuses_a_non_loopback_bind_naming_the_rule( + corpus: Path, tmp_path: Path) -> None: + """F13.1: `OPENDOX_INSTALL_MODE=local … --host 0.0.0.0` is refused, BOUNDED, + and stderr names loopback (13.4).""" + rc, err = _probe(corpus, tmp_path, "--host", "0.0.0.0", "--port", "0", + env={MODE: "local"}) + assert rc != 0, err + assert "loopback" in err.lower(), err + assert "--host" in err, err + + +def test_the_flag_refuses_a_non_loopback_bind_exactly_as_the_setting_does( + corpus: Path, tmp_path: Path) -> None: + rc, err = _probe(corpus, tmp_path, runtime_config.LOCAL_FLAG, + "--host", "0.0.0.0") + assert rc != 0, err + assert "loopback" in err.lower(), err + + +@pytest.mark.parametrize("mode", ["hosted", None]) +def test_a_hosted_or_unset_install_with_no_issuer_refuses_naming_it( + corpus: Path, tmp_path: Path, mode) -> None: + """F13.1's last two probes: hosted, and then the selector UNSET, each with + every hosted setting except the issuer; each refuses, BOUNDED, naming + `OPENDOX_OIDC_ISSUER` (13.5). The second is what proves the unset DEFAULT + refuses exactly as `hosted` does.""" + env = dict(HOSTED_WITHOUT_ISSUER) + if mode is not None: + env[MODE] = mode + rc, err = _probe(corpus, tmp_path, "--port", "0", env=env) + assert rc != 0, err + assert PREFIX + "OIDC_ISSUER" in err, err + + +def test_with_nothing_configured_the_refusal_names_the_issuer_and_the_flag( + corpus: Path, tmp_path: Path) -> None: + """Plan 034's requirement-13 scenario 2: with no setting at all the + install is hosted, and the refusal is about the ISSUER and names how to + select local — not `OPENDOX_DATABASE_URL`, which `load_settings` would + have asked for first.""" + rc, err = _probe(corpus, tmp_path, "--port", "0") + assert rc != 0, err + assert PREFIX + "OIDC_ISSUER" in err, err + assert runtime_config.LOCAL_FLAG in err, err + assert PREFIX + "DATABASE_URL" not in err, err + + +def test_a_flag_and_a_setting_that_disagree_are_refused_naming_both( + corpus: Path, tmp_path: Path) -> None: + """T070's own case (plan 034's fail-closed reading): `--local` beside + `OPENDOX_INSTALL_MODE=hosted`, with a COMPLETE hosted configuration, so + either selection alone would have been accepted.""" + env = {**HOSTED_WITHOUT_ISSUER, + PREFIX + "OIDC_ISSUER": "https://issuer.example.invalid/realms/x", + MODE: "hosted"} + rc, err = _probe(corpus, tmp_path, runtime_config.LOCAL_FLAG, env=env) + assert rc != 0, err + assert runtime_config.LOCAL_FLAG in err, err + assert f"{MODE}=hosted" in err, err + + +def test_a_broker_setting_beside_the_local_flag_is_refused_by_name( + corpus: Path, tmp_path: Path) -> None: + rc, err = _probe(corpus, tmp_path, runtime_config.LOCAL_FLAG, + env={PREFIX + "OIDC_ISSUER": + "https://issuer.example.invalid/realms/x"}) + assert rc != 0, err + assert PREFIX + "OIDC_ISSUER" in err, err + + +# -- in process: what each shape resolves to ---------------------------------- + + +def _args(*extra: str): + return cli_mod.build_parser().parse_args( + ["generate-and-open", "--repo-root", "/nonexistent", + "--repository", "fixture", *extra]) + + +def test_the_local_shape_needs_no_broker_and_no_setting_at_all() -> None: + """13.4: `local` needs no broker. Resolved with an EMPTY environment.""" + assert cli_mod._resolve_install_shape( + _args(runtime_config.LOCAL_FLAG), env={}) == \ + runtime_config.INSTALL_MODE_LOCAL + assert cli_mod._resolve_install_shape( + _args(), env={MODE: "local"}) == runtime_config.INSTALL_MODE_LOCAL + + +@pytest.mark.parametrize("host", sorted(serve_mod.LOOPBACK_HOSTS)) +def test_the_local_shape_accepts_each_loopback_host(host: str) -> None: + assert cli_mod._resolve_install_shape( + _args(runtime_config.LOCAL_FLAG, "--host", host), env={}) == \ + runtime_config.INSTALL_MODE_LOCAL + + +def test_a_complete_hosted_configuration_resolves_hosted_unchanged() -> None: + """13.6: a hosted install with its broker configured serves as before.""" + env = {**HOSTED_WITHOUT_ISSUER, + PREFIX + "OIDC_ISSUER": "https://issuer.example.invalid/realms/x"} + assert cli_mod._resolve_install_shape(_args(), env=env) == \ + runtime_config.INSTALL_MODE_HOSTED + # a hosted document server may still bind beyond loopback, as it always + # could: the loopback rule is the LOCAL mode's + assert cli_mod._resolve_install_shape( + _args("--host", "0.0.0.0"), env=env) == \ + runtime_config.INSTALL_MODE_HOSTED + + +def test_the_local_bind_rule_is_the_document_servers_own_loopback_set() -> None: + """13.4: "the same judgement at the mode's own boundary". `config` cannot + import `serve`, so it spells the set; this holds the two equal.""" + assert runtime_config.LOCAL_BIND_HOSTS == serve_mod.LOOPBACK_HOSTS + + +def test_the_flag_is_declared_on_generate_and_open_and_follows_the_verb() -> None: + """10.1: every option follows its verb; `--local` is `generate-and-open`'s.""" + assert _args(runtime_config.LOCAL_FLAG).local is True + assert _args().local is False + with pytest.raises(SystemExit): + cli_mod.build_parser().parse_args( + [runtime_config.LOCAL_FLAG, "generate-and-open", "--repo-root", + "/x", "--repository", "fixture"]) diff --git a/tests_runtime/test_install_mode.py b/tests_runtime/test_install_mode.py new file mode 100644 index 00000000..24d44eb6 --- /dev/null +++ b/tests_runtime/test_install_mode.py @@ -0,0 +1,427 @@ +"""`OPENDOX_INSTALL_MODE`: the local single-user install, and the hosted one +it cannot be reached from by omission (plan 034 T070; #1144 13.4, 13.5, 13.6). + +HERMETIC, WITH ONE EXCEPTION. Every case but one uses the standard library +plus `opendox.runtime.config` and the runtime CLI, both stdlib-only at import, +and reaches no database. Each DSN in those cases is a well-formed PostgreSQL +URI aimed at port 1 of the loopback, so a refusal there is always a +CONFIGURATION refusal, which is the whole of what 13.4-13.6 ask of +`load_settings`. + +The exception is `test_runtime_status_of_a_healthy_local_install_exits_zero`, +which is DB-BACKED (Copilot review of openDox-code#67). A healthy local +`status` exits 0 only against a database that answers, so that case takes the +suite's `postgres_dsn` and `database` fixtures (`tests_runtime/conftest.py`). +Like every DB-backed case, it is skipped where there is no Postgres, and it +fails under CI. + +WHAT IS RULED AND WHAT IS READ, so a reviewer can tell them apart: + + * RULED: the selector, its two values and its hosted default (#1144 13.4); + a hosted install with no issuer refuses naming it (13.5); the hosted mode + unchanged (13.6); local binds loopback only with no opt-in (13.4); + `generate-and-open --local` is the same selection (R1Q15 (b), T007 batch + H's 13.4 addendum). + * PLAN 034's FAIL-CLOSED READING, not in #1144: a `--local` flag and an + `OPENDOX_INSTALL_MODE` setting that disagree are refused, naming both + (T070; `evidence/analyze-round-2.md` U2-1, V2-6). + * HOLDER READINGS on openxFactory#656 that Brett may overrule (T070): an + unrecognised value is refused, case-sensitively; a broker setting given + beside `local` is refused by name; `runtime serve` refuses under `local`; + `runtime status` under `local` reports the broker as not configured and + does not count it as a fault. +""" + +from __future__ import annotations + +import argparse +import io +import json +from contextlib import redirect_stdout + +import pytest + +from opendox.runtime import cli +from opendox.runtime.config import ( + HOSTED_ONLY_SETTINGS, + INSTALL_MODE_HOSTED, + INSTALL_MODE_LOCAL, + INSTALL_MODES, + LOCAL_BIND_HOSTS, + LOCAL_FLAG, + PREFIX, + SETTING_NAMES, + ConfigurationError, + install_mode, + load_migration_settings, + load_settings, + require_the_hosted_issuer, +) + +MODE = PREFIX + "INSTALL_MODE" + +#: Two DSNs that pass T071's three refusals — one dialect, one database, two +#: different values — so the install shape is the only thing under test. +DSNS = { + PREFIX + "DATABASE_URL": "postgresql://serve@127.0.0.1:1/opendox", + PREFIX + "MIGRATION_DATABASE_URL": "postgresql://migrate@127.0.0.1:1/opendox", +} +#: Every setting a HOSTED install needs, well-formed. +HOSTED = {**DSNS, + PREFIX + "OIDC_ISSUER": "https://issuer.example.invalid/realms/fixture", + PREFIX + "OIDC_AUDIENCE": "fixture"} + + +def _refusal(env: dict, **kwargs) -> str: + try: + load_settings(env, **kwargs) + except ConfigurationError as exc: + return str(exc) + raise AssertionError(f"accepted: {env} {kwargs}") + + +@pytest.fixture() +def scrubbed(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: + """No runtime setting inherited from the shell reaches a CLI verb.""" + for name in SETTING_NAMES: + monkeypatch.delenv(name, raising=False) + return monkeypatch + + +def _run(argv: list[str]) -> tuple[int, dict]: + args: argparse.Namespace = cli.build_parser().parse_args(argv) + buffer = io.StringIO() + with redirect_stdout(buffer): + code = args.func(args) + return code, json.loads(buffer.getvalue()) + + +# -- the selector ------------------------------------------------------------- + + +def test_the_selector_has_two_values_and_its_default_is_hosted() -> None: + """13.4: `local` and `hosted`, defaulting to `hosted`; UNSET is the safe one.""" + assert set(INSTALL_MODES) == {"local", "hosted"} + assert install_mode({}) == INSTALL_MODE_HOSTED + assert install_mode({MODE: ""}) == INSTALL_MODE_HOSTED + assert install_mode({MODE: " "}) == INSTALL_MODE_HOSTED + assert install_mode({MODE: "hosted"}) == INSTALL_MODE_HOSTED + assert install_mode({MODE: "local"}) == INSTALL_MODE_LOCAL + + +def test_the_flag_selects_local_exactly_as_the_setting_does() -> None: + """R1Q15 (b), as T007 batch H's 13.4 addendum reads.""" + assert LOCAL_FLAG == "--local" + assert install_mode({}, local_flag=True) == INSTALL_MODE_LOCAL + assert install_mode({MODE: "local"}, local_flag=True) == INSTALL_MODE_LOCAL + assert install_mode({MODE: "local"}) == install_mode({}, local_flag=True) + + +def test_a_flag_and_a_setting_that_disagree_are_refused_naming_both() -> None: + """T070's falsifier's second half: the disagreeing pair (plan 034's reading). + + Neither explicit selection may silently override the other, in either + direction the loader can see: the flag cannot win over a hosted setting, + and the setting cannot win over the flag. + """ + with pytest.raises(ConfigurationError) as caught: + install_mode({MODE: "hosted"}, local_flag=True) + message = str(caught.value) + assert LOCAL_FLAG in message, message + assert f"{MODE}=hosted" in message, message + # ...and the same refusal on the loader every served verb goes through, + # so the pair cannot be resolved one way here and another way there. + assert f"{MODE}=hosted" in _refusal({**HOSTED, MODE: "hosted"}, + local_flag=True) + + +@pytest.mark.parametrize("value", ["Local", "LOCAL", "Hosted", "single-user", + "locals", "loc al"]) +def test_an_unrecognised_value_is_refused_naming_the_two(value: str) -> None: + """A holder reading (#656, T070): matched case-sensitively, never guessed.""" + with pytest.raises(ConfigurationError) as caught: + install_mode({MODE: value}) + message = str(caught.value) + assert MODE in message and "`local`" in message and "`hosted`" in message + # every loader reads the one selector, so every loader refuses it + assert MODE in _refusal({**HOSTED, MODE: value}) + with pytest.raises(ConfigurationError): + load_migration_settings({**DSNS, MODE: value}) + + +# -- 13.5 and 13.6: the hosted install --------------------------------------- + + +@pytest.mark.parametrize("mode", [None, "hosted", ""]) +def test_a_hosted_install_with_no_issuer_refuses_naming_it(mode) -> None: + """13.5, set or by default, with every other hosted setting well-formed.""" + env = {k: v for k, v in HOSTED.items() if k != PREFIX + "OIDC_ISSUER"} + if mode is not None: + env[MODE] = mode + assert PREFIX + "OIDC_ISSUER" in _refusal(env) + with pytest.raises(ConfigurationError) as caught: + require_the_hosted_issuer(env) + assert PREFIX + "OIDC_ISSUER" in str(caught.value) + assert LOCAL_FLAG in str(caught.value), ( + "the refusal must say how a single-user install selects local") + + +def test_the_hosted_issuer_is_asked_first_by_the_entry_point_check() -> None: + """With NOTHING set, the entry point's refusal names the issuer (13.5), + not the served DSN `load_settings` happens to ask for first.""" + with pytest.raises(ConfigurationError) as caught: + require_the_hosted_issuer({}) + assert PREFIX + "OIDC_ISSUER" in str(caught.value) + assert "unset" in str(caught.value) + require_the_hosted_issuer(HOSTED) # and a present issuer passes + + +def test_the_hosted_mode_is_unchanged() -> None: + """13.6: same broker, same pinned issuer, loaded exactly as before.""" + for env in (HOSTED, {**HOSTED, MODE: "hosted"}): + settings = load_settings(env) + assert settings.install_mode == INSTALL_MODE_HOSTED + assert settings.oidc_issuer == HOSTED[PREFIX + "OIDC_ISSUER"] + assert settings.oidc_audience == "fixture" + assert settings.jwks_url() == (HOSTED[PREFIX + "OIDC_ISSUER"] + + "/protocol/openid-connect/certs") + # and a hosted install may still bind wherever its operator says + assert load_settings({**HOSTED, PREFIX + "BIND_HOST": "0.0.0.0"} + ).bind_host == "0.0.0.0" + # and it still refuses a missing audience, as it always did + env = {k: v for k, v in HOSTED.items() if k != PREFIX + "OIDC_AUDIENCE"} + assert PREFIX + "OIDC_AUDIENCE" in _refusal(env) + + +# -- 13.4: the local install ------------------------------------------------- + + +@pytest.mark.parametrize("selection", ["setting", "flag"]) +def test_a_local_install_needs_no_broker(selection: str) -> None: + env = dict(DSNS) + kwargs = {} + if selection == "setting": + env[MODE] = "local" + else: + kwargs["local_flag"] = True + settings = load_settings(env, **kwargs) + assert settings.install_mode == INSTALL_MODE_LOCAL + assert settings.oidc_issuer == "" + assert settings.oidc_audience == "" + assert settings.oidc_jwks_url is None + # no endpoint is derived from an issuer that does not exist + assert settings.jwks_url() == "" + assert settings.discovery_url() == "" + assert "install_mode='local'" in repr(settings) + + +@pytest.mark.parametrize("name", HOSTED_ONLY_SETTINGS) +def test_a_broker_setting_beside_the_local_mode_is_refused_by_name( + name: str) -> None: + """A holder reading (#656, T070): a broker setting says hosted was meant.""" + assert set(HOSTED_ONLY_SETTINGS) == {PREFIX + "OIDC_ISSUER", + PREFIX + "OIDC_AUDIENCE", + PREFIX + "OIDC_JWKS_URL"} + secret = "https://svc:hunter2@broker.example.invalid/realms/x" + message = _refusal({**DSNS, MODE: "local", name: secret}) + assert name in message, message + assert "hunter2" not in message, "the value must not be repeated" + # and the flag spelling of the same selection refuses it the same way + assert name in _refusal({**DSNS, name: secret}, local_flag=True) + + +def test_every_broker_setting_given_is_named_at_once() -> None: + env = {**HOSTED, MODE: "local"} + message = _refusal(env) + for name in (PREFIX + "OIDC_ISSUER", PREFIX + "OIDC_AUDIENCE"): + assert name in message, message + + +@pytest.mark.parametrize("host", sorted(LOCAL_BIND_HOSTS)) +def test_a_local_install_binds_each_loopback_spelling(host: str) -> None: + settings = load_settings({**DSNS, MODE: "local", + PREFIX + "BIND_HOST": host}) + assert settings.bind_host == host + + +@pytest.mark.parametrize("host", ["0.0.0.0", "::", "192.0.2.10", + "127.0.0.2", "example.invalid"]) +def test_a_local_install_refuses_a_non_loopback_bind_naming_the_rule( + host: str) -> None: + """13.4: loopback ONLY, and no opt-in. `127.0.0.2` is refused too: the + document server does not treat it as loopback (`serve.LOOPBACK_HOSTS`), + and the mode makes the SAME judgement at its own boundary.""" + message = _refusal({**DSNS, MODE: "local", PREFIX + "BIND_HOST": host}) + assert PREFIX + "BIND_HOST" in message, message + assert "loopback" in message.lower(), message + assert "no opt-in" in message.lower(), message + + +# -- the runtime CLI under the two modes -------------------------------------- + + +def test_runtime_serve_refuses_under_the_local_mode(scrubbed) -> None: + """A holder reading (#656, T070): the API's identity is the broker's. + + uvicorn and the application are STUBBED, so that a regression which + served anyway returns at once — and fails the assertions below — instead + of binding a real listener and blocking the suite forever (measured: the + un-stubbed form of this case hung under exactly that mutant). + """ + import sys + import types + + from opendox.runtime import app as app_module + + served = [] + + class _Config: + def __init__(self, app: object, **kwargs: object) -> None: + pass + + class _Server: + def __init__(self, config: object) -> None: + self.started = False + + def run(self) -> None: + served.append(True) + self.started = True + + stub = types.ModuleType("uvicorn") + stub.Config, stub.Server = _Config, _Server + scrubbed.setitem(sys.modules, "uvicorn", stub) + scrubbed.setattr(app_module, "create_app", lambda **kwargs: object()) + for name, value in DSNS.items(): + scrubbed.setenv(name, value) + scrubbed.setenv(MODE, "local") + code, evidence = _run(["runtime", "serve"]) + assert served == [], "the API was started for a LOCAL install" + assert code == 1 + assert evidence["ok"] is False + assert evidence["refusal"] == "local-mode-has-no-broker", evidence + assert f"generate-and-open {LOCAL_FLAG}" in evidence["message"] + + +def test_runtime_status_under_the_local_mode_probes_no_broker( + scrubbed, monkeypatch: pytest.MonkeyPatch) -> None: + """`broker_keys` is reported as not configured, and the verifier is never + built: a local install has no broker to reach, and a status verb that + called that a fault would exit nonzero for a healthy install.""" + from opendox.runtime import oidc + + def _no_broker(_settings): + raise AssertionError("status built a broker verifier for a LOCAL " + "install, which has no broker") + + monkeypatch.setattr(oidc, "build_verifier", _no_broker) + for name, value in DSNS.items(): + scrubbed.setenv(name, value) + scrubbed.setenv(MODE, "local") + code, evidence = _run(["runtime", "status", "--probe-timeout", "0.2"]) + assert evidence.get("refusal") is None, evidence + assert evidence["broker_keys"] == "not configured (local mode)" + assert evidence["broker_discovery"] is None + assert evidence["settings"][MODE] == INSTALL_MODE_LOCAL + assert evidence["settings"][PREFIX + "OIDC_ISSUER"] == "" + assert evidence["settings"][PREFIX + "OIDC_JWKS_URL"] == "" + # the database half (port 1, unreachable) is the ONLY reason `ok` is false + assert evidence["database"].startswith("unreachable"), evidence + assert code == 1 + + +@pytest.mark.parametrize("verb", [["runtime", "migrate"], + ["runtime", "reset", "--confirm", + cli.RESET_CONFIRMATION]]) +@pytest.mark.parametrize("name", [PREFIX + "OIDC_ISSUER", + PREFIX + "OIDC_AUDIENCE", + PREFIX + "OIDC_JWKS_URL"]) +def test_migrate_and_reset_refuse_a_broker_setting_beside_the_local_mode( + scrubbed, verb: list[str], name: str) -> None: + """The migration loader asks what every other loader asks of `local` + (Copilot review of openDox-code#67): refused at CONFIGURATION, before any + database is reached — `reset` included, confirmation and all.""" + scrubbed.setenv(PREFIX + "MIGRATION_DATABASE_URL", DSNS[PREFIX + "MIGRATION_DATABASE_URL"]) + scrubbed.setenv(MODE, "local") + scrubbed.setenv(name, "https://issuer.example.invalid/realms/x" + if name != PREFIX + "OIDC_AUDIENCE" else "fixture") + code, evidence = _run(verb) + assert code == 1 + assert evidence["refusal"] == "configuration", evidence + assert name in evidence["message"], evidence + + +def test_migrate_refuses_a_non_loopback_bind_beside_the_local_mode( + scrubbed) -> None: + scrubbed.setenv(PREFIX + "MIGRATION_DATABASE_URL", DSNS[PREFIX + "MIGRATION_DATABASE_URL"]) + scrubbed.setenv(MODE, "local") + scrubbed.setenv(PREFIX + "BIND_HOST", "0.0.0.0") + code, evidence = _run(["runtime", "migrate"]) + assert code == 1 and evidence["refusal"] == "configuration", evidence + assert "loopback" in evidence["message"].lower(), evidence + + +def test_runtime_status_of_a_healthy_local_install_exits_zero( + scrubbed, monkeypatch: pytest.MonkeyPatch, postgres_dsn: str, + database) -> None: + """THE EXIT CODE IS THE DATABASE'S VERDICT ALONE (Copilot review of + openDox-code#67). A migrated, reachable database is the only thing a + local install's `status` needs, and with it the verb exits 0. The broker + is reported as not configured and is never probed, so F13.1's `set -e` + survives. The other local cases force a database fault and exit 1, so + they cannot tell a broker counted as a fault from a database that failed. + """ + from opendox.runtime import oidc + + def _no_broker(_settings): + raise AssertionError("status built a broker verifier for a LOCAL " + "install, which has no broker") + + monkeypatch.setattr(oidc, "build_verifier", _no_broker) + joiner = "&" if "?" in postgres_dsn else "?" + scrubbed.setenv(PREFIX + "DATABASE_URL", + f"{postgres_dsn}{joiner}options=-c%20search_path%3D" + f"{database.schema}%2Cpublic") + scrubbed.setenv(MODE, "local") + code, evidence = _run(["runtime", "status", "--probe-timeout", "5"]) + assert evidence["database"] == "reachable", evidence + assert evidence["pending_migrations"] == [], evidence + assert not evidence["migration_drift"], evidence + assert evidence["broker_keys"] == "not configured (local mode)", evidence + assert evidence["broker_discovery"] is None + assert evidence["ok"] is True and code == 0, evidence + + +@pytest.mark.parametrize("mode", [INSTALL_MODE_LOCAL, INSTALL_MODE_HOSTED]) +def test_runtime_status_without_the_runtime_extra_reports_the_broker_by_mode( + scrubbed, mode: str) -> None: + """`status` returns early when the runtime extra is absent, and that + return gives the local broker the same answer the full report gives + (Copilot review of openDox-code#67). A hosted install's broker reads + "not probed", unchanged (13.6). `None` in `sys.modules` is how an absent + module is simulated: the import raises `ImportError`, as it would without + the extra.""" + import sys + + scrubbed.setitem(sys.modules, "opendox.runtime.db", None) + for name, value in (HOSTED if mode == INSTALL_MODE_HOSTED else DSNS).items(): + scrubbed.setenv(name, value) + scrubbed.setenv(MODE, mode) + code, evidence = _run(["runtime", "status", "--probe-timeout", "0.2"]) + assert evidence["runtime_extra"].startswith("absent"), evidence + assert evidence["database"] == "not probed", evidence + assert code == 1 + if mode == INSTALL_MODE_LOCAL: + assert evidence["broker_keys"] == "not configured (local mode)", evidence + assert "broker_discovery" in evidence, evidence + assert evidence["broker_discovery"] is None + else: + assert evidence["broker_keys"] == "not probed", evidence + assert "broker_discovery" not in evidence, evidence + + +def test_runtime_status_reports_the_hosted_mode_it_loaded(scrubbed) -> None: + for name, value in HOSTED.items(): + scrubbed.setenv(name, value) + _code, evidence = _run(["runtime", "status", "--probe-timeout", "0.2"]) + assert evidence["settings"][MODE] == INSTALL_MODE_HOSTED + assert evidence["broker_keys"].startswith("unreachable"), evidence