diff --git a/docs/internal/cli-design-review.md b/docs/internal/cli-design-review.md index 74e880df..22078c5e 100644 --- a/docs/internal/cli-design-review.md +++ b/docs/internal/cli-design-review.md @@ -73,9 +73,9 @@ Docs say default: `~/.authsome/logs/authsome.log`. --- -### 1e. `profile` command exists but is not documented +### 1e. Legacy `profile` command is removed -`authsome profile` with subcommands `create` and `use` appears in the CLI but is absent from `docs/site/reference/cli.mdx`. +The local signing-key command surface is `authsome agent create` and `authsome agent use`. --- @@ -224,7 +224,7 @@ If the behavior differs for bundled vs. custom providers, the `--help` text shou | P1 | Fix `--quiet` — stop suppressing data output | Medium | | P1 | Fix `--force` on `register` — imply `--yes` or document split | Small | | P1 | Add `connection set-default` subgroup (or alias to match docs) | Small | -| P1 | Document `profile` command, `shell` export format, corrected `--log-file` path | Small | +| P1 | Document `shell` export format and corrected `--log-file` path | Small | | P2 | Resolve `inspect` vs `get` overlap — pick a clear model | Medium | | P2 | Add human-readable default to `inspect` and `daemon status` | Medium | | P2 | Fix `daemon stop` — wait for actual stop before returning | Medium | diff --git a/docs/internal/manual-testing.md b/docs/internal/manual-testing.md index 380c029e..105f7c5b 100644 --- a/docs/internal/manual-testing.md +++ b/docs/internal/manual-testing.md @@ -39,17 +39,17 @@ uv run authsome whoami **Expected (first run):** the command prints a claim URL to stderr, opens it in a browser, and blocks while polling: ``` -Open this URL in your browser to claim this identity: +Open this URL in your browser to claim this agent: http://127.0.0.1:7998/claim?token=claim_ ``` **Human action:** 1. The browser opens the claim page automatically (open the printed URL yourself if it doesn't, e.g. on a headless box). 2. Register with an email + password — or log in if the account already exists. The first account on a fresh server becomes the **admin** Principal. -3. Confirm that the displayed identity handle is yours. +3. Confirm that the displayed agent handle is yours. 4. The CLI unblocks and `whoami` prints your context. Subsequent commands reuse the accepted claim — no browser step. -**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `profile` (registered non-default identity handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`). +**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `agent` (registered non-default agent handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`). ```bash uv run authsome doctor @@ -327,20 +327,20 @@ uv run authsome provider list # github connection gone --- -## 15. Profiles +## 15. Agents ```bash -uv run authsome profile create --handle work +uv run authsome agent create --handle work ``` -**Expected:** `{"v": 1, "status": "created", "profile": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this profile triggers its own browser claim. +**Expected:** `{"v": 1, "status": "created", "agent": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this agent triggers its own browser claim. ```bash -uv run authsome profile use work -uv run authsome whoami # profile reflects "work" (claim required on first use) +uv run authsome agent use work +uv run authsome whoami # agent reflects "work" (claim required on first use) ``` -**Expected:** `profile use` → `{"status": "active", "profile": "work", ...}`. +**Expected:** `agent use` -> `{"status": "active", "agent": "work", ...}`. --- diff --git a/docs/site/reference/cli.mdx b/docs/site/reference/cli.mdx index 78cf8a17..9bc80dbc 100644 --- a/docs/site/reference/cli.mdx +++ b/docs/site/reference/cli.mdx @@ -12,11 +12,11 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress | `connections` | Inspect and manage stored provider connections. | | `daemon` | Manage the local Authsome daemon. | | `doctor` | Run health checks on directory layout and encryption. | -| `init` | Initialize local storage and register a fresh profile. | +| `agent` | Manage local agents backed by signing keys. | +| `init` | Initialize local storage and register a fresh agent. | | `log` | View structured audit entries or the raw client debug log. | | `login ` | Authenticate with PROVIDER using the configured flow. | | `logout ` | Log out of the specified PROVIDER connection. | -| `profile` | Manage local profiles backed by identity keys. | | `provider` | Manage provider definitions and provider-level operations. | | `run -- ` | Run COMMAND as a subprocess injected with authentication credentials. | | `scan` | Scan env files and process env for provider API keys. | @@ -38,8 +38,8 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress ### `init` / `whoami` / `doctor` ```bash -authsome init # initialize local storage and register profile -authsome whoami # show identity context and encryption mode +authsome init # initialize local storage and register agent +authsome whoami # show agent context and encryption mode authsome doctor # run health checks authsome doctor --json # structured output for monitoring ``` @@ -153,14 +153,14 @@ Sets the default connection for a provider. The proxy and library calls use the authsome connections set-default github work ``` -### `profile` +### `agent` ```bash -authsome profile create # create a new local profile keypair -authsome profile use # switch the active local profile +authsome agent create # create a new local agent keypair +authsome agent use # switch the active local agent ``` -Profiles are backed by Ed25519 identity keys at `~/.authsome/identities/`. Each profile has its own credential namespace in the vault. +Agents are backed by Ed25519 signing keys at `~/.authsome/identities/`. Credentials are scoped to the active vault, not to the agent key. ### `daemon` diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index 8b09fb0f..b057acff 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -53,7 +53,7 @@ def raise_for_error(response: httpx.Response) -> None: try: data = response.json() if response.status_code == status.HTTP_401_UNAUTHORIZED and data.get("detail") == "Unknown identity handle": - raise err_mod.IdentityNotRegisteredError("current identity") from exc + raise err_mod.IdentityNotRegisteredError("current agent") from exc error_name = data.get("error") message = data.get("message") if error_name and message: @@ -175,15 +175,15 @@ async def _check_server_registration(self, runtime: RuntimeIdentity) -> None: self._open_claim_url(claim_url) await self._poll_claim_completion(runtime.handle) elif reg_status == "rejected": - raise RuntimeError(f"Identity '{runtime.handle}' claim was rejected by the server") + raise RuntimeError(f"Agent '{runtime.handle}' claim was rejected by the server") def _open_claim_url(self, claim_url: str) -> None: - print(f"Open this URL in your browser to claim this identity:\n {claim_url}", file=sys.stderr) + print(f"Open this URL in your browser to claim this agent:\n {claim_url}", file=sys.stderr) with suppress(Exception): webbrowser.open(claim_url) async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 300) -> None: - print("Waiting for identity to be claimed...", file=sys.stderr) + print("Waiting for agent to be claimed...", file=sys.stderr) deadline = asyncio.get_running_loop().time() + timeout_seconds while True: status = await self.get_identity_status(handle) @@ -191,9 +191,9 @@ async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 30 if reg_status == "claimed": return if reg_status == "rejected": - raise RuntimeError(f"Identity '{handle}' claim was rejected") + raise RuntimeError(f"Agent '{handle}' claim was rejected") if asyncio.get_running_loop().time() >= deadline: - raise TimeoutError(f"Timed out waiting for identity '{handle}' to be claimed") + raise TimeoutError(f"Timed out waiting for agent '{handle}' to be claimed") await asyncio.sleep(1) async def _get(self, path: str, *, protected: bool = True) -> dict[str, Any]: diff --git a/src/authsome/cli/commands/__init__.py b/src/authsome/cli/commands/__init__.py index 7099fcc2..804870c4 100644 --- a/src/authsome/cli/commands/__init__.py +++ b/src/authsome/cli/commands/__init__.py @@ -1,9 +1,9 @@ """CLI command registration.""" +import authsome.cli.commands.agent as agent_module import authsome.cli.commands.connections as connections_module import authsome.cli.commands.core as core_module import authsome.cli.commands.daemon as daemon_module -import authsome.cli.commands.profile as profile_module import authsome.cli.commands.provider as provider_module @@ -19,5 +19,5 @@ def register_commands(cli) -> None: cli.add_command(core_module.log_cmd) cli.add_command(provider_module.provider) cli.add_command(connections_module.connections) - cli.add_command(profile_module.profile) + cli.add_command(agent_module.agent) cli.add_command(daemon_module.daemon) diff --git a/src/authsome/cli/commands/profile.py b/src/authsome/cli/commands/agent.py similarity index 56% rename from src/authsome/cli/commands/profile.py rename to src/authsome/cli/commands/agent.py index e9276a6e..c8aa1c3c 100644 --- a/src/authsome/cli/commands/profile.py +++ b/src/authsome/cli/commands/agent.py @@ -1,4 +1,4 @@ -"""Profile CLI commands.""" +"""Local agent CLI commands.""" import click @@ -9,23 +9,19 @@ from authsome.config import get_authsome_config -@click.group(name="profile") -def profile() -> None: - """Manage local profiles backed by identity keys.""" +@click.group(name="agent") +def agent() -> None: + """Manage local agents backed by signing keys.""" -@profile.command(name="create") -@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local profile handle.") -@auth_command -async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None: - """Create a local profile keypair.""" +async def _create_agent(ctx_obj: ContextObj, handle: str | None) -> None: home = get_authsome_config().home identity = RuntimeIdentity.create(home, handle) data = { "status": "created", "home": str(home), - "profile": identity.handle, + "agent": identity.handle, "did": identity.did, "registration_status": "local", "switched": True, @@ -33,18 +29,30 @@ async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None: ctx_obj.print_json(data) -@profile.command(name="use") -@click.argument("handle") -@auth_command -async def profile_use(ctx_obj: ContextObj, handle: str) -> None: - """Select the active local profile.""" +async def _use_agent(ctx_obj: ContextObj, handle: str) -> None: home = get_authsome_config().home identity = RuntimeIdentity.from_filesystem(home, handle) ClientConfig.load(home).model_copy(update={"active_identity": identity.handle}).save(home) data = { "status": "active", - "profile": identity.handle, + "agent": identity.handle, "did": identity.did, } ctx_obj.print_json(data) + + +@agent.command(name="create") +@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local agent handle.") +@auth_command +async def agent_create(ctx_obj: ContextObj, handle: str | None) -> None: + """Create a local agent keypair.""" + await _create_agent(ctx_obj, handle) + + +@agent.command(name="use") +@click.argument("handle") +@auth_command +async def agent_use(ctx_obj: ContextObj, handle: str) -> None: + """Select the active local agent.""" + await _use_agent(ctx_obj, handle) diff --git a/src/authsome/cli/commands/core.py b/src/authsome/cli/commands/core.py index 668615dc..4ec4e942 100644 --- a/src/authsome/cli/commands/core.py +++ b/src/authsome/cli/commands/core.py @@ -269,7 +269,7 @@ async def run(ctx_obj: ContextObj, command: tuple[str]) -> None: @click.command() @auth_command async def init(ctx_obj: ContextObj) -> None: - """Initialize local storage and register a fresh profile.""" + """Initialize local storage and register a fresh agent.""" home = get_authsome_config().home RuntimeIdentity.ensure_local(home) @@ -280,7 +280,7 @@ async def init(ctx_obj: ContextObj) -> None: data = { "status": "initialized", "home": str(home), - "profile": identity.handle, + "agent": identity.handle, "did": identity.did, "registration_status": "registered", "configured_encryption_mode": whoami_data.get("configured_encryption_mode"), @@ -319,10 +319,11 @@ async def whoami(ctx_obj: ContextObj) -> None: issues.append(f"connections: {exc}") vault_status = "ERROR" + agent = whoami_data.get("identity", whoami_data.get("active_identity")) data = { "authsome_version": whoami_data["version"], "home_directory": whoami_data["home"], - "profile": whoami_data.get("identity", whoami_data.get("active_identity")), + "agent": agent, "principal_id": whoami_data.get("principal_id"), "vault_id": whoami_data.get("vault_id"), "did": whoami_data.get("did"), diff --git a/src/authsome/server/account_auth.py b/src/authsome/server/account_auth.py index 73b1f750..ae5a01ca 100644 --- a/src/authsome/server/account_auth.py +++ b/src/authsome/server/account_auth.py @@ -64,11 +64,30 @@ async def login(self, *, email: str, password: str) -> BrowserSession: principal = await self._principals.get_by_email(self._normalize_email(email)) if principal is None or not principal.password_hash: raise ValueError("Invalid email or password") + self._verify_password(principal.password_hash, password, message="Invalid email or password") + return self._sessions.create_browser_session(principal_id=principal.principal_id, email=principal.email) + + async def change_password( + self, + *, + principal_id: str, + current_password: str, + new_password: str, + ) -> PrincipalRecord: + principal = await self._principals.get(principal_id) + if principal is None or not principal.password_hash: + raise ValueError("Invalid current password") + self._verify_password(principal.password_hash, current_password, message="Invalid current password") + self._validate_password(new_password) + return await self._principals.update_password(principal_id, password_hash=self._hasher.hash(new_password)) + + def _verify_password(self, password_hash: str, password: str, *, message: str) -> None: try: - self._hasher.verify(principal.password_hash, password) + self._hasher.verify(password_hash, password) except (VerificationError, VerifyMismatchError) as exc: - raise ValueError("Invalid email or password") from exc - return self._sessions.create_browser_session(principal_id=principal.principal_id, email=principal.email) + raise ValueError(message) from exc + except ValueError as exc: + raise ValueError(message) from exc @staticmethod def _normalize_email(email: str) -> str: diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index f19db76a..4730626b 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -102,6 +102,11 @@ def _account_auth_next_url(value: Any) -> str: return next_url +def _append_query(url: str, values: dict[str, str]) -> str: + separator = "&" if "?" in url else "?" + return f"{url}{separator}{urlencode(values)}" + + @router.post("/auth/providers/{provider_name}/connect", include_in_schema=False) async def connect_provider( # noqa: PLR0913 provider_name: str, @@ -243,6 +248,35 @@ async def register_account( return response +@router.post("/auth/password", include_in_schema=False) +async def change_account_password(request: Request) -> Response: + await resolve_ui_request_identity(request) + principal_id = getattr(request.state, "ui_principal_id", None) + form = await request.form() + next_url = _account_auth_next_url(form.get("next") or "/settings?tab=security") + if not principal_id: + return RedirectResponse(url=_account_auth_entry_url(next_url), status_code=status.HTTP_303_SEE_OTHER) + + try: + await request.app.state.account_auth_service.change_password( + principal_id=principal_id, + current_password=str(form.get("current_password", "")), + new_password=str(form.get("new_password", "")), + ) + except ValueError as exc: + return RedirectResponse( + url=_append_query(next_url, {"password_error": str(exc)}), + status_code=status.HTTP_303_SEE_OTHER, + ) + + audit.emit_event("account.password_changed", principal_id=principal_id, status="success") + capture_event(getattr(request.state, "ui_email", ""), "account_password_changed", {"principal_id": principal_id}) + return RedirectResponse( + url=_append_query(next_url, {"password_changed": "1"}), + status_code=status.HTTP_303_SEE_OTHER, + ) + + @router.post("/auth/login", include_in_schema=False) async def login_account( request: Request, diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index e36b08a6..1c647754 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -216,7 +216,6 @@ def export(self, batch: Sequence[Any]) -> LogRecordExportResult: future = asyncio.run_coroutine_threadsafe(self._registry.insert_many(rows), self._loop) with self._lock: self._futures.append(future) - future.result() except Exception as exc: logger.warning("Could not persist audit events: {}", exc) return LogRecordExportResult.FAILURE diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index c724fb7a..67e17cde 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -244,7 +244,7 @@ def fake_request(method, url, data=None, headers=None, timeout=None): @pytest.mark.asyncio -async def test_bootstrapped_identity_is_saved_as_active_profile(monkeypatch, tmp_path: Path) -> None: +async def test_bootstrapped_identity_is_saved_as_active_agent(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) captured: dict = {} @@ -322,7 +322,7 @@ async def test_env_identity_private_key_without_handle_errors(monkeypatch, tmp_p @pytest.mark.asyncio -async def test_env_identity_does_not_update_active_profile(monkeypatch, tmp_path: Path) -> None: +async def test_env_identity_does_not_update_active_agent(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) stored = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") ClientConfig(active_identity=stored.handle).save(tmp_path) diff --git a/tests/cli/test_identity.py b/tests/cli/test_identity.py index cf9e84a3..d1cfcb72 100644 --- a/tests/cli/test_identity.py +++ b/tests/cli/test_identity.py @@ -1,4 +1,4 @@ -"""Tests for `authsome profile` commands.""" +"""Tests for `authsome agent` commands.""" import json from pathlib import Path @@ -8,48 +8,47 @@ from authsome.cli.main import cli -class TestProfileCommands: - """Tests for local profile management commands.""" +class TestAgentCommands: + """Tests for local agent management commands.""" - def test_profile_create_writes_local_keypair(self, runner, mock_client, tmp_path: Path) -> None: + def test_root_help_shows_agent_not_legacy_profile(self, runner) -> None: + result = runner.invoke(cli, ["--log-file", "", "--help"]) + + assert result.exit_code == 0, result.output + assert "agent" in result.output + assert "profile" not in result.output + + def test_profile_command_is_removed(self, runner) -> None: + result = runner.invoke(cli, ["--log-file", "", "profile", "--help"]) + + assert result.exit_code != 0 + assert "No such command 'profile'" in result.output + + def test_agent_create_writes_local_keypair(self, runner, mock_client, tmp_path: Path) -> None: result = runner.invoke( cli, - ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"], + ["--log-file", "", "agent", "create", "--handle", "steady-wisely-boldly-0042"], ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["status"] == "created" - assert data["profile"] == "steady-wisely-boldly-0042" + assert data["agent"] == "steady-wisely-boldly-0042" assert data["switched"] is True stored = RuntimeIdentity.from_filesystem(tmp_path, "steady-wisely-boldly-0042") assert stored.did == data["did"] assert ClientConfig.load(tmp_path).active_identity == stored.handle - def test_profile_create_switches_active_profile(self, runner, mock_client, tmp_path: Path) -> None: - runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"]) - result = runner.invoke( - cli, - ["--log-file", "", "profile", "create", "--handle", "rapid-brightly-firmly-0007"], - ) - - data = json.loads(result.output) - assert result.exit_code == 0, result.output - assert data["status"] == "created" - assert data["profile"] == "rapid-brightly-firmly-0007" - assert data["switched"] is True - assert ClientConfig.load(tmp_path).active_identity == "rapid-brightly-firmly-0007" - - def test_profile_use_sets_active_identity(self, runner, mock_client, tmp_path: Path) -> None: - runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"]) - runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "rapid-brightly-firmly-0007"]) + def test_agent_use_sets_active_agent(self, runner, mock_client, tmp_path: Path) -> None: + runner.invoke(cli, ["--log-file", "", "agent", "create", "--handle", "steady-wisely-boldly-0042"]) + runner.invoke(cli, ["--log-file", "", "agent", "create", "--handle", "rapid-brightly-firmly-0007"]) stored = RuntimeIdentity.from_filesystem(tmp_path, "steady-wisely-boldly-0042") - result = runner.invoke(cli, ["--log-file", "", "profile", "use", "steady-wisely-boldly-0042"]) + result = runner.invoke(cli, ["--log-file", "", "agent", "use", "steady-wisely-boldly-0042"]) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["status"] == "active" - assert data["profile"] == stored.handle + assert data["agent"] == stored.handle assert data["did"] == stored.did assert ClientConfig.load(tmp_path).active_identity == stored.handle diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index 0a01a734..10cd6a0a 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -26,7 +26,8 @@ def test_init_removes_legacy_default_state_and_registers_identity( assert result.exit_code == 0, result.output data = json.loads(result.output) - assert data["profile"] != "default" + assert data["agent"] != "default" + assert "profile" not in data assert data["registration_status"] == "registered" assert data["configured_encryption_mode"] == "auto" assert data["effective_encryption_source"] == "local_key" @@ -38,10 +39,10 @@ def test_init_removes_legacy_default_state_and_registers_identity( config_data = ClientConfig.load(tmp_path) assert config_data.version == __version__ - assert config_data.active_identity == data["profile"] + assert config_data.active_identity == data["agent"] -def test_init_skips_registration_for_registered_active_profile( +def test_init_skips_registration_for_registered_active_agent( runner, mock_client, tmp_path: Path, @@ -53,6 +54,7 @@ def test_init_skips_registration_for_registered_active_profile( assert result.exit_code == 0, result.output data = json.loads(result.output) - assert data["profile"] == identity.handle + assert data["agent"] == identity.handle + assert "profile" not in data assert data["configured_encryption_mode"] == "auto" mock_client.ensure_identity_ready.assert_called_once() diff --git a/tests/cli/test_whoami.py b/tests/cli/test_whoami.py index c6566fe2..f0674d3f 100644 --- a/tests/cli/test_whoami.py +++ b/tests/cli/test_whoami.py @@ -40,7 +40,8 @@ def test_json_output_shape(self, runner, mock_client) -> None: assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["authsome_version"] == "1.2.3" - assert data["profile"] == "steady-wisely-boldly-0042" + assert data["agent"] == "steady-wisely-boldly-0042" + assert "profile" not in data assert data["principal_id"] == "principal_1" assert data["vault_id"] == "vault_default" assert data["vault_status"] == "OK" @@ -90,7 +91,8 @@ def test_connections_failure_keeps_whoami_usable(self, runner, mock_client) -> N assert result.exit_code == 0 data = json.loads(result.output) - assert data["profile"] == "steady-wisely-boldly-0042" + assert data["agent"] == "steady-wisely-boldly-0042" + assert "profile" not in data assert data["vault_status"] == "ERROR" assert data["connected_providers_count"] == 0 assert any("connections:" in issue for issue in data["issues"]) diff --git a/tests/server/test_account_auth.py b/tests/server/test_account_auth.py index 44fb59b3..68ba6e68 100644 --- a/tests/server/test_account_auth.py +++ b/tests/server/test_account_auth.py @@ -98,3 +98,32 @@ async def test_login_rejects_wrong_password(tmp_path: Path) -> None: await service.login(email="dev@example.com", password="wrong-password") finally: await _close(store) + + +@pytest.mark.asyncio +async def test_change_password_requires_current_password_and_updates_login(tmp_path: Path) -> None: + service, store = await _service(tmp_path) + + try: + principal = await service.register(email="dev@example.com", password="password-1") + + with pytest.raises(ValueError, match="Invalid current password"): + await service.change_password( + principal_id=principal.principal_id, + current_password="wrong-password", + new_password="password-2", + ) + + await service.change_password( + principal_id=principal.principal_id, + current_password="password-1", + new_password="password-2", + ) + + with pytest.raises(ValueError, match="Invalid email or password"): + await service.login(email="dev@example.com", password="password-1") + + session = await service.login(email="dev@example.com", password="password-2") + assert session.principal_id == principal.principal_id + finally: + await _close(store) diff --git a/tests/server/test_health_routes.py b/tests/server/test_health_routes.py index a2ef5d08..f430f477 100644 --- a/tests/server/test_health_routes.py +++ b/tests/server/test_health_routes.py @@ -15,13 +15,3 @@ def test_root_health_alias_matches_api_health(monkeypatch, tmp_path) -> None: assert root.status_code == status.HTTP_200_OK assert root.json()["status"] == "ok" assert root.json()["version"] == api.json()["version"] - - -def test_api_health_route_is_registered_once(monkeypatch, tmp_path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with create_server_test_client() as client: - openapi = client.get("/openapi.json") - - assert openapi.status_code == status.HTTP_200_OK - assert list(openapi.json()["paths"]).count("/api/health") == 1 diff --git a/tests/server/test_ui_account.py b/tests/server/test_ui_account.py new file mode 100644 index 00000000..22c1f331 --- /dev/null +++ b/tests/server/test_ui_account.py @@ -0,0 +1,40 @@ +from fastapi import status + +from tests.server.helpers import create_server_test_client + + +def test_browser_session_can_change_account_password(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + registered = client.post( + "/api/auth/register", + data={"email": "dev@example.com", "password": "password-1", "next": "/settings?tab=security"}, + follow_redirects=False, + ) + response = client.post( + "/api/auth/password", + data={ + "current_password": "password-1", + "new_password": "password-2", + "next": "/settings?tab=security", + }, + follow_redirects=False, + ) + client.post("/api/logout", follow_redirects=False) + old_login = client.post( + "/api/auth/login", + data={"email": "dev@example.com", "password": "password-1", "next": "/"}, + follow_redirects=False, + ) + new_login = client.post( + "/api/auth/login", + data={"email": "dev@example.com", "password": "password-2", "next": "/"}, + follow_redirects=False, + ) + + assert registered.status_code == status.HTTP_303_SEE_OTHER + assert response.status_code == status.HTTP_303_SEE_OTHER + assert response.headers["location"] == "/settings?tab=security&password_changed=1" + assert old_login.headers["location"] == "/login?next=%2F&error=Invalid+email+or+password&tab=login" + assert new_login.status_code == status.HTTP_303_SEE_OTHER diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 4167159f..4582ceab 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -17,7 +17,7 @@ const jetbrainsMono = JetBrains_Mono({ export const metadata: Metadata = { title: "Authsome Dashboard", - description: "Local dashboard for Authsome identities, providers, and connections.", + description: "Local dashboard for Authsome agents, providers, and connections.", }; export default function RootLayout({ diff --git a/ui/src/components/dashboard/auth-flows.tsx b/ui/src/components/dashboard/auth-flows.tsx index ff99f119..7e672822 100644 --- a/ui/src/components/dashboard/auth-flows.tsx +++ b/ui/src/components/dashboard/auth-flows.tsx @@ -134,7 +134,7 @@ export function AuthsomeClaim({ token }: { token: string }) { if (!data) { return ( ); @@ -156,7 +156,7 @@ export function AuthsomeClaim({ token }: { token: string }) { return (