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