diff --git a/deployments/local-mundus.yml b/deployments/local-mundus.yml index d39afcff7..9273ced18 100644 --- a/deployments/local-mundus.yml +++ b/deployments/local-mundus.yml @@ -83,9 +83,23 @@ mundus: codices: [] # --- Stage 3: verification --------------------------------------------------- +# +# `integration_tests` is consumed by .github/workflows/_verify-mundus.yml +# (run as part of ci-main.yml after install-mundus). The local PR +# pipeline (ci-pr.yml) calls scripts/ci_install_mundus.py which does +# NOT execute these tests today — they're kept here as the canonical +# manifest of which tests cover the layered local install, and they +# will start running automatically when ci-pr.yml is wired through +# _verify-mundus.yml. Until then, these doubly serve as the recommended +# `pytest` command for anyone bringing up a local realm by hand. verify: e2e_specs: - src/realm_frontend/tests/e2e/specs/layered-parity.spec.ts integration_tests: - - tests/backend/test_status_api.py - - tests/backend/test_extensions_api.py + - tests/integration/test_status_api.py + - tests/integration/test_extensions_api.py + # End-to-end coverage for the invitation-link → join_realm_with_invite + # flow. Skips itself when admin_dashboard is not installed (see + # the test's _has_admin_dashboard_installed guard), so it is safe + # to keep in the list even on minimal mundus installs. + - tests/integration/test_invitations_join.py diff --git a/extensions b/extensions index c063df6d5..606f252aa 160000 --- a/extensions +++ b/extensions @@ -1 +1 @@ -Subproject commit c063df6d58820194fe05063d2038c58684fab48e +Subproject commit 606f252aa1f7074160af23004cb616ad6ab0960f diff --git a/src/realm_backend/ggg/governance/realm.py b/src/realm_backend/ggg/governance/realm.py index 1d0ccd751..cdc8718f8 100644 --- a/src/realm_backend/ggg/governance/realm.py +++ b/src/realm_backend/ggg/governance/realm.py @@ -42,3 +42,13 @@ class Realm(Entity, TimestampedMixin): # Comma-separated canister principal IDs trusted for inter-canister calls # (DAO controllers, AI agents, parent realms). These bypass User-based access checks. trusted_principals = String(max_length=2048, default="") + # Principal of the human that triggered this realm's creation. Set once + # at deploy time by the canister controller (typically the + # canister-management service deploying on the human's behalf, or + # the dfx identity for manual deploys) via set_creator_principal. + # Used to authorize the bootstrap-admin path in join_realm: the + # creator principal is allowed to claim the first 'admin' profile + # on a fresh realm, even when they are not themselves a canister + # controller. After that first admin exists, every subsequent + # admin claim requires a valid invitation code. + creator_principal = String(max_length=64, default="") diff --git a/src/realm_backend/main.py b/src/realm_backend/main.py index c18fa9e36..c74bdb1df 100644 --- a/src/realm_backend/main.py +++ b/src/realm_backend/main.py @@ -429,10 +429,169 @@ def _assign_quarter(principal: str, realm, quarters, preferred_quarter: str) -> return active_quarters[idx].canister_id -@update -def join_realm(profile: str, preferred_quarter: text) -> RealmResponse: +def _has_any_admin_user() -> bool: + """True if at least one User in the realm has the `admin` profile. + + Used by the bootstrap-admin path of ``join_realm``: on a freshly + deployed realm with zero admins, the canister controller is allowed + to claim the ``admin`` profile without an invite code. Once an + admin exists, every subsequent ``admin`` join requires a valid + invitation. + """ + try: + from ggg import User + from ggg.system.user_profile import Operations + + for u in User.instances(): + for p in u.profiles: + allowed = str(p.allowed_to or "").split(",") + if Operations.ALL in allowed: + return True + return False + except Exception: + return False + + +def _consume_invite_code(invite_code: str, caller: str) -> dict: + """Atomically validate and redeem an invite code via the + admin_dashboard extension. Returns the parsed extension result. + + Returns ``{"success": False, "error": "..."}`` if the extension is + not available so callers can decide how to handle the failure. + """ + if not invite_code: + return {"success": False, "error": "No invite code provided"} + try: + import json as _json + + raw = api.extensions.extension_sync_call( + "admin_dashboard", + "consume_registration_code", + _json.dumps({"code": invite_code, "principal": caller}), + ) + if isinstance(raw, str): + try: + return _json.loads(raw) + except Exception: + return {"success": False, "error": str(raw)} + if isinstance(raw, dict): + return raw + return {"success": False, "error": "Unexpected extension response"} + except Exception as e: + logger.error( + f"Error consuming invite code via admin_dashboard: {e}\n{traceback.format_exc()}" + ) + return {"success": False, "error": f"Invitation system unavailable: {e}"} + + +def _do_join_realm( + profile: str, preferred_quarter: text, invite_code: str +) -> RealmResponse: + """Internal join-realm worker shared by ``join_realm`` and + ``join_realm_with_invite``. + + Profile rules: + * ``member``: anyone authenticated can self-join as a member, no + invitation required. + * ``admin``: only allowed when **one** of these conditions holds: + 1. There are no admin Users yet **and** the caller is the + canister controller (bootstrap admin — typically the realm + creator from the registry). + 2. The caller presented a valid, non-expired, non-revoked + ``invite_code`` whose ``profile`` is ``"admin"``. The code + is atomically consumed by ``admin_dashboard``'s + ``consume_registration_code`` extension method. + + * Any other (non-``member``, non-``admin``) profile is treated as + a free member-equivalent today, but if an ``invite_code`` is + supplied it must match. + + When ``invite_code`` is non-empty it is consumed regardless of the + requested ``profile``: the granted profile is the one stored on the + code, not the one the caller asked for. + """ try: - user = user_register(ic.caller().to_str(), profile) + caller = ic.caller().to_str() + + granted_profile = profile or "member" + + if invite_code: + consumption = _consume_invite_code(invite_code, caller) + if not consumption.get("success"): + return RealmResponse( + success=False, + data=RealmResponseData( + error=consumption.get("error") or "Invalid invitation code" + ), + ) + granted_profile = ( + consumption.get("data", {}).get("profile") or "member" + ) + elif granted_profile == "admin": + # Bootstrap-admin path. Allowed only when the realm has zero + # admin Users yet AND the caller can be identified as the + # legitimate creator. Three resolution rules, in order: + # + # 1. Realm.creator_principal is set and matches the caller. + # This is the registry-driven flow: canister-management + # calls set_creator_principal() right + # after install, recording the human's principal that + # will be presented when they sign in to the realm. + # + # 2. ic.is_controller(caller) is true. Manual dfx deploy + # path — the developer that ran `dfx canister install` + # is automatically a controller and can self-promote + # to admin once. Also covers any future deployment + # scheme where the canister-management service deploys + # AND remains a controller AND happens to be the same + # principal that joins (rare but legal). + # + # 3. Init-time controller fallback for older basilisk + # runtimes that don't expose ic.is_controller. + from core.access import _controller_principal + from ggg import Realm + + try: + is_controller = ic.is_controller(ic.caller()) + except Exception: + is_controller = False + is_init_controller = bool( + _controller_principal and caller == _controller_principal + ) + realm_entity = Realm.load("1") + creator_principal = ( + getattr(realm_entity, "creator_principal", "") or "" + if realm_entity + else "" + ) + is_creator = bool(creator_principal) and caller == creator_principal + + if _has_any_admin_user(): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "An admin invitation code is required to join as " + "Administrator. Ask an existing administrator to " + "send you an invite link." + ) + ), + ) + if not (is_creator or is_controller or is_init_controller): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "Only the realm creator can claim the bootstrap " + "administrator role on a fresh realm. If the " + "realm was deployed by canister-management on " + "your behalf, ask the registry to issue you a " + "bootstrap admin invitation link." + ) + ), + ) + + user = user_register(caller, granted_profile) profiles = Vec[text]() if "profiles" in user and user["profiles"]: for p in user["profiles"]: @@ -446,10 +605,10 @@ def join_realm(profile: str, preferred_quarter: text) -> RealmResponse: quarters = list(Quarter.instances()) if realm else [] if realm and quarters: assigned_quarter_canister_id = _assign_quarter( - ic.caller().to_str(), realm, quarters, preferred_quarter + caller, realm, quarters, preferred_quarter ) # Persist the assignment on the User entity - u = User[ic.caller().to_str()] + u = User[caller] if u and assigned_quarter_canister_id: u.home_quarter = assigned_quarter_canister_id @@ -471,6 +630,223 @@ def join_realm(profile: str, preferred_quarter: text) -> RealmResponse: return RealmResponse(success=False, data=RealmResponseData(error=str(e))) +@update +def join_realm(profile: str, preferred_quarter: text) -> RealmResponse: + """Backwards-compatible 2-arg join. See ``_do_join_realm`` for full + semantics. Equivalent to ``join_realm_with_invite(profile, + preferred_quarter, "")``: anyone can self-join as a member, and the + ``admin`` profile is only granted on the bootstrap path (no admin + Users yet AND caller is the canister controller). + """ + return _do_join_realm(profile, preferred_quarter, "") + + +@update +def join_realm_with_invite( + profile: str, preferred_quarter: text, invite_code: text +) -> RealmResponse: + """Join the realm using an invitation code (member or admin). + + The invitation code is atomically consumed by the + ``admin_dashboard`` extension's ``consume_registration_code`` call. + The granted profile is the one stored on the code, regardless of + the requested ``profile``. + """ + return _do_join_realm(profile, preferred_quarter, invite_code or "") + + +def _caller_is_canister_controller() -> bool: + """True if the calling principal is registered as a controller of + this canister (via IC management settings) or matches the init-time + controller principal captured at install time.""" + from core.access import _controller_principal + + caller = ic.caller().to_str() + try: + if ic.is_controller(ic.caller()): + return True + except Exception: + pass + return bool(_controller_principal) and caller == _controller_principal + + +@update +def set_creator_principal(principal: text) -> RealmResponse: + """Record the principal of the human that triggered this realm's creation. + + Intended to be called exactly once, by a canister controller, + immediately after install — typically by the canister-management + service that deployed the realm on the human's behalf. The principal + written here is the **realm-side** Internet Identity principal the + human will present when they sign in to the deployed realm (which, + because II principals are per-origin, is generally different from + the principal the same human had on the registry frontend). + + Once set, the value can be re-affirmed (no-op when called again + with the same principal) but cannot be silently overwritten: + re-pointing the creator at a different principal is rejected to + prevent a compromised controller from quietly transferring + bootstrap-admin rights. To genuinely change the creator, the new + creator must first claim admin (e.g. via an invite minted by a + current admin) and the realm operators can then update the field + through governance. + """ + try: + if not principal or not principal.strip(): + return RealmResponse( + success=False, + data=RealmResponseData(error="principal must not be empty"), + ) + if not _caller_is_canister_controller(): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "Only a canister controller can set the realm " + "creator principal." + ) + ), + ) + + from ggg import Realm + + realm = Realm.load("1") + if not realm: + return RealmResponse( + success=False, + data=RealmResponseData(error="Realm entity not initialized"), + ) + + existing = (getattr(realm, "creator_principal", "") or "").strip() + new_value = principal.strip() + if existing and existing != new_value: + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "creator_principal is already set to a different " + "principal; refusing to overwrite. Use governance " + "to change the realm creator." + ) + ), + ) + + realm.creator_principal = new_value + return RealmResponse( + success=True, + data=RealmResponseData(message=new_value), + ) + except Exception as e: + logger.error( + f"Error in set_creator_principal: {str(e)}\n{traceback.format_exc()}" + ) + return RealmResponse(success=False, data=RealmResponseData(error=str(e))) + + +@update +def mint_bootstrap_admin_invite(expires_in_hours: nat) -> RealmResponse: + """Mint a single-use admin invitation for the realm's bootstrap admin. + + Callable only by a canister controller, only while the realm has + zero admin Users. Designed to be invoked by the canister-management + service immediately after install: the returned URL is what the + registry hands the human as their "Claim your realm" button. + + The plaintext invitation code is generated inside the + ``admin_dashboard`` extension, returned **once** in this response so + the caller can build the URL it needs to display, and is never + persisted in canister state (only its SHA-256 hash is stored). + Subsequent attempts to mint a bootstrap invite once an admin + already exists are rejected — by then admins should be minting + further invites through ``admin_dashboard`` themselves. + + Args: + expires_in_hours: Lifetime of the invite. Defaults to 24 when 0. + """ + try: + if not _caller_is_canister_controller(): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "Only a canister controller can mint a bootstrap " + "admin invitation." + ) + ), + ) + if _has_any_admin_user(): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "Realm already has at least one admin; mint " + "further admin invites through admin_dashboard." + ) + ), + ) + + import json as _json + + ttl = int(expires_in_hours) if expires_in_hours else 24 + args = _json.dumps( + { + "user_id": "realm_creator", + "profile": "admin", + "max_uses": 1, + "expires_in_hours": ttl, + "frontend_url": "", + "created_by": ic.caller().to_str(), + } + ) + raw = api.extensions.extension_sync_call( + "admin_dashboard", "generate_registration_url", args + ) + if isinstance(raw, str): + try: + ext_response = _json.loads(raw) + except Exception: + ext_response = {"success": False, "error": str(raw)} + elif isinstance(raw, dict): + ext_response = raw + else: + ext_response = {"success": False, "error": "Unexpected extension response"} + + if not ext_response.get("success"): + return RealmResponse( + success=False, + data=RealmResponseData( + error=ext_response.get("error") + or "admin_dashboard rejected the bootstrap invite" + ), + ) + + # The plaintext code lives in ext_response["data"]["code"] and + # is intentionally returned to the controller exactly once. The + # realm canister itself never persists it. We pack the relevant + # fields into the message field for convenience; the controller + # parses this JSON to extract the code / URL. + payload = ext_response.get("data") or {} + return RealmResponse( + success=True, + data=RealmResponseData( + message=_json.dumps( + { + "code": payload.get("code", ""), + "code_hash": payload.get("code_hash", ""), + "expires_at": payload.get("expires_at", ""), + "profile": payload.get("profile", "admin"), + } + ) + ), + ) + except Exception as e: + logger.error( + f"Error in mint_bootstrap_admin_invite: {str(e)}\n" + f"{traceback.format_exc()}" + ) + return RealmResponse(success=False, data=RealmResponseData(error=str(e))) + + @update @require(Operations.SELF_CHANGE_QUARTER) def change_quarter(new_quarter_canister_id: text) -> RealmResponse: diff --git a/src/realm_frontend/src/lib/realm_backend.did.js b/src/realm_frontend/src/lib/realm_backend.did.js index 74e1d445c..fb20f678d 100644 --- a/src/realm_frontend/src/lib/realm_backend.did.js +++ b/src/realm_frontend/src/lib/realm_backend.did.js @@ -113,6 +113,9 @@ service : () -> { initialize : () -> (); change_quarter : (text) -> (RealmResponse); join_realm : (text, text) -> (RealmResponse); + join_realm_with_invite : (text, text, text) -> (RealmResponse); + set_creator_principal : (text) -> (RealmResponse); + mint_bootstrap_admin_invite : (nat) -> (RealmResponse); list_extensions : (text) -> (RealmResponse) query; register_realm_with_registry : (text, text, text, text) -> (text); refresh_invoice : (text) -> (text); diff --git a/src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte b/src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte index 754ad51a6..5109bdb70 100644 --- a/src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte +++ b/src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte @@ -19,6 +19,13 @@ let loading = false; let realmName = 'Realm'; let selectedProfile = ''; // No default - user must choose + + // Invitation state (populated from ?invite= in the URL) + let inviteCode = ''; + let inviteValidating = false; + let inviteProfile = ''; // 'member' | 'admin' | '' (no/invalid invite) + let inviteInfo = null; // raw payload from validate_registration_code + let inviteError = ''; // human-readable rejection reason if invalid // Available profiles with icon names (rendered as SVGs) const allProfiles = [ @@ -36,8 +43,26 @@ }, ]; - // Only show admin profile when TEST_MODE_ADMIN_SELF_REGISTRATION is active - $: profiles = allProfiles.filter(p => p.value !== 'admin' || TEST_MODE_ADMIN_SELF_REGISTRATION); + // Reactive list of profile cards to render in the wizard. + // - With a valid invite, only the card matching the invite's role is shown. + // - Without an invite, only "Member" is shown by default. The "Administrator" + // card is additionally shown when TEST_MODE_ADMIN_SELF_REGISTRATION is on + // (legacy dev/test path). + $: profiles = (() => { + if (inviteProfile) { + return allProfiles.filter(p => p.value === inviteProfile); + } + if (TEST_MODE_ADMIN_SELF_REGISTRATION) { + return allProfiles; + } + return allProfiles.filter(p => p.value === 'member'); + })(); + + // Auto-select the only available profile so the user doesn't have to click + // when there is exactly one option (the common case once we filter by invite). + $: if (profiles.length === 1 && selectedProfile !== profiles[0].value) { + selectedProfile = profiles[0].value; + } // Default fallback image if realm has no welcome image configured const defaultWelcomeImage = '/images/default_welcome.jpg'; @@ -60,6 +85,49 @@ } } + /** + * Parse the `?invite=` URL parameter (with `?code=` accepted as a + * legacy alias) and ask the realm backend to validate the code through + * the admin_dashboard extension. On success, sets `inviteProfile` so + * the wizard renders only the matching profile card. + */ + async function loadInviteFromUrl() { + if (typeof window === 'undefined') return; + try { + const params = new URLSearchParams(window.location.search); + const code = params.get('invite') || params.get('code') || ''; + if (!code) return; + + inviteCode = code; + inviteValidating = true; + const response = await backend.extension_sync_call({ + extension_name: 'admin_dashboard', + function_name: 'validate_registration_code', + args: JSON.stringify({ code }) + }); + let result; + try { + result = JSON.parse(response.response); + } catch (e) { + console.warn('[JOIN PAGE] Could not parse invite validation response', e); + return; + } + if (result && result.success) { + inviteInfo = result.data || {}; + inviteProfile = inviteInfo.profile || 'member'; + inviteError = ''; + } else { + inviteError = (result && result.error) || 'Invitation code is not valid'; + inviteProfile = ''; + inviteInfo = null; + } + } catch (e) { + console.warn('[JOIN PAGE] Invite validation failed:', e); + } finally { + inviteValidating = false; + } + } + onMount(async () => { console.log('[JOIN PAGE v2] onMount - isAuthenticated:', $isAuthenticated); // Fetch realm info @@ -67,6 +135,10 @@ if ($realmNameStore) { realmName = $realmNameStore; } + + // Validate any ?invite= in the URL up-front so the profile step + // already knows which card to render the moment the user gets there. + await loadInviteFromUrl(); // In test mode with II bypass, auto-login if not already authenticated if (TEST_MODE_II_BYPASS && !$isAuthenticated) { @@ -134,9 +206,16 @@ try { loading = true; - console.log(`Joining realm with profile: ${selectedProfile}`); - // Step 1: Register on the capital (current backend) — gets quarter assignment - const response = await backend.join_realm(selectedProfile, ''); + console.log( + `Joining realm with profile: ${selectedProfile}` + + (inviteCode ? ` (invite: ${inviteCode})` : '') + ); + // Step 1: Register on the capital (current backend) — gets quarter assignment. + // When an invite code was presented in the URL, use the invite-aware + // endpoint so the realm canister can verify and consume the code. + const response = inviteCode + ? await backend.join_realm_with_invite(selectedProfile, '', inviteCode) + : await backend.join_realm(selectedProfile, ''); if (response.success) { // Step 2: If assigned to a quarter, switch to it and register there too const assignedQuarter = response.data?.userGet?.assigned_quarter; @@ -145,7 +224,10 @@ activeQuarterId.set(assignedQuarter); await setActiveQuarter(assignedQuarter); - // Register on the assigned quarter backend + // Register on the assigned quarter backend. + // The invite has already been consumed on the capital; on the + // quarter we just record membership with the same profile and + // never re-attempt to consume the (now-used) code. try { await backend.join_realm(selectedProfile, ''); console.log('Registered on assigned quarter'); @@ -388,10 +470,28 @@ {:else if currentStep === 'profile'}
-

