From bcddf5d1c8674e7a436e6fa5ad3427be1bb5422e Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 11:27:36 +0100 Subject: [PATCH] feat: add create-invite CLI command --- src/porchlight/cli.py | 41 +++++++++++++++++++++++++++++++++ tests/test_cli.py | 53 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/porchlight/cli.py create mode 100644 tests/test_cli.py diff --git a/src/porchlight/cli.py b/src/porchlight/cli.py new file mode 100644 index 0000000..10f3f45 --- /dev/null +++ b/src/porchlight/cli.py @@ -0,0 +1,41 @@ +import asyncio +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from porchlight.config import Settings +from porchlight.invite.service import MagicLinkService +from porchlight.store.sqlite.db import open_db +from porchlight.store.sqlite.repositories import SQLiteMagicLinkRepository + +PACKAGE_DIR = Path(__file__).resolve().parent +MIGRATIONS_DIR = PACKAGE_DIR / "store" / "sqlite" / "migrations" + +app = typer.Typer() + + +async def _create_invite(settings: Settings, username: str, ttl: int, note: str | None) -> str: + """Create an invite link and return the full registration URL.""" + async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db: + repo = SQLiteMagicLinkRepository(db) + service = MagicLinkService(repo, ttl=ttl) + link = await service.create(username=username, created_by="cli", note=note) + return f"{settings.issuer}/register/{link.token}" + + +@app.command() +def create_invite( + username: str, + ttl: Annotated[Optional[int], typer.Option(help="Link expiration in seconds")] = None, + note: Annotated[Optional[str], typer.Option(help="Optional note stored with the link")] = None, +) -> None: + """Generate a magic link registration URL for a new user.""" + settings = Settings() + effective_ttl = ttl if ttl is not None else settings.invite_ttl + url = asyncio.run(_create_invite(settings, username, effective_ttl, note)) + typer.echo(url) + + +def main() -> None: + app() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..694b6f7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,53 @@ +import os +import tempfile + +from typer.testing import CliRunner + +from porchlight.cli import app + +runner = CliRunner() + + +def test_create_invite_prints_registration_url() -> None: + """create-invite should print a URL containing /register/.""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + result = runner.invoke( + app, + ["testuser"], + env={"OIDC_OP_ISSUER": "https://example.com", "OIDC_OP_SQLITE_PATH": db_path}, + ) + assert result.exit_code == 0, result.output + assert "https://example.com/register/" in result.output + + +def test_create_invite_with_note() -> None: + """create-invite with --note should work.""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + result = runner.invoke( + app, + ["testuser", "--note", "Welcome aboard"], + env={"OIDC_OP_ISSUER": "https://example.com", "OIDC_OP_SQLITE_PATH": db_path}, + ) + assert result.exit_code == 0, result.output + assert "https://example.com/register/" in result.output + + +def test_create_invite_with_custom_ttl() -> None: + """create-invite with --ttl should use the custom TTL.""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + result = runner.invoke( + app, + ["testuser", "--ttl", "3600"], + env={"OIDC_OP_ISSUER": "https://example.com", "OIDC_OP_SQLITE_PATH": db_path}, + ) + assert result.exit_code == 0, result.output + assert "https://example.com/register/" in result.output + + +def test_create_invite_missing_username_shows_error() -> None: + """create-invite without a username should show an error.""" + result = runner.invoke(app, []) + assert result.exit_code != 0