From 1c21d6d199989ef698557e88ca95200bdf48e38b Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Tue, 17 Feb 2026 14:09:00 +0100 Subject: [PATCH 1/5] test: add failing test for registering pre-existing users Part of CLI module work (fastapi-oidc-op-9lb.1). The test verifies that /register/{token} handles users already created by initial-admin. --- .../test_register_magic_link.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/test_auth_routes/test_register_magic_link.py b/tests/test_auth_routes/test_register_magic_link.py index 6a0cbd6..1a03fed 100644 --- a/tests/test_auth_routes/test_register_magic_link.py +++ b/tests/test_auth_routes/test_register_magic_link.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime, timedelta from httpx import AsyncClient -from porchlight.models import MagicLink +from porchlight.models import MagicLink, User async def test_register_invalid_token_returns_error_page(client: AsyncClient) -> None: @@ -70,3 +70,39 @@ async def test_register_used_token_returns_error(client: AsyncClient) -> None: res = await client.get("/register/used", follow_redirects=False) assert res.status_code == 400 + + +async def test_register_existing_user_logs_in_and_redirects(client: AsyncClient) -> None: + """When initial-admin creates a user, the invite link should log them in.""" + app = client._transport.app # type: ignore[union-attr] + magic_link_repo = app.state.magic_link_repo + user_repo = app.state.user_repo + + # Pre-create the user (as initial-admin would) + user = User(userid="lusab-bansen", username="admin", groups=["admin", "users"]) + await user_repo.create(user) + + # Create invite for the same username + await magic_link_repo.create( + MagicLink( + token="admin-setup", + username="admin", + expires_at=datetime.now(UTC) + timedelta(hours=1), + ) + ) + + res = await client.get("/register/admin-setup", follow_redirects=False) + assert res.status_code in (302, 303) + assert "/manage/credentials" in res.headers["location"] + assert "setup=1" in res.headers["location"] + + # Token should be marked used + link = await magic_link_repo.get_by_token("admin-setup") + assert link is not None + assert link.used is True + + # Original user should still exist with original groups + existing = await user_repo.get_by_username("admin") + assert existing is not None + assert existing.userid == "lusab-bansen" + assert "admin" in existing.groups From 0c3157ea3a81a5f0a8420c604cf3c3e3055a8c18 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 10:29:44 +0100 Subject: [PATCH 2/5] feat: handle pre-existing users in register_magic_link route --- src/porchlight/authn/routes.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/porchlight/authn/routes.py b/src/porchlight/authn/routes.py index 1ded1d2..08233d8 100644 --- a/src/porchlight/authn/routes.py +++ b/src/porchlight/authn/routes.py @@ -78,9 +78,13 @@ async def register_magic_link(request: Request, token: str) -> Response: if link is None: return HTMLResponse("

Invalid or expired registration link.