Select Profile

+

+ {inviteProfile ? 'Confirm your role' : 'Select Profile'} +

{#if TEST_MODE}Test Mode{/if}
-

Choose how you want to participate

+ {#if inviteProfile} +

+ You've been invited to join {realmName} as + {inviteProfile === 'admin' ? 'an Administrator' : 'a Member'}. +

+ {:else if inviteError} +
+ Invitation problem: {inviteError}. + You can still join as a Member. +
+

Choose how you want to participate

+ {:else} +

+ Anyone can join {realmName} as a Member. To join as an Administrator + you need an invitation link from an existing administrator. +

+ {/if}
{#each profiles as profile} diff --git a/tests/integration/test_invitations_join.py b/tests/integration/test_invitations_join.py new file mode 100644 index 000000000..2e1c4adcb --- /dev/null +++ b/tests/integration/test_invitations_join.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +"""Integration tests for the invitation-link → join_realm_with_invite flow. + +Exercises the end-to-end contract introduced together with +``admin_dashboard``'s ``consume_registration_code`` endpoint and +``realm_backend.join_realm_with_invite``: + + 1. An admin mints a registration code via ``admin_dashboard``. + 2. ``validate_registration_code`` returns the same code's role. + 3. The realm backend's ``join_realm_with_invite`` calls into the + extension to atomically validate and consume the code, then + registers the caller as a User with the right profile. + +The tests run against a realm that already has ``admin_dashboard`` +installed. Locally that's whatever ``realms realm deploy`` brings up; +in CI we need a workflow that installs the extension before running +this file (see the test_environment_ready guard below). + +Following the convention of the other ``tests/integration/*.py`` +scripts, this is a plain Python script (no pytest), invoked via +``tests/integration/run_tests.sh``. +""" + +import json +import os +import sys +import traceback + +# Allow `from fixtures.dfx_helpers import …` when run from the repo root. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from fixtures.dfx_helpers import dfx_call, dfx_call_json # noqa: E402 + + +REALM_BACKEND = os.environ.get("REALM_BACKEND_CANISTER", "realm_backend") +ADMIN_DASHBOARD_EXTENSION = "admin_dashboard" + + +# --------------------------------------------------------------------------- +# Low-level helpers +# --------------------------------------------------------------------------- + + +def _candid_quote(s: str) -> str: + """Quote a Python string for use inside a Candid `text` arg.""" + return s.replace("\\", "\\\\").replace('"', '\\"') + + +def _extension_call(method: str, args: dict) -> dict: + """Call an admin_dashboard method through realm_backend.extension_sync_call. + + Returns the parsed JSON response from the extension. Raises on dfx + failure or if the extension says ``success=false``. + """ + args_json = json.dumps(args) + candid_args = ( + f'(record {{ extension_name = "{ADMIN_DASHBOARD_EXTENSION}"; ' + f'function_name = "{method}"; ' + f'args = "{_candid_quote(args_json)}" }})' + ) + response = dfx_call_json( + REALM_BACKEND, "extension_sync_call", candid_args, is_update=True + ) + + if not response.get("success"): + raise AssertionError( + f"extension_sync_call wrapper reported success=false: {response}" + ) + + inner_text = response.get("response", "") + try: + inner = json.loads(inner_text) + except json.JSONDecodeError as e: + raise AssertionError( + f"Could not parse extension response as JSON: {e}\n" + f"Raw response: {inner_text!r}" + ) + return inner + + +def _join_with_invite(profile: str, invite_code: str) -> dict: + """Call realm_backend.join_realm_with_invite and return the parsed RealmResponse.""" + return dfx_call_json( + REALM_BACKEND, + "join_realm_with_invite", + f'("{profile}", "", "{_candid_quote(invite_code)}")', + is_update=True, + ) + + +def _set_creator_principal(principal: str) -> dict: + return dfx_call_json( + REALM_BACKEND, + "set_creator_principal", + f'("{_candid_quote(principal)}")', + is_update=True, + ) + + +def _mint_bootstrap_admin_invite(expires_in_hours: int = 24) -> dict: + return dfx_call_json( + REALM_BACKEND, + "mint_bootstrap_admin_invite", + f"({int(expires_in_hours)} : nat)", + is_update=True, + ) + + +def _get_my_principal() -> str: + """Return the principal the realm currently sees as the caller.""" + out, code = dfx_call(REALM_BACKEND, "get_my_principal", "()") + if code != 0: + raise RuntimeError(f"get_my_principal failed: {out}") + # Candid output for a query returning text looks like: ("aaaaa-aa") + text = out.strip() + if text.startswith("(") and text.endswith(")"): + text = text[1:-1].strip() + if text.startswith('"') and text.endswith('"'): + text = text[1:-1] + return text + + +def _has_admin_dashboard_installed() -> bool: + """Return True when extension_sync_call to admin_dashboard responds. + + Used to skip the entire suite gracefully on test harnesses that do + not install ``admin_dashboard`` (for example, the current PR CI + descriptor sets ``artifacts.extensions: []``). + """ + try: + _extension_call("get_entity_types", {}) + return True + except Exception as e: + print(f" [SKIP] admin_dashboard not callable: {e}") + return False + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- + + +def test_environment_ready(): + """Sanity check: the realm canister exists and admin_dashboard answers.""" + print(" - test_environment_ready...", end=" ") + output, code = dfx_call(REALM_BACKEND, "status", "()", output_json=False) + assert code == 0, f"status failed (rc={code}): {output}" + if not _has_admin_dashboard_installed(): + print("SKIP") + raise _Skip("admin_dashboard extension not installed in this realm") + print("✓") + + +def test_mint_member_invite_returns_member_profile(): + """generate_registration_url with profile=member round-trips.""" + print(" - test_mint_member_invite_returns_member_profile...", end=" ") + result = _extension_call( + "generate_registration_url", + { + "user_id": "ittest_member_invitee", + "created_by": "test", + "frontend_url": "http://localhost:8000", + "expires_in_hours": 24, + "profile": "member", + "max_uses": 1, + }, + ) + assert result.get("success"), f"mint failed: {result}" + data = result.get("data", {}) + assert data.get("profile") == "member", data + assert data.get("code"), data + assert data.get("code_hash"), ( + f"Expected code_hash in mint response (used by admin UI for revoke " + f"without the plaintext): {data}" + ) + assert data["code"] != data["code_hash"], ( + f"Plaintext code and code_hash must differ: {data}" + ) + assert data.get("registration_url", "").endswith(f"/join?invite={data['code']}"), ( + data.get("registration_url"), + ) + print("✓") + + +def test_listing_does_not_leak_plaintext_code(): + """get_registration_codes must expose only code_hash, never plaintext.""" + print(" - test_listing_does_not_leak_plaintext_code...", end=" ") + # Mint at least one so the listing is non-empty. + _extension_call( + "generate_registration_url", + { + "user_id": "ittest_no_leak", + "created_by": "test", + "frontend_url": "http://localhost:8000", + "expires_in_hours": 24, + "profile": "member", + "max_uses": 1, + }, + ) + listing = _extension_call( + "get_registration_codes", {"include_used": True, "include_revoked": True} + ) + assert listing.get("success"), listing + rows = listing.get("data") or [] + leaked = [r for r in rows if r.get("code") or r.get("registration_url")] + assert not leaked, ( + f"Listing leaked plaintext for {len(leaked)} row(s); only code_hash " + f"should be returned. Sample: {leaked[:1]}" + ) + print("✓") + + +def test_validate_returns_admin_profile(): + """A freshly-minted admin invite reports profile=admin via validate.""" + print(" - test_validate_returns_admin_profile...", end=" ") + minted = _extension_call( + "generate_registration_url", + { + "user_id": "ittest_admin_validate", + "created_by": "test", + "frontend_url": "http://localhost:8000", + "expires_in_hours": 24, + "profile": "admin", + "max_uses": 1, + }, + ) + assert minted.get("success"), minted + code = minted["data"]["code"] + validated = _extension_call("validate_registration_code", {"code": code}) + assert validated.get("success"), validated + assert validated.get("data", {}).get("profile") == "admin", validated + print("✓") + + +def test_join_realm_with_invite_grants_invite_profile(): + """join_realm_with_invite consumes the code and grants the invite's role. + + Asserts the *intended* contract: + - on success, the response has success=true and the granted + profile (from the code, not from the requested ``profile`` arg) + is reflected in the returned ``userGet.profiles`` list. + - the same code cannot be consumed twice by the same caller. + """ + print(" - test_join_realm_with_invite_grants_invite_profile...", end=" ") + # Mint a member invite (to avoid the bootstrap-admin guard). + minted = _extension_call( + "generate_registration_url", + { + "user_id": "ittest_join_member", + "created_by": "test", + "frontend_url": "http://localhost:8000", + "expires_in_hours": 24, + "profile": "member", + "max_uses": 1, + }, + ) + assert minted.get("success"), minted + code = minted["data"]["code"] + + # join_realm_with_invite asks for "member"; the code is also "member". + response = _join_with_invite("member", code) + assert response.get("success"), response + profiles_granted = ( + response.get("data", {}).get("userGet", {}).get("profiles") or [] + ) + assert "member" in profiles_granted, ( + f"Expected 'member' in returned profiles; got {profiles_granted!r}" + ) + + # Second consume by the same principal must fail (single-use guard). + second = _join_with_invite("member", code) + assert not second.get("success"), ( + f"Second consume should be rejected, got: {second}" + ) + err = (second.get("data") or {}).get("error", "") + assert "code" in err.lower() or "invitation" in err.lower() or "redeem" in err.lower(), ( + f"Expected an invitation-related error message, got: {err!r}" + ) + print("✓") + + +def test_join_realm_without_invite_rejects_admin_when_admin_exists(): + """If the realm already has an admin, plain join_realm('admin', ...) must fail. + + (When no admin exists yet and the caller is the canister controller, + the bootstrap path applies — that's not what's tested here.) + """ + print(" - test_join_realm_without_invite_rejects_admin_when_admin_exists...", end=" ") + # Probe by *attempting* the call. We don't know whether a previous + # test already promoted somebody, so accept either outcome: + # - rejected with an "admin invitation" error (the new tightened + # behaviour we want to validate), or + # - accepted with success=true (which means the realm had no admin + # yet AND the test identity was the controller — bootstrap path). + # + # Either way, a *second* attempt must be rejected with the + # admin-invite-required error: by then there's at least one admin. + response = _join_with_invite("admin", "") + assert isinstance(response, dict), response + + second = _join_with_invite("admin", "") + assert not second.get("success"), ( + f"After bootstrap, plain admin self-join must be rejected; got: {second}" + ) + err = ((second.get("data") or {}).get("error") or "").lower() + assert "admin" in err and ("invit" in err or "invitation" in err), ( + f"Expected an 'admin invitation required' error; got: {err!r}" + ) + print("✓") + + +def test_invalid_invite_code_is_rejected(): + """Bogus invite codes must be rejected by join_realm_with_invite.""" + print(" - test_invalid_invite_code_is_rejected...", end=" ") + response = _join_with_invite("member", "this-code-does-not-exist-zzzz") + assert not response.get("success"), ( + f"Invalid invite should be rejected, got: {response}" + ) + print("✓") + + +def test_set_creator_principal_records_value(): + """set_creator_principal stores the value, is idempotent, but won't overwrite. + + Runs before any admin Users exist. Uses the current dfx identity's + principal as the creator (because in the integration harness the + dfx identity is the canister controller, so the call is allowed). + Re-setting to the same value succeeds; setting a different value + is rejected. + """ + print(" - test_set_creator_principal_records_value...", end=" ") + me = _get_my_principal() + first = _set_creator_principal(me) + assert first.get("success"), first + msg = (first.get("data") or {}).get("message", "") + assert msg == me, f"Expected message to echo the principal; got {msg!r}" + + same_again = _set_creator_principal(me) + assert same_again.get("success"), ( + f"Re-setting creator_principal to the same value must be idempotent; " + f"got: {same_again}" + ) + + # Pick a syntactically-valid different principal to attempt overwrite. + other = "aaaaa-aa" # IC management canister id, definitely != us + if other == me: + other = "2vxsx-fae" # arbitrary anonymous-ish principal + overwrite = _set_creator_principal(other) + assert not overwrite.get("success"), ( + f"set_creator_principal must refuse silent overwrite; got: {overwrite}" + ) + err = ((overwrite.get("data") or {}).get("error") or "").lower() + assert "already set" in err or "overwrite" in err, ( + f"Expected an 'already set / refusing to overwrite' error; got: {err!r}" + ) + print("✓") + + +def test_mint_bootstrap_admin_invite_when_no_admin_yet(): + """mint_bootstrap_admin_invite returns a usable single-use admin invite. + + Must run BEFORE any admin User exists in the realm. Asserts that the + response carries a plaintext code distinct from its hash (the + plaintext is returned exactly once, by design) and that the invite + can be redeemed via consume_registration_code for an admin profile. + """ + print(" - test_mint_bootstrap_admin_invite_when_no_admin_yet...", end=" ") + response = _mint_bootstrap_admin_invite(24) + assert response.get("success"), response + raw_message = (response.get("data") or {}).get("message", "") + try: + payload = json.loads(raw_message) + except json.JSONDecodeError as e: + raise AssertionError( + f"Could not parse mint_bootstrap_admin_invite payload: {e}\n" + f"Raw: {raw_message!r}" + ) + assert payload.get("profile") == "admin", payload + code = payload.get("code") + code_hash = payload.get("code_hash") + assert code, f"Expected plaintext code in payload; got: {payload}" + assert code_hash, f"Expected code_hash in payload; got: {payload}" + assert code != code_hash, ( + f"Plaintext and hash must differ: {payload}" + ) + + # Ask the extension to confirm the code resolves and carries the + # admin profile. We don't redeem it via join_realm_with_invite here + # because consuming it would create a User with the test caller as + # admin and pollute the rest of the suite — validate is enough. + validated = _extension_call("validate_registration_code", {"code": code}) + assert validated.get("success"), validated + assert validated.get("data", {}).get("profile") == "admin", validated + print("✓") + + +def test_mint_bootstrap_admin_invite_rejected_after_admin_exists(): + """Once an admin exists, mint_bootstrap_admin_invite must be refused. + + Must run AFTER ``test_join_realm_without_invite_rejects_admin_when_admin_exists``, + which is the test that actually creates the first admin via the + bootstrap path. Subsequent admin invites have to be minted by + existing admins through admin_dashboard, not via this convenience + endpoint. + """ + print( + " - test_mint_bootstrap_admin_invite_rejected_after_admin_exists...", + end=" ", + ) + response = _mint_bootstrap_admin_invite(24) + assert not response.get("success"), ( + f"Bootstrap mint must be rejected once admin exists; got: {response}" + ) + err = ((response.get("data") or {}).get("error") or "").lower() + assert "admin" in err and ("already" in err or "exist" in err), ( + f"Expected an 'already has admin' error; got: {err!r}" + ) + print("✓") + + +# --------------------------------------------------------------------------- +# Tiny custom skip mechanism (no pytest dependency on this script) +# --------------------------------------------------------------------------- + + +class _Skip(Exception): + pass + + +if __name__ == "__main__": + print("Testing invitation-link → join_realm_with_invite flow:") + tests = [ + test_environment_ready, + test_mint_member_invite_returns_member_profile, + test_listing_does_not_leak_plaintext_code, + test_validate_returns_admin_profile, + test_set_creator_principal_records_value, + test_mint_bootstrap_admin_invite_when_no_admin_yet, + test_join_realm_with_invite_grants_invite_profile, + test_join_realm_without_invite_rejects_admin_when_admin_exists, + test_mint_bootstrap_admin_invite_rejected_after_admin_exists, + test_invalid_invite_code_is_rejected, + ] + + failed = 0 + skipped = 0 + for t in tests: + try: + t() + except _Skip as s: + print(f" SKIPPED: {s}") + skipped += 1 + # If the environment is not ready, skip the rest too. + if t is test_environment_ready: + print("\nℹ️ Skipping remaining tests because admin_dashboard is unavailable.") + break + except AssertionError as e: + print("✗") + print(f" AssertionError: {e}") + failed += 1 + except Exception as e: + print("✗") + print(f" Error: {e}") + traceback.print_exc() + failed += 1 + + print() + if failed == 0: + print(f"✅ All tests passed (skipped: {skipped})") + sys.exit(0) + else: + print(f"❌ {failed} test(s) failed (skipped: {skipped})") + sys.exit(1)