diff --git a/.dockerignore b/.dockerignore index f677f92..9ec482f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -49,3 +49,9 @@ Dockerfile **/.mypy_cache/ **/.dmypy.json **/dmypy.json + +# Node / frontend +**/node_modules/ +**/dist/ +**/.__mf__temp/ +**/.vite/ diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index df3aa15..ac9d8da 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -44,8 +44,6 @@ jobs: pnpm-version: "11" working-directory: ui - name: Test with pytest - env: - SYNAPSE_REGISTRATION_SECRET: "ci-test-secret" # pragma: allowlist secret run: | uv run py.test -v --junitxml=pytest.xml - name: Publish Test Report diff --git a/.github/workflows/release_please.yml b/.github/workflows/release_please.yml index ba5e6c6..021a9a4 100644 --- a/.github/workflows/release_please.yml +++ b/.github/workflows/release_please.yml @@ -35,7 +35,6 @@ jobs: python-version: "3.12" - name: Generate and attach OpenAPI spec env: - SYNAPSE_REGISTRATION_SECRET: "ci-test-secret" # pragma: allowlist secret GH_TOKEN: ${{ github.token }} run: | uv run matrixrmapi openapi > openapi.json diff --git a/Dockerfile b/Dockerfile index bee0003..628aa79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -104,7 +104,6 @@ COPY --from=production_build /ui_build /ui_build COPY --from=production_build /docker-entrypoint.sh /docker-entrypoint.sh COPY --from=production_build /container-init.sh /container-init.sh COPY --from=ghcr.io/pvarki/kraftwerk-helper-tool:1.3.0-260513 /kw_product_init /kw_product_init -COPY --from=ghcr.io/pvarki/keycloak-helper-tool:1.1.2-260630 /kc_client_init /kc_client_init WORKDIR /app # Install system level deps for running the package (not devel versions for building wheels) # and install the wheels we built in the previous step. generate default config @@ -159,12 +158,16 @@ RUN --mount=type=ssh uv sync \ FROM devel_build as devel_shell # Copy everything to the image COPY --from=ghcr.io/pvarki/kraftwerk-helper-tool:1.3.0-260513 /kw_product_init /kw_product_init -COPY --from=ghcr.io/pvarki/keycloak-helper-tool:1.1.2-260630 /kc_client_init /kc_client_init WORKDIR /app +ENV COREPACK_HOME=/opt/corepack RUN apt-get update && apt-get install -y zsh \ && sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" \ && echo "source /root/.profile" >>/root/.zshrc \ && pip3 install git-up \ && ln -s /app/docker/container-init.sh /container-init.sh \ + && mkdir -p "$COREPACK_HOME" \ + && corepack prepare pnpm@11.1.0 --activate \ + && chmod -R 0777 "$COREPACK_HOME" \ + && chmod 0777 /app/ui /app/ui/node_modules \ && true ENTRYPOINT ["/bin/zsh", "-l"] diff --git a/README.rst b/README.rst index 9ad95da..5921d75 100644 --- a/README.rst +++ b/README.rst @@ -25,10 +25,9 @@ Startup sequence The startup runs as a background task so the HTTP server is available immediately:: - 1. Poll GET /health on Synapse until it responds 200 (up to 5 minutes). - 2. Acquire a file lock and register the admin bot via the Synapse HMAC-signed - registration endpoint (idempotent: if a valid token file already exists, - re-registration is skipped). + 1. Poll GET /health on MAS and Synapse until they respond 200 (up to 5 minutes each). + 2. Acquire a file lock; ensure the bot user exists in MAS and create its access token + via the MAS admin API (held in memory, replaced automatically before expiry). 3. Remove rate-limiting for the bot user so concurrent room operations never hit 429. 4. Ensure the Space and four rooms exist (creates them if missing, looks them up by alias otherwise). @@ -78,8 +77,9 @@ User lifecycle endpoints ``homeserver.yaml`` will join the user when they first log in via OIDC. ``POST /api/v1/users/revoked`` - Device certificate revoked. Deactivates and erases the user from Synapse (their messages - are removed from the server). If Synapse is not ready yet, returns success with a warning. + Device certificate revoked. Deactivates the user via MAS, erasing them from Synapse + (their messages are removed) and invalidating their sessions. + If MAS is not ready yet, returns success with a warning. ``POST /api/v1/users/promoted`` User promoted to admin in Deploy App. Sets the user's power level to 100 in the Space and @@ -116,19 +116,39 @@ Configuration * - ``SYNAPSE_URL`` - ``http://synapse:8008`` - Internal URL of the Synapse homeserver - * - ``SYNAPSE_REGISTRATION_SECRET`` - - *(required)* - - Shared secret for bot registration (HMAC-SHA1) + * - ``MAS_URL`` + - ``http://mas:8081`` + - Internal URL of the MAS internal listener (oauth + admin API) + * - ``MAS_HEALTH_URL`` + - ``http://mas:8081`` + - Internal URL of the MAS health listener + * - ``MAS_ADMIN_CLIENT_ID`` + - *(unset)* + - Admin client id (ULID); the same value is configured in the MAS container + * - ``MAS_ADMIN_CLIENT_SECRET`` + - *(unset)* + - Admin client secret; the same value is configured in the MAS container * - ``SYNAPSE_BOT_USERNAME`` - ``matrixrmapi-bot`` - Local part of the admin bot Matrix user - * - ``SYNAPSE_TOKEN_FILE`` - - ``/data/persistent/synapse_admin_token`` - - File where the bot's access token is cached between restarts * - ``SERVER_DOMAIN`` - *(from kraftwerk manifest)* - Matrix server_name; derived automatically from the product DNS label +Matrix Authentication Service +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Synapse delegates authentication to MAS (`element-hq/matrix-authentication-service`_). +This service uses the MAS admin API at ``MAS_URL`` for user provisioning, deactivation +and bot token creation, authenticating via the ``client_credentials`` grant of an OAuth2 +client listed in MAS's ``policy.data.admin_clients``. The admin client id (a ULID, as +required by MAS — a public identifier) comes from +``MAS_ADMIN_CLIENT_ID`` and must match the client configured in MAS, with the shared +secret in ``MAS_ADMIN_CLIENT_SECRET``. The bot token is short-lived, held in memory +only, and replaced automatically before it expires. + +.. _element-hq/matrix-authentication-service: https://github.com/element-hq/matrix-authentication-service + Docker ------ diff --git a/devspace.yaml b/devspace.yaml index 2f13555..62c48e6 100644 --- a/devspace.yaml +++ b/devspace.yaml @@ -22,12 +22,28 @@ dev: namespace: app-matrix container: matrixrmapi devImage: ${runtime.images.matrixrmapi} + patches: + - op: replace + path: spec.containers.name=matrixrmapi.resources.limits.memory + value: 1Gi + - op: replace + path: spec.containers.name=matrixrmapi.resources.requests.memory + value: 1Gi command: - /bin/bash - -lc - - source /container-init.sh && uv sync --frozen && uv run uvicorn --host 0.0.0.0 --port 8012 --log-level debug --factory matrixrmapi.app:get_app --reload + - mkdir -p /ui_files/matrix && (pnpm --dir /app/ui exec vite build --outDir /ui_files/matrix --emptyOutDir --watch &) && uv run --no-sync uvicorn --host 0.0.0.0 --port 8012 --log-level debug --factory matrixrmapi.app:get_app --reload sync: - path: ./src:/app/src + excludePaths: + - __pycache__/ + - "**/__pycache__/" + - "*.pyc" + - path: ./ui/src:/app/ui/src + - path: ./ui/public:/app/ui/public + - path: ./ui/public:/ui_files/matrix + disableDownload: true + initialSync: preferLocal - path: ./docker:/app/docker # container-init.sh is provided read-only via the configmap mount # (/container-init.sh -> /app/docker/container-init.sh symlink), so DevSpace diff --git a/docker/container-init.sh b/docker/container-init.sh index edd2467..808ea7e 100755 --- a/docker/container-init.sh +++ b/docker/container-init.sh @@ -4,7 +4,7 @@ set -e GW_IP=$(getent ahostsv4 host.docker.internal | grep RAW | awk '{ print $1 }') echo "GW_IP=$GW_IP" grep -v "$GW_IP" /etc/hosts > /etc/hosts.new && cat /etc/hosts.new > /etc/hosts -echo "$GW_IP ${SERVER_DOMAIN} ${MTLS_DOMAIN} ${KCDOMAIN}" >>/etc/hosts +echo "$GW_IP ${SERVER_DOMAIN} ${MTLS_DOMAIN}" >>/etc/hosts echo "*** BEGIN /etc/hosts ***" cat /etc/hosts echo "*** END /etc/hosts ***" @@ -17,44 +17,6 @@ else sleep 2 fi -if [ "${NGINX_HTTPS_PORT}" == "443" ]; then - export MTLS_BASEURL="https://mtls.${SERVER_DOMAIN}" -else - export MTLS_BASEURL="https://mtls.${SERVER_DOMAIN}:${NGINX_HTTPS_PORT}" -fi - -# Generate the manifest using environment variables -# TODO use envsubst + dedicated file -cat < /tmp/manifest.json -{ - "rasenmaeher": { - "mtls": { - "base_uri": "${MTLS_BASEURL}" - }, - "kc": { - "base_uri": "https://${KCDOMAIN}:9443", - "realm": "${KCREALM}" - } - }, - "oidc": { - "client_registration": { - "client_name": "Synapse" - } - } -} -EOF - -MAX_RETRIES=5 -COUNT=0 -until /kc_client_init get_jwt /tmp/manifest.json || [ $COUNT -eq $MAX_RETRIES ]; do - echo "JWT fetch failed, retrying in 2s..." - sleep 2 - ((COUNT++)) -done - -# Register the synapse server as an OIDC integration -/kc_client_init register_oidc /tmp/manifest.json - if [ -f /data/persistent/firstrun.done ] then echo "First run already cone" diff --git a/pyproject.toml b/pyproject.toml index ea718e6..ae48549 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "gunicorn>=21.0,<25.0", "httpx>=0.28.1,<1.0", "filelock>=3.12,<4.0", + "python-ulid>=3.0,<4.0", ] [project.urls] diff --git a/src/matrixrmapi/api/description.py b/src/matrixrmapi/api/description.py index fc0316c..d54b88d 100644 --- a/src/matrixrmapi/api/description.py +++ b/src/matrixrmapi/api/description.py @@ -17,14 +17,14 @@ PRODUCT_SHORTNAME = "matrix" -class ProductComponent(BaseModel): # pylint: disable=too-few-public-methods +class ProductComponent(BaseModel): """Project component info""" type: Literal["link", "markdown", "component"] ref: str -class ProductDescriptionExtended(BaseModel): # pylint: disable=too-few-public-methods +class ProductDescriptionExtended(BaseModel): """Description of a product""" shortname: str = Field( @@ -37,7 +37,7 @@ class ProductDescriptionExtended(BaseModel): # pylint: disable=too-few-public-m docs: str = Field(description="Link to documentation") component: ProductComponent = Field(description="Component type and ref") - class Config: # pylint: disable=too-few-public-methods + class Config: """Pydantic configs""" extra = Extra.forbid diff --git a/src/matrixrmapi/api/healthcheck.py b/src/matrixrmapi/api/healthcheck.py index 6222f58..0644605 100644 --- a/src/matrixrmapi/api/healthcheck.py +++ b/src/matrixrmapi/api/healthcheck.py @@ -2,9 +2,11 @@ import logging -from fastapi import APIRouter +import httpx +from fastapi import APIRouter, Request from libpvarki.schemas.product import ProductHealthCheckResponse +from ..config import MAS_HEALTH_URL, SYNAPSE_URL LOGGER = logging.getLogger(__name__) @@ -12,8 +14,22 @@ @router.get("") -async def request_healthcheck() -> ProductHealthCheckResponse: - """Check that we are healthy, return accordingly""" - return ProductHealthCheckResponse( - healthy=True, extra="Dummy, nothing actually checked" - ) +async def request_healthcheck(request: Request) -> ProductHealthCheckResponse: + """Check that the Matrix integration is initialised and Synapse and MAS respond""" + if getattr(request.app.state, "synapse", None) is None or not getattr( + request.app.state, "rooms", None + ): + return ProductHealthCheckResponse( + healthy=False, extra="Matrix integration not initialised" + ) + async with httpx.AsyncClient() as client: + for name, url in (("Synapse", SYNAPSE_URL), ("MAS", MAS_HEALTH_URL)): + try: + resp = await client.get(f"{url}/health", timeout=2.0) + resp.raise_for_status() + except httpx.HTTPError as exc: + LOGGER.warning("%s health check failed: %s", name, exc) + return ProductHealthCheckResponse( + healthy=False, extra=f"{name} health check failed" + ) + return ProductHealthCheckResponse(healthy=True) diff --git a/src/matrixrmapi/api/usercrud.py b/src/matrixrmapi/api/usercrud.py index f25e38d..db2c4e0 100644 --- a/src/matrixrmapi/api/usercrud.py +++ b/src/matrixrmapi/api/usercrud.py @@ -5,13 +5,15 @@ import logging from typing import Dict, Optional +import httpx from fastapi import APIRouter, Depends, HTTPException, Request from libpvarki.middleware import MTLSHeader from libpvarki.schemas.generic import OperationResultResponse from libpvarki.schemas.product import UserCRUDRequest from ..config import get_manifest, get_server_domain -from ..synapseutils.synapse_admin import SynapseAdmin, matrix_user_id +from ..utils.mas_admin import MasAdmin +from ..utils.synapse_admin import SynapseAdmin, matrix_user_id from ..types import AdminAction LOGGER = logging.getLogger(__name__) @@ -33,6 +35,12 @@ def get_synapse(request: Request) -> Optional[SynapseAdmin]: return val +def get_mas(request: Request) -> Optional[MasAdmin]: + """Return MasAdmin from app state, or None if not yet ready.""" + val: Optional[MasAdmin] = getattr(request.app.state, "mas", None) + return val + + def get_rooms(request: Request) -> Optional[Dict[str, str]]: """Return room IDs dict from app state, or None if not yet ready.""" val: Optional[Dict[str, str]] = getattr(request.app.state, "rooms", None) @@ -77,23 +85,29 @@ async def user_revoked( user: UserCRUDRequest, request: Request, ) -> OperationResultResponse: - """Device cert revoked — deactivate and erase user from Synapse.""" + """Device cert revoked — deactivate user in MAS (MAS erases them from Synapse + and invalidates their sessions).""" comes_from_rm(request) - synapse = get_synapse(request) - if synapse is None: - LOGGER.warning("Synapse not ready; cannot deactivate %s", user.callsign) + mas = get_mas(request) + if mas is None: + LOGGER.warning("MAS not ready; cannot deactivate %s", user.callsign) return OperationResultResponse(success=True) try: - uid = matrix_user_id(user.callsign, get_server_domain()) + # validates the callsign + matrix_user_id(user.callsign, get_server_domain()) except ValueError as exc: LOGGER.error("Invalid callsign for Matrix: %s", exc) return OperationResultResponse(success=False) + localpart = user.callsign.lower() try: - await synapse.deactivate(uid) - except Exception as exc: # pylint: disable=broad-except - LOGGER.error("Failed to deactivate %s in Synapse: %s", uid, exc) + deactivated = await mas.deactivate_user(localpart) + except httpx.HTTPError as exc: + LOGGER.error("Failed to deactivate %s in MAS: %s", localpart, exc) return OperationResultResponse(success=False) - LOGGER.info("Deactivated and erased %s from Synapse", uid) + if deactivated: + LOGGER.info("Deactivated %s in MAS (erased from Synapse)", localpart) + else: + LOGGER.info("%s not in MAS (never logged in); nothing to deactivate", localpart) return OperationResultResponse(success=True) @@ -118,7 +132,7 @@ async def apply_admin_action( await synapse.force_join(admin_id, uid) else: await synapse.kick(admin_id, uid) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: LOGGER.error("Failed to %s %s: %s", action.value, uid, exc) return OperationResultResponse(success=False) LOGGER.info("%sd %s (power level %d)", action.value.capitalize(), uid, level) diff --git a/src/matrixrmapi/app.py b/src/matrixrmapi/app.py index 180a6c9..58201ac 100644 --- a/src/matrixrmapi/app.py +++ b/src/matrixrmapi/app.py @@ -15,16 +15,17 @@ from matrixrmapi import __version__ from .config import LOG_LEVEL, get_manifest from .api import all_routers, all_routers_v2 -from .synapseutils.synapse_admin import SynapseAdmin -from .synapseutils.startup import synapse_startup +from .utils.mas_admin import MasAdmin +from .utils.synapse_admin import SynapseAdmin +from .utils.startup import connect_to_matrix LOGGER = logging.getLogger(__name__) @asynccontextmanager async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: - """Start Synapse integration as a non-blocking background task.""" - task = asyncio.create_task(synapse_startup(app)) + """Start MAS and Synapse integration as a non-blocking background task.""" + task = asyncio.create_task(connect_to_matrix(app)) try: yield finally: @@ -34,6 +35,9 @@ async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: synapse: Optional[SynapseAdmin] = getattr(app.state, "synapse", None) if synapse: await synapse.close() + mas: Optional[MasAdmin] = getattr(app.state, "mas", None) + if mas: + await mas.close() def get_app() -> FastAPI: diff --git a/src/matrixrmapi/config.py b/src/matrixrmapi/config.py index baa6dda..dd70e6b 100644 --- a/src/matrixrmapi/config.py +++ b/src/matrixrmapi/config.py @@ -4,9 +4,12 @@ from pathlib import Path import json import functools +import logging from starlette.config import Config +LOGGER = logging.getLogger(__name__) + cfg = Config() # not supporting .env files anymore because https://github.com/encode/starlette/discussions/2446 LOG_LEVEL: int = cfg("LOG_LEVEL", default=20, cast=int) @@ -16,22 +19,13 @@ SYNAPSE_URL: str = cfg("SYNAPSE_URL", default="http://synapse:8008") - -def _require_nonempty(value: str) -> str: - if not value: - raise ValueError("SYNAPSE_REGISTRATION_SECRET must not be empty") - return value - - -SYNAPSE_REGISTRATION_SECRET: str = cfg( - "SYNAPSE_REGISTRATION_SECRET", cast=_require_nonempty -) +# MAS internal listener: health, oauth, admin API +MAS_URL: str = cfg("MAS_URL", default="http://mas:8081") +MAS_HEALTH_URL: str = cfg("MAS_HEALTH_URL", default="http://mas:8081") +# Admin API client, shared with MAS via the environment. ID is required to be ULID. +MAS_ADMIN_CLIENT_ID: str = cfg("MAS_ADMIN_CLIENT_ID", default="") +MAS_ADMIN_CLIENT_SECRET: str = cfg("MAS_ADMIN_CLIENT_SECRET", default="") SYNAPSE_BOT_USERNAME: str = cfg("SYNAPSE_BOT_USERNAME", default="matrixrmapi-bot") -SYNAPSE_TOKEN_FILE: Path = cfg( - "SYNAPSE_TOKEN_FILE", - cast=Path, - default=Path("/data/persistent/synapse_admin_token"), -) @functools.cache diff --git a/src/matrixrmapi/console.py b/src/matrixrmapi/console.py index 80762f7..935a682 100644 --- a/src/matrixrmapi/console.py +++ b/src/matrixrmapi/console.py @@ -84,4 +84,4 @@ def dump_openapi(ctx: click.Context) -> None: def matrixrmapi_cli() -> None: """matrixrmapi""" init_logging(logging.WARNING) - cli_group() # pylint: disable=no-value-for-parameter + cli_group() diff --git a/src/matrixrmapi/synapseutils/__init__.py b/src/matrixrmapi/synapseutils/__init__.py deleted file mode 100644 index 7d82813..0000000 --- a/src/matrixrmapi/synapseutils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Synapse admin utilities""" diff --git a/src/matrixrmapi/utils/__init__.py b/src/matrixrmapi/utils/__init__.py new file mode 100644 index 0000000..4660f12 --- /dev/null +++ b/src/matrixrmapi/utils/__init__.py @@ -0,0 +1 @@ +"""MAS and Synapse admin utilities""" diff --git a/src/matrixrmapi/utils/mas_admin.py b/src/matrixrmapi/utils/mas_admin.py new file mode 100644 index 0000000..ff9df59 --- /dev/null +++ b/src/matrixrmapi/utils/mas_admin.py @@ -0,0 +1,154 @@ +"""MAS (Matrix Authentication Service) admin API helper""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, Optional, Tuple +from urllib.parse import quote + +import httpx + +LOGGER = logging.getLogger(__name__) + +ADMIN_SCOPE = "urn:mas:admin" +BOT_DEVICE_ID = "MATRIXRMAPIBOT" +BOT_TOKEN_SCOPE = ( + "urn:matrix:org.matrix.msc2967.client:api:* " + "urn:synapse:admin:* " + f"urn:matrix:org.matrix.msc2967.client:device:{BOT_DEVICE_ID}" +) +# Re-fetch the client-credentials token this many seconds before it expires +TOKEN_EXPIRY_SKEW = 30.0 +# Lifetime of the bot personal session; a new one is created in memory when it runs out +BOT_TOKEN_EXPIRES_IN = 3600 + + +class MasAdmin: + """Async wrapper for the MAS admin API. + + Authenticates with OAuth2 client credentials (``urn:mas:admin`` scope); + the short-lived admin token is cached and refreshed on demand. + Call close() when done (or use as an async context manager). + """ + + def __init__(self, mas_url: str, client_id: str, client_secret: str) -> None: + self._url = mas_url.rstrip("/") + self._client_id = client_id + self._client_secret = client_secret + self._token: Optional[str] = None + self._token_expires: float = 0.0 + self._client: httpx.AsyncClient = httpx.AsyncClient() + + async def close(self) -> None: + """Close the underlying HTTP client.""" + await self._client.aclose() + + async def __aenter__(self) -> "MasAdmin": + return self + + async def __aexit__(self, *_: Any) -> None: + await self.close() + + async def _ensure_admin_token(self) -> str: + """Return a cached admin token, fetching a fresh one when missing or stale.""" + if self._token and time.monotonic() < self._token_expires: + return self._token + resp = await self._client.post( + f"{self._url}/oauth2/token", + auth=(self._client_id, self._client_secret), + data={"grant_type": "client_credentials", "scope": ADMIN_SCOPE}, + timeout=10.0, + ) + resp.raise_for_status() + payload = resp.json() + self._token = str(payload["access_token"]) + self._token_expires = ( + time.monotonic() + float(payload.get("expires_in", 300)) - TOKEN_EXPIRY_SKEW + ) + return self._token + + async def _auth(self) -> Dict[str, str]: + return {"Authorization": f"Bearer {await self._ensure_admin_token()}"} + + async def user_ulid_by_username(self, localpart: str) -> Optional[str]: + """Return the MAS user ULID for localpart, or None if not found.""" + encoded = quote(localpart, safe="") + resp = await self._client.get( + f"{self._url}/api/admin/v1/users/by-username/{encoded}", + headers=await self._auth(), + timeout=10.0, + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return str(resp.json()["data"]["id"]) + + async def ensure_user(self, localpart: str) -> str: + """Return the user's ULID, creating the user if needed. + + MAS provisions the user on the homeserver synchronously. + """ + existing = await self.user_ulid_by_username(localpart) + if existing: + return existing + resp = await self._client.post( + f"{self._url}/api/admin/v1/users", + headers=await self._auth(), + json={"username": localpart}, + timeout=30.0, + ) + resp.raise_for_status() + ulid = str(resp.json()["data"]["id"]) + LOGGER.info("Created MAS user %s (%s)", localpart, ulid) + return ulid + + async def create_bot_token( + self, + user_ulid: str, + human_name: str, + device_id: str = BOT_DEVICE_ID, + expires_in: int = BOT_TOKEN_EXPIRES_IN, + ) -> Tuple[str, float]: + """Create an expiring personal session token for the bot user. + + Returns (access_token, expires_in seconds). + """ + scope = ( + "urn:matrix:org.matrix.msc2967.client:api:* " + "urn:synapse:admin:* " + f"urn:matrix:org.matrix.msc2967.client:device:{device_id}" + ) + resp = await self._client.post( + f"{self._url}/api/admin/v1/personal-sessions", + headers=await self._auth(), + json={ + "actor_user_id": user_ulid, + "human_name": human_name, + "scope": scope, + "expires_in": expires_in, + }, + timeout=30.0, + ) + resp.raise_for_status() + attrs = resp.json()["data"]["attributes"] + return str(attrs["access_token"]), float(attrs.get("expires_in", expires_in)) + + async def deactivate_user(self, localpart: str) -> bool: + """Deactivate user in MAS (erases them from the homeserver too). + + Returns True if the user was deactivated, False if they do not exist + in MAS (never logged in). Raises httpx.HTTPError on real failures. + """ + ulid = await self.user_ulid_by_username(localpart) + if ulid is None: + return False + resp = await self._client.post( + f"{self._url}/api/admin/v1/users/{ulid}/deactivate", + headers=await self._auth(), + json={}, + timeout=30.0, + ) + resp.raise_for_status() + LOGGER.info("Deactivated MAS user %s (%s)", localpart, ulid) + return True diff --git a/src/matrixrmapi/synapseutils/startup.py b/src/matrixrmapi/utils/startup.py similarity index 74% rename from src/matrixrmapi/synapseutils/startup.py rename to src/matrixrmapi/utils/startup.py index f1bd77b..e02f2d5 100644 --- a/src/matrixrmapi/synapseutils/startup.py +++ b/src/matrixrmapi/utils/startup.py @@ -1,9 +1,11 @@ -"""Synapse startup helpers: health-check, bot registration, room setup.""" +"""MAS and Synapse startup helpers: health-check, bot registration, room setup.""" from __future__ import annotations import asyncio import logging +import tempfile +from pathlib import Path from typing import Dict, List, Optional, Tuple import filelock @@ -11,14 +13,17 @@ from fastapi import FastAPI from ..config import ( + MAS_ADMIN_CLIENT_ID, + MAS_ADMIN_CLIENT_SECRET, + MAS_HEALTH_URL, + MAS_URL, SYNAPSE_BOT_USERNAME, - SYNAPSE_REGISTRATION_SECRET, - SYNAPSE_TOKEN_FILE, SYNAPSE_URL, get_manifest, get_server_domain, ) from ..types import AdminAction, CALL_EVENTS_DEFAULT_LEVEL +from .mas_admin import MasAdmin from .synapse_admin import SynapseAdmin LOGGER = logging.getLogger(__name__) @@ -39,74 +44,62 @@ } -async def wait_for_synapse( - synapse_url: str, retries: int = 60, interval: float = 5.0 +async def wait_for_service( + name: str, url: str, retries: int = 60, interval: float = 5.0 ) -> bool: - """Poll Synapse /health until it responds 200. Returns True on success.""" - LOGGER.info("Waiting for Synapse at %s ...", synapse_url) + """Poll a service's /health until it responds 200. Returns True on success.""" + LOGGER.info("Waiting for %s at %s ...", name, url) async with httpx.AsyncClient() as client: for attempt in range(retries): try: - resp = await client.get(f"{synapse_url}/health", timeout=5.0) + resp = await client.get(f"{url}/health", timeout=5.0) if resp.status_code == 200: - LOGGER.info("Synapse is ready") + LOGGER.info("%s is ready", name) return True - except Exception: # pylint: disable=broad-except # nosec B110 + except Exception: # nosec B110 pass if attempt < retries - 1: await asyncio.sleep(interval) LOGGER.error( - "Synapse not reachable after %d attempts — integration disabled", retries + "%s not reachable after %d attempts — integration disabled", name, retries ) return False -async def acquire_bot_token(synapse: SynapseAdmin) -> Tuple[bool, bool]: - """Acquire the admin bot token using a file lock for worker coordination. +async def acquire_bot_token(synapse: SynapseAdmin, mas: MasAdmin) -> Tuple[bool, bool]: + """Set up the bot session, using a file lock for worker coordination. - Returns ``(success, is_init_worker)``. Only the init worker should run - room creation and configuration; follower workers merely load the token - and then derive existing room IDs. + Returns ``(success, is_init_worker)``. Only the init worker + should run room creation and configuration. """ - lock_path = SYNAPSE_TOKEN_FILE.parent / "synapse_init.lock" + lock_path = Path(tempfile.gettempdir()) / "matrixrmapi_synapse_init.lock" lock = filelock.FileLock(str(lock_path)) - acquired = False + is_init = False try: lock.acquire(timeout=0.0) - acquired = True - # We are the init worker — register bot (idempotent: reads file if present) - registration_secret = SYNAPSE_REGISTRATION_SECRET - await synapse.setup( - registration_secret, SYNAPSE_BOT_USERNAME, SYNAPSE_TOKEN_FILE - ) - del registration_secret - return True, True + is_init = True except filelock.Timeout: - LOGGER.warning("Another worker is initialising the Synapse bot, waiting ...") - except Exception as exc: # pylint: disable=broad-except - LOGGER.error("Bot token acquisition failed: %s", exc) - return False, False - finally: - if acquired: - lock.release() - - # Non-init worker: wait for the token file to appear - for _ in range(60): - if SYNAPSE_TOKEN_FILE.exists(): - break - await asyncio.sleep(2) - else: - LOGGER.error("Token file never appeared after waiting — integration disabled") - return False, False + LOGGER.info("Another worker is initialising the Synapse bot, waiting ...") + # filelock acquisition is blocking — poll so the event loop keeps running + for _ in range(60): + try: + lock.acquire(timeout=0.0) + break + except filelock.Timeout: + await asyncio.sleep(2) + else: + LOGGER.error("Init worker never released the lock — integration disabled") + return False, False - # Load and validate the token written by the init worker try: - await synapse.setup("", SYNAPSE_BOT_USERNAME, SYNAPSE_TOKEN_FILE) - return True, False - except Exception as exc: # pylint: disable=broad-except - LOGGER.error("Failed to load bot token from file: %s", exc) + await synapse.setup(SYNAPSE_BOT_USERNAME, mas) + return True, is_init + except Exception as exc: + LOGGER.error("Bot session setup failed: %s", exc) return False, False + finally: + lock.release() async def ensure_room( @@ -167,7 +160,7 @@ async def apply_pending( if admin_id: await synapse.kick(admin_id, uid) LOGGER.info("Applied deferred demotion for %s", uid) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: LOGGER.error( "Failed to apply deferred %s for %s: %s", action.value, uid, exc ) @@ -228,9 +221,35 @@ async def configure_rooms_state( LOGGER.info("Room state configuration applied") -async def synapse_startup(app: FastAPI) -> None: - """Background task: connect to Synapse, create bot and rooms.""" - if not await wait_for_synapse(SYNAPSE_URL): +def setup_mas_admin(app: FastAPI) -> Optional[MasAdmin]: + """Build the MAS admin client from the shared client id and secret.""" + if not MAS_ADMIN_CLIENT_SECRET: + LOGGER.error( + "MAS_ADMIN_CLIENT_SECRET not set — deactivation " + "and bot session creation disabled" + ) + return None + if not MAS_ADMIN_CLIENT_ID: + LOGGER.error( + "MAS_ADMIN_CLIENT_ID not set — deactivation " + "and bot session creation disabled" + ) + return None + mas = MasAdmin(MAS_URL, MAS_ADMIN_CLIENT_ID, MAS_ADMIN_CLIENT_SECRET) + app.state.mas = mas + return mas + + +async def connect_to_matrix(app: FastAPI) -> None: + """Background task: connect to MAS and Synapse, create bot and rooms.""" + mas = setup_mas_admin(app) + if mas is None: + LOGGER.error("No MAS admin client — Matrix integration disabled") + return + + if not await wait_for_service("MAS", MAS_HEALTH_URL): + return + if not await wait_for_service("Synapse", SYNAPSE_URL): return manifest = get_manifest() @@ -239,7 +258,7 @@ async def synapse_startup(app: FastAPI) -> None: synapse = SynapseAdmin(SYNAPSE_URL, domain) - ok, is_init = await acquire_bot_token(synapse) + ok, is_init = await acquire_bot_token(synapse, mas) if not ok: await synapse.close() return @@ -248,7 +267,7 @@ async def synapse_startup(app: FastAPI) -> None: try: room_ids = await ensure_rooms(synapse, deployment, domain) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: LOGGER.error("Room setup failed: %s", exc) return @@ -257,7 +276,7 @@ async def synapse_startup(app: FastAPI) -> None: # duplicate PUTs from every worker on every restart. try: await configure_rooms_state(synapse, room_ids, deployment) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: LOGGER.error("Room configuration failed (rooms still usable): %s", exc) else: LOGGER.info( diff --git a/src/matrixrmapi/synapseutils/synapse_admin.py b/src/matrixrmapi/utils/synapse_admin.py similarity index 67% rename from src/matrixrmapi/synapseutils/synapse_admin.py rename to src/matrixrmapi/utils/synapse_admin.py index 4caffec..9e1c9a7 100644 --- a/src/matrixrmapi/synapseutils/synapse_admin.py +++ b/src/matrixrmapi/utils/synapse_admin.py @@ -2,23 +2,23 @@ from __future__ import annotations -import hashlib -import hmac +import asyncio import logging -import os import re -import secrets -from pathlib import Path +import time from typing import Any, Dict, List, Optional from urllib.parse import quote import httpx from ..types import CALL_EVENTS_DEFAULT_LEVEL +from .mas_admin import MasAdmin LOGGER = logging.getLogger(__name__) MATRIX_LOCALPART_RE = re.compile(r"^[a-z0-9._\-=/+]+$") +# Create a new bot session this many seconds before the old one expires +BOT_TOKEN_EXPIRY_SKEW = 60.0 def matrix_user_id(callsign: str, server_domain: str) -> str: @@ -42,6 +42,11 @@ def __init__(self, synapse_url: str, server_domain: str) -> None: self._url = synapse_url.rstrip("/") self._domain = server_domain self._token: Optional[str] = None + self._token_expires: float = 0.0 + self._token_lock: asyncio.Lock = asyncio.Lock() + self._mas: Optional[MasAdmin] = None + self._bot_username: Optional[str] = None + self._bot_ulid: Optional[str] = None self._bot_user_id: Optional[str] = None self._client: httpx.AsyncClient = httpx.AsyncClient() @@ -59,29 +64,38 @@ async def __aexit__(self, *_: Any) -> None: # Initialisation # ------------------------------------------------------------------ - async def setup( - self, registration_secret: str, bot_username: str, token_file: Path - ) -> None: - """Acquire admin token: load from file or register bot if missing/invalid.""" + async def setup(self, bot_username: str, mas: MasAdmin) -> None: + """Ensure the bot user exists in MAS and create an in-memory session token.""" self._bot_user_id = f"@{bot_username}:{self._domain}" - if token_file.exists(): - candidate = token_file.read_text().strip() - if await self._validate(candidate): - self._token = candidate - LOGGER.info("Reused bot token from %s", token_file) - await self._exempt_bot_from_ratelimit(bot_username) - return - LOGGER.warning("Stored token invalid, will re-register bot") - - token = await self._register_bot(registration_secret, bot_username) - token_file.parent.mkdir(parents=True, exist_ok=True) - token_file.write_text(token) - os.chmod(token_file, 0o600) - self._token = token - LOGGER.info("Bot registered; token saved to %s", token_file) + self._bot_username = bot_username + self._mas = mas + self._bot_ulid = await mas.ensure_user(bot_username) + await self._create_token() + LOGGER.info("Bot session created via MAS") await self._exempt_bot_from_ratelimit(bot_username) + async def _create_token(self) -> None: + """Create a new bot session via MAS; recreates the bot user if it no longer exists.""" + if self._mas is None or self._bot_ulid is None: + raise RuntimeError("SynapseAdmin.setup() has not been called") + human_name = f"matrixrmapi bot ({self._domain})" + try: + token, expires_in = await self._mas.create_bot_token( + self._bot_ulid, human_name + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code != 404 or self._bot_username is None: + raise + # Bot user no longer exists in MAS (e.g. wiped database) -- re-create and retry once + LOGGER.warning("Bot user %s not found in MAS, recreating", self._bot_ulid) + self._bot_ulid = await self._mas.ensure_user(self._bot_username) + token, expires_in = await self._mas.create_bot_token( + self._bot_ulid, human_name + ) + self._token = token + self._token_expires = time.monotonic() + expires_in - BOT_TOKEN_EXPIRY_SKEW + async def _exempt_bot_from_ratelimit(self, bot_username: str) -> None: """Remove rate-limit restrictions for the bot user so concurrent room setup never gets 429.""" user_id = f"@{bot_username}:{self._domain}" @@ -89,79 +103,23 @@ async def _exempt_bot_from_ratelimit(self, bot_username: str) -> None: try: resp = await self._client.post( f"{self._url}/_synapse/admin/v1/users/{encoded}/override_ratelimit", - headers=self._auth, + headers=await self._auth(), json={"messages_per_second": 0, "burst_count": 0}, timeout=10.0, ) resp.raise_for_status() LOGGER.info("Rate-limit override applied for %s", user_id) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: LOGGER.warning("Failed to override rate limit for %s: %s", user_id, exc) - async def _validate(self, token: str) -> bool: - """Return True if token is accepted by the admin API.""" - try: - resp = await self._client.get( - f"{self._url}/_synapse/admin/v1/server_version", - headers={"Authorization": f"Bearer {token}"}, - timeout=10.0, - ) - return resp.status_code == 200 - except Exception: # pylint: disable=broad-except - return False - - async def _register_bot(self, registration_secret: str, username: str) -> str: - """Register a new Synapse admin user via HMAC-signed register endpoint.""" - nonce_resp = await self._client.get( - f"{self._url}/_synapse/admin/v1/register", - timeout=10.0, - ) - nonce_resp.raise_for_status() - nonce: str = nonce_resp.json()["nonce"] - - # Synapse uses HMAC-SHA1 for the registration MAC - rand_password = secrets.token_hex(32) - mac_content = f"{nonce}\0{username}\0{rand_password}\0admin" - mac = hmac.new( - registration_secret.encode("utf-8"), - mac_content.encode("utf-8"), - hashlib.sha1, # nosec B324 - required by Synapse registration API - ).hexdigest() - - reg_resp = await self._client.post( - f"{self._url}/_synapse/admin/v1/register", - json={ - "nonce": nonce, - "username": username, - "password": rand_password, - "admin": True, - "mac": mac, - }, - timeout=30.0, - ) - - if ( - reg_resp.status_code == 400 - and reg_resp.json().get("errcode") == "M_USER_IN_USE" - ): - LOGGER.critical( - "Bot user @%s:%s already exists but no valid token file was found. " - "Manual recovery: deactivate the bot via Synapse admin UI, " - "delete the token file, then restart matrixrmapi.", - username, - self._domain, - ) - raise RuntimeError( - f"Bot user already exists and cannot be recovered automatically: {username}" - ) - - reg_resp.raise_for_status() - return str(reg_resp.json()["access_token"]) - - @property - def _auth(self) -> Dict[str, str]: + async def _auth(self) -> Dict[str, str]: + """Return the bearer header, creating a new session when missing or expired.""" if not self._token: raise RuntimeError("SynapseAdmin.setup() has not been called") + if time.monotonic() >= self._token_expires: + async with self._token_lock: + if time.monotonic() >= self._token_expires: + await self._create_token() return {"Authorization": f"Bearer {self._token}"} # ------------------------------------------------------------------ @@ -173,7 +131,7 @@ async def room_id_for_alias(self, alias: str) -> Optional[str]: encoded = quote(alias, safe="") resp = await self._client.get( f"{self._url}/_matrix/client/v3/directory/room/{encoded}", - headers=self._auth, + headers=await self._auth(), timeout=10.0, ) if resp.status_code == 404: @@ -210,7 +168,7 @@ async def create_room( resp = await self._client.post( f"{self._url}/_matrix/client/v3/createRoom", - headers=self._auth, + headers=await self._auth(), json=body, timeout=30.0, ) @@ -222,7 +180,7 @@ async def add_child_to_space(self, space_id: str, room_id: str) -> None: encoded_room = quote(room_id, safe="") resp = await self._client.put( f"{self._url}/_matrix/client/v3/rooms/{space_id}/state/m.space.child/{encoded_room}", - headers=self._auth, + headers=await self._auth(), json={"via": [self._domain], "suggested": False}, timeout=10.0, ) @@ -240,7 +198,7 @@ async def set_room_state( if state_key: path = f"{path}/{quote(state_key, safe='')}" resp = await self._client.put( - path, headers=self._auth, json=content, timeout=10.0 + path, headers=await self._auth(), json=content, timeout=10.0 ) resp.raise_for_status() @@ -256,7 +214,7 @@ async def force_join(self, room_id: str, user_id: str) -> None: """ resp = await self._client.post( f"{self._url}/_synapse/admin/v1/join/{room_id}", - headers=self._auth, + headers=await self._auth(), json={"user_id": user_id}, timeout=10.0, ) @@ -279,26 +237,11 @@ async def force_join(self, room_id: str, user_id: str) -> None: return resp.raise_for_status() - async def deactivate(self, user_id: str) -> None: - """Deactivate and erase user. Silently succeeds if user does not exist in Synapse.""" - # Use v1 endpoint — v2 path (/v2/users/{id}/deactivate) is unrecognised in some - # Synapse versions; v1 has been stable since early Synapse releases. - resp = await self._client.post( - f"{self._url}/_synapse/admin/v1/deactivate/{quote(user_id, safe='')}", - headers=self._auth, - json={"erase": True}, - timeout=30.0, - ) - if resp.status_code == 404: - LOGGER.info("User %s not found in Synapse; nothing to deactivate", user_id) - return - resp.raise_for_status() - async def get_power_levels(self, room_id: str) -> Dict[str, Any]: """Get the m.room.power_levels state for a room.""" resp = await self._client.get( f"{self._url}/_matrix/client/v3/rooms/{room_id}/state/m.room.power_levels", - headers=self._auth, + headers=await self._auth(), timeout=10.0, ) resp.raise_for_status() @@ -317,7 +260,7 @@ async def set_user_power_level( levels["users"] = users resp = await self._client.put( f"{self._url}/_matrix/client/v3/rooms/{room_id}/state/m.room.power_levels", - headers=self._auth, + headers=await self._auth(), json=levels, timeout=10.0, ) @@ -327,7 +270,7 @@ async def invite(self, room_id: str, user_id: str) -> None: """Invite user to room.""" resp = await self._client.post( f"{self._url}/_matrix/client/v3/rooms/{room_id}/invite", - headers=self._auth, + headers=await self._auth(), json={"user_id": user_id}, timeout=10.0, ) @@ -337,7 +280,7 @@ async def kick(self, room_id: str, user_id: str) -> None: """Kick user from room. Silently skips if user is not in the room.""" resp = await self._client.post( f"{self._url}/_matrix/client/v3/rooms/{room_id}/kick", - headers=self._auth, + headers=await self._auth(), json={"user_id": user_id}, timeout=10.0, ) diff --git a/tests/test_app.py b/tests/test_app.py index 9c1009e..1d17cfd 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -7,8 +7,8 @@ import pytest -from matrixrmapi.synapseutils.startup import apply_pending, ensure_room -from matrixrmapi.synapseutils.synapse_admin import SynapseAdmin +from matrixrmapi.utils.startup import apply_pending, ensure_room +from matrixrmapi.utils.synapse_admin import SynapseAdmin from matrixrmapi.types import AdminAction ROOMS: Dict[str, str] = { diff --git a/tests/test_crud.py b/tests/test_crud.py index 50a6c1e..24f5737 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -3,6 +3,9 @@ from typing import Dict import logging import uuid +from unittest.mock import AsyncMock + +import httpx from fastapi.testclient import TestClient from matrixrmapi.config import get_server_domain @@ -12,9 +15,6 @@ LOGGER = logging.getLogger(__name__) -# pylint: disable=redefined-outer-name - - def test_unauth(norppa11: Dict[str, str]) -> None: """Check that unauth call to auth endpoint fails""" client = TestClient(APP) @@ -41,7 +41,7 @@ def test_update(norppa11: Dict[str, str], rm_mtlsclient: TestClient) -> None: def test_revoke(norppa11: Dict[str, str], rm_mtlsclient: TestClient) -> None: - """Check that revoking user works""" + """Check that revoking user works (MAS not ready -> success with warning)""" resp = rm_mtlsclient.post("/api/v1/users/revoked", json=norppa11) assert resp.status_code == 200 payload = resp.json() @@ -49,6 +49,52 @@ def test_revoke(norppa11: Dict[str, str], rm_mtlsclient: TestClient) -> None: assert payload["success"] +def test_revoke_deactivates_in_mas( + norppa11: Dict[str, str], rm_mtlsclient: TestClient +) -> None: + """When MAS is ready, /revoked must deactivate the user via the MAS admin API""" + mas = AsyncMock() + mas.deactivate_user.return_value = True + APP.state.mas = mas + try: + resp = rm_mtlsclient.post("/api/v1/users/revoked", json=norppa11) + assert resp.status_code == 200 + assert resp.json()["success"] is True + mas.deactivate_user.assert_awaited_once_with("norppa11a") + finally: + del APP.state.mas + + +def test_revoke_user_not_in_mas( + norppa11: Dict[str, str], rm_mtlsclient: TestClient +) -> None: + """User that never logged in (absent from MAS) still revokes successfully""" + mas = AsyncMock() + mas.deactivate_user.return_value = False + APP.state.mas = mas + try: + resp = rm_mtlsclient.post("/api/v1/users/revoked", json=norppa11) + assert resp.status_code == 200 + assert resp.json()["success"] is True + finally: + del APP.state.mas + + +def test_revoke_mas_error_fails( + norppa11: Dict[str, str], rm_mtlsclient: TestClient +) -> None: + """A MAS API failure must be reported as success=False""" + mas = AsyncMock() + mas.deactivate_user.side_effect = httpx.ConnectError("boom") + APP.state.mas = mas + try: + resp = rm_mtlsclient.post("/api/v1/users/revoked", json=norppa11) + assert resp.status_code == 200 + assert resp.json()["success"] is False + finally: + del APP.state.mas + + def test_promote(norppa11: Dict[str, str], rm_mtlsclient: TestClient) -> None: """Check that promoting user works""" resp = rm_mtlsclient.post("/api/v1/users/promoted", json=norppa11) diff --git a/tests/test_mas_admin.py b/tests/test_mas_admin.py new file mode 100644 index 0000000..3ae6beb --- /dev/null +++ b/tests/test_mas_admin.py @@ -0,0 +1,221 @@ +"""Unit tests for MasAdmin. + +All HTTP calls are intercepted by patching the httpx.AsyncClient method on the +MasAdmin instance, so no real network is required. +""" + +from __future__ import annotations + +import time +from typing import Any, Dict +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +from fastapi import FastAPI + +from matrixrmapi.utils import startup +from matrixrmapi.utils.mas_admin import MasAdmin + +# httpx.Response needs a request object to call raise_for_status() cleanly. +FAKE_REQUEST = httpx.Request("POST", "http://mas.test/fake") + +BOT_ULID = "01TESTULID0000000000000000" + + +def _fake(status: int, body: Dict[str, Any]) -> httpx.Response: + """Build a minimal fake httpx.Response.""" + return httpx.Response(status, json=body, request=FAKE_REQUEST) + + +def _make_mas(*, with_token: bool = True) -> MasAdmin: + """Return a MasAdmin ready for unit testing. + + With with_token=True the admin token is pre-seeded so tests can focus on + the admin API call itself without mocking the token fetch. + """ + mas = MasAdmin("http://mas.test", "clientid", "clientsecret") + if with_token: + mas._token = "mas_admin_token" # nosec B105 + mas._token_expires = time.monotonic() + 1000 + return mas + + +# --------------------------------------------------------------------------- +# _ensure_admin_token +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_admin_token_request_shape() -> None: + """Token fetch must use basic auth and client_credentials with admin scope.""" + mas = _make_mas(with_token=False) + with patch.object(mas._client, "post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = _fake(200, {"access_token": "tok", "expires_in": 300}) + token = await mas._ensure_admin_token() + assert token == "tok" # nosec B105 + assert mock_post.call_args.kwargs["auth"] == ("clientid", "clientsecret") + data: Dict[str, str] = mock_post.call_args.kwargs["data"] + assert data["grant_type"] == "client_credentials" + assert data["scope"] == "urn:mas:admin" + + +@pytest.mark.asyncio +async def test_admin_token_cached() -> None: + """A fresh token must be reused, not re-fetched on every call.""" + mas = _make_mas(with_token=False) + with patch.object(mas._client, "post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = _fake(200, {"access_token": "tok", "expires_in": 300}) + await mas._ensure_admin_token() + await mas._ensure_admin_token() + mock_post.assert_called_once() + + +@pytest.mark.asyncio +async def test_admin_token_refetched_after_expiry() -> None: + """A stale token must trigger a new token fetch.""" + mas = _make_mas() + mas._token_expires = time.monotonic() - 1 + with patch.object(mas._client, "post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = _fake(200, {"access_token": "tok2", "expires_in": 300}) + token = await mas._ensure_admin_token() + assert token == "tok2" # nosec B105 + mock_post.assert_called_once() + + +# --------------------------------------------------------------------------- +# ensure_user +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ensure_user_existing_skips_create() -> None: + """Existing user: ULID returned from lookup, no create POST.""" + mas = _make_mas() + client = mas._client + with ( + patch.object(client, "get", new_callable=AsyncMock) as mock_get, + patch.object(client, "post", new_callable=AsyncMock) as mock_post, + ): + mock_get.return_value = _fake(200, {"data": {"id": BOT_ULID}}) + ulid = await mas.ensure_user("bot") + assert ulid == BOT_ULID + mock_post.assert_not_called() + + +@pytest.mark.asyncio +async def test_ensure_user_creates_on_404() -> None: + """Unknown user: 404 lookup must be followed by a create POST.""" + mas = _make_mas() + client = mas._client + with ( + patch.object(client, "get", new_callable=AsyncMock) as mock_get, + patch.object(client, "post", new_callable=AsyncMock) as mock_post, + ): + mock_get.return_value = _fake(404, {"errors": [{"title": "not found"}]}) + mock_post.return_value = _fake(201, {"data": {"id": BOT_ULID}}) + ulid = await mas.ensure_user("bot") + assert ulid == BOT_ULID + body: Dict[str, Any] = mock_post.call_args.kwargs["json"] + assert body == {"username": "bot"} + + +# --------------------------------------------------------------------------- +# create_bot_token +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_bot_token_scopes_and_expiry() -> None: + """Personal session must carry all three scopes and an expiry.""" + mas = _make_mas() + with patch.object(mas._client, "post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = _fake( + 201, + { + "data": { + "attributes": {"access_token": "mpt_bot_token", "expires_in": 3600} + } + }, + ) + token, expires_in = await mas.create_bot_token(BOT_ULID, "matrixrmapi bot") + assert token == "mpt_bot_token" # nosec B105 + assert expires_in == 3600 + body: Dict[str, Any] = mock_post.call_args.kwargs["json"] + assert body["expires_in"] == 3600 + assert body["actor_user_id"] == BOT_ULID + scope: str = body["scope"] + assert "urn:matrix:org.matrix.msc2967.client:api:*" in scope + assert "urn:synapse:admin:*" in scope + assert "urn:matrix:org.matrix.msc2967.client:device:MATRIXRMAPIBOT" in scope + + +# --------------------------------------------------------------------------- +# deactivate_user +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_deactivate_user_found() -> None: + """Known user: deactivate POST hits the user's ULID endpoint.""" + mas = _make_mas() + client = mas._client + with ( + patch.object(client, "get", new_callable=AsyncMock) as mock_get, + patch.object(client, "post", new_callable=AsyncMock) as mock_post, + ): + mock_get.return_value = _fake(200, {"data": {"id": BOT_ULID}}) + mock_post.return_value = _fake(200, {"data": {"id": BOT_ULID}}) + deactivated = await mas.deactivate_user("norppa11") + assert deactivated is True + url: str = mock_post.call_args.args[0] + assert url.endswith(f"/api/admin/v1/users/{BOT_ULID}/deactivate") + + +@pytest.mark.asyncio +async def test_deactivate_user_not_found_skips() -> None: + """Unknown user (never logged in): no deactivate call, no raise, returns False.""" + mas = _make_mas() + client = mas._client + with ( + patch.object(client, "get", new_callable=AsyncMock) as mock_get, + patch.object(client, "post", new_callable=AsyncMock) as mock_post, + ): + mock_get.return_value = _fake(404, {"errors": [{"title": "not found"}]}) + deactivated = await mas.deactivate_user("ghost") # must not raise + assert deactivated is False + mock_post.assert_not_called() + + +# --------------------------------------------------------------------------- +# setup_mas_admin +# --------------------------------------------------------------------------- + + +def test_setup_mas_admin(monkeypatch: pytest.MonkeyPatch) -> None: + """Env-provided client id and secret yield a MasAdmin bound to app state.""" + monkeypatch.setattr(startup, "MAS_ADMIN_CLIENT_ID", "cid") + monkeypatch.setattr(startup, "MAS_ADMIN_CLIENT_SECRET", "csecret") + app = FastAPI() + mas = startup.setup_mas_admin(app) + assert mas is not None + assert app.state.mas is mas + assert mas._client_id == "cid" + + +def test_setup_mas_admin_missing_secret(monkeypatch: pytest.MonkeyPatch) -> None: + """Unset secret must return None instead of raising.""" + monkeypatch.setattr(startup, "MAS_ADMIN_CLIENT_ID", "cid") + monkeypatch.setattr(startup, "MAS_ADMIN_CLIENT_SECRET", "") + app = FastAPI() + assert startup.setup_mas_admin(app) is None + assert getattr(app.state, "mas", None) is None + + +def test_setup_mas_admin_missing_id(monkeypatch: pytest.MonkeyPatch) -> None: + """Unset client id must return None instead of raising.""" + monkeypatch.setattr(startup, "MAS_ADMIN_CLIENT_ID", "") + monkeypatch.setattr(startup, "MAS_ADMIN_CLIENT_SECRET", "csecret") + app = FastAPI() + assert startup.setup_mas_admin(app) is None + assert getattr(app.state, "mas", None) is None diff --git a/tests/test_matrixrmapi.py b/tests/test_matrixrmapi.py index 7bb410c..7534a7a 100644 --- a/tests/test_matrixrmapi.py +++ b/tests/test_matrixrmapi.py @@ -1,7 +1,12 @@ """Package level tests""" +from unittest.mock import AsyncMock, patch + +import httpx from fastapi.testclient import TestClient + from matrixrmapi import __version__ +from .conftest import APP def test_version() -> None: @@ -9,9 +14,49 @@ def test_version() -> None: assert __version__ == "1.2.2" # x-release-please-version -def test_healthcheck(mtlsclient: TestClient) -> None: - """Check that health-check works""" +def test_healthcheck_not_initialised(mtlsclient: TestClient) -> None: + """Without the initialization the service reports unhealthy""" resp = mtlsclient.get("/api/v1/healthcheck") assert resp.status_code == 200 payload = resp.json() - assert payload["healthy"] is True + assert payload["healthy"] is False + + +def test_healthcheck_healthy(mtlsclient: TestClient) -> None: + """With integration initialised and Synapse/MAS responding the service reports healthy""" + APP.state.synapse = AsyncMock() + APP.state.rooms = {"space": "!space:x"} + try: + with patch( + "matrixrmapi.api.healthcheck.httpx.AsyncClient.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = httpx.Response( + 200, request=httpx.Request("GET", "http://x/health") + ) + resp = mtlsclient.get("/api/v1/healthcheck") + assert resp.status_code == 200 + payload = resp.json() + assert payload["healthy"] is True + finally: + del APP.state.synapse + del APP.state.rooms + + +def test_healthcheck_synapse_down(mtlsclient: TestClient) -> None: + """A failing Synapse health endpoint makes the service unhealthy""" + APP.state.synapse = AsyncMock() + APP.state.rooms = {"space": "!space:x"} + try: + with patch( + "matrixrmapi.api.healthcheck.httpx.AsyncClient.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.side_effect = httpx.ConnectError("boom") + resp = mtlsclient.get("/api/v1/healthcheck") + assert resp.status_code == 200 + payload = resp.json() + assert payload["healthy"] is False + finally: + del APP.state.synapse + del APP.state.rooms diff --git a/tests/test_synapse_admin.py b/tests/test_synapse_admin.py index 4344357..03c620d 100644 --- a/tests/test_synapse_admin.py +++ b/tests/test_synapse_admin.py @@ -6,13 +6,14 @@ from __future__ import annotations +import time from typing import Any, Dict from unittest.mock import AsyncMock, patch import httpx import pytest -from matrixrmapi.synapseutils.synapse_admin import SynapseAdmin, matrix_user_id +from matrixrmapi.utils.synapse_admin import SynapseAdmin, matrix_user_id # httpx.Response needs a request object to call raise_for_status() cleanly. FAKE_REQUEST = httpx.Request("POST", "http://synapse.test/fake") @@ -26,8 +27,9 @@ def _fake(status: int, body: Dict[str, Any]) -> httpx.Response: def _make_synapse() -> SynapseAdmin: """Return a SynapseAdmin ready for unit testing (real token + bot user set directly).""" sa = SynapseAdmin("http://synapse.test", "example.test") - sa._token = "test-token" # pylint: disable=protected-access # nosec B105 - sa._bot_user_id = "@bot:example.test" # pylint: disable=protected-access + sa._token = "test-token" # nosec B105 + sa._token_expires = time.monotonic() + 1000 + sa._bot_user_id = "@bot:example.test" return sa @@ -78,7 +80,7 @@ def test_matrix_user_id_special_chars_raise() -> None: async def test_force_join_success() -> None: """Happy path: 200 response is accepted without error.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake(200, {"room_id": "!r:example.test"}) await sa.force_join("!r:example.test", "@user:example.test") mock_post.assert_called_once() @@ -88,7 +90,7 @@ async def test_force_join_success() -> None: async def test_force_join_user_not_in_synapse_skips() -> None: """404 means user hasn't logged in yet — must silently skip.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake(404, {"errcode": "M_NOT_FOUND"}) await sa.force_join("!r:example.test", "@ghost:example.test") # must not raise @@ -97,7 +99,7 @@ async def test_force_join_user_not_in_synapse_skips() -> None: async def test_force_join_already_in_room_skips() -> None: """403 + already in the room — idempotent, must not raise.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake( 403, {"errcode": "M_FORBIDDEN", "error": "User is already in the room."} ) @@ -108,7 +110,7 @@ async def test_force_join_already_in_room_skips() -> None: async def test_force_join_other_403_raises() -> None: """403 with an unrecognised reason must propagate as HTTPStatusError.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake( 403, {"errcode": "M_FORBIDDEN", "error": "You do not have permission."} ) @@ -116,53 +118,6 @@ async def test_force_join_other_403_raises() -> None: await sa.force_join("!r:example.test", "@user:example.test") -# --------------------------------------------------------------------------- -# deactivate -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_deactivate_success() -> None: - """Happy path: 200 response is accepted without error.""" - sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access - mock_post.return_value = _fake(200, {"id_server_unbind_result": "success"}) - await sa.deactivate("@user:example.test") - mock_post.assert_called_once() - - -@pytest.mark.asyncio -async def test_deactivate_user_not_found_skips() -> None: - """User never logged in to Matrix — 404 must silently succeed.""" - sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access - mock_post.return_value = _fake(404, {"errcode": "M_NOT_FOUND"}) - await sa.deactivate("@ghost:example.test") # must not raise - - -@pytest.mark.asyncio -async def test_deactivate_calls_v1_endpoint() -> None: - """Admin API endpoint must be the stable v1 path.""" - sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access - mock_post.return_value = _fake(200, {}) - await sa.deactivate("@user:example.test") - url: str = mock_post.call_args.args[0] - assert "/_synapse/admin/v1/deactivate/" in url - assert "v2" not in url - - -@pytest.mark.asyncio -async def test_deactivate_sends_erase_true() -> None: - """Deactivation must request GDPR erase.""" - sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access - mock_post.return_value = _fake(200, {}) - await sa.deactivate("@user:example.test") - body: Dict[str, Any] = mock_post.call_args.kwargs["json"] - assert body.get("erase") is True - - # --------------------------------------------------------------------------- # kick # --------------------------------------------------------------------------- @@ -172,7 +127,7 @@ async def test_deactivate_sends_erase_true() -> None: async def test_kick_success() -> None: """Happy path: 200 response is accepted without error.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake(200, {}) await sa.kick("!r:example.test", "@user:example.test") mock_post.assert_called_once() @@ -182,7 +137,7 @@ async def test_kick_success() -> None: async def test_kick_not_in_room_skips() -> None: """403 + not in the room — user already left; must not raise.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake( 403, {"errcode": "M_FORBIDDEN", "error": "User is not in the room."} ) @@ -193,7 +148,7 @@ async def test_kick_not_in_room_skips() -> None: async def test_kick_other_403_raises() -> None: """403 with an unrecognised reason must propagate.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake( 403, {"errcode": "M_FORBIDDEN", "error": "You do not have permission."} ) @@ -210,7 +165,7 @@ async def test_kick_other_403_raises() -> None: async def test_create_room_sets_bot_at_power_200() -> None: """Bot must start at power level 200 so it can demote users at 100.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake(200, {"room_id": "!new:example.test"}) await sa.create_room("TestRoom", "#test-room:example.test") body: Dict[str, Any] = mock_post.call_args.kwargs["json"] @@ -224,7 +179,7 @@ async def test_create_room_sets_bot_at_power_200() -> None: async def test_create_space_sets_creation_content() -> None: """Spaces need creation_content.type = m.space.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake(200, {"room_id": "!space:example.test"}) await sa.create_room("MySpace", "#my-space:example.test", is_space=True) body: Dict[str, Any] = mock_post.call_args.kwargs["json"] @@ -235,7 +190,7 @@ async def test_create_space_sets_creation_content() -> None: async def test_create_private_room_uses_private_preset() -> None: """private_chat preset must be used when is_private=True.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake(200, {"room_id": "!priv:example.test"}) await sa.create_room("Admin", "#admin:example.test", is_private=True) body: Dict[str, Any] = mock_post.call_args.kwargs["json"] @@ -251,7 +206,7 @@ async def test_create_private_room_uses_private_preset() -> None: async def test_room_id_for_alias_found() -> None: """Resolving a known alias returns the room ID.""" sa = _make_synapse() - with patch.object(sa._client, "get", new_callable=AsyncMock) as mock_get: # pylint: disable=protected-access + with patch.object(sa._client, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = _fake(200, {"room_id": "!abc:example.test"}) result = await sa.room_id_for_alias("#general:example.test") assert result == "!abc:example.test" @@ -261,7 +216,7 @@ async def test_room_id_for_alias_found() -> None: async def test_room_id_for_alias_not_found_returns_none() -> None: """404 from directory lookup must return None, not raise.""" sa = _make_synapse() - with patch.object(sa._client, "get", new_callable=AsyncMock) as mock_get: # pylint: disable=protected-access + with patch.object(sa._client, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = _fake(404, {"errcode": "M_NOT_FOUND"}) result = await sa.room_id_for_alias("#nonexistent:example.test") assert result is None @@ -276,7 +231,7 @@ async def test_room_id_for_alias_not_found_returns_none() -> None: async def test_add_child_to_space() -> None: """Child room must be linked via m.space.child state event.""" sa = _make_synapse() - with patch.object(sa._client, "put", new_callable=AsyncMock) as mock_put: # pylint: disable=protected-access + with patch.object(sa._client, "put", new_callable=AsyncMock) as mock_put: mock_put.return_value = _fake(200, {}) await sa.add_child_to_space("!space:example.test", "!room:example.test") mock_put.assert_called_once() @@ -293,7 +248,7 @@ async def test_add_child_to_space() -> None: async def test_set_room_state_without_state_key() -> None: """State event type must appear in the PUT URL when no state key is given.""" sa = _make_synapse() - with patch.object(sa._client, "put", new_callable=AsyncMock) as mock_put: # pylint: disable=protected-access + with patch.object(sa._client, "put", new_callable=AsyncMock) as mock_put: mock_put.return_value = _fake(200, {}) await sa.set_room_state("!r:example.test", "m.room.name", {"name": "Test"}) url: str = mock_put.call_args.args[0] @@ -304,7 +259,7 @@ async def test_set_room_state_without_state_key() -> None: async def test_set_room_state_with_state_key() -> None: """State key must be URL-encoded into the PUT path.""" sa = _make_synapse() - with patch.object(sa._client, "put", new_callable=AsyncMock) as mock_put: # pylint: disable=protected-access + with patch.object(sa._client, "put", new_callable=AsyncMock) as mock_put: mock_put.return_value = _fake(200, {}) await sa.set_room_state( "!r:example.test", @@ -327,7 +282,7 @@ async def test_get_power_levels() -> None: """Power level state is returned as a dict.""" sa = _make_synapse() power_state = {"users": {"@bot:example.test": 200}, "users_default": 0} - with patch.object(sa._client, "get", new_callable=AsyncMock) as mock_get: # pylint: disable=protected-access + with patch.object(sa._client, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = _fake(200, power_state) result = await sa.get_power_levels("!r:example.test") assert result["users"]["@bot:example.test"] == 200 @@ -337,7 +292,7 @@ async def test_get_power_levels() -> None: async def test_set_user_power_level_nonzero() -> None: """Setting a non-zero level must PUT the updated power levels state.""" sa = _make_synapse() - client = sa._client # pylint: disable=protected-access + client = sa._client initial = {"users": {}, "users_default": 0} with ( patch.object(client, "get", new_callable=AsyncMock) as mock_get, @@ -354,7 +309,7 @@ async def test_set_user_power_level_nonzero() -> None: async def test_set_user_power_level_zero_removes_user() -> None: """Setting level 0 must remove the user entry rather than writing 0.""" sa = _make_synapse() - client = sa._client # pylint: disable=protected-access + client = sa._client initial = {"users": {"@user:example.test": 100}, "users_default": 0} with ( patch.object(client, "get", new_callable=AsyncMock) as mock_get, @@ -376,7 +331,7 @@ async def test_set_user_power_level_zero_removes_user() -> None: async def test_invite_success() -> None: """Invite POST must include user_id in the request body.""" sa = _make_synapse() - with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: # pylint: disable=protected-access + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: mock_post.return_value = _fake(200, {}) await sa.invite("!r:example.test", "@user:example.test") body: Dict[str, Any] = mock_post.call_args.kwargs["json"] @@ -392,7 +347,7 @@ async def test_invite_success() -> None: async def test_set_power_level_in_rooms_calls_each_room() -> None: """set_power_level_in_rooms must call get+put once per room.""" sa = _make_synapse() - client = sa._client # pylint: disable=protected-access + client = sa._client room_ids = ["!r1:example.test", "!r2:example.test", "!r3:example.test"] initial = {"users": {}, "users_default": 0} with ( @@ -404,3 +359,56 @@ async def test_set_power_level_in_rooms_calls_each_room() -> None: await sa.set_power_level_in_rooms(room_ids, "@user:example.test", 100) assert mock_get.call_count == 3 assert mock_put.call_count == 3 + + +# =========================================================================== +# MAS setup / _auth +# =========================================================================== + + +def _make_mas_mock(token: str = "mpt_test_token") -> AsyncMock: # nosec B107 + """Return a MasAdmin mock whose create_bot_token yields the given token.""" + mas = AsyncMock() + mas.ensure_user.return_value = "01TESTULID0000000000000000" + mas.create_bot_token.return_value = (token, 3600) + return mas + + +async def _setup_with_mas(mas: AsyncMock) -> SynapseAdmin: + """Run SynapseAdmin.setup() against the mocked MasAdmin.""" + sa = SynapseAdmin("http://synapse.test", "example.test") + with patch.object(sa._client, "post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = _fake(200, {}) # ratelimit override + await sa.setup("bot", mas) + return sa + + +@pytest.mark.asyncio +async def test_setup_creates_token_via_mas() -> None: + """Setup must ensure the bot user in MAS and hold the created token in memory.""" + mas = _make_mas_mock() + sa = await _setup_with_mas(mas) + mas.ensure_user.assert_awaited_once_with("bot") + mas.create_bot_token.assert_awaited_once() + auth = await sa._auth() + assert auth["Authorization"] == "Bearer mpt_test_token" + + +@pytest.mark.asyncio +async def test_auth_before_setup_raises() -> None: + """Using the client before setup() must raise.""" + sa = SynapseAdmin("http://synapse.test", "example.test") + with pytest.raises(RuntimeError): + await sa._auth() + + +@pytest.mark.asyncio +async def test_auth_replaces_token_after_expiry() -> None: + """An expired token must be replaced via MAS on the next use.""" + mas = _make_mas_mock("mpt_first") + sa = await _setup_with_mas(mas) + sa._token_expires = time.monotonic() - 1 + mas.create_bot_token.return_value = ("mpt_second", 3600) # nosec B105 + auth = await sa._auth() + assert auth["Authorization"] == "Bearer mpt_second" + assert mas.create_bot_token.await_count == 2 diff --git a/ui/public/assets/elementx/elementx1.webp b/ui/public/assets/elementx/elementx1.webp new file mode 100644 index 0000000..344b5e9 Binary files /dev/null and b/ui/public/assets/elementx/elementx1.webp differ diff --git a/ui/public/assets/elementx/elementx2.webp b/ui/public/assets/elementx/elementx2.webp new file mode 100644 index 0000000..6c2eec0 Binary files /dev/null and b/ui/public/assets/elementx/elementx2.webp differ diff --git a/ui/public/assets/elementx/elementx3.webp b/ui/public/assets/elementx/elementx3.webp new file mode 100644 index 0000000..2b5fc03 Binary files /dev/null and b/ui/public/assets/elementx/elementx3.webp differ diff --git a/ui/public/assets/elementx/elementx4.webp b/ui/public/assets/elementx/elementx4.webp new file mode 100644 index 0000000..3f50ceb Binary files /dev/null and b/ui/public/assets/elementx/elementx4.webp differ diff --git a/ui/public/assets/elementx/elementx5.webp b/ui/public/assets/elementx/elementx5.webp new file mode 100644 index 0000000..4908859 Binary files /dev/null and b/ui/public/assets/elementx/elementx5.webp differ diff --git a/ui/public/assets/elementx/elementx6.webp b/ui/public/assets/elementx/elementx6.webp new file mode 100644 index 0000000..eee70a2 Binary files /dev/null and b/ui/public/assets/elementx/elementx6.webp differ diff --git a/ui/public/assets/elementx/elementx7.webp b/ui/public/assets/elementx/elementx7.webp new file mode 100644 index 0000000..e4d2268 Binary files /dev/null and b/ui/public/assets/elementx/elementx7.webp differ diff --git a/ui/public/download-buttons/en-apple.svg b/ui/public/download-buttons/en-apple.svg new file mode 100644 index 0000000..072b425 --- /dev/null +++ b/ui/public/download-buttons/en-apple.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/public/download-buttons/en-googleplay.svg b/ui/public/download-buttons/en-googleplay.svg new file mode 100644 index 0000000..fb8ad2f --- /dev/null +++ b/ui/public/download-buttons/en-googleplay.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/public/download-buttons/fi-apple.svg b/ui/public/download-buttons/fi-apple.svg new file mode 100644 index 0000000..a6694a5 --- /dev/null +++ b/ui/public/download-buttons/fi-apple.svg @@ -0,0 +1,41 @@ + + Download_on_the_App_Store_Badge_FI_RGB_blk_100217 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/public/download-buttons/fi-googleplay.svg b/ui/public/download-buttons/fi-googleplay.svg new file mode 100644 index 0000000..c1c4c39 --- /dev/null +++ b/ui/public/download-buttons/fi-googleplay.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/public/download-buttons/sv-apple.svg b/ui/public/download-buttons/sv-apple.svg new file mode 100644 index 0000000..23b5209 --- /dev/null +++ b/ui/public/download-buttons/sv-apple.svg @@ -0,0 +1,39 @@ + + Download_on_the_App_Store_Badge_SE_RGB_blk_100317 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/public/download-buttons/sv-googleplay.svg b/ui/public/download-buttons/sv-googleplay.svg new file mode 100644 index 0000000..7a843b7 --- /dev/null +++ b/ui/public/download-buttons/sv-googleplay.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/components/ElementDownload.tsx b/ui/src/components/ElementDownload.tsx index 0319ef4..42106a7 100644 --- a/ui/src/components/ElementDownload.tsx +++ b/ui/src/components/ElementDownload.tsx @@ -7,21 +7,34 @@ interface Props { platform: Platform; } +type BadgeLang = "en" | "fi" | "sv"; + +const badgeLang = (language: string): BadgeLang => { + const lang = language.toLowerCase().split("-")[0]; + if (lang === "fi") return "fi"; + if (lang === "sv") return "sv"; + return "en"; +}; + export function ElementDownload({ platform }: Props) { - const { t } = useTranslation(PRODUCT_SHORTNAME); + const { t, i18n } = useTranslation(PRODUCT_SHORTNAME); + const lang = badgeLang(i18n.resolvedLanguage ?? i18n.language); if (platform === Platform.Android) { return (
- + + {t("onboarding.downloads.google_play")} +
); } @@ -29,15 +42,18 @@ export function ElementDownload({ platform }: Props) { if (platform === Platform.iOS) { return (
- + + {t("onboarding.downloads.app_store")} +
); } diff --git a/ui/src/components/OnboardingGuide.tsx b/ui/src/components/OnboardingGuide.tsx index d918b03..82758e9 100644 --- a/ui/src/components/OnboardingGuide.tsx +++ b/ui/src/components/OnboardingGuide.tsx @@ -122,36 +122,50 @@ const ONBOARDING_GROUPS: OnboardingGroup[] = [ id: "mobile-step-1", title: "onboarding.steps.mobile-step-1.title", description: "onboarding.steps.mobile-step-1.description", - image: "/ui/matrix/assets/classic-1.webp", - mobileImage: "/ui/matrix/assets/classic-1.webp", + image: "/ui/matrix/assets/elementx/elementx1.webp", + mobileImage: "/ui/matrix/assets/elementx/elementx1.webp", }, { id: "mobile-step-2", title: "onboarding.steps.mobile-step-2.title", description: "onboarding.steps.mobile-step-2.description", - image: "/ui/matrix/assets/classic-2.webp", - mobileImage: "/ui/matrix/assets/classic-2.webp", + image: "/ui/matrix/assets/elementx/elementx2.webp", + mobileImage: "/ui/matrix/assets/elementx/elementx2.webp", }, { id: "mobile-step-3", title: "onboarding.steps.mobile-step-3.title", description: "onboarding.steps.mobile-step-3.description", - image: "/ui/matrix/assets/classic-3.webp", - mobileImage: "/ui/matrix/assets/classic-3.webp", + image: "/ui/matrix/assets/elementx/elementx3.webp", + mobileImage: "/ui/matrix/assets/elementx/elementx3.webp", }, { id: "mobile-step-4", title: "onboarding.steps.mobile-step-4.title", description: "onboarding.steps.mobile-step-4.description", - image: "/ui/matrix/assets/classic-4.webp", - mobileImage: "/ui/matrix/assets/classic-4.webp", + image: "/ui/matrix/assets/elementx/elementx4.webp", + mobileImage: "/ui/matrix/assets/elementx/elementx4.webp", }, { id: "mobile-step-5", title: "onboarding.steps.mobile-step-5.title", description: "onboarding.steps.mobile-step-5.description", - image: "/ui/matrix/assets/classic-5.webp", - mobileImage: "/ui/matrix/assets/classic-5.webp", + image: "/ui/matrix/assets/elementx/elementx5.webp", + mobileImage: "/ui/matrix/assets/elementx/elementx5.webp", + }, + { + id: "mobile-step-6", + title: "onboarding.steps.mobile-step-6.title", + description: "onboarding.steps.mobile-step-6.description", + image: "/ui/matrix/assets/elementx/elementx6.webp", + mobileImage: "/ui/matrix/assets/elementx/elementx6.webp", + }, + { + id: "mobile-step-7", + title: "onboarding.steps.mobile-step-7.title", + description: "onboarding.steps.mobile-step-7.description", + image: "/ui/matrix/assets/elementx/elementx7.webp", + mobileImage: "/ui/matrix/assets/elementx/elementx7.webp", }, ], }, diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index fae938c..95ae6a6 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -126,27 +126,35 @@ }, "mobile-intro": { "title": "Welcome to Matrix", - "description": "This guide uses the Element Classic application, but you can use almost any Matrix application. The steps are similar in other applications as well." + "description": "This guide uses the Element X application, but you can use almost any Matrix application. The steps are similar in other applications as well." }, "mobile-step-1": { - "title": "Log in to Element Classic - 1", - "description": "Open Element and select \"Sign in\"." + "title": "Sign in | Element X", + "description": "Open Element X and select \"Sign in manually\"." }, "mobile-step-2": { - "title": "Log in to Element Classic - 2", - "description": "Press \"Edit\" next to the home server address." + "title": "Choose account provider | Element X", + "description": "Select \"Other\"." }, "mobile-step-3": { - "title": "Log in to Element Classic - 3", - "description": "Paste the server address you copied earlier. Press \"Next\"." + "title": "Server address | Element X", + "description": "Paste the server address you copied earlier (1). Select the server below (2)." }, "mobile-step-4": { - "title": "Log in to Element Classic - 4", - "description": "Press \"Continue with Keycloak\". Continue in your browser, which should prompt for your mTLS certificate. Authenticate with your mTLS certificate." + "title": "Log in | Element X", + "description": "Continue to log in. Your browser should open for the next steps." }, "mobile-step-5": { - "title": "Element Classic home view", - "description": "You are now logged in and can use Matrix. You can search for users and rooms from the top right corner (1). You can start a conversation from the bottom right corner (2)." + "title": "Log in | Element X", + "description": "Provide your certificate if asked for (1) and continue (2)." + }, + "mobile-step-6": { + "title": "Create account | Element X", + "description": "Create account (1) and continue (2)." + }, + "mobile-step-7": { + "title": "Homepage | Element X", + "description": "You are now logged in and can use Matrix. You can search for users and rooms from the top right corner (1). You can start a conversation from the bottom right corner (2). You see your active rooms at the homepage (3)." } }, "title": "Matrix onboarding", diff --git a/ui/src/locales/fi.json b/ui/src/locales/fi.json index ebcd78e..fffc544 100644 --- a/ui/src/locales/fi.json +++ b/ui/src/locales/fi.json @@ -126,27 +126,35 @@ }, "mobile-intro": { "title": "Tervetuloa Matrixiin", - "description": "Tämä opas käyttää Element Classic -sovellusta, mutta voit käyttää lähes mitä tahansa Matrix-sovellusta. Vaiheet ovat samankaltaisia muissakin sovelluksissa." + "description": "Tämä opas käyttää Element X -sovellusta, mutta voit käyttää lähes mitä tahansa Matrix-sovellusta. Vaiheet ovat samankaltaisia muissakin sovelluksissa." }, "mobile-step-1": { - "title": "Kirjaudu Element Classiciin - 1", - "description": "Avaa Element ja valitse \"Sign in\"." + "title": "Kirjaudu sisään | Element X", + "description": "Avaa Element X ja valitse \"Kirjaudu sisään manuaalisesti\"." }, "mobile-step-2": { - "title": "Kirjaudu Element Classiciin - 2", - "description": "Paina \"Edit\" kotipalvelimen osoitteen kohdalta." + "title": "Valitse tilin tarjoaja | Element X", + "description": "Valitse \"Muu\"." }, "mobile-step-3": { - "title": "Kirjaudu Element Classiciin - 3", - "description": "Liitä aiemmin kopioimasi palvelimen osoite. Paina \"Next\"." + "title": "Palvelimen osoite | Element X", + "description": "Liitä aiemmin kopioimasi palvelimen osoite (1). Valitse alla oleva palvelin (2)." }, "mobile-step-4": { - "title": "Kirjaudu Element Classiciin - 4", - "description": "Paina \"Continue with Keycloak\". Jatka selaimessasi, jonka pitäisi pyytää mTLS-varmennettasi. Tunnistaudu mTLS-varmenteella." + "title": "Kirjaudu sisään | Element X", + "description": "Jatka kirjautumiseen. Selaimesi pitäisi avautua seuraavia vaiheita varten." }, "mobile-step-5": { - "title": "Element Classicin kotinäkymä", - "description": "Olet nyt kirjautunut sisään ja voit käyttää Matrixia. Voit etsiä käyttäjiä ja huoneita oikeasta yläkulmasta (1). Voit aloittaa keskustelun oikeasta alakulmasta (2)." + "title": "Kirjaudu sisään | Element X", + "description": "Anna varmenteesi pyydettäessä (1) ja jatka (2)." + }, + "mobile-step-6": { + "title": "Luo tili | Element X", + "description": "Luo tili (1) ja jatka (2)." + }, + "mobile-step-7": { + "title": "Kotinäkymä | Element X", + "description": "Olet nyt kirjautunut sisään ja voit käyttää Matrixia. Voit etsiä käyttäjiä ja huoneita oikeasta yläkulmasta (1). Voit aloittaa keskustelun oikeasta alakulmasta (2). Näet aktiiviset huoneesi kotinäkymässä (3)." } }, "title": "Matrix-perehdytys", diff --git a/ui/src/locales/sv.json b/ui/src/locales/sv.json index 9aab6fd..d23c0f7 100644 --- a/ui/src/locales/sv.json +++ b/ui/src/locales/sv.json @@ -126,27 +126,35 @@ }, "mobile-intro": { "title": "Välkommen till Matrix", - "description": "Denna introduktion använder Element Classic-klienten, men du kan använda nästan vilken Matrix-klient som helst. Stegen bör vara liknande i andra klienter." + "description": "Denna introduktion använder Element X-klienten, men du kan använda nästan vilken Matrix-klient som helst. Stegen bör vara liknande i andra klienter." }, "mobile-step-1": { - "title": "Logga in i Element Classic - 1", - "description": "Öppna Element och välj \"Sign in\"." + "title": "Logga in | Element X", + "description": "Öppna Element X och välj \"Logga in manuellt\"." }, "mobile-step-2": { - "title": "Logga in i Element Classic - 2", - "description": "Tryck på \"Edit\" vid hemserverns adress." + "title": "Välj kontoleverantör | Element X", + "description": "Välj \"Annan\"." }, "mobile-step-3": { - "title": "Logga in i Element Classic - 3", - "description": "Klistra in serveradressen du kopierade tidigare. Tryck på \"Next\"." + "title": "Serveradress | Element X", + "description": "Klistra in serveradressen du kopierade tidigare (1). Välj servern nedan (2)." }, "mobile-step-4": { - "title": "Logga in i Element Classic - 4", - "description": "Tryck på \"Continue with Keycloak\". Fortsätt i din webbläsare, som bör fråga efter ditt mTLS-certifikat. Autentisera med mTLS." + "title": "Logga in | Element X", + "description": "Fortsätt för att logga in. Din webbläsare bör öppnas för nästa steg." }, "mobile-step-5": { - "title": "Element Classics startsida", - "description": "Du är nu inloggad och kan använda Matrix. Du kan söka efter användare och rum i det övre högra hörnet (1). Du kan starta en chatt från det nedre högra hörnet (2)." + "title": "Logga in | Element X", + "description": "Ange ditt certifikat om du blir ombedd (1) och fortsätt om du blir ombedd (2)." + }, + "mobile-step-6": { + "title": "Skapa konto | Element X", + "description": "Skapa konto (1) och fortsätt (2)." + }, + "mobile-step-7": { + "title": "Startsida | Element X", + "description": "Du är nu inloggad och kan använda Matrix. Du kan söka efter användare och rum i det övre högra hörnet (1). Du kan starta en chatt från det nedre högra hörnet (2). Du ser dina aktiva rum på startsidan (3)." } }, "title": "Introduktion till Matrix", diff --git a/uv.lock b/uv.lock index 235a4e4..71d2325 100644 --- a/uv.lock +++ b/uv.lock @@ -946,6 +946,7 @@ dependencies = [ { name = "libadvian" }, { name = "libpvarki" }, { name = "pydantic" }, + { name = "python-ulid" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -971,6 +972,7 @@ requires-dist = [ { name = "libadvian", specifier = ">=1.4,<2.0" }, { name = "libpvarki", specifier = ">=2.2,<3.0", index = "https://nexus.dev.pvarki.fi/repository/pypilocal/simple" }, { name = "pydantic", specifier = ">=2.0,<3.0" }, + { name = "python-ulid", specifier = ">=3.0,<4.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.20,<1.0" }, ] @@ -1512,6 +1514,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-ulid" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/7e/0d6c82b5ccc71e7c833aed43d9e8468e1f2ff0be1b3f657a6fcafbb8433d/python_ulid-3.1.0.tar.gz", hash = "sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636", size = 93175, upload-time = "2025-08-18T16:09:26.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"