mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
Merge pull request #444 from PR0M3TH3AN/codex/implement-relay-management-commands-and-api
Codex/implement relay management commands and api
This commit is contained in:
16
README.md
16
README.md
@@ -50,6 +50,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
- **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar.
|
||||
- **2FA Secret Issuance & Import:** Derive new TOTP secrets from your seed or import existing `otpauth://` URIs.
|
||||
- **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps.
|
||||
- **Display TOTP Codes:** Show all active 2FA codes with a countdown timer.
|
||||
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
|
||||
- **Auto‑Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
|
||||
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
|
||||
@@ -175,7 +176,8 @@ seedpass import --file "~/seedpass_backup.json"
|
||||
seedpass search "github"
|
||||
seedpass search --tags "work,personal"
|
||||
seedpass get "github"
|
||||
seedpass totp "email"
|
||||
# Retrieve a TOTP entry
|
||||
seedpass entry get "email"
|
||||
# The code is printed and copied to your clipboard
|
||||
|
||||
# Sort or filter the list view
|
||||
@@ -186,6 +188,9 @@ seedpass list --filter totp
|
||||
# on an external drive.
|
||||
```
|
||||
|
||||
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md).
|
||||
Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
|
||||
|
||||
### Vault JSON Layout
|
||||
|
||||
The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
|
||||
@@ -213,6 +218,12 @@ After successfully installing the dependencies, you can run SeedPass using the f
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
You can also use the new Typer-based CLI:
|
||||
```bash
|
||||
seedpass --help
|
||||
```
|
||||
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
|
||||
|
||||
### Running the Application
|
||||
|
||||
1. **Start the Application:**
|
||||
@@ -373,7 +384,7 @@ Back in the Settings menu you can:
|
||||
|
||||
## Running Tests
|
||||
|
||||
SeedPass includes a small suite of unit tests located under `src/tests`. After activating your virtual environment and installing dependencies, run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
|
||||
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install -r src/requirements.txt` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
|
||||
|
||||
|
||||
```bash
|
||||
@@ -441,6 +452,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an
|
||||
- **Backup Your Data:** Regularly back up your encrypted data and checksum files to prevent data loss.
|
||||
- **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays.
|
||||
- **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique.
|
||||
- **Revealing the Parent Seed:** The `vault reveal-parent-seed` command and `/api/v1/parent-seed` endpoint print your seed in plain text. Run them only in a secure environment.
|
||||
- **No PBKDF2 Salt Needed:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
|
||||
- **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications.
|
||||
- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information.
|
||||
|
@@ -4,14 +4,22 @@ This directory contains supplementary guides for using SeedPass.
|
||||
|
||||
## Quick Example: Get a TOTP Code
|
||||
|
||||
Run `seedpass totp <query>` to retrieve a time-based one-time password (TOTP). The
|
||||
`<query>` can be a label, title, or index. A progress bar shows the remaining
|
||||
Run `seedpass entry get <query>` to retrieve a time-based one-time password (TOTP).
|
||||
The `<query>` can be a label, title, or index. A progress bar shows the remaining
|
||||
seconds in the current period.
|
||||
|
||||
```bash
|
||||
$ seedpass totp "email"
|
||||
$ seedpass entry get "email"
|
||||
[##########----------] 15s
|
||||
Code: 123456
|
||||
```
|
||||
|
||||
See [advanced_cli.md](advanced_cli.md) (future feature set) for details on the upcoming advanced CLI.
|
||||
To show all stored TOTP codes with their countdown timers, run:
|
||||
|
||||
```bash
|
||||
$ seedpass entry totp-codes
|
||||
```
|
||||
|
||||
## CLI and API Reference
|
||||
|
||||
See [advanced_cli.md](advanced_cli.md) for a list of command examples. Detailed information about the REST API is available in [api_reference.md](api_reference.md). When starting the API, set `SEEDPASS_CORS_ORIGINS` if you need to allow requests from specific web origins.
|
||||
|
@@ -6,7 +6,7 @@ Welcome to the **Advanced CLI and API Documentation** for **SeedPass**, a secure
|
||||
|
||||
SeedPass uses a `noun-verb` command structure (e.g., `seedpass entry get <query>`) for a clear, scalable, and discoverable interface. You can explore the available actions for any command group with the `--help` flag (for example, `seedpass entry --help`).
|
||||
|
||||
> **Note:** These commands describe planned functionality. The advanced CLI is not yet part of the stable release but will follow the current SeedPass design of fingerprint-based profiles and a local API for secure integrations.
|
||||
The commands in this document reflect the Typer-based CLI shipped with SeedPass. Each command accepts the optional `--fingerprint` flag to operate on a specific seed profile.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,8 +20,9 @@ SeedPass uses a `noun-verb` command structure (e.g., `seedpass entry get <query>
|
||||
- [Config Commands](#config-commands)
|
||||
- [Fingerprint Commands](#fingerprint-commands)
|
||||
- [Utility Commands](#utility-commands)
|
||||
- [API Commands](#api-commands)
|
||||
3. [Detailed Command Descriptions](#detailed-command-descriptions)
|
||||
4. [Planned API Integration](#planned-api-integration)
|
||||
4. [API Integration](#api-integration)
|
||||
5. [Usage Guidelines](#usage-guidelines)
|
||||
|
||||
---
|
||||
@@ -45,12 +46,22 @@ Manage individual entries within a vault.
|
||||
|
||||
| Action | Command | Examples |
|
||||
| :--- | :--- | :--- |
|
||||
| Add a new entry | `entry add` | `seedpass entry add --type password --label "GitHub" --username "user" --length 20` |
|
||||
| Retrieve an entry's secret | `entry get` | `seedpass entry get "GitHub"` |
|
||||
| List entries | `entry list` | `seedpass entry list --sort label` |
|
||||
| Search for entries | `entry search` | `seedpass entry search "GitHub"` |
|
||||
| Modify an entry | `entry modify` | `seedpass entry modify "GitHub" --notes "New note"` |
|
||||
| Delete an entry | `entry delete` | `seedpass entry delete "GitHub"` |
|
||||
| Retrieve an entry's secret (password or TOTP code) | `entry get` | `seedpass entry get "GitHub"` |
|
||||
| Add a password entry | `entry add` | `seedpass entry add Example --length 16` |
|
||||
| Add a TOTP entry | `entry add-totp` | `seedpass entry add-totp Email --secret JBSW...` |
|
||||
| Add an SSH key entry | `entry add-ssh` | `seedpass entry add-ssh Server --index 0` |
|
||||
| Add a PGP key entry | `entry add-pgp` | `seedpass entry add-pgp Personal --user-id me@example.com` |
|
||||
| Add a Nostr key entry | `entry add-nostr` | `seedpass entry add-nostr Chat` |
|
||||
| Add a seed phrase entry | `entry add-seed` | `seedpass entry add-seed Backup --words 24` |
|
||||
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --value abc123` |
|
||||
| Add a managed account entry | `entry add-managed-account` | `seedpass entry add-managed-account Trading` |
|
||||
| Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` |
|
||||
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
|
||||
| Unarchive an entry | `entry unarchive` | `seedpass entry unarchive 1` |
|
||||
| Export all TOTP secrets | `entry export-totp` | `seedpass entry export-totp --file totp.json` |
|
||||
| Show all TOTP codes | `entry totp-codes` | `seedpass entry totp-codes` |
|
||||
|
||||
### Vault Commands
|
||||
|
||||
@@ -60,7 +71,9 @@ Manage the entire vault for a profile.
|
||||
| :--- | :--- | :--- |
|
||||
| Export the vault | `vault export` | `seedpass vault export --file backup.json` |
|
||||
| Import a vault | `vault import` | `seedpass vault import --file backup.json` |
|
||||
| Change master password | `vault changepw` | `seedpass vault changepw` |
|
||||
| Change the master password | `vault change-password` | `seedpass vault change-password` |
|
||||
| Lock the vault | `vault lock` | `seedpass vault lock` |
|
||||
| Show profile statistics | `vault stats` | `seedpass vault stats` |
|
||||
|
||||
### Nostr Commands
|
||||
|
||||
@@ -70,7 +83,6 @@ Interact with the Nostr network for backup and synchronization.
|
||||
| :--- | :--- | :--- |
|
||||
| Sync with relays | `nostr sync` | `seedpass nostr sync` |
|
||||
| Get public key | `nostr get-pubkey` | `seedpass nostr get-pubkey` |
|
||||
| Manage relays | `nostr relays` | `seedpass nostr relays --add wss://relay.example.com` |
|
||||
|
||||
### Config Commands
|
||||
|
||||
@@ -79,7 +91,7 @@ Manage profile‑specific settings.
|
||||
| Action | Command | Examples |
|
||||
| :--- | :--- | :--- |
|
||||
| Get a setting value | `config get` | `seedpass config get inactivity_timeout` |
|
||||
| Set a setting value | `config set` | `seedpass config set secret_mode true` |
|
||||
| Set a setting value | `config set` | `seedpass config set inactivity_timeout 300` |
|
||||
|
||||
### Fingerprint Commands
|
||||
|
||||
@@ -87,10 +99,10 @@ Manage seed profiles (fingerprints).
|
||||
|
||||
| Action | Command | Examples |
|
||||
| :--- | :--- | :--- |
|
||||
| Add a new profile | `fingerprint add` | `seedpass fingerprint add` |
|
||||
| List all profiles | `fingerprint list` | `seedpass fingerprint list` |
|
||||
| Remove a profile | `fingerprint remove` | `seedpass fingerprint remove <FP>` |
|
||||
| Set active profile | `fingerprint use` | `seedpass fingerprint use <FP>` |
|
||||
| Add a profile | `fingerprint add` | `seedpass fingerprint add` |
|
||||
| Remove a profile | `fingerprint remove` | `seedpass fingerprint remove <fp>` |
|
||||
| Switch profile | `fingerprint switch` | `seedpass fingerprint switch <fp>` |
|
||||
|
||||
### Utility Commands
|
||||
|
||||
@@ -100,6 +112,16 @@ Miscellaneous helper commands.
|
||||
| :--- | :--- | :--- |
|
||||
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24` |
|
||||
| Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` |
|
||||
| Update script checksum | `util update-checksum` | `seedpass util update-checksum` |
|
||||
|
||||
### API Commands
|
||||
|
||||
Run or stop the local HTTP API.
|
||||
|
||||
| Action | Command | Examples |
|
||||
| :--- | :--- | :--- |
|
||||
| Start the API | `api start` | `seedpass api start --host 0.0.0.0 --port 8000` |
|
||||
| Stop the API | `api stop` | `seedpass api stop` |
|
||||
|
||||
---
|
||||
|
||||
@@ -107,47 +129,76 @@ Miscellaneous helper commands.
|
||||
|
||||
### `entry` Commands
|
||||
|
||||
- **`seedpass entry add`** – Add a new entry. Use `--type` to specify `password`, `totp`, `ssh`, `pgp`, `nostr`, `key-value`, or `managed-account`. Include `--tags tag1,tag2` to categorize the entry.
|
||||
- **`seedpass entry get <query>`** – Retrieve the primary secret for one matching entry.
|
||||
- **`seedpass entry list`** – List entries in the vault, optionally sorted or filtered.
|
||||
- **`seedpass entry search <query>`** – Search across labels, usernames, URLs, notes, and tags.
|
||||
- **`seedpass entry modify <query>`** – Update fields on an existing entry. Use `--archive` to hide or `--restore` to un‑archive. Specify `--tags tag1,tag2` to replace the entry's tags.
|
||||
- **`seedpass entry delete <query>`** – Permanently delete an entry after confirmation.
|
||||
- **`seedpass entry search <query>`** – Search across labels, usernames, URLs and notes.
|
||||
- **`seedpass entry get <query>`** – Retrieve the password or TOTP code for one matching entry, depending on the entry's type.
|
||||
- **`seedpass entry add <label>`** – Create a new password entry. Use `--length` to set the password length and optional `--username`/`--url` values.
|
||||
- **`seedpass entry add-totp <label>`** – Create a TOTP entry. Use `--secret` to import an existing secret or `--index` to derive from the seed.
|
||||
- **`seedpass entry add-ssh <label>`** – Create an SSH key entry derived from the seed.
|
||||
- **`seedpass entry add-pgp <label>`** – Create a PGP key entry. Provide `--user-id` and `--key-type` as needed.
|
||||
- **`seedpass entry add-nostr <label>`** – Create a Nostr key entry for decentralised chat.
|
||||
- **`seedpass entry add-seed <label>`** – Store a derived seed phrase. Use `--words` to set the word count.
|
||||
- **`seedpass entry add-key-value <label>`** – Store arbitrary data with `--value`.
|
||||
- **`seedpass entry add-managed-account <label>`** – Store a BIP‑85 derived account seed.
|
||||
- **`seedpass entry modify <id>`** – Update an entry's label, username, URL or notes.
|
||||
- **`seedpass entry archive <id>`** – Mark an entry as archived so it is hidden from normal lists.
|
||||
- **`seedpass entry unarchive <id>`** – Restore an archived entry.
|
||||
- **`seedpass entry export-totp --file <path>`** – Export all stored TOTP secrets to a JSON file.
|
||||
- **`seedpass entry totp-codes`** – Display all current TOTP codes with remaining time.
|
||||
|
||||
Example retrieving a TOTP code:
|
||||
|
||||
```bash
|
||||
$ seedpass entry get "email"
|
||||
[##########----------] 15s
|
||||
Code: 123456
|
||||
```
|
||||
|
||||
### `vault` Commands
|
||||
|
||||
- **`seedpass vault export`** – Export the entire vault to an encrypted JSON file.
|
||||
- **`seedpass vault import`** – Import entries from an exported file, replacing the current vault after creating a backup.
|
||||
- **`seedpass vault changepw`** – Interactively change the master password for the current profile.
|
||||
- **`seedpass vault import`** – Import a vault from an encrypted JSON file.
|
||||
- **`seedpass vault change-password`** – Change the master password used for encryption.
|
||||
- **`seedpass vault lock`** – Clear sensitive data from memory and require reauthentication.
|
||||
- **`seedpass vault stats`** – Display statistics about the active seed profile.
|
||||
|
||||
### `nostr` Commands
|
||||
|
||||
- **`seedpass nostr sync`** – Perform a two‑way sync with configured Nostr relays.
|
||||
- **`seedpass nostr get-pubkey`** – Display the Nostr public key for the active profile.
|
||||
- **`seedpass nostr relays`** – Manage the relay list (`--list`, `--add`, `--remove`, `--reset`).
|
||||
|
||||
### `config` Commands
|
||||
|
||||
- **`seedpass config get <key>`** – Retrieve a configuration value such as `inactivity_timeout`, `secret_mode`, or `auto_sync`.
|
||||
- **`seedpass config set <key> <value>`** – Set a configuration value for the active profile.
|
||||
- **`seedpass config set <key> <value>`** – Update a configuration option. Example: `seedpass config set inactivity_timeout 300`.
|
||||
- **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay.
|
||||
|
||||
### `fingerprint` Commands
|
||||
|
||||
- **`seedpass fingerprint add`** – Add a new seed profile (interactive or via `--import-seed`).
|
||||
- **`seedpass fingerprint list`** – List available profiles by fingerprint.
|
||||
- **`seedpass fingerprint remove <FP>`** – Delete a profile and its data after confirmation.
|
||||
- **`seedpass fingerprint use <FP>`** – Make the given fingerprint active in the current shell session.
|
||||
- **`seedpass fingerprint add`** – Create a new seed profile.
|
||||
- **`seedpass fingerprint remove <fp>`** – Delete the specified profile.
|
||||
- **`seedpass fingerprint switch <fp>`** – Switch the active profile.
|
||||
|
||||
### `util` Commands
|
||||
|
||||
- **`seedpass util generate-password`** – Generate a strong password of the requested length.
|
||||
- **`seedpass util verify-checksum`** – Verify the program checksum for integrity.
|
||||
- **`seedpass util verify-checksum`** – Verify the SeedPass script checksum.
|
||||
- **`seedpass util update-checksum`** – Regenerate the script checksum file.
|
||||
|
||||
---
|
||||
|
||||
## Planned API Integration
|
||||
## API Integration
|
||||
|
||||
The advanced CLI will act as a client for a locally hosted REST API. Starting the API loads the vault into memory after prompting for the master password and prints a temporary API key. Third‑party clients include this key in the `Authorization` header when calling endpoints such as `GET /api/v1/entry?query=GitHub`. The server automatically shuts down after a period of inactivity or when `seedpass api stop` is run.
|
||||
SeedPass provides a small REST API for automation. Run `seedpass api start` to launch the server. The command prints a one‑time token which clients must include in the `Authorization` header.
|
||||
|
||||
Set the `SEEDPASS_CORS_ORIGINS` environment variable to a comma‑separated list of allowed origins when you need cross‑origin requests:
|
||||
|
||||
```bash
|
||||
SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
|
||||
```
|
||||
|
||||
Shut down the server with `seedpass api stop`.
|
||||
|
||||
---
|
||||
|
||||
|
137
docs/api_reference.md
Normal file
137
docs/api_reference.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# SeedPass REST API Reference
|
||||
|
||||
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
|
||||
|
||||
## Starting the API
|
||||
|
||||
Run `seedpass api start` from your terminal. The command prints a one‑time token used for authentication:
|
||||
|
||||
```bash
|
||||
$ seedpass api start
|
||||
API token: abcdef1234567890
|
||||
```
|
||||
|
||||
Keep this token secret. Every request must include it in the `Authorization` header using the `Bearer` scheme.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /api/v1/entry?query=<text>` – Search entries matching a query.
|
||||
- `GET /api/v1/entry/{id}` – Retrieve a single entry by its index.
|
||||
- `POST /api/v1/entry` – Create a new entry of any supported type.
|
||||
- `PUT /api/v1/entry/{id}` – Modify an existing entry.
|
||||
- `PUT /api/v1/config/{key}` – Update a configuration value.
|
||||
- `POST /api/v1/secret-mode` – Enable or disable Secret Mode and set the clipboard delay.
|
||||
- `POST /api/v1/entry/{id}/archive` – Archive an entry.
|
||||
- `POST /api/v1/entry/{id}/unarchive` – Unarchive an entry.
|
||||
- `GET /api/v1/config/{key}` – Return the value for a configuration key.
|
||||
- `GET /api/v1/fingerprint` – List available seed fingerprints.
|
||||
- `POST /api/v1/fingerprint` – Add a new seed fingerprint.
|
||||
- `DELETE /api/v1/fingerprint/{fp}` – Remove a fingerprint.
|
||||
- `POST /api/v1/fingerprint/select` – Switch the active fingerprint.
|
||||
- `GET /api/v1/totp/export` – Export all TOTP entries as JSON.
|
||||
- `GET /api/v1/totp` – Return current TOTP codes and remaining time.
|
||||
- `GET /api/v1/stats` – Return statistics about the active seed profile.
|
||||
- `GET /api/v1/parent-seed` – Reveal the parent seed or save it with `?file=`.
|
||||
- `GET /api/v1/nostr/pubkey` – Fetch the Nostr public key for the active seed.
|
||||
- `POST /api/v1/checksum/verify` – Verify the checksum of the running script.
|
||||
- `POST /api/v1/checksum/update` – Update the stored script checksum.
|
||||
- `POST /api/v1/change-password` – Change the master password for the active profile.
|
||||
- `POST /api/v1/vault/import` – Import a vault backup from a file or path.
|
||||
- `POST /api/v1/vault/lock` – Lock the vault and clear sensitive data from memory.
|
||||
- `POST /api/v1/shutdown` – Stop the server gracefully.
|
||||
|
||||
**Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment.
|
||||
|
||||
## Example Requests
|
||||
|
||||
Send requests with the token in the header:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
"http://127.0.0.1:8000/api/v1/entry?query=email"
|
||||
```
|
||||
|
||||
### Creating an Entry
|
||||
|
||||
`POST /api/v1/entry` accepts a JSON body with at least a `label` field. Set
|
||||
`type` (or `kind`) to choose the entry variant (`password`, `totp`, `ssh`, `pgp`,
|
||||
`nostr`, `seed`, `key_value`, or `managed_account`). Additional fields vary by
|
||||
type:
|
||||
|
||||
- **password** – `length`, optional `username`, `url` and `notes`
|
||||
- **totp** – `secret` or `index`, optional `period`, `digits`, `notes`, `archived`
|
||||
- **ssh/nostr/seed/managed_account** – `index`, optional `notes`, `archived`
|
||||
- **pgp** – `index`, `key_type`, `user_id`, optional `notes`, `archived`
|
||||
- **key_value** – `value`, optional `notes`
|
||||
|
||||
Example creating a TOTP entry:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/entry \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type": "totp", "label": "Email", "secret": "JBSW..."}'
|
||||
```
|
||||
|
||||
### Updating an Entry
|
||||
|
||||
Use `PUT /api/v1/entry/{id}` to change fields such as `label`, `username`,
|
||||
`url`, `notes`, `period`, `digits` or `value` depending on the entry type.
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8000/api/v1/entry/1 \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "alice"}'
|
||||
```
|
||||
|
||||
### Updating Configuration
|
||||
|
||||
Send a JSON body containing a `value` field to `PUT /api/v1/config/{key}`:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8000/api/v1/config/inactivity_timeout \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": 300}'
|
||||
```
|
||||
|
||||
### Toggling Secret Mode
|
||||
|
||||
Send both `enabled` and `delay` values to `/api/v1/secret-mode`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/secret-mode \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"enabled": true, "delay": 20}'
|
||||
```
|
||||
|
||||
### Switching Fingerprints
|
||||
|
||||
Change the active seed profile via `POST /api/v1/fingerprint/select`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/fingerprint/select \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"fingerprint": "abc123"}'
|
||||
```
|
||||
|
||||
### Enabling CORS
|
||||
|
||||
Cross‑origin requests are disabled by default. Set `SEEDPASS_CORS_ORIGINS` to a comma‑separated list of allowed origins before starting the API:
|
||||
|
||||
```bash
|
||||
SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
|
||||
```
|
||||
|
||||
Browsers can then call the API from the specified origins, for example using JavaScript:
|
||||
|
||||
```javascript
|
||||
fetch('http://127.0.0.1:8000/api/v1/entry?query=email', {
|
||||
headers: { Authorization: 'Bearer <token>' }
|
||||
});
|
||||
```
|
||||
|
||||
Without CORS enabled, only same‑origin or command‑line tools like `curl` can access the API.
|
@@ -1,3 +1,10 @@
|
||||
[project]
|
||||
name = "seedpass"
|
||||
version = "0.1.0"
|
||||
|
||||
[project.scripts]
|
||||
seedpass = "seedpass.cli:app"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
|
@@ -19,6 +19,7 @@ cryptography==45.0.4
|
||||
ecdsa==0.19.1
|
||||
ed25519-blake2b==1.4.1
|
||||
execnet==2.1.1
|
||||
fastapi==0.116.0
|
||||
frozenlist==1.7.0
|
||||
glob2==0.7
|
||||
hypothesis==6.135.20
|
||||
@@ -57,7 +58,10 @@ termcolor==3.1.0
|
||||
toml==0.10.2
|
||||
tomli==2.2.1
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.35.0
|
||||
httpx==0.28.1
|
||||
varint==1.0.2
|
||||
websocket-client==1.7.0
|
||||
websockets==15.0.1
|
||||
yarl==1.20.1
|
||||
typer==0.12.3
|
||||
|
@@ -597,6 +597,35 @@ class EntryManager:
|
||||
period = int(entry.get("period", 30))
|
||||
return TotpManager.time_remaining(period)
|
||||
|
||||
def export_totp_entries(self, parent_seed: str) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Return all TOTP secrets and metadata for external use."""
|
||||
data = self.vault.load_index()
|
||||
entries = data.get("entries", {})
|
||||
exported: list[dict[str, Any]] = []
|
||||
for entry in entries.values():
|
||||
etype = entry.get("type", entry.get("kind"))
|
||||
if etype != EntryType.TOTP.value:
|
||||
continue
|
||||
label = entry.get("label", "")
|
||||
period = int(entry.get("period", 30))
|
||||
digits = int(entry.get("digits", 6))
|
||||
if "secret" in entry:
|
||||
secret = entry["secret"]
|
||||
else:
|
||||
idx = int(entry.get("index", 0))
|
||||
secret = TotpManager.derive_secret(parent_seed, idx)
|
||||
uri = TotpManager.make_otpauth_uri(label, secret, period, digits)
|
||||
exported.append(
|
||||
{
|
||||
"label": label,
|
||||
"secret": secret,
|
||||
"period": period,
|
||||
"digits": digits,
|
||||
"uri": uri,
|
||||
}
|
||||
)
|
||||
return {"entries": exported}
|
||||
|
||||
def get_encrypted_index(self) -> Optional[bytes]:
|
||||
"""
|
||||
Retrieves the encrypted password index file's contents.
|
||||
|
@@ -3309,9 +3309,15 @@ class PasswordManager:
|
||||
print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
|
||||
return None
|
||||
|
||||
def handle_backup_reveal_parent_seed(self) -> None:
|
||||
"""
|
||||
Handles the backup and reveal of the parent seed.
|
||||
def handle_backup_reveal_parent_seed(self, file: Path | None = None) -> None:
|
||||
"""Reveal the parent seed and optionally save an encrypted backup.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
file:
|
||||
Optional path where an encrypted backup should be written. When
|
||||
provided, the confirmation and filename prompts are skipped and the
|
||||
seed is saved directly to this location.
|
||||
"""
|
||||
try:
|
||||
fp, parent_fp, child_fp = self.header_fingerprint_args
|
||||
@@ -3360,24 +3366,26 @@ class PasswordManager:
|
||||
)
|
||||
)
|
||||
|
||||
# Option to save to file with default filename
|
||||
if confirm_action(
|
||||
"Do you want to save this to an encrypted backup file? (Y/N): "
|
||||
):
|
||||
filename = input(
|
||||
f"Enter filename to save (default: {DEFAULT_SEED_BACKUP_FILENAME}): "
|
||||
).strip()
|
||||
filename = filename if filename else DEFAULT_SEED_BACKUP_FILENAME
|
||||
backup_path = (
|
||||
self.fingerprint_dir / filename
|
||||
) # Save in fingerprint directory
|
||||
backup_path: Path | None = None
|
||||
if file is not None:
|
||||
backup_path = file
|
||||
save = True
|
||||
else:
|
||||
save = confirm_action(
|
||||
"Do you want to save this to an encrypted backup file? (Y/N): "
|
||||
)
|
||||
if save:
|
||||
filename = input(
|
||||
f"Enter filename to save (default: {DEFAULT_SEED_BACKUP_FILENAME}): "
|
||||
).strip()
|
||||
filename = filename if filename else DEFAULT_SEED_BACKUP_FILENAME
|
||||
backup_path = self.fingerprint_dir / filename
|
||||
|
||||
# Validate filename
|
||||
if not self.is_valid_filename(filename):
|
||||
if save and backup_path is not None:
|
||||
if not self.is_valid_filename(backup_path.name):
|
||||
print(colored("Invalid filename. Operation aborted.", "red"))
|
||||
return
|
||||
|
||||
# Encrypt and save the parent seed to the backup path
|
||||
self.encryption_manager.encrypt_and_save_file(
|
||||
self.parent_seed.encode("utf-8"), backup_path
|
||||
)
|
||||
|
@@ -24,3 +24,9 @@ pyotp>=2.8.0
|
||||
freezegun
|
||||
pyperclip
|
||||
qrcode>=8.2
|
||||
typer>=0.12.3
|
||||
fastapi>=0.116.0
|
||||
uvicorn>=0.35.0
|
||||
httpx>=0.28.1
|
||||
requests>=2.32
|
||||
python-multipart
|
||||
|
0
src/seedpass/__init__.py
Normal file
0
src/seedpass/__init__.py
Normal file
457
src/seedpass/api.py
Normal file
457
src/seedpass/api.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""SeedPass FastAPI server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, Request
|
||||
import asyncio
|
||||
import sys
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from password_manager.entry_types import EntryType
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
_pm: Optional[PasswordManager] = None
|
||||
_token: str = ""
|
||||
|
||||
|
||||
def _check_token(auth: str | None) -> None:
|
||||
if auth != f"Bearer {_token}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
def start_server(fingerprint: str | None = None) -> str:
|
||||
"""Initialize global state and return the API token.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fingerprint:
|
||||
Optional seed profile fingerprint to select before starting the server.
|
||||
"""
|
||||
global _pm, _token
|
||||
_pm = PasswordManager()
|
||||
if fingerprint:
|
||||
_pm.select_fingerprint(fingerprint)
|
||||
_token = secrets.token_urlsafe(16)
|
||||
print(f"API token: {_token}")
|
||||
origins = [
|
||||
o.strip()
|
||||
for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",")
|
||||
if o.strip()
|
||||
]
|
||||
if origins and app.middleware_stack is None:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
return _token
|
||||
|
||||
|
||||
@app.get("/api/v1/entry")
|
||||
def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]:
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
results = _pm.entry_manager.search_entries(query)
|
||||
return [
|
||||
{
|
||||
"id": idx,
|
||||
"label": label,
|
||||
"username": username,
|
||||
"url": url,
|
||||
"archived": archived,
|
||||
}
|
||||
for idx, label, username, url, archived in results
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/v1/entry/{entry_id}")
|
||||
def get_entry(entry_id: int, authorization: str | None = Header(None)) -> Any:
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
entry = _pm.entry_manager.retrieve_entry(entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return entry
|
||||
|
||||
|
||||
@app.post("/api/v1/entry")
|
||||
def create_entry(
|
||||
entry: dict,
|
||||
authorization: str | None = Header(None),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new entry.
|
||||
|
||||
If ``entry['type']`` or ``entry['kind']`` specifies ``totp``, ``ssh`` and so
|
||||
on, the corresponding entry type is created. When omitted or set to
|
||||
``password`` the behaviour matches the legacy password-entry API.
|
||||
"""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
|
||||
etype = (entry.get("type") or entry.get("kind") or "password").lower()
|
||||
|
||||
if etype == "password":
|
||||
index = _pm.entry_manager.add_entry(
|
||||
entry.get("label"),
|
||||
int(entry.get("length", 12)),
|
||||
entry.get("username"),
|
||||
entry.get("url"),
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
if etype == "totp":
|
||||
index = _pm.entry_manager.get_next_index()
|
||||
uri = _pm.entry_manager.add_totp(
|
||||
entry.get("label"),
|
||||
_pm.parent_seed,
|
||||
secret=entry.get("secret"),
|
||||
index=entry.get("index"),
|
||||
period=int(entry.get("period", 30)),
|
||||
digits=int(entry.get("digits", 6)),
|
||||
notes=entry.get("notes", ""),
|
||||
archived=entry.get("archived", False),
|
||||
)
|
||||
return {"id": index, "uri": uri}
|
||||
|
||||
if etype == "ssh":
|
||||
index = _pm.entry_manager.add_ssh_key(
|
||||
entry.get("label"),
|
||||
_pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
notes=entry.get("notes", ""),
|
||||
archived=entry.get("archived", False),
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
if etype == "pgp":
|
||||
index = _pm.entry_manager.add_pgp_key(
|
||||
entry.get("label"),
|
||||
_pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
key_type=entry.get("key_type", "ed25519"),
|
||||
user_id=entry.get("user_id", ""),
|
||||
notes=entry.get("notes", ""),
|
||||
archived=entry.get("archived", False),
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
if etype == "nostr":
|
||||
index = _pm.entry_manager.add_nostr_key(
|
||||
entry.get("label"),
|
||||
index=entry.get("index"),
|
||||
notes=entry.get("notes", ""),
|
||||
archived=entry.get("archived", False),
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
if etype == "key_value":
|
||||
index = _pm.entry_manager.add_key_value(
|
||||
entry.get("label"),
|
||||
entry.get("value"),
|
||||
notes=entry.get("notes", ""),
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
if etype in {"seed", "managed_account"}:
|
||||
func = (
|
||||
_pm.entry_manager.add_seed
|
||||
if etype == "seed"
|
||||
else _pm.entry_manager.add_managed_account
|
||||
)
|
||||
index = func(
|
||||
entry.get("label"),
|
||||
_pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
notes=entry.get("notes", ""),
|
||||
)
|
||||
return {"id": index}
|
||||
|
||||
raise HTTPException(status_code=400, detail="Unsupported entry type")
|
||||
|
||||
|
||||
@app.put("/api/v1/entry/{entry_id}")
|
||||
def update_entry(
|
||||
entry_id: int,
|
||||
entry: dict,
|
||||
authorization: str | None = Header(None),
|
||||
) -> dict[str, str]:
|
||||
"""Update an existing entry.
|
||||
|
||||
Additional fields like ``period``, ``digits`` and ``value`` are forwarded for
|
||||
specialized entry types (e.g. TOTP or key/value entries).
|
||||
"""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
_pm.entry_manager.modify_entry(
|
||||
entry_id,
|
||||
username=entry.get("username"),
|
||||
url=entry.get("url"),
|
||||
notes=entry.get("notes"),
|
||||
label=entry.get("label"),
|
||||
period=entry.get("period"),
|
||||
digits=entry.get("digits"),
|
||||
value=entry.get("value"),
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/v1/entry/{entry_id}/archive")
|
||||
def archive_entry(
|
||||
entry_id: int, authorization: str | None = Header(None)
|
||||
) -> dict[str, str]:
|
||||
"""Archive an entry."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
_pm.entry_manager.archive_entry(entry_id)
|
||||
return {"status": "archived"}
|
||||
|
||||
|
||||
@app.post("/api/v1/entry/{entry_id}/unarchive")
|
||||
def unarchive_entry(
|
||||
entry_id: int, authorization: str | None = Header(None)
|
||||
) -> dict[str, str]:
|
||||
"""Restore an archived entry."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
_pm.entry_manager.restore_entry(entry_id)
|
||||
return {"status": "active"}
|
||||
|
||||
|
||||
@app.get("/api/v1/config/{key}")
|
||||
def get_config(key: str, authorization: str | None = Header(None)) -> Any:
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
value = _pm.config_manager.load_config(require_pin=False).get(key)
|
||||
if value is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return {"key": key, "value": value}
|
||||
|
||||
|
||||
@app.put("/api/v1/config/{key}")
|
||||
def update_config(
|
||||
key: str, data: dict, authorization: str | None = Header(None)
|
||||
) -> dict[str, str]:
|
||||
"""Update a configuration setting."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
cfg = _pm.config_manager
|
||||
mapping = {
|
||||
"relays": lambda v: cfg.set_relays(v, require_pin=False),
|
||||
"pin": cfg.set_pin,
|
||||
"password_hash": cfg.set_password_hash,
|
||||
"inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)),
|
||||
"additional_backup_path": cfg.set_additional_backup_path,
|
||||
"secret_mode_enabled": cfg.set_secret_mode_enabled,
|
||||
"clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)),
|
||||
}
|
||||
|
||||
action = mapping.get(key)
|
||||
if action is None:
|
||||
raise HTTPException(status_code=400, detail="Unknown key")
|
||||
|
||||
if "value" not in data:
|
||||
raise HTTPException(status_code=400, detail="Missing value")
|
||||
|
||||
action(data["value"])
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/v1/secret-mode")
|
||||
def set_secret_mode(
|
||||
data: dict, authorization: str | None = Header(None)
|
||||
) -> dict[str, str]:
|
||||
"""Enable/disable secret mode and set the clipboard delay."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
enabled = data.get("enabled")
|
||||
delay = data.get("delay")
|
||||
if enabled is None or delay is None:
|
||||
raise HTTPException(status_code=400, detail="Missing fields")
|
||||
cfg = _pm.config_manager
|
||||
cfg.set_secret_mode_enabled(bool(enabled))
|
||||
cfg.set_clipboard_clear_delay(int(delay))
|
||||
_pm.secret_mode_enabled = bool(enabled)
|
||||
_pm.clipboard_clear_delay = int(delay)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/v1/fingerprint")
|
||||
def list_fingerprints(authorization: str | None = Header(None)) -> List[str]:
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
return _pm.fingerprint_manager.list_fingerprints()
|
||||
|
||||
|
||||
@app.post("/api/v1/fingerprint")
|
||||
def add_fingerprint(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||
"""Create a new seed profile."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
_pm.add_new_fingerprint()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/api/v1/fingerprint/{fingerprint}")
|
||||
def remove_fingerprint(
|
||||
fingerprint: str, authorization: str | None = Header(None)
|
||||
) -> dict[str, str]:
|
||||
"""Remove a seed profile."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
_pm.fingerprint_manager.remove_fingerprint(fingerprint)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@app.post("/api/v1/fingerprint/select")
|
||||
def select_fingerprint(
|
||||
data: dict, authorization: str | None = Header(None)
|
||||
) -> dict[str, str]:
|
||||
"""Switch the active seed profile."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
fp = data.get("fingerprint")
|
||||
if not fp:
|
||||
raise HTTPException(status_code=400, detail="Missing fingerprint")
|
||||
_pm.select_fingerprint(fp)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/v1/totp/export")
|
||||
def export_totp(authorization: str | None = Header(None)) -> dict:
|
||||
"""Return all stored TOTP entries in JSON format."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
return _pm.entry_manager.export_totp_entries(_pm.parent_seed)
|
||||
|
||||
|
||||
@app.get("/api/v1/totp")
|
||||
def get_totp_codes(authorization: str | None = Header(None)) -> dict:
|
||||
"""Return active TOTP codes with remaining seconds."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
entries = _pm.entry_manager.list_entries(
|
||||
filter_kind=EntryType.TOTP.value, include_archived=False
|
||||
)
|
||||
codes = []
|
||||
for idx, label, _u, _url, _arch in entries:
|
||||
code = _pm.entry_manager.get_totp_code(idx, _pm.parent_seed)
|
||||
rem = _pm.entry_manager.get_totp_time_remaining(idx)
|
||||
codes.append(
|
||||
{"id": idx, "label": label, "code": code, "seconds_remaining": rem}
|
||||
)
|
||||
return {"codes": codes}
|
||||
|
||||
|
||||
@app.get("/api/v1/stats")
|
||||
def get_profile_stats(authorization: str | None = Header(None)) -> dict:
|
||||
"""Return statistics about the active seed profile."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
return _pm.get_profile_stats()
|
||||
|
||||
|
||||
@app.get("/api/v1/parent-seed")
|
||||
def get_parent_seed(
|
||||
authorization: str | None = Header(None), file: str | None = None
|
||||
) -> dict:
|
||||
"""Return the parent seed or save it as an encrypted backup."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
if file:
|
||||
path = Path(file)
|
||||
_pm.encryption_manager.encrypt_and_save_file(
|
||||
_pm.parent_seed.encode("utf-8"), path
|
||||
)
|
||||
return {"status": "saved", "path": str(path)}
|
||||
return {"seed": _pm.parent_seed}
|
||||
|
||||
|
||||
@app.get("/api/v1/nostr/pubkey")
|
||||
def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any:
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
return {"npub": _pm.nostr_client.key_manager.get_npub()}
|
||||
|
||||
|
||||
@app.post("/api/v1/checksum/verify")
|
||||
def verify_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||
"""Verify the SeedPass script checksum."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
_pm.handle_verify_checksum()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/v1/checksum/update")
|
||||
def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||
"""Regenerate the script checksum file."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
_pm.handle_update_script_checksum()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/v1/vault/import")
|
||||
async def import_vault(
|
||||
request: Request, authorization: str | None = Header(None)
|
||||
) -> dict[str, str]:
|
||||
"""Import a vault backup from a file upload or a server path."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
|
||||
ctype = request.headers.get("content-type", "")
|
||||
if ctype.startswith("multipart/form-data"):
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
if file is None:
|
||||
raise HTTPException(status_code=400, detail="Missing file")
|
||||
data = await file.read()
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
_pm.handle_import_database(tmp_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
else:
|
||||
body = await request.json()
|
||||
path = body.get("path")
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="Missing file or path")
|
||||
_pm.handle_import_database(Path(path))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/v1/change-password")
|
||||
def change_password(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||
"""Change the master password for the active profile."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
_pm.change_password()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/v1/vault/lock")
|
||||
def lock_vault(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||
"""Lock the vault and clear sensitive data from memory."""
|
||||
_check_token(authorization)
|
||||
assert _pm is not None
|
||||
_pm.lock_vault()
|
||||
return {"status": "locked"}
|
||||
|
||||
|
||||
@app.post("/api/v1/shutdown")
|
||||
async def shutdown_server(authorization: str | None = Header(None)) -> dict[str, str]:
|
||||
_check_token(authorization)
|
||||
asyncio.get_event_loop().call_soon(sys.exit, 0)
|
||||
return {"status": "shutting down"}
|
575
src/seedpass/cli.py
Normal file
575
src/seedpass/cli.py
Normal file
@@ -0,0 +1,575 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
import typer
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from password_manager.entry_types import EntryType
|
||||
import uvicorn
|
||||
from . import api as api_module
|
||||
|
||||
app = typer.Typer(help="SeedPass command line interface")
|
||||
|
||||
# Global option shared across all commands
|
||||
fingerprint_option = typer.Option(
|
||||
None,
|
||||
"--fingerprint",
|
||||
"-f",
|
||||
help="Specify which seed profile to use",
|
||||
)
|
||||
|
||||
# Sub command groups
|
||||
entry_app = typer.Typer(help="Manage individual entries")
|
||||
vault_app = typer.Typer(help="Manage the entire vault")
|
||||
nostr_app = typer.Typer(help="Interact with Nostr relays")
|
||||
config_app = typer.Typer(help="Get or set configuration values")
|
||||
fingerprint_app = typer.Typer(help="Manage seed profiles")
|
||||
util_app = typer.Typer(help="Utility commands")
|
||||
api_app = typer.Typer(help="Run the API server")
|
||||
|
||||
app.add_typer(entry_app, name="entry")
|
||||
app.add_typer(vault_app, name="vault")
|
||||
app.add_typer(nostr_app, name="nostr")
|
||||
app.add_typer(config_app, name="config")
|
||||
app.add_typer(fingerprint_app, name="fingerprint")
|
||||
app.add_typer(util_app, name="util")
|
||||
app.add_typer(api_app, name="api")
|
||||
|
||||
|
||||
def _get_pm(ctx: typer.Context) -> PasswordManager:
|
||||
"""Return a PasswordManager optionally selecting a fingerprint."""
|
||||
pm = PasswordManager()
|
||||
fp = ctx.obj.get("fingerprint")
|
||||
if fp:
|
||||
# `select_fingerprint` will initialize managers
|
||||
pm.select_fingerprint(fp)
|
||||
return pm
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None:
|
||||
"""SeedPass CLI entry point."""
|
||||
ctx.obj = {"fingerprint": fingerprint}
|
||||
|
||||
|
||||
@entry_app.command("list")
|
||||
def entry_list(
|
||||
ctx: typer.Context,
|
||||
sort: str = typer.Option(
|
||||
"index", "--sort", help="Sort by 'index', 'label', or 'username'"
|
||||
),
|
||||
kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
|
||||
archived: bool = typer.Option(False, "--archived", help="Include archived"),
|
||||
) -> None:
|
||||
"""List entries in the vault."""
|
||||
pm = _get_pm(ctx)
|
||||
entries = pm.entry_manager.list_entries(
|
||||
sort_by=sort, filter_kind=kind, include_archived=archived
|
||||
)
|
||||
for idx, label, username, url, is_archived in entries:
|
||||
line = f"{idx}: {label}"
|
||||
if username:
|
||||
line += f" ({username})"
|
||||
if url:
|
||||
line += f" {url}"
|
||||
if is_archived:
|
||||
line += " [archived]"
|
||||
typer.echo(line)
|
||||
|
||||
|
||||
@entry_app.command("search")
|
||||
def entry_search(ctx: typer.Context, query: str) -> None:
|
||||
"""Search entries."""
|
||||
pm = _get_pm(ctx)
|
||||
results = pm.entry_manager.search_entries(query)
|
||||
if not results:
|
||||
typer.echo("No matching entries found")
|
||||
return
|
||||
for idx, label, username, url, _arch in results:
|
||||
line = f"{idx}: {label}"
|
||||
if username:
|
||||
line += f" ({username})"
|
||||
if url:
|
||||
line += f" {url}"
|
||||
typer.echo(line)
|
||||
|
||||
|
||||
@entry_app.command("get")
|
||||
def entry_get(ctx: typer.Context, query: str) -> None:
|
||||
"""Retrieve a single entry's secret."""
|
||||
pm = _get_pm(ctx)
|
||||
matches = pm.entry_manager.search_entries(query)
|
||||
if len(matches) == 0:
|
||||
typer.echo("No matching entries found")
|
||||
raise typer.Exit(code=1)
|
||||
if len(matches) > 1:
|
||||
typer.echo("Matches:")
|
||||
for idx, label, username, _url, _arch in matches:
|
||||
name = f"{idx}: {label}"
|
||||
if username:
|
||||
name += f" ({username})"
|
||||
typer.echo(name)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
index = matches[0][0]
|
||||
entry = pm.entry_manager.retrieve_entry(index)
|
||||
etype = entry.get("type", entry.get("kind"))
|
||||
if etype == EntryType.PASSWORD.value:
|
||||
length = int(entry.get("length", 12))
|
||||
password = pm.password_generator.generate_password(length, index)
|
||||
typer.echo(password)
|
||||
elif etype == EntryType.TOTP.value:
|
||||
code = pm.entry_manager.get_totp_code(index, pm.parent_seed)
|
||||
typer.echo(code)
|
||||
else:
|
||||
typer.echo("Unsupported entry type")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@entry_app.command("add")
|
||||
def entry_add(
|
||||
ctx: typer.Context,
|
||||
label: str,
|
||||
length: int = typer.Option(12, "--length"),
|
||||
username: Optional[str] = typer.Option(None, "--username"),
|
||||
url: Optional[str] = typer.Option(None, "--url"),
|
||||
) -> None:
|
||||
"""Add a new password entry and output its index."""
|
||||
pm = _get_pm(ctx)
|
||||
index = pm.entry_manager.add_entry(label, length, username, url)
|
||||
typer.echo(str(index))
|
||||
|
||||
|
||||
@entry_app.command("add-totp")
|
||||
def entry_add_totp(
|
||||
ctx: typer.Context,
|
||||
label: str,
|
||||
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
||||
secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"),
|
||||
period: int = typer.Option(30, "--period", help="TOTP period in seconds"),
|
||||
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
|
||||
) -> None:
|
||||
"""Add a TOTP entry and output the otpauth URI."""
|
||||
pm = _get_pm(ctx)
|
||||
uri = pm.entry_manager.add_totp(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
secret=secret,
|
||||
period=period,
|
||||
digits=digits,
|
||||
)
|
||||
typer.echo(uri)
|
||||
|
||||
|
||||
@entry_app.command("add-ssh")
|
||||
def entry_add_ssh(
|
||||
ctx: typer.Context,
|
||||
label: str,
|
||||
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
) -> None:
|
||||
"""Add an SSH key entry and output its index."""
|
||||
pm = _get_pm(ctx)
|
||||
idx = pm.entry_manager.add_ssh_key(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
|
||||
|
||||
@entry_app.command("add-pgp")
|
||||
def entry_add_pgp(
|
||||
ctx: typer.Context,
|
||||
label: str,
|
||||
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
||||
key_type: str = typer.Option("ed25519", "--key-type", help="Key type"),
|
||||
user_id: str = typer.Option("", "--user-id", help="User ID"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
) -> None:
|
||||
"""Add a PGP key entry and output its index."""
|
||||
pm = _get_pm(ctx)
|
||||
idx = pm.entry_manager.add_pgp_key(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
key_type=key_type,
|
||||
user_id=user_id,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
|
||||
|
||||
@entry_app.command("add-nostr")
|
||||
def entry_add_nostr(
|
||||
ctx: typer.Context,
|
||||
label: str,
|
||||
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
) -> None:
|
||||
"""Add a Nostr key entry and output its index."""
|
||||
pm = _get_pm(ctx)
|
||||
idx = pm.entry_manager.add_nostr_key(
|
||||
label,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
|
||||
|
||||
@entry_app.command("add-seed")
|
||||
def entry_add_seed(
|
||||
ctx: typer.Context,
|
||||
label: str,
|
||||
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
||||
words: int = typer.Option(24, "--words", help="Word count"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
) -> None:
|
||||
"""Add a derived seed phrase entry and output its index."""
|
||||
pm = _get_pm(ctx)
|
||||
idx = pm.entry_manager.add_seed(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
words_num=words,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
|
||||
|
||||
@entry_app.command("add-key-value")
|
||||
def entry_add_key_value(
|
||||
ctx: typer.Context,
|
||||
label: str,
|
||||
value: str = typer.Option(..., "--value", help="Stored value"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
) -> None:
|
||||
"""Add a key/value entry and output its index."""
|
||||
pm = _get_pm(ctx)
|
||||
idx = pm.entry_manager.add_key_value(label, value, notes=notes)
|
||||
typer.echo(str(idx))
|
||||
|
||||
|
||||
@entry_app.command("add-managed-account")
|
||||
def entry_add_managed_account(
|
||||
ctx: typer.Context,
|
||||
label: str,
|
||||
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
) -> None:
|
||||
"""Add a managed account seed entry and output its index."""
|
||||
pm = _get_pm(ctx)
|
||||
idx = pm.entry_manager.add_managed_account(
|
||||
label,
|
||||
pm.parent_seed,
|
||||
index=index,
|
||||
notes=notes,
|
||||
)
|
||||
typer.echo(str(idx))
|
||||
|
||||
|
||||
@entry_app.command("modify")
|
||||
def entry_modify(
|
||||
ctx: typer.Context,
|
||||
entry_id: int,
|
||||
label: Optional[str] = typer.Option(None, "--label"),
|
||||
username: Optional[str] = typer.Option(None, "--username"),
|
||||
url: Optional[str] = typer.Option(None, "--url"),
|
||||
notes: Optional[str] = typer.Option(None, "--notes"),
|
||||
period: Optional[int] = typer.Option(
|
||||
None, "--period", help="TOTP period in seconds"
|
||||
),
|
||||
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
|
||||
value: Optional[str] = typer.Option(None, "--value", help="New value"),
|
||||
) -> None:
|
||||
"""Modify an existing entry."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.entry_manager.modify_entry(
|
||||
entry_id,
|
||||
username=username,
|
||||
url=url,
|
||||
notes=notes,
|
||||
label=label,
|
||||
period=period,
|
||||
digits=digits,
|
||||
value=value,
|
||||
)
|
||||
|
||||
|
||||
@entry_app.command("archive")
|
||||
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
|
||||
"""Archive an entry."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.entry_manager.archive_entry(entry_id)
|
||||
typer.echo(str(entry_id))
|
||||
|
||||
|
||||
@entry_app.command("unarchive")
|
||||
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
|
||||
"""Restore an archived entry."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.entry_manager.restore_entry(entry_id)
|
||||
typer.echo(str(entry_id))
|
||||
|
||||
|
||||
@entry_app.command("totp-codes")
|
||||
def entry_totp_codes(ctx: typer.Context) -> None:
|
||||
"""Display all current TOTP codes."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.handle_display_totp_codes()
|
||||
|
||||
|
||||
@entry_app.command("export-totp")
|
||||
def entry_export_totp(
|
||||
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
|
||||
) -> None:
|
||||
"""Export all TOTP secrets to a JSON file."""
|
||||
pm = _get_pm(ctx)
|
||||
data = pm.entry_manager.export_totp_entries(pm.parent_seed)
|
||||
Path(file).write_text(json.dumps(data, indent=2))
|
||||
typer.echo(str(file))
|
||||
|
||||
|
||||
@vault_app.command("export")
|
||||
def vault_export(
|
||||
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
|
||||
) -> None:
|
||||
"""Export the vault."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.handle_export_database(Path(file))
|
||||
typer.echo(str(file))
|
||||
|
||||
|
||||
@vault_app.command("import")
|
||||
def vault_import(
|
||||
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
|
||||
) -> None:
|
||||
"""Import a vault from an encrypted JSON file."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.handle_import_database(Path(file))
|
||||
typer.echo(str(file))
|
||||
|
||||
|
||||
@vault_app.command("change-password")
|
||||
def vault_change_password(ctx: typer.Context) -> None:
|
||||
"""Change the master password used for encryption."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.change_password()
|
||||
|
||||
|
||||
@vault_app.command("lock")
|
||||
def vault_lock(ctx: typer.Context) -> None:
|
||||
"""Lock the vault and clear sensitive data from memory."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.lock_vault()
|
||||
typer.echo("locked")
|
||||
|
||||
|
||||
@vault_app.command("stats")
|
||||
def vault_stats(ctx: typer.Context) -> None:
|
||||
"""Display statistics about the current seed profile."""
|
||||
pm = _get_pm(ctx)
|
||||
stats = pm.get_profile_stats()
|
||||
typer.echo(json.dumps(stats, indent=2))
|
||||
|
||||
|
||||
@vault_app.command("reveal-parent-seed")
|
||||
def vault_reveal_parent_seed(
|
||||
ctx: typer.Context,
|
||||
file: Optional[str] = typer.Option(
|
||||
None, "--file", help="Save encrypted seed to this path"
|
||||
),
|
||||
) -> None:
|
||||
"""Display the parent seed and optionally write an encrypted backup file."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.handle_backup_reveal_parent_seed(Path(file) if file else None)
|
||||
|
||||
|
||||
@nostr_app.command("sync")
|
||||
def nostr_sync(ctx: typer.Context) -> None:
|
||||
"""Sync with configured Nostr relays."""
|
||||
pm = _get_pm(ctx)
|
||||
event_id = pm.sync_vault()
|
||||
if event_id:
|
||||
typer.echo(event_id)
|
||||
else:
|
||||
typer.echo("Error: Failed to sync vault")
|
||||
|
||||
|
||||
@nostr_app.command("get-pubkey")
|
||||
def nostr_get_pubkey(ctx: typer.Context) -> None:
|
||||
"""Display the active profile's npub."""
|
||||
pm = _get_pm(ctx)
|
||||
npub = pm.nostr_client.key_manager.get_npub()
|
||||
typer.echo(npub)
|
||||
|
||||
|
||||
@config_app.command("get")
|
||||
def config_get(ctx: typer.Context, key: str) -> None:
|
||||
"""Get a configuration value."""
|
||||
pm = _get_pm(ctx)
|
||||
value = pm.config_manager.load_config(require_pin=False).get(key)
|
||||
if value is None:
|
||||
typer.echo("Key not found")
|
||||
else:
|
||||
typer.echo(str(value))
|
||||
|
||||
|
||||
@config_app.command("set")
|
||||
def config_set(ctx: typer.Context, key: str, value: str) -> None:
|
||||
"""Set a configuration value."""
|
||||
pm = _get_pm(ctx)
|
||||
cfg = pm.config_manager
|
||||
|
||||
mapping = {
|
||||
"inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)),
|
||||
"secret_mode_enabled": lambda v: cfg.set_secret_mode_enabled(
|
||||
v.lower() in ("1", "true", "yes", "y", "on")
|
||||
),
|
||||
"clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)),
|
||||
"additional_backup_path": lambda v: cfg.set_additional_backup_path(v or None),
|
||||
"relays": lambda v: cfg.set_relays(
|
||||
[r.strip() for r in v.split(",") if r.strip()], require_pin=False
|
||||
),
|
||||
}
|
||||
|
||||
action = mapping.get(key)
|
||||
if action is None:
|
||||
typer.echo("Unknown key")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
try:
|
||||
action(value)
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
typer.echo("Updated")
|
||||
|
||||
|
||||
@config_app.command("toggle-secret-mode")
|
||||
def config_toggle_secret_mode(ctx: typer.Context) -> None:
|
||||
"""Interactively enable or disable secret mode."""
|
||||
pm = _get_pm(ctx)
|
||||
cfg = pm.config_manager
|
||||
try:
|
||||
enabled = cfg.get_secret_mode_enabled()
|
||||
delay = cfg.get_clipboard_clear_delay()
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error loading settings: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
typer.echo(f"Secret mode is currently {'ON' if enabled else 'OFF'}")
|
||||
choice = (
|
||||
typer.prompt(
|
||||
"Enable secret mode? (y/n, blank to keep)", default="", show_default=False
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if choice in ("y", "yes"):
|
||||
enabled = True
|
||||
elif choice in ("n", "no"):
|
||||
enabled = False
|
||||
|
||||
inp = typer.prompt(
|
||||
f"Clipboard clear delay in seconds [{delay}]", default="", show_default=False
|
||||
).strip()
|
||||
if inp:
|
||||
try:
|
||||
delay = int(inp)
|
||||
if delay <= 0:
|
||||
typer.echo("Delay must be positive")
|
||||
raise typer.Exit(code=1)
|
||||
except ValueError:
|
||||
typer.echo("Invalid number")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
try:
|
||||
cfg.set_secret_mode_enabled(enabled)
|
||||
cfg.set_clipboard_clear_delay(delay)
|
||||
pm.secret_mode_enabled = enabled
|
||||
pm.clipboard_clear_delay = delay
|
||||
except Exception as exc: # pragma: no cover - pass through errors
|
||||
typer.echo(f"Error: {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
status = "enabled" if enabled else "disabled"
|
||||
typer.echo(f"Secret mode {status}.")
|
||||
|
||||
|
||||
@fingerprint_app.command("list")
|
||||
def fingerprint_list(ctx: typer.Context) -> None:
|
||||
"""List available seed profiles."""
|
||||
pm = _get_pm(ctx)
|
||||
for fp in pm.fingerprint_manager.list_fingerprints():
|
||||
typer.echo(fp)
|
||||
|
||||
|
||||
@fingerprint_app.command("add")
|
||||
def fingerprint_add(ctx: typer.Context) -> None:
|
||||
"""Create a new seed profile."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.add_new_fingerprint()
|
||||
|
||||
|
||||
@fingerprint_app.command("remove")
|
||||
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
|
||||
"""Remove a seed profile."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.fingerprint_manager.remove_fingerprint(fingerprint)
|
||||
|
||||
|
||||
@fingerprint_app.command("switch")
|
||||
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
|
||||
"""Switch to another seed profile."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.select_fingerprint(fingerprint)
|
||||
|
||||
|
||||
@util_app.command("generate-password")
|
||||
def generate_password(ctx: typer.Context, length: int = 24) -> None:
|
||||
"""Generate a strong password."""
|
||||
pm = _get_pm(ctx)
|
||||
password = pm.password_generator.generate_password(length)
|
||||
typer.echo(password)
|
||||
|
||||
|
||||
@util_app.command("verify-checksum")
|
||||
def verify_checksum(ctx: typer.Context) -> None:
|
||||
"""Verify the SeedPass script checksum."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.handle_verify_checksum()
|
||||
|
||||
|
||||
@util_app.command("update-checksum")
|
||||
def update_checksum(ctx: typer.Context) -> None:
|
||||
"""Regenerate the script checksum file."""
|
||||
pm = _get_pm(ctx)
|
||||
pm.handle_update_script_checksum()
|
||||
|
||||
|
||||
@api_app.command("start")
|
||||
def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
|
||||
"""Start the SeedPass API server."""
|
||||
token = api_module.start_server(ctx.obj.get("fingerprint"))
|
||||
typer.echo(f"API token: {token}")
|
||||
uvicorn.run(api_module.app, host=host, port=port)
|
||||
|
||||
|
||||
@api_app.command("stop")
|
||||
def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
|
||||
"""Stop the SeedPass API server."""
|
||||
import requests
|
||||
|
||||
try:
|
||||
requests.post(
|
||||
f"http://{host}:{port}/api/v1/shutdown",
|
||||
headers={"Authorization": f"Bearer {api_module._token}"},
|
||||
timeout=2,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
typer.echo(f"Failed to stop server: {exc}")
|
233
src/tests/test_api.py
Normal file
233
src/tests/test_api.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from seedpass import api
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
dummy = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(
|
||||
search_entries=lambda q: [(1, "Site", "user", "url", False)],
|
||||
retrieve_entry=lambda i: {"label": "Site"},
|
||||
add_entry=lambda *a, **k: 1,
|
||||
modify_entry=lambda *a, **k: None,
|
||||
archive_entry=lambda i: None,
|
||||
restore_entry=lambda i: None,
|
||||
),
|
||||
config_manager=SimpleNamespace(
|
||||
load_config=lambda require_pin=False: {"k": "v"},
|
||||
set_pin=lambda v: None,
|
||||
set_password_hash=lambda v: None,
|
||||
set_relays=lambda v, require_pin=False: None,
|
||||
set_inactivity_timeout=lambda v: None,
|
||||
set_additional_backup_path=lambda v: None,
|
||||
set_secret_mode_enabled=lambda v: None,
|
||||
set_clipboard_clear_delay=lambda v: None,
|
||||
),
|
||||
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]),
|
||||
nostr_client=SimpleNamespace(
|
||||
key_manager=SimpleNamespace(get_npub=lambda: "np")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
|
||||
monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")
|
||||
token = api.start_server()
|
||||
client = TestClient(api.app)
|
||||
return client, token
|
||||
|
||||
|
||||
def test_cors_and_auth(client):
|
||||
cl, token = client
|
||||
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
|
||||
res = cl.get("/api/v1/entry", params={"query": "s"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||
|
||||
|
||||
def test_invalid_token(client):
|
||||
cl, _token = client
|
||||
res = cl.get(
|
||||
"/api/v1/entry",
|
||||
params={"query": "s"},
|
||||
headers={"Authorization": "Bearer bad"},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
def test_get_entry_by_id(client):
|
||||
cl, token = client
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Origin": "http://example.com",
|
||||
}
|
||||
res = cl.get("/api/v1/entry/1", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"label": "Site"}
|
||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||
|
||||
|
||||
def test_get_config_value(client):
|
||||
cl, token = client
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Origin": "http://example.com",
|
||||
}
|
||||
res = cl.get("/api/v1/config/k", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"key": "k", "value": "v"}
|
||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||
|
||||
|
||||
def test_list_fingerprint(client):
|
||||
cl, token = client
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Origin": "http://example.com",
|
||||
}
|
||||
res = cl.get("/api/v1/fingerprint", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == ["fp"]
|
||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||
|
||||
|
||||
def test_get_nostr_pubkey(client):
|
||||
cl, token = client
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Origin": "http://example.com",
|
||||
}
|
||||
res = cl.get("/api/v1/nostr/pubkey", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"npub": "np"}
|
||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||
|
||||
|
||||
def test_create_modify_archive_entry(client):
|
||||
cl, token = client
|
||||
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
|
||||
|
||||
res = cl.post(
|
||||
"/api/v1/entry",
|
||||
json={"label": "test", "length": 12},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"id": 1}
|
||||
|
||||
res = cl.put(
|
||||
"/api/v1/entry/1",
|
||||
json={"username": "bob"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
|
||||
res = cl.post("/api/v1/entry/1/archive", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "archived"}
|
||||
|
||||
res = cl.post("/api/v1/entry/1/unarchive", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "active"}
|
||||
|
||||
|
||||
def test_update_config(client):
|
||||
cl, token = client
|
||||
called = {}
|
||||
|
||||
def set_timeout(val):
|
||||
called["val"] = val
|
||||
|
||||
api._pm.config_manager.set_inactivity_timeout = set_timeout
|
||||
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
|
||||
res = cl.put(
|
||||
"/api/v1/config/inactivity_timeout",
|
||||
json={"value": 42},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert called["val"] == 42
|
||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||
|
||||
|
||||
def test_change_password_route(client):
|
||||
cl, token = client
|
||||
called = {}
|
||||
|
||||
api._pm.change_password = lambda: called.setdefault("called", True)
|
||||
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
|
||||
res = cl.post("/api/v1/change-password", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert called.get("called") is True
|
||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||
|
||||
|
||||
def test_update_config_unknown_key(client):
|
||||
cl, token = client
|
||||
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
|
||||
res = cl.put(
|
||||
"/api/v1/config/bogus",
|
||||
json={"value": 1},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_shutdown(client, monkeypatch):
|
||||
cl, token = client
|
||||
|
||||
calls = {}
|
||||
|
||||
class Loop:
|
||||
def call_soon(self, func, *args):
|
||||
calls["func"] = func
|
||||
calls["args"] = args
|
||||
|
||||
monkeypatch.setattr(api.asyncio, "get_event_loop", lambda: Loop())
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Origin": "http://example.com",
|
||||
}
|
||||
res = cl.post("/api/v1/shutdown", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "shutting down"}
|
||||
assert calls["func"] is sys.exit
|
||||
assert calls["args"] == (0,)
|
||||
assert res.headers.get("access-control-allow-origin") == "http://example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path",
|
||||
[
|
||||
("get", "/api/v1/entry/1"),
|
||||
("get", "/api/v1/config/k"),
|
||||
("get", "/api/v1/fingerprint"),
|
||||
("get", "/api/v1/nostr/pubkey"),
|
||||
("post", "/api/v1/shutdown"),
|
||||
("post", "/api/v1/entry"),
|
||||
("put", "/api/v1/entry/1"),
|
||||
("put", "/api/v1/config/inactivity_timeout"),
|
||||
("post", "/api/v1/entry/1/archive"),
|
||||
("post", "/api/v1/entry/1/unarchive"),
|
||||
("post", "/api/v1/change-password"),
|
||||
("post", "/api/v1/vault/lock"),
|
||||
],
|
||||
)
|
||||
def test_invalid_token_other_endpoints(client, method, path):
|
||||
cl, _token = client
|
||||
req = getattr(cl, method)
|
||||
kwargs = {"headers": {"Authorization": "Bearer bad"}}
|
||||
if method in {"post", "put"}:
|
||||
kwargs["json"] = {}
|
||||
res = req(path, **kwargs)
|
||||
assert res.status_code == 401
|
302
src/tests/test_api_new_endpoints.py
Normal file
302
src/tests/test_api_new_endpoints.py
Normal file
@@ -0,0 +1,302 @@
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from seedpass import api
|
||||
from test_api import client
|
||||
|
||||
|
||||
def test_create_and_modify_totp_entry(client):
|
||||
cl, token = client
|
||||
calls = {}
|
||||
|
||||
def add_totp(label, seed, **kwargs):
|
||||
calls["create"] = kwargs
|
||||
return "uri"
|
||||
|
||||
def modify(idx, **kwargs):
|
||||
calls["modify"] = (idx, kwargs)
|
||||
|
||||
api._pm.entry_manager.add_totp = add_totp
|
||||
api._pm.entry_manager.modify_entry = modify
|
||||
api._pm.entry_manager.get_next_index = lambda: 5
|
||||
api._pm.parent_seed = "seed"
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = cl.post(
|
||||
"/api/v1/entry",
|
||||
json={
|
||||
"type": "totp",
|
||||
"label": "T",
|
||||
"index": 1,
|
||||
"secret": "abc",
|
||||
"period": 60,
|
||||
"digits": 8,
|
||||
"notes": "n",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"id": 5, "uri": "uri"}
|
||||
assert calls["create"] == {
|
||||
"index": 1,
|
||||
"secret": "abc",
|
||||
"period": 60,
|
||||
"digits": 8,
|
||||
"notes": "n",
|
||||
"archived": False,
|
||||
}
|
||||
|
||||
res = cl.put(
|
||||
"/api/v1/entry/5",
|
||||
json={"period": 90, "digits": 6},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert calls["modify"][0] == 5
|
||||
assert calls["modify"][1]["period"] == 90
|
||||
assert calls["modify"][1]["digits"] == 6
|
||||
|
||||
|
||||
def test_create_and_modify_ssh_entry(client):
|
||||
cl, token = client
|
||||
calls = {}
|
||||
|
||||
def add_ssh(label, seed, **kwargs):
|
||||
calls["create"] = kwargs
|
||||
return 2
|
||||
|
||||
def modify(idx, **kwargs):
|
||||
calls["modify"] = (idx, kwargs)
|
||||
|
||||
api._pm.entry_manager.add_ssh_key = add_ssh
|
||||
api._pm.entry_manager.modify_entry = modify
|
||||
api._pm.parent_seed = "seed"
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = cl.post(
|
||||
"/api/v1/entry",
|
||||
json={"type": "ssh", "label": "S", "index": 2, "notes": "n"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"id": 2}
|
||||
assert calls["create"] == {"index": 2, "notes": "n", "archived": False}
|
||||
|
||||
res = cl.put(
|
||||
"/api/v1/entry/2",
|
||||
json={"notes": "x"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert calls["modify"][0] == 2
|
||||
assert calls["modify"][1]["notes"] == "x"
|
||||
|
||||
|
||||
def test_update_config_secret_mode(client):
|
||||
cl, token = client
|
||||
called = {}
|
||||
|
||||
def set_secret(val):
|
||||
called["val"] = val
|
||||
|
||||
api._pm.config_manager.set_secret_mode_enabled = set_secret
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = cl.put(
|
||||
"/api/v1/config/secret_mode_enabled",
|
||||
json={"value": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert called["val"] is True
|
||||
|
||||
|
||||
def test_totp_export_endpoint(client):
|
||||
cl, token = client
|
||||
api._pm.entry_manager.export_totp_entries = lambda seed: {"entries": ["x"]}
|
||||
api._pm.parent_seed = "seed"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = cl.get("/api/v1/totp/export", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"entries": ["x"]}
|
||||
|
||||
|
||||
def test_totp_codes_endpoint(client):
|
||||
cl, token = client
|
||||
api._pm.entry_manager.list_entries = lambda **kw: [(0, "Email", None, None, False)]
|
||||
api._pm.entry_manager.get_totp_code = lambda i, s: "123456"
|
||||
api._pm.entry_manager.get_totp_time_remaining = lambda i: 30
|
||||
api._pm.parent_seed = "seed"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = cl.get("/api/v1/totp", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"codes": [
|
||||
{"id": 0, "label": "Email", "code": "123456", "seconds_remaining": 30}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_parent_seed_endpoint(client, tmp_path):
|
||||
cl, token = client
|
||||
api._pm.parent_seed = "seed"
|
||||
called = {}
|
||||
api._pm.encryption_manager = SimpleNamespace(
|
||||
encrypt_and_save_file=lambda data, path: called.setdefault("path", path)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
res = cl.get("/api/v1/parent-seed", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"seed": "seed"}
|
||||
|
||||
out = tmp_path / "bk.enc"
|
||||
res = cl.get("/api/v1/parent-seed", params={"file": str(out)}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "saved", "path": str(out)}
|
||||
assert called["path"] == out
|
||||
|
||||
|
||||
def test_fingerprint_endpoints(client):
|
||||
cl, token = client
|
||||
calls = {}
|
||||
|
||||
api._pm.add_new_fingerprint = lambda: calls.setdefault("add", True)
|
||||
api._pm.fingerprint_manager.remove_fingerprint = lambda fp: calls.setdefault(
|
||||
"remove", fp
|
||||
)
|
||||
api._pm.select_fingerprint = lambda fp: calls.setdefault("select", fp)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
res = cl.post("/api/v1/fingerprint", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert calls.get("add") is True
|
||||
|
||||
res = cl.delete("/api/v1/fingerprint/abc", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "deleted"}
|
||||
assert calls.get("remove") == "abc"
|
||||
|
||||
res = cl.post(
|
||||
"/api/v1/fingerprint/select",
|
||||
json={"fingerprint": "xyz"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert calls.get("select") == "xyz"
|
||||
|
||||
|
||||
def test_checksum_endpoints(client):
|
||||
cl, token = client
|
||||
calls = {}
|
||||
|
||||
api._pm.handle_verify_checksum = lambda: calls.setdefault("verify", True)
|
||||
api._pm.handle_update_script_checksum = lambda: calls.setdefault("update", True)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
res = cl.post("/api/v1/checksum/verify", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert calls.get("verify") is True
|
||||
|
||||
res = cl.post("/api/v1/checksum/update", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert calls.get("update") is True
|
||||
|
||||
|
||||
def test_vault_import_via_path(client, tmp_path):
|
||||
cl, token = client
|
||||
called = {}
|
||||
|
||||
def import_db(path):
|
||||
called["path"] = path
|
||||
|
||||
api._pm.handle_import_database = import_db
|
||||
file_path = tmp_path / "b.json"
|
||||
file_path.write_text("{}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = cl.post(
|
||||
"/api/v1/vault/import",
|
||||
json={"path": str(file_path)},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert called["path"] == file_path
|
||||
|
||||
|
||||
def test_vault_import_via_upload(client, tmp_path):
|
||||
cl, token = client
|
||||
called = {}
|
||||
|
||||
def import_db(path):
|
||||
called["path"] = path
|
||||
|
||||
api._pm.handle_import_database = import_db
|
||||
file_path = tmp_path / "c.json"
|
||||
file_path.write_text("{}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
with open(file_path, "rb") as fh:
|
||||
res = cl.post(
|
||||
"/api/v1/vault/import",
|
||||
files={"file": ("c.json", fh.read())},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert isinstance(called.get("path"), Path)
|
||||
|
||||
|
||||
def test_vault_lock_endpoint(client):
|
||||
cl, token = client
|
||||
called = {}
|
||||
|
||||
def lock():
|
||||
called["locked"] = True
|
||||
api._pm.locked = True
|
||||
|
||||
api._pm.lock_vault = lock
|
||||
api._pm.locked = False
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = cl.post("/api/v1/vault/lock", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "locked"}
|
||||
assert called.get("locked") is True
|
||||
assert api._pm.locked is True
|
||||
api._pm.unlock_vault = lambda: setattr(api._pm, "locked", False)
|
||||
api._pm.unlock_vault()
|
||||
assert api._pm.locked is False
|
||||
|
||||
|
||||
def test_secret_mode_endpoint(client):
|
||||
cl, token = client
|
||||
called = {}
|
||||
|
||||
def set_secret(val):
|
||||
called.setdefault("enabled", val)
|
||||
|
||||
def set_delay(val):
|
||||
called.setdefault("delay", val)
|
||||
|
||||
api._pm.config_manager.set_secret_mode_enabled = set_secret
|
||||
api._pm.config_manager.set_clipboard_clear_delay = set_delay
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
res = cl.post(
|
||||
"/api/v1/secret-mode",
|
||||
json={"enabled": True, "delay": 12},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
assert called["enabled"] is True
|
||||
assert called["delay"] == 12
|
13
src/tests/test_api_profile_stats.py
Normal file
13
src/tests/test_api_profile_stats.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from test_api import client
|
||||
|
||||
|
||||
def test_profile_stats_endpoint(client):
|
||||
cl, token = client
|
||||
stats = {"total_entries": 1}
|
||||
# monkeypatch set _pm.get_profile_stats after client fixture started
|
||||
import seedpass.api as api
|
||||
|
||||
api._pm.get_profile_stats = lambda: stats
|
||||
res = cl.get("/api/v1/stats", headers={"Authorization": f"Bearer {token}"})
|
||||
assert res.status_code == 200
|
||||
assert res.json() == stats
|
44
src/tests/test_cli_config_set_extra.py
Normal file
44
src/tests/test_cli_config_set_extra.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass.cli import app
|
||||
from seedpass import cli
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key,value,method,expected",
|
||||
[
|
||||
("secret_mode_enabled", "true", "set_secret_mode_enabled", True),
|
||||
("clipboard_clear_delay", "10", "set_clipboard_clear_delay", 10),
|
||||
("additional_backup_path", "", "set_additional_backup_path", None),
|
||||
(
|
||||
"relays",
|
||||
"wss://a.com, wss://b.com",
|
||||
"set_relays",
|
||||
["wss://a.com", "wss://b.com"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_config_set_variants(monkeypatch, key, value, method, expected):
|
||||
called = {}
|
||||
|
||||
def func(val, **kwargs):
|
||||
called["val"] = val
|
||||
called.update(kwargs)
|
||||
|
||||
pm = SimpleNamespace(
|
||||
config_manager=SimpleNamespace(**{method: func}),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
|
||||
result = runner.invoke(app, ["config", "set", key, value])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Updated" in result.stdout
|
||||
assert called.get("val") == expected
|
||||
if key == "relays":
|
||||
assert called.get("require_pin") is False
|
112
src/tests/test_cli_entry_add_commands.py
Normal file
112
src/tests/test_cli_entry_add_commands.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass.cli import app
|
||||
from seedpass import cli
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,method,cli_args,expected_args,expected_kwargs,stdout",
|
||||
[
|
||||
(
|
||||
"add-totp",
|
||||
"add_totp",
|
||||
[
|
||||
"Label",
|
||||
"--index",
|
||||
"1",
|
||||
"--secret",
|
||||
"abc",
|
||||
"--period",
|
||||
"45",
|
||||
"--digits",
|
||||
"7",
|
||||
],
|
||||
("Label", "seed"),
|
||||
{"index": 1, "secret": "abc", "period": 45, "digits": 7},
|
||||
"otpauth://uri",
|
||||
),
|
||||
(
|
||||
"add-ssh",
|
||||
"add_ssh_key",
|
||||
["Label", "--index", "2", "--notes", "n"],
|
||||
("Label", "seed"),
|
||||
{"index": 2, "notes": "n"},
|
||||
"3",
|
||||
),
|
||||
(
|
||||
"add-pgp",
|
||||
"add_pgp_key",
|
||||
[
|
||||
"Label",
|
||||
"--index",
|
||||
"3",
|
||||
"--key-type",
|
||||
"rsa",
|
||||
"--user-id",
|
||||
"uid",
|
||||
"--notes",
|
||||
"n",
|
||||
],
|
||||
("Label", "seed"),
|
||||
{"index": 3, "key_type": "rsa", "user_id": "uid", "notes": "n"},
|
||||
"4",
|
||||
),
|
||||
(
|
||||
"add-nostr",
|
||||
"add_nostr_key",
|
||||
["Label", "--index", "4", "--notes", "n"],
|
||||
("Label",),
|
||||
{"index": 4, "notes": "n"},
|
||||
"5",
|
||||
),
|
||||
(
|
||||
"add-seed",
|
||||
"add_seed",
|
||||
["Label", "--index", "5", "--words", "12", "--notes", "n"],
|
||||
("Label", "seed"),
|
||||
{"index": 5, "words_num": 12, "notes": "n"},
|
||||
"6",
|
||||
),
|
||||
(
|
||||
"add-key-value",
|
||||
"add_key_value",
|
||||
["Label", "--value", "val", "--notes", "note"],
|
||||
("Label", "val"),
|
||||
{"notes": "note"},
|
||||
"7",
|
||||
),
|
||||
(
|
||||
"add-managed-account",
|
||||
"add_managed_account",
|
||||
["Label", "--index", "7", "--notes", "n"],
|
||||
("Label", "seed"),
|
||||
{"index": 7, "notes": "n"},
|
||||
"8",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_entry_add_commands(
|
||||
monkeypatch, command, method, cli_args, expected_args, expected_kwargs, stdout
|
||||
):
|
||||
called = {}
|
||||
|
||||
def func(*args, **kwargs):
|
||||
called["args"] = args
|
||||
called["kwargs"] = kwargs
|
||||
return stdout
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(**{method: func}),
|
||||
parent_seed="seed",
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["entry", command] + cli_args)
|
||||
assert result.exit_code == 0
|
||||
assert stdout in result.stdout
|
||||
assert called["args"] == expected_args
|
||||
assert called["kwargs"] == expected_kwargs
|
44
src/tests/test_cli_toggle_secret_mode.py
Normal file
44
src/tests/test_cli_toggle_secret_mode.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass.cli import app
|
||||
from seedpass import cli
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _make_pm(called, enabled=False, delay=45):
|
||||
cfg = SimpleNamespace(
|
||||
get_secret_mode_enabled=lambda: enabled,
|
||||
get_clipboard_clear_delay=lambda: delay,
|
||||
set_secret_mode_enabled=lambda v: called.setdefault("enabled", v),
|
||||
set_clipboard_clear_delay=lambda v: called.setdefault("delay", v),
|
||||
)
|
||||
pm = SimpleNamespace(
|
||||
config_manager=cfg,
|
||||
secret_mode_enabled=enabled,
|
||||
clipboard_clear_delay=delay,
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
return pm
|
||||
|
||||
|
||||
def test_toggle_secret_mode_updates(monkeypatch):
|
||||
called = {}
|
||||
pm = _make_pm(called)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["config", "toggle-secret-mode"], input="y\n10\n")
|
||||
assert result.exit_code == 0
|
||||
assert called == {"enabled": True, "delay": 10}
|
||||
assert "Secret mode enabled." in result.stdout
|
||||
|
||||
|
||||
def test_toggle_secret_mode_keep(monkeypatch):
|
||||
called = {}
|
||||
pm = _make_pm(called, enabled=True, delay=30)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["config", "toggle-secret-mode"], input="\n\n")
|
||||
assert result.exit_code == 0
|
||||
assert called == {"enabled": True, "delay": 30}
|
||||
assert "Secret mode enabled." in result.stdout
|
25
src/tests/test_cli_vault_stats.py
Normal file
25
src/tests/test_cli_vault_stats.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass.cli import app
|
||||
from seedpass import cli
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_vault_stats_command(monkeypatch):
|
||||
stats = {
|
||||
"total_entries": 2,
|
||||
"entries": {"password": 1, "totp": 1},
|
||||
}
|
||||
pm = SimpleNamespace(
|
||||
get_profile_stats=lambda: stats, select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["vault", "stats"])
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout
|
||||
# Output should be pretty JSON with the expected values
|
||||
data = json.loads(out)
|
||||
assert data == stats
|
449
src/tests/test_typer_cli.py
Normal file
449
src/tests/test_typer_cli.py
Normal file
@@ -0,0 +1,449 @@
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from seedpass.cli import app, PasswordManager
|
||||
from seedpass import cli
|
||||
from password_manager.entry_types import EntryType
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_entry_list(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def list_entries(sort_by="index", filter_kind=None, include_archived=False):
|
||||
called["args"] = (sort_by, filter_kind, include_archived)
|
||||
return [(0, "Site", "user", "", False)]
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(list_entries=list_entries),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["entry", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "Site" in result.stdout
|
||||
assert called["args"] == ("index", None, False)
|
||||
|
||||
|
||||
def test_entry_search(monkeypatch):
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(
|
||||
search_entries=lambda q: [(1, "L", None, None, False)]
|
||||
),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["entry", "search", "l"])
|
||||
assert result.exit_code == 0
|
||||
assert "1: L" in result.stdout
|
||||
|
||||
|
||||
def test_entry_get_password(monkeypatch):
|
||||
def search(q):
|
||||
return [(2, "Example", "", "", False)]
|
||||
|
||||
entry = {"type": EntryType.PASSWORD.value, "length": 8}
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(
|
||||
search_entries=search,
|
||||
retrieve_entry=lambda i: entry,
|
||||
get_totp_code=lambda i, s: "",
|
||||
),
|
||||
password_generator=SimpleNamespace(generate_password=lambda l, i: "pw"),
|
||||
parent_seed="seed",
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["entry", "get", "ex"])
|
||||
assert result.exit_code == 0
|
||||
assert "pw" in result.stdout
|
||||
|
||||
|
||||
def test_vault_export(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
|
||||
def export_db(path):
|
||||
called["path"] = path
|
||||
|
||||
pm = SimpleNamespace(
|
||||
handle_export_database=export_db, select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
out_path = tmp_path / "out.json"
|
||||
result = runner.invoke(app, ["vault", "export", "--file", str(out_path)])
|
||||
assert result.exit_code == 0
|
||||
assert called["path"] == out_path
|
||||
|
||||
|
||||
def test_vault_import(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
|
||||
def import_db(path):
|
||||
called["path"] = path
|
||||
|
||||
pm = SimpleNamespace(
|
||||
handle_import_database=import_db, select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
in_path = tmp_path / "in.json"
|
||||
in_path.write_text("{}")
|
||||
result = runner.invoke(app, ["vault", "import", "--file", str(in_path)])
|
||||
assert result.exit_code == 0
|
||||
assert called["path"] == in_path
|
||||
|
||||
|
||||
def test_vault_change_password(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def change_pw():
|
||||
called["called"] = True
|
||||
|
||||
pm = SimpleNamespace(change_password=change_pw, select_fingerprint=lambda fp: None)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["vault", "change-password"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("called") is True
|
||||
|
||||
|
||||
def test_vault_lock(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def lock():
|
||||
called["locked"] = True
|
||||
pm.locked = True
|
||||
|
||||
pm = SimpleNamespace(
|
||||
lock_vault=lock, locked=False, select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["vault", "lock"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("locked") is True
|
||||
assert pm.locked is True
|
||||
|
||||
|
||||
def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
|
||||
def reveal(path=None):
|
||||
called["path"] = path
|
||||
|
||||
pm = SimpleNamespace(
|
||||
handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
out_path = tmp_path / "seed.enc"
|
||||
result = runner.invoke(
|
||||
app, ["vault", "reveal-parent-seed", "--file", str(out_path)]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert called["path"] == out_path
|
||||
|
||||
|
||||
def test_nostr_get_pubkey(monkeypatch):
|
||||
pm = SimpleNamespace(
|
||||
nostr_client=SimpleNamespace(
|
||||
key_manager=SimpleNamespace(get_npub=lambda: "np")
|
||||
),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["nostr", "get-pubkey"])
|
||||
assert result.exit_code == 0
|
||||
assert "np" in result.stdout
|
||||
|
||||
|
||||
def test_fingerprint_list(monkeypatch):
|
||||
pm = SimpleNamespace(
|
||||
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["a", "b"]),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["fingerprint", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "a" in result.stdout and "b" in result.stdout
|
||||
|
||||
|
||||
def test_fingerprint_add(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def add():
|
||||
called["add"] = True
|
||||
|
||||
pm = SimpleNamespace(
|
||||
add_new_fingerprint=add,
|
||||
select_fingerprint=lambda fp: None,
|
||||
fingerprint_manager=SimpleNamespace(),
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["fingerprint", "add"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("add") is True
|
||||
|
||||
|
||||
def test_fingerprint_remove(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def remove(fp):
|
||||
called["fp"] = fp
|
||||
|
||||
pm = SimpleNamespace(
|
||||
fingerprint_manager=SimpleNamespace(remove_fingerprint=remove),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["fingerprint", "remove", "abc"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("fp") == "abc"
|
||||
|
||||
|
||||
def test_fingerprint_switch(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def switch(fp):
|
||||
called["fp"] = fp
|
||||
|
||||
pm = SimpleNamespace(
|
||||
select_fingerprint=switch, fingerprint_manager=SimpleNamespace()
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["fingerprint", "switch", "def"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("fp") == "def"
|
||||
|
||||
|
||||
def test_config_get(monkeypatch):
|
||||
pm = SimpleNamespace(
|
||||
config_manager=SimpleNamespace(
|
||||
load_config=lambda require_pin=False: {"x": "1"}
|
||||
),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["config", "get", "x"])
|
||||
assert result.exit_code == 0
|
||||
assert "1" in result.stdout
|
||||
|
||||
|
||||
def test_config_set(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def set_timeout(val):
|
||||
called["timeout"] = float(val)
|
||||
|
||||
pm = SimpleNamespace(
|
||||
config_manager=SimpleNamespace(set_inactivity_timeout=set_timeout),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["config", "set", "inactivity_timeout", "5"])
|
||||
assert result.exit_code == 0
|
||||
assert called["timeout"] == 5.0
|
||||
assert "Updated" in result.stdout
|
||||
|
||||
|
||||
def test_config_set_unknown_key(monkeypatch):
|
||||
pm = SimpleNamespace(
|
||||
config_manager=SimpleNamespace(), select_fingerprint=lambda fp: None
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["config", "set", "bogus", "val"])
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown key" in result.stdout
|
||||
|
||||
|
||||
def test_nostr_sync(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def sync_vault():
|
||||
called["called"] = True
|
||||
return "evt123"
|
||||
|
||||
pm = SimpleNamespace(sync_vault=sync_vault, select_fingerprint=lambda fp: None)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["nostr", "sync"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("called") is True
|
||||
assert "evt123" in result.stdout
|
||||
|
||||
|
||||
def test_generate_password(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def gen_pw(length):
|
||||
called["length"] = length
|
||||
return "secretpw"
|
||||
|
||||
pm = SimpleNamespace(
|
||||
password_generator=SimpleNamespace(generate_password=gen_pw),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["util", "generate-password", "--length", "12"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("length") == 12
|
||||
assert "secretpw" in result.stdout
|
||||
|
||||
|
||||
def test_api_start_passes_fingerprint(monkeypatch):
|
||||
"""Ensure the API start command forwards the selected fingerprint."""
|
||||
called = {}
|
||||
|
||||
def fake_start(fp=None):
|
||||
called["fp"] = fp
|
||||
return "tok"
|
||||
|
||||
monkeypatch.setattr(cli.api_module, "start_server", fake_start)
|
||||
monkeypatch.setattr(cli, "uvicorn", SimpleNamespace(run=lambda *a, **k: None))
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "abc", "api", "start"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("fp") == "abc"
|
||||
|
||||
|
||||
def test_entry_add(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def add_entry(label, length, username=None, url=None):
|
||||
called["args"] = (label, length, username, url)
|
||||
return 2
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(add_entry=add_entry),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"entry",
|
||||
"add",
|
||||
"Example",
|
||||
"--length",
|
||||
"16",
|
||||
"--username",
|
||||
"bob",
|
||||
"--url",
|
||||
"ex.com",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "2" in result.stdout
|
||||
assert called["args"] == ("Example", 16, "bob", "ex.com")
|
||||
|
||||
|
||||
def test_entry_modify(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def modify_entry(index, username=None, url=None, notes=None, label=None, **kwargs):
|
||||
called["args"] = (index, username, url, notes, label, kwargs)
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(modify_entry=modify_entry),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"])
|
||||
assert result.exit_code == 0
|
||||
assert called["args"][:5] == (1, "alice", None, None, None)
|
||||
|
||||
|
||||
def test_entry_archive(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def archive_entry(i):
|
||||
called["id"] = i
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(archive_entry=archive_entry),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["entry", "archive", "3"])
|
||||
assert result.exit_code == 0
|
||||
assert "3" in result.stdout
|
||||
assert called["id"] == 3
|
||||
|
||||
|
||||
def test_entry_unarchive(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def restore_entry(i):
|
||||
called["id"] = i
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(restore_entry=restore_entry),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["entry", "unarchive", "4"])
|
||||
assert result.exit_code == 0
|
||||
assert "4" in result.stdout
|
||||
assert called["id"] == 4
|
||||
|
||||
|
||||
def test_entry_export_totp(monkeypatch, tmp_path):
|
||||
called = {}
|
||||
|
||||
pm = SimpleNamespace(
|
||||
entry_manager=SimpleNamespace(
|
||||
export_totp_entries=lambda seed: called.setdefault("called", True)
|
||||
or {"entries": []}
|
||||
),
|
||||
parent_seed="seed",
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
|
||||
out = tmp_path / "t.json"
|
||||
result = runner.invoke(app, ["entry", "export-totp", "--file", str(out)])
|
||||
assert result.exit_code == 0
|
||||
assert out.exists()
|
||||
assert called.get("called") is True
|
||||
|
||||
|
||||
def test_entry_totp_codes(monkeypatch):
|
||||
called = {}
|
||||
|
||||
pm = SimpleNamespace(
|
||||
handle_display_totp_codes=lambda: called.setdefault("called", True),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["entry", "totp-codes"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("called") is True
|
||||
|
||||
|
||||
def test_verify_checksum_command(monkeypatch):
|
||||
called = {}
|
||||
|
||||
pm = SimpleNamespace(
|
||||
handle_verify_checksum=lambda: called.setdefault("called", True),
|
||||
handle_update_script_checksum=lambda: None,
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["util", "verify-checksum"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("called") is True
|
||||
|
||||
|
||||
def test_update_checksum_command(monkeypatch):
|
||||
called = {}
|
||||
|
||||
pm = SimpleNamespace(
|
||||
handle_verify_checksum=lambda: None,
|
||||
handle_update_script_checksum=lambda: called.setdefault("called", True),
|
||||
select_fingerprint=lambda fp: None,
|
||||
)
|
||||
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
|
||||
result = runner.invoke(app, ["util", "update-checksum"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("called") is True
|
Reference in New Issue
Block a user