", status_code=400) - userid = await generate_unique_userid(user_repo) - user = User(userid=userid, username=link.username, groups=["users"]) - await user_repo.create(user) + existing_user = await user_repo.get_by_username(link.username) + if existing_user is not None: + user = existing_user + else: + userid = await generate_unique_userid(user_repo) + user = User(userid=userid, username=link.username, groups=["users"]) + await user_repo.create(user) await magic_link_service.mark_used(token) From bcddf5d1c8674e7a436e6fa5ad3427be1bb5422e Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 11:27:36 +0100 Subject: [PATCH 3/5] 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 From 4e83c3807ebbb6d3ed6ae0d4d9d8d0c110e60378 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 11:29:13 +0100 Subject: [PATCH 4/5] feat: add initial-admin CLI command --- src/porchlight/cli.py | 35 ++++++++++++++++++++- tests/test_cli.py | 71 +++++++++++++++++++++++++++++++------------ 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/src/porchlight/cli.py b/src/porchlight/cli.py index 10f3f45..da2165b 100644 --- a/src/porchlight/cli.py +++ b/src/porchlight/cli.py @@ -6,8 +6,10 @@ import typer from porchlight.config import Settings from porchlight.invite.service import MagicLinkService +from porchlight.models import User from porchlight.store.sqlite.db import open_db -from porchlight.store.sqlite.repositories import SQLiteMagicLinkRepository +from porchlight.store.sqlite.repositories import SQLiteMagicLinkRepository, SQLiteUserRepository +from porchlight.userid import generate_unique_userid PACKAGE_DIR = Path(__file__).resolve().parent MIGRATIONS_DIR = PACKAGE_DIR / "store" / "sqlite" / "migrations" @@ -24,6 +26,25 @@ async def _create_invite(settings: Settings, username: str, ttl: int, note: str return f"{settings.issuer}/register/{link.token}" +async def _initial_admin(settings: Settings, username: str, groups: list[str]) -> str: + """Create an admin user and invite link, return the full registration URL.""" + async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db: + user_repo = SQLiteUserRepository(db) + + existing = await user_repo.get_by_username(username) + if existing is not None: + raise typer.BadParameter(f"User '{username}' already exists.") + + userid = await generate_unique_userid(user_repo) + user = User(userid=userid, username=username, groups=groups) + await user_repo.create(user) + + magic_link_repo = SQLiteMagicLinkRepository(db) + service = MagicLinkService(magic_link_repo, ttl=settings.invite_ttl) + link = await service.create(username=username, created_by="cli", note="initial admin setup") + return f"{settings.issuer}/register/{link.token}" + + @app.command() def create_invite( username: str, @@ -37,5 +58,17 @@ def create_invite( typer.echo(url) +@app.command() +def initial_admin( + username: str, + group: Annotated[Optional[list[str]], typer.Option(help="Groups to assign (repeatable)")] = None, +) -> None: + """Bootstrap the first admin user with a registration link.""" + settings = Settings() + groups = group if group is not None else ["admin", "users"] + url = asyncio.run(_initial_admin(settings, username, groups)) + typer.echo(url) + + def main() -> None: app() diff --git a/tests/test_cli.py b/tests/test_cli.py index 694b6f7..f22b61d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,16 +7,20 @@ from porchlight.cli import app runner = CliRunner() +ENV = {"OIDC_OP_ISSUER": "https://example.com"} + + +def _env(tmpdir: str) -> dict[str, str]: + return {**ENV, "OIDC_OP_SQLITE_PATH": os.path.join(tmpdir, "test.db")} + + +# -- create-invite ----------------------------------------------------------- + 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}, - ) + result = runner.invoke(app, ["create-invite", "testuser"], env=_env(tmpdir)) assert result.exit_code == 0, result.output assert "https://example.com/register/" in result.output @@ -24,12 +28,7 @@ def test_create_invite_prints_registration_url() -> None: 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}, - ) + result = runner.invoke(app, ["create-invite", "testuser", "--note", "Welcome aboard"], env=_env(tmpdir)) assert result.exit_code == 0, result.output assert "https://example.com/register/" in result.output @@ -37,17 +36,51 @@ def test_create_invite_with_note() -> None: 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}, - ) + result = runner.invoke(app, ["create-invite", "testuser", "--ttl", "3600"], env=_env(tmpdir)) 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, []) + result = runner.invoke(app, ["create-invite"]) + assert result.exit_code != 0 + + +# -- initial-admin ------------------------------------------------------------ + + +def test_initial_admin_creates_user_and_prints_url() -> None: + """initial-admin should create a user and print a registration URL.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = runner.invoke(app, ["initial-admin", "admin"], env=_env(tmpdir)) + assert result.exit_code == 0, result.output + assert "https://example.com/register/" in result.output + + +def test_initial_admin_with_custom_groups() -> None: + """initial-admin with --group should assign those groups.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = runner.invoke( + app, ["initial-admin", "admin", "--group", "admin", "--group", "superusers"], env=_env(tmpdir) + ) + assert result.exit_code == 0, result.output + assert "https://example.com/register/" in result.output + + +def test_initial_admin_duplicate_username_fails() -> None: + """initial-admin should fail if username already exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + env = _env(tmpdir) + # Create the user first + result1 = runner.invoke(app, ["initial-admin", "admin"], env=env) + assert result1.exit_code == 0, result1.output + # Try again — should fail + result2 = runner.invoke(app, ["initial-admin", "admin"], env=env) + assert result2.exit_code != 0 + + +def test_initial_admin_missing_username_shows_error() -> None: + """initial-admin without a username should show an error.""" + result = runner.invoke(app, ["initial-admin"]) assert result.exit_code != 0 From e43720cd626fee0529ef4e1f41f92d03b7d913eb Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 11:34:00 +0100 Subject: [PATCH 5/5] refactor: fix lint and type check issues in CLI module --- src/porchlight/cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/porchlight/cli.py b/src/porchlight/cli.py index da2165b..6bc5f7a 100644 --- a/src/porchlight/cli.py +++ b/src/porchlight/cli.py @@ -1,6 +1,6 @@ import asyncio from pathlib import Path -from typing import Annotated, Optional +from typing import Annotated import typer @@ -48,11 +48,11 @@ async def _initial_admin(settings: Settings, username: str, groups: list[str]) - @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, + ttl: Annotated[int | None, typer.Option(help="Link expiration in seconds")] = None, + note: Annotated[str | None, typer.Option(help="Optional note stored with the link")] = None, ) -> None: """Generate a magic link registration URL for a new user.""" - settings = Settings() + settings = Settings() # type: ignore[call-arg] 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) @@ -61,10 +61,10 @@ def create_invite( @app.command() def initial_admin( username: str, - group: Annotated[Optional[list[str]], typer.Option(help="Groups to assign (repeatable)")] = None, + group: Annotated[list[str] | None, typer.Option(help="Groups to assign (repeatable)")] = None, ) -> None: """Bootstrap the first admin user with a registration link.""" - settings = Settings() + settings = Settings() # type: ignore[call-arg] groups = group if group is not None else ["admin", "users"] url = asyncio.run(_initial_admin(settings, username, groups)) typer.echo(url)