mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
Add agent mode, hardened TUI harness, coverage gates, and cross-platform installer smoke CI
This commit delivers four related improvements focused on reliability and automation while preserving existing human/TUI workflows. 1) Agent-first CLI mode (additive) - Added new `seedpass agent` command group (`src/seedpass/cli/agent.py`) and wired it into CLI root. - Implemented non-interactive profile bootstrap: `seedpass agent init` with `--seed|--seed-file|--generate-seed` and password env injection (`--password-env`). - Added policy management commands: `agent policy-show` and `agent policy-set` with allow/deny kind controls. - Added JSON-based secret retrieval: `seedpass -f <fingerprint> agent get <query> --ttl <sec>` with lease metadata and policy enforcement. - Kept regular human mode fully available; no behavioral changes to interactive TUI entrypoint. 2) TUI agent harness deepening and stabilization - Refactored harness into scenario profiles: `core`, `extended`, `stress` with `--scenario` selection. - Added deterministic seeded stress campaigns: `--stress-cycles`, `--stress-seed`. - Added scenario-specific required coverage keys and report metadata. - Added post-condition assertions in extended scenario (archive/restore consistency, lock/unlock retrieval recovery). - Hardened prompt matching for output variants and fail-fast behavior on non-zero SeedPass exit codes. - Added harness unit tests for helper logic and control flow. 3) Critical module coverage gate tooling - Added `scripts/check_critical_coverage.py` to enforce per-module thresholds from coverage JSON and report uncovered functions. - Added override controls (`--threshold`, `--no-default-thresholds`) and optional JSON result output. - Integrated coverage JSON generation into CI helper script and optional gate execution via `CRITICAL_COVERAGE_GATE=1`. - Tuned default thresholds using full-suite baseline and increased strictness: - `src/main.py >= 50%` - `src/seedpass/core/manager.py >= 60%` - `src/seedpass/core/encryption.py >= 80%` - `src/seedpass/core/migrations.py >= 90%` - Added tests for coverage gate parser/analysis logic and targeted manager worker path tests. 4) Installer validation framework for beta/main - Added cross-platform installer smoke scripts: - `scripts/installer_smoke_unix.sh` - `scripts/installer_smoke_windows.ps1` - Added GitHub Actions workflow `.github/workflows/installer-smoke.yml` with: - reduced PR matrix - full scheduled/manual matrix across OS/branch/mode - per-job log capture and artifact uploads for failure triage - Added installer testing documentation and linked from README. Additional housekeeping - Added `.venv/` to `.gitignore`. - Updated docs for new harness scenarios, seeded stress mode, post-condition metadata, coverage gate usage, and installer testing workflow.
This commit is contained in:
1 parent
dd7290e83b
commit
03958106e1
16 files changed
+1826
-54
No files matched your search
@@ -0,0 +1,125 @@
|
||||
name: Installer Smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "scripts/install.sh"
|
||||
- "scripts/install.ps1"
|
||||
- "scripts/installer_smoke_unix.sh"
|
||||
- "scripts/installer_smoke_windows.ps1"
|
||||
- ".github/workflows/installer-smoke.yml"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "17 4 * * *"
|
||||
|
||||
jobs:
|
||||
smoke_pr:
|
||||
if: github.event_name == 'pull_request'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
branch: beta
|
||||
mode: tui
|
||||
- os: windows-latest
|
||||
branch: beta
|
||||
mode: tui
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Unix installer smoke
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/installer-smoke
|
||||
bash scripts/installer_smoke_unix.sh "${{ matrix.branch }}" "${{ matrix.mode }}" 2>&1 | tee "artifacts/installer-smoke/${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}.log"
|
||||
|
||||
- name: Windows installer smoke
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path artifacts/installer-smoke | Out-Null
|
||||
$log = "artifacts/installer-smoke/${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}.log"
|
||||
& ./scripts/installer_smoke_windows.ps1 -Branch "${{ matrix.branch }}" -Mode "${{ matrix.mode }}" *>&1 | Tee-Object -FilePath $log
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Upload installer smoke logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-smoke-logs-${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}-pr
|
||||
path: artifacts/installer-smoke/*.log
|
||||
if-no-files-found: warn
|
||||
|
||||
smoke_full:
|
||||
if: github.event_name != 'pull_request'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
branch: main
|
||||
mode: tui
|
||||
- os: ubuntu-latest
|
||||
branch: beta
|
||||
mode: tui
|
||||
- os: ubuntu-latest
|
||||
branch: beta
|
||||
mode: both
|
||||
- os: macos-latest
|
||||
branch: main
|
||||
mode: tui
|
||||
- os: macos-latest
|
||||
branch: beta
|
||||
mode: tui
|
||||
- os: macos-latest
|
||||
branch: beta
|
||||
mode: both
|
||||
- os: windows-latest
|
||||
branch: main
|
||||
mode: tui
|
||||
- os: windows-latest
|
||||
branch: beta
|
||||
mode: tui
|
||||
- os: windows-latest
|
||||
branch: beta
|
||||
mode: both
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Unix installer smoke
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/installer-smoke
|
||||
bash scripts/installer_smoke_unix.sh "${{ matrix.branch }}" "${{ matrix.mode }}" 2>&1 | tee "artifacts/installer-smoke/${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}.log"
|
||||
|
||||
- name: Windows installer smoke
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path artifacts/installer-smoke | Out-Null
|
||||
$log = "artifacts/installer-smoke/${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}.log"
|
||||
& ./scripts/installer_smoke_windows.ps1 -Branch "${{ matrix.branch }}" -Mode "${{ matrix.mode }}" *>&1 | Tee-Object -FilePath $log
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Upload installer smoke logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-smoke-logs-${{ runner.os }}-${{ matrix.branch }}-${{ matrix.mode }}-full
|
||||
path: artifacts/installer-smoke/*.log
|
||||
if-no-files-found: warn
|
||||
@@ -1,5 +1,6 @@
|
||||
# Ignore virtual environment directory
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Ignore context tool directory
|
||||
context_tool/
|
||||
|
||||
@@ -126,6 +126,8 @@ Use the automated installer to download SeedPass and its dependencies in one ste
|
||||
The default `tui` mode installs only the text interface, so it runs headlessly and works well in CI or other automation. GUI backends are optional and must be explicitly requested (`--mode gui` or `--mode both` on Linux/macOS, `-IncludeGui` on Windows). If the GTK `gi` bindings are missing, the installer attempts to install the
|
||||
necessary system packages using `apt`, `yum`, `pacman`, or Homebrew. When no display server is detected, GUI components are skipped automatically.
|
||||
|
||||
For installer validation workflows and local smoke tests, see [Installer Testing](docs/installer_testing.md).
|
||||
|
||||
**Linux and macOS:**
|
||||
```bash
|
||||
# TUI-only/agent install (headless default)
|
||||
@@ -443,6 +445,42 @@ which seedpass # or 'where seedpass' on Windows
|
||||
|
||||
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).
|
||||
|
||||
### Agent Mode (Non-Interactive)
|
||||
|
||||
SeedPass now includes an additive `agent` command group for automation and CI.
|
||||
This does not replace or modify the regular human-facing TUI flow.
|
||||
|
||||
1. Initialize a profile non-interactively:
|
||||
|
||||
```bash
|
||||
export SEEDPASS_PASSWORD='your-strong-password'
|
||||
seedpass agent init --generate-seed
|
||||
```
|
||||
|
||||
Or use an existing seed from a file:
|
||||
|
||||
```bash
|
||||
export SEEDPASS_PASSWORD='your-strong-password'
|
||||
seedpass agent init --seed-file /secure/path/seed.txt --switch-existing
|
||||
```
|
||||
|
||||
2. Configure agent policy (kind allow-list and private-kind deny-list):
|
||||
|
||||
```bash
|
||||
seedpass agent policy-set --allow-kind password --allow-kind totp --deny-private-kind seed
|
||||
seedpass agent policy-show
|
||||
```
|
||||
|
||||
3. Retrieve a secret as JSON with lease metadata:
|
||||
|
||||
```bash
|
||||
export SEEDPASS_PASSWORD='your-strong-password'
|
||||
seedpass -f <fingerprint> agent get github --ttl 30
|
||||
```
|
||||
|
||||
By default, agent policy blocks private key/seed-style entry kinds from being
|
||||
revealed through `agent get`.
|
||||
|
||||
### Running the Application
|
||||
|
||||
1. **Start the Application:**
|
||||
|
||||
@@ -23,9 +23,15 @@ The runner:
|
||||
|
||||
- creates an isolated temporary `HOME`
|
||||
- launches `src/main.py --no-clipboard`
|
||||
- executes an end-to-end scenario through onboarding and major menu flows
|
||||
- executes a scenario profile (`core`, `extended`, or `stress`)
|
||||
- writes artifacts to `artifacts/agent_tui_test/<timestamp>/`
|
||||
|
||||
## Scenario Profiles
|
||||
|
||||
- `core`: fast smoke path (onboarding, invalid input checks, add/retrieve/search/list, exit)
|
||||
- `extended`: full end-to-end path including all entry kinds, archive/restore, and settings flows
|
||||
- `stress`: deterministic repeated negative-input and back-navigation loops
|
||||
|
||||
## Agent Commands Today
|
||||
|
||||
Use these command tiers when agents are validating changes:
|
||||
@@ -35,7 +41,8 @@ Use these command tiers when agents are validating changes:
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
pytest -q src/tests/test_manager_add_password.py src/tests/test_archive_restore.py src/tests/test_stats_screen.py src/tests/test_bip85_init.py
|
||||
python scripts/ai_tui_agent_test.py
|
||||
pytest -q src/tests/test_ai_tui_agent_harness.py
|
||||
python scripts/ai_tui_agent_test.py --scenario core
|
||||
```
|
||||
|
||||
2. Full regression check (before merge):
|
||||
@@ -43,14 +50,16 @@ python scripts/ai_tui_agent_test.py
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
pytest -q
|
||||
python scripts/ai_tui_agent_test.py
|
||||
pytest -q --cov=src --cov-report=json:artifacts/coverage/coverage.json src/tests
|
||||
python scripts/check_critical_coverage.py artifacts/coverage/coverage.json
|
||||
python scripts/ai_tui_agent_test.py --scenario extended
|
||||
```
|
||||
|
||||
3. Stability sweep for TUI prompt timing:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python scripts/ai_tui_agent_test.py && python scripts/ai_tui_agent_test.py && python scripts/ai_tui_agent_test.py
|
||||
python scripts/ai_tui_agent_test.py --scenario stress --stress-cycles 6 --stress-seed 1337
|
||||
```
|
||||
|
||||
## Artifacts
|
||||
@@ -61,6 +70,12 @@ Each run writes:
|
||||
- `transcript.clean.txt`: ANSI-stripped terminal output.
|
||||
- `transcript.raw.txt`: raw terminal output with ANSI.
|
||||
|
||||
`report.json` also includes scenario-specific validation metadata:
|
||||
|
||||
- `required_coverage_keys`
|
||||
- `required_post_conditions`
|
||||
- `post_conditions`
|
||||
|
||||
## Current Coverage Points
|
||||
|
||||
The harness marks these checkpoints as `true` only when observed:
|
||||
@@ -82,6 +97,11 @@ The harness marks these checkpoints as `true` only when observed:
|
||||
- `settings_stats`
|
||||
- `graceful_exit`
|
||||
|
||||
`extended` scenario also enforces post-conditions:
|
||||
|
||||
- `archive_restore_consistency`: restored entries are no longer listed in archived view
|
||||
- `lock_unlock_recovers_retrieval`: retrieval still works after lock/unlock
|
||||
|
||||
## CI/Agent Use Pattern
|
||||
|
||||
Recommended agent sequence:
|
||||
@@ -90,6 +110,7 @@ Recommended agent sequence:
|
||||
2. Parse `report.json`
|
||||
3. Fail task if `status != "passed"` or any checkpoint is `false`
|
||||
4. If failed, inspect `transcript.clean.txt` for prompt-flow drift or regressions
|
||||
5. Inspect `artifacts/coverage/coverage.json` and critical coverage gate output when module thresholds fail
|
||||
|
||||
## Optional Flags
|
||||
|
||||
@@ -99,12 +120,21 @@ python scripts/ai_tui_agent_test.py --help
|
||||
|
||||
Useful options:
|
||||
|
||||
- `--scenario`: `core`, `extended`, or `stress`
|
||||
- `--stress-cycles`: number of loops for `stress` profile
|
||||
- `--stress-seed`: fixed RNG seed for deterministic invalid-input campaigns in `stress`
|
||||
- `--timeout`: per-prompt timeout
|
||||
- `--password`: fixed password used during test
|
||||
- `--output-dir`: custom artifact root
|
||||
- `--keep-home`: preserve generated profile state for local debugging
|
||||
- `--verbose`: print step progress
|
||||
|
||||
Coverage gate helper:
|
||||
|
||||
- `python scripts/check_critical_coverage.py artifacts/coverage/coverage.json`
|
||||
- Use `--threshold PATH=PERCENT` to override defaults.
|
||||
- Use `--no-default-thresholds` for focused local smoke checks.
|
||||
|
||||
## Extending Coverage
|
||||
|
||||
When adding new TUI flows:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Installer Testing
|
||||
|
||||
This document describes how SeedPass installer smoke tests are validated across
|
||||
operating systems and branches (`main`, `beta`).
|
||||
|
||||
## CI Workflow
|
||||
|
||||
GitHub Actions workflow: `.github/workflows/installer-smoke.yml`
|
||||
|
||||
- `pull_request` (reduced matrix):
|
||||
- Ubuntu + Windows
|
||||
- `beta` branch
|
||||
- `tui` mode
|
||||
- `schedule` + `workflow_dispatch` (full matrix):
|
||||
- Ubuntu / macOS / Windows
|
||||
- `main` + `beta`
|
||||
- `tui` and GUI-capable modes (`both` where supported)
|
||||
|
||||
Each job verifies:
|
||||
|
||||
1. installer exits successfully
|
||||
2. launcher is created
|
||||
3. installed git branch matches requested branch
|
||||
4. `seedpass --help` runs through launcher
|
||||
5. installer can be re-run (idempotence smoke check)
|
||||
|
||||
## Local Smoke Tests
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
bash scripts/installer_smoke_unix.sh beta tui
|
||||
bash scripts/installer_smoke_unix.sh beta both
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
./scripts/installer_smoke_windows.ps1 -Branch beta -Mode tui
|
||||
./scripts/installer_smoke_windows.ps1 -Branch beta -Mode both
|
||||
```
|
||||
|
||||
The smoke scripts use an isolated temporary home/profile directory so local
|
||||
user data is not modified.
|
||||
+375
-48
@@ -18,6 +18,7 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import random
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
@@ -142,6 +143,24 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run deterministic, repeatable TUI coverage checks for SeedPass."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("core", "extended", "stress"),
|
||||
default="extended",
|
||||
help="Scenario profile to run (default: extended).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stress-cycles",
|
||||
type=int,
|
||||
default=6,
|
||||
help="Deterministic menu-stress loops for --scenario stress.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stress-seed",
|
||||
type=int,
|
||||
default=1337,
|
||||
help="RNG seed for deterministic invalid-input campaigns in stress scenario.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-root",
|
||||
type=Path,
|
||||
@@ -289,7 +308,7 @@ def _add_entry_menu_open(
|
||||
)
|
||||
|
||||
|
||||
def _scenario(
|
||||
def _complete_initial_seed_setup(
|
||||
runner: TUIRunner, timeout: float, password: str, coverage: dict[str, bool]
|
||||
) -> None:
|
||||
# First-run onboarding.
|
||||
@@ -302,34 +321,26 @@ def _scenario(
|
||||
coverage["startup_onboarding"] = True
|
||||
coverage["main_menu"] = True
|
||||
|
||||
# Invalid input resilience checks.
|
||||
runner.sendline("99")
|
||||
runner.wait_for(r"Invalid choice\. Please select a valid option\.", timeout)
|
||||
_add_entry_menu_open(runner, timeout, "9")
|
||||
runner.wait_for(r"Invalid choice\.", timeout)
|
||||
runner.expect_and_send(r"Select entry type or press Enter to go back:", "", timeout)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
runner.sendline("2")
|
||||
runner.expect_and_send(
|
||||
r"Enter the index number of the entry to retrieve:", "abc", timeout
|
||||
)
|
||||
runner.wait_for(r"Error: Index must be a number\.", timeout)
|
||||
runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout)
|
||||
coverage["invalid_input_resilience"] = True
|
||||
|
||||
# Add password in quick mode, including validation checks.
|
||||
def _add_quick_password_entry(
|
||||
runner: TUIRunner,
|
||||
timeout: float,
|
||||
*,
|
||||
label: str,
|
||||
username: str,
|
||||
url: str,
|
||||
exercise_invalid_length: bool,
|
||||
coverage: dict[str, bool],
|
||||
) -> None:
|
||||
_add_entry_menu_open(runner, timeout, "1")
|
||||
runner.expect_and_send(r"Choose mode: \[Q\]uick or \[A\]dvanced\?", "q", timeout)
|
||||
runner.expect_and_send(
|
||||
r"Enter the label or website name:", "agent.example", timeout
|
||||
)
|
||||
runner.expect_and_send(r"Enter the username \(optional\):", "agent", timeout)
|
||||
runner.expect_and_send(
|
||||
r"Enter the URL \(optional\):", "https://agent.example", timeout
|
||||
)
|
||||
runner.expect_and_send(r"Enter desired password length .*:", "3", timeout)
|
||||
runner.wait_for(r"Password length must be between 8 and 128", timeout)
|
||||
coverage["invalid_length_validation"] = True
|
||||
runner.expect_and_send(r"Enter the label or website name:", label, timeout)
|
||||
runner.expect_and_send(r"Enter the username \(optional\):", username, timeout)
|
||||
runner.expect_and_send(r"Enter the URL \(optional\):", url, timeout)
|
||||
if exercise_invalid_length:
|
||||
runner.expect_and_send(r"Enter desired password length .*:", "3", timeout)
|
||||
runner.wait_for(r"Password length must be between 8 and 128", timeout)
|
||||
coverage["invalid_length_validation"] = True
|
||||
runner.expect_and_send(r"Enter desired password length .*:", "12", timeout)
|
||||
runner.expect_and_send(r"Include special characters\? \(Y/n\):", "", timeout)
|
||||
runner.wait_for(r"Password generated and indexed with ID 0", timeout)
|
||||
@@ -337,12 +348,214 @@ def _scenario(
|
||||
runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
|
||||
|
||||
def _invalid_input_checks(
|
||||
runner: TUIRunner,
|
||||
timeout: float,
|
||||
*,
|
||||
main_menu_choice: str = "99",
|
||||
add_entry_choice: str = "9",
|
||||
retrieve_index: str = "abc",
|
||||
) -> None:
|
||||
runner.sendline(main_menu_choice)
|
||||
runner.wait_for(r"Invalid choice\. Please select a valid option\.", timeout)
|
||||
_add_entry_menu_open(runner, timeout, add_entry_choice)
|
||||
runner.wait_for(r"Invalid choice\.", timeout)
|
||||
runner.expect_and_send(r"Select entry type or press Enter to go back:", "", timeout)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
runner.sendline("2")
|
||||
runner.expect_and_send(
|
||||
r"Enter the index number of the entry to retrieve:", retrieve_index, timeout
|
||||
)
|
||||
runner.wait_for(r"Error: Index must be a number\.", timeout)
|
||||
runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
|
||||
|
||||
def _wait_for_retrieve_password_output(
|
||||
runner: TUIRunner, timeout: float, label: str
|
||||
) -> None:
|
||||
runner.wait_for(
|
||||
rf"(Retrieved Password for {re.escape(label)}|Retrieving password for '{re.escape(label)}')",
|
||||
timeout,
|
||||
)
|
||||
|
||||
|
||||
def _scenario_core(
|
||||
runner: TUIRunner,
|
||||
timeout: float,
|
||||
password: str,
|
||||
coverage: dict[str, bool],
|
||||
post_conditions: dict[str, bool],
|
||||
) -> None:
|
||||
_complete_initial_seed_setup(runner, timeout, password, coverage)
|
||||
|
||||
# Invalid input resilience checks.
|
||||
_invalid_input_checks(runner, timeout)
|
||||
coverage["invalid_input_resilience"] = True
|
||||
|
||||
# Add a password and exercise length validation.
|
||||
_add_quick_password_entry(
|
||||
runner,
|
||||
timeout,
|
||||
label="agent.example",
|
||||
username="agent",
|
||||
url="https://agent.example",
|
||||
exercise_invalid_length=True,
|
||||
coverage=coverage,
|
||||
)
|
||||
|
||||
# Retrieve and action menu.
|
||||
runner.sendline("2")
|
||||
runner.expect_and_send(
|
||||
r"Enter the index number of the entry to retrieve:", "0", timeout
|
||||
)
|
||||
runner.wait_for(r"Retrieved Password for agent\.example", timeout)
|
||||
_wait_for_retrieve_password_output(runner, timeout, "agent.example")
|
||||
if runner.try_wait_for(r"Reveal hidden fields\? \(y/N\):", timeout=0.4):
|
||||
runner.sendline("")
|
||||
runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout)
|
||||
runner.wait_for(r"Entry Actions:", timeout)
|
||||
coverage["retrieve_entry"] = True
|
||||
runner.expect_and_send(r"Select an action or press Enter to return:", "", timeout)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
|
||||
# Search.
|
||||
runner.sendline("3")
|
||||
runner.expect_and_send(r"Enter search string:", "agent", timeout)
|
||||
runner.wait_for(r"Search Results", timeout)
|
||||
runner.wait_for(r"0\. Password - agent\.example", timeout)
|
||||
coverage["search_entries"] = True
|
||||
runner.expect_and_send(
|
||||
r"Enter index to view details or press Enter to go back:", "", timeout
|
||||
)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
|
||||
# List entries.
|
||||
runner.sendline("4")
|
||||
runner.expect_and_send(
|
||||
r"Select entry type or press Enter to go back:", "1", timeout
|
||||
)
|
||||
runner.wait_for(r"\[\+\] Entries:", timeout)
|
||||
runner.wait_for(r"0\. Password - agent\.example", timeout)
|
||||
coverage["list_entries"] = True
|
||||
runner.expect_and_send(
|
||||
r"Enter index to view details or press Enter to go back:", "", timeout
|
||||
)
|
||||
runner.expect_and_send(r"Select entry type or press Enter to go back:", "", timeout)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
|
||||
# Graceful exit.
|
||||
runner.sendline("")
|
||||
runner.wait_for(r"Exiting the program\.", timeout)
|
||||
coverage["graceful_exit"] = True
|
||||
|
||||
|
||||
def _scenario_stress(
|
||||
runner: TUIRunner,
|
||||
timeout: float,
|
||||
password: str,
|
||||
coverage: dict[str, bool],
|
||||
post_conditions: dict[str, bool],
|
||||
*,
|
||||
cycles: int,
|
||||
seed: int,
|
||||
) -> None:
|
||||
_complete_initial_seed_setup(runner, timeout, password, coverage)
|
||||
|
||||
_add_quick_password_entry(
|
||||
runner,
|
||||
timeout,
|
||||
label="stress.example",
|
||||
username="stress",
|
||||
url="https://stress.example",
|
||||
exercise_invalid_length=False,
|
||||
coverage=coverage,
|
||||
)
|
||||
|
||||
rng = random.Random(seed)
|
||||
invalid_main_menu_inputs = ["99", "0", "x", "-1", "one", "1x"]
|
||||
invalid_add_entry_inputs = ["9", "0", "x", "-1", "one", "1x"]
|
||||
invalid_retrieve_inputs = ["abc", "!", "one", "1x", "index", "0x1"]
|
||||
|
||||
# Deterministic negative-input + back-navigation stress cycles.
|
||||
for _ in range(cycles):
|
||||
_invalid_input_checks(
|
||||
runner,
|
||||
timeout,
|
||||
main_menu_choice=rng.choice(invalid_main_menu_inputs),
|
||||
add_entry_choice=rng.choice(invalid_add_entry_inputs),
|
||||
retrieve_index=rng.choice(invalid_retrieve_inputs),
|
||||
)
|
||||
|
||||
runner.sendline("4")
|
||||
runner.expect_and_send(
|
||||
r"Select entry type or press Enter to go back:", "1", timeout
|
||||
)
|
||||
runner.wait_for(r"\[\+\] Entries:", timeout)
|
||||
runner.wait_for(r"0\. Password - stress\.example", timeout)
|
||||
runner.expect_and_send(
|
||||
r"Enter index to view details or press Enter to go back:", "", timeout
|
||||
)
|
||||
runner.expect_and_send(
|
||||
r"Select entry type or press Enter to go back:", "", timeout
|
||||
)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
|
||||
runner.sendline("2")
|
||||
runner.expect_and_send(
|
||||
r"Enter the index number of the entry to retrieve:", "0", timeout
|
||||
)
|
||||
_wait_for_retrieve_password_output(runner, timeout, "stress.example")
|
||||
if runner.try_wait_for(r"Reveal hidden fields\? \(y/N\):", timeout=0.4):
|
||||
runner.sendline("")
|
||||
runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout)
|
||||
runner.wait_for(r"Entry Actions:", timeout)
|
||||
runner.expect_and_send(
|
||||
r"Select an action or press Enter to return:", "", timeout
|
||||
)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
|
||||
coverage["invalid_input_resilience"] = True
|
||||
coverage["retrieve_entry"] = True
|
||||
coverage["list_entries"] = True
|
||||
coverage["stress_cycles"] = True
|
||||
coverage["seeded_negative_campaign"] = True
|
||||
|
||||
runner.sendline("")
|
||||
runner.wait_for(r"Exiting the program\.", timeout)
|
||||
coverage["graceful_exit"] = True
|
||||
|
||||
|
||||
def _scenario_extended(
|
||||
runner: TUIRunner,
|
||||
timeout: float,
|
||||
password: str,
|
||||
coverage: dict[str, bool],
|
||||
post_conditions: dict[str, bool],
|
||||
) -> None:
|
||||
_complete_initial_seed_setup(runner, timeout, password, coverage)
|
||||
|
||||
# Invalid input resilience checks.
|
||||
_invalid_input_checks(runner, timeout)
|
||||
coverage["invalid_input_resilience"] = True
|
||||
|
||||
# Add password in quick mode, including validation checks.
|
||||
_add_quick_password_entry(
|
||||
runner,
|
||||
timeout,
|
||||
label="agent.example",
|
||||
username="agent",
|
||||
url="https://agent.example",
|
||||
exercise_invalid_length=True,
|
||||
coverage=coverage,
|
||||
)
|
||||
|
||||
# Retrieve and action menu.
|
||||
runner.sendline("2")
|
||||
runner.expect_and_send(
|
||||
r"Enter the index number of the entry to retrieve:", "0", timeout
|
||||
)
|
||||
_wait_for_retrieve_password_output(runner, timeout, "agent.example")
|
||||
if runner.try_wait_for(r"Reveal hidden fields\? \(y/N\):", timeout=0.4):
|
||||
runner.sendline("")
|
||||
runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout)
|
||||
@@ -541,6 +754,23 @@ def _scenario(
|
||||
runner.wait_for(r"Entry at index 0 modified successfully", timeout)
|
||||
coverage["archive_restore"] = True
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
runner.sendline("8")
|
||||
if runner.try_wait_for(r"Archived Entries", timeout=0.8):
|
||||
if runner.try_wait_for(r"0\. agent\.example", timeout=0.4):
|
||||
raise TUIHarnessError(
|
||||
"Archive/restore post-condition failed: restored entry is still archived."
|
||||
)
|
||||
runner.expect_and_send(
|
||||
r"Enter index to manage or press Enter to go back:", "", timeout
|
||||
)
|
||||
elif runner.try_wait_for(r"Press Enter to continue\.\.\.", timeout=0.8):
|
||||
runner.sendline("")
|
||||
else:
|
||||
raise TUIHarnessError(
|
||||
"Archive/restore post-condition failed: archived view prompt was not recognized."
|
||||
)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
post_conditions["archive_restore_consistency"] = True
|
||||
|
||||
# 2FA codes view remains accessible after mixed operations.
|
||||
runner.sendline("6")
|
||||
@@ -589,6 +819,17 @@ def _scenario(
|
||||
runner.expect_and_send(r"Select an option or press Enter to go back:", "", timeout)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
coverage["settings_toggles_lock_unlock"] = True
|
||||
runner.sendline("2")
|
||||
runner.expect_and_send(
|
||||
r"Enter the index number of the entry to retrieve:", "0", timeout
|
||||
)
|
||||
_wait_for_retrieve_password_output(runner, timeout, "agent.example")
|
||||
if runner.try_wait_for(r"Reveal hidden fields\? \(y/N\):", timeout=0.4):
|
||||
runner.sendline("")
|
||||
runner.expect_and_send(r"Press Enter to continue\.\.\.", "", timeout)
|
||||
runner.expect_and_send(r"Select an action or press Enter to return:", "", timeout)
|
||||
_drain_to_main_menu(runner, timeout)
|
||||
post_conditions["lock_unlock_recovers_retrieval"] = True
|
||||
|
||||
# Settings -> Stats and back.
|
||||
runner.sendline("7")
|
||||
@@ -609,6 +850,10 @@ def _scenario(
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
if args.stress_cycles < 1:
|
||||
print("Error: --stress-cycles must be >= 1", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
repo_root = args.repo_root.resolve()
|
||||
src_main = repo_root / "src" / "main.py"
|
||||
if not src_main.exists():
|
||||
@@ -640,32 +885,97 @@ def main() -> int:
|
||||
runner = TUIRunner(proc, master_fd)
|
||||
|
||||
results: list[StepResult] = []
|
||||
coverage = {
|
||||
"startup_onboarding": False,
|
||||
"main_menu": False,
|
||||
"invalid_input_resilience": False,
|
||||
"add_password_quick": False,
|
||||
"invalid_length_validation": False,
|
||||
"add_totp": False,
|
||||
"add_all_entry_types": False,
|
||||
"retrieve_entry": False,
|
||||
"search_entries": False,
|
||||
"list_entries": False,
|
||||
"modify_entry": False,
|
||||
"archive_restore": False,
|
||||
"totp_codes_view": False,
|
||||
"settings_toggles_lock_unlock": False,
|
||||
"settings_stats": False,
|
||||
"graceful_exit": False,
|
||||
scenario_coverage_keys: dict[str, list[str]] = {
|
||||
"core": [
|
||||
"startup_onboarding",
|
||||
"main_menu",
|
||||
"invalid_input_resilience",
|
||||
"add_password_quick",
|
||||
"invalid_length_validation",
|
||||
"retrieve_entry",
|
||||
"search_entries",
|
||||
"list_entries",
|
||||
"graceful_exit",
|
||||
],
|
||||
"extended": [
|
||||
"startup_onboarding",
|
||||
"main_menu",
|
||||
"invalid_input_resilience",
|
||||
"add_password_quick",
|
||||
"invalid_length_validation",
|
||||
"add_totp",
|
||||
"add_all_entry_types",
|
||||
"retrieve_entry",
|
||||
"search_entries",
|
||||
"list_entries",
|
||||
"modify_entry",
|
||||
"archive_restore",
|
||||
"totp_codes_view",
|
||||
"settings_toggles_lock_unlock",
|
||||
"settings_stats",
|
||||
"graceful_exit",
|
||||
],
|
||||
"stress": [
|
||||
"startup_onboarding",
|
||||
"main_menu",
|
||||
"add_password_quick",
|
||||
"invalid_input_resilience",
|
||||
"retrieve_entry",
|
||||
"list_entries",
|
||||
"stress_cycles",
|
||||
"seeded_negative_campaign",
|
||||
"graceful_exit",
|
||||
],
|
||||
}
|
||||
scenario_post_condition_keys: dict[str, list[str]] = {
|
||||
"core": [],
|
||||
"extended": [
|
||||
"archive_restore_consistency",
|
||||
"lock_unlock_recovers_retrieval",
|
||||
],
|
||||
"stress": [],
|
||||
}
|
||||
coverage = {
|
||||
key: False
|
||||
for key in sorted({k for keys in scenario_coverage_keys.values() for k in keys})
|
||||
}
|
||||
post_conditions = {
|
||||
key: False
|
||||
for key in sorted(
|
||||
{k for keys in scenario_post_condition_keys.values() for k in keys}
|
||||
)
|
||||
}
|
||||
required_keys = scenario_coverage_keys[args.scenario]
|
||||
required_post_conditions = scenario_post_condition_keys[args.scenario]
|
||||
|
||||
failure: str | None = None
|
||||
start_time = time.monotonic()
|
||||
scenario_handlers = {
|
||||
"core": lambda: _scenario_core(
|
||||
runner, args.timeout, args.password, coverage, post_conditions
|
||||
),
|
||||
"extended": lambda: _scenario_extended(
|
||||
runner, args.timeout, args.password, coverage, post_conditions
|
||||
),
|
||||
"stress": lambda: _scenario_stress(
|
||||
runner,
|
||||
args.timeout,
|
||||
args.password,
|
||||
coverage,
|
||||
post_conditions,
|
||||
cycles=args.stress_cycles,
|
||||
seed=args.stress_seed,
|
||||
),
|
||||
}
|
||||
try:
|
||||
_run_step(
|
||||
"full_scenario",
|
||||
"Drive end-to-end deterministic TUI coverage workflow.",
|
||||
lambda: _scenario(runner, args.timeout, args.password, coverage),
|
||||
f"{args.scenario}_scenario",
|
||||
(
|
||||
"Drive end-to-end deterministic TUI coverage workflow."
|
||||
if args.scenario == "extended"
|
||||
else f"Drive deterministic {args.scenario} TUI scenario."
|
||||
),
|
||||
scenario_handlers[args.scenario],
|
||||
results,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
@@ -674,6 +984,8 @@ def main() -> int:
|
||||
finally:
|
||||
exit_code = runner.close()
|
||||
total_duration = time.monotonic() - start_time
|
||||
if failure is None and exit_code != 0:
|
||||
failure = f"SeedPass exited with non-zero code: {exit_code}"
|
||||
|
||||
transcript_clean = artifact_dir / "transcript.clean.txt"
|
||||
transcript_raw = artifact_dir / "transcript.raw.txt"
|
||||
@@ -685,12 +997,27 @@ def main() -> int:
|
||||
"command": cmd,
|
||||
"repo_root": str(repo_root),
|
||||
"home_dir": str(temp_home_dir),
|
||||
"scenario": args.scenario,
|
||||
"stress_cycles": args.stress_cycles if args.scenario == "stress" else None,
|
||||
"stress_seed": args.stress_seed if args.scenario == "stress" else None,
|
||||
"required_coverage_keys": required_keys,
|
||||
"required_post_conditions": required_post_conditions,
|
||||
"duration_sec": round(total_duration, 3),
|
||||
"seedpass_exit_code": exit_code,
|
||||
"status": "passed" if failure is None and all(coverage.values()) else "failed",
|
||||
"status": (
|
||||
"passed"
|
||||
if (
|
||||
failure is None
|
||||
and all(coverage[key] for key in required_keys)
|
||||
and all(post_conditions[key] for key in required_post_conditions)
|
||||
and exit_code == 0
|
||||
)
|
||||
else "failed"
|
||||
),
|
||||
"failure": failure,
|
||||
"steps": [asdict(item) for item in results],
|
||||
"coverage_points": coverage,
|
||||
"post_conditions": post_conditions,
|
||||
"transcript_clean": str(transcript_clean),
|
||||
"transcript_raw": str(transcript_raw),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enforce coverage thresholds for critical SeedPass modules.
|
||||
|
||||
Reads coverage.py JSON output (pytest --cov-report=json:<path>) and:
|
||||
1. Fails if critical modules are below configured coverage thresholds.
|
||||
2. Reports fully uncovered functions/methods for those modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_THRESHOLDS: dict[str, float] = {
|
||||
"src/main.py": 50.0,
|
||||
"src/seedpass/core/manager.py": 60.0,
|
||||
"src/seedpass/core/encryption.py": 80.0,
|
||||
"src/seedpass/core/migrations.py": 90.0,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FunctionCoverage:
|
||||
name: str
|
||||
start_line: int
|
||||
end_line: int
|
||||
covered: bool
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate coverage thresholds for critical modules."
|
||||
)
|
||||
parser.add_argument(
|
||||
"coverage_json",
|
||||
type=Path,
|
||||
help="Path to coverage JSON report.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="PATH=PERCENT",
|
||||
help="Override/add threshold mapping, e.g. src/main.py=40",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-default-thresholds",
|
||||
action="store_true",
|
||||
help="Disable built-in threshold set; use only --threshold entries.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-root",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[1],
|
||||
help="Repository root for resolving module paths.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json-output",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Optional path to write machine-readable gate results JSON.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _parse_threshold_overrides(values: list[str]) -> dict[str, float]:
|
||||
parsed: dict[str, float] = {}
|
||||
for item in values:
|
||||
if "=" not in item:
|
||||
raise ValueError(
|
||||
f"Invalid --threshold value {item!r}; expected PATH=PERCENT"
|
||||
)
|
||||
path, raw_percent = item.split("=", 1)
|
||||
path = path.strip()
|
||||
if not path:
|
||||
raise ValueError(f"Invalid --threshold value {item!r}; empty path")
|
||||
try:
|
||||
percent = float(raw_percent)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Invalid --threshold value {item!r}; percent must be numeric"
|
||||
) from exc
|
||||
if not (0.0 <= percent <= 100.0):
|
||||
raise ValueError(
|
||||
f"Invalid --threshold value {item!r}; percent must be 0..100"
|
||||
)
|
||||
parsed[path] = percent
|
||||
return parsed
|
||||
|
||||
|
||||
def _load_coverage_json(path: Path) -> dict:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(f"Coverage JSON not found: {path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Invalid coverage JSON: {path}: {exc}") from exc
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return path.replace("\\", "/")
|
||||
|
||||
|
||||
def _find_file_payload(files_payload: dict, module_path: str):
|
||||
module_norm = _normalize_path(module_path)
|
||||
for candidate_key, payload in files_payload.items():
|
||||
candidate_norm = _normalize_path(candidate_key)
|
||||
if candidate_norm.endswith(module_norm):
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
class _FunctionCollector(ast.NodeVisitor):
|
||||
def __init__(self) -> None:
|
||||
self.stack: list[str] = []
|
||||
self.functions: list[tuple[str, int, int]] = []
|
||||
|
||||
def _collect_fn(self, node: ast.AST, fn_name: str) -> None:
|
||||
end_line = getattr(node, "end_lineno", None) or getattr(node, "lineno", 0)
|
||||
full_name = ".".join(self.stack + [fn_name])
|
||||
self.functions.append((full_name, int(node.lineno), int(end_line)))
|
||||
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||
self.stack.append(node.name)
|
||||
self.generic_visit(node)
|
||||
self.stack.pop()
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
||||
self._collect_fn(node, node.name)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
||||
self._collect_fn(node, node.name)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def _compute_function_coverage(
|
||||
file_path: Path, executed_lines: set[int]
|
||||
) -> list[FunctionCoverage]:
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
collector = _FunctionCollector()
|
||||
collector.visit(tree)
|
||||
functions: list[FunctionCoverage] = []
|
||||
for name, start_line, end_line in collector.functions:
|
||||
line_range = range(start_line, end_line + 1)
|
||||
covered = any(line in executed_lines for line in line_range)
|
||||
functions.append(
|
||||
FunctionCoverage(
|
||||
name=name,
|
||||
start_line=start_line,
|
||||
end_line=end_line,
|
||||
covered=covered,
|
||||
)
|
||||
)
|
||||
return functions
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
try:
|
||||
overrides = _parse_threshold_overrides(args.threshold)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
thresholds = {} if args.no_default_thresholds else dict(DEFAULT_THRESHOLDS)
|
||||
thresholds.update(overrides)
|
||||
if not thresholds:
|
||||
print(
|
||||
"Error: no thresholds configured. Provide --threshold entries.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
try:
|
||||
coverage = _load_coverage_json(args.coverage_json)
|
||||
except RuntimeError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
files_payload = coverage.get("files")
|
||||
if not isinstance(files_payload, dict):
|
||||
print("Error: coverage JSON missing 'files' map", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
repo_root = args.repo_root.resolve()
|
||||
failing_modules: list[str] = []
|
||||
result_payload: dict[str, dict] = {}
|
||||
|
||||
print("Critical module coverage gates:")
|
||||
for module_path, threshold in sorted(thresholds.items()):
|
||||
payload = _find_file_payload(files_payload, module_path)
|
||||
module_result = {
|
||||
"threshold_percent": threshold,
|
||||
"actual_percent": None,
|
||||
"meets_threshold": False,
|
||||
"uncovered_functions": [],
|
||||
"error": None,
|
||||
}
|
||||
if payload is None:
|
||||
msg = "missing from coverage report"
|
||||
print(f"- FAIL {module_path}: {msg}")
|
||||
module_result["error"] = msg
|
||||
failing_modules.append(module_path)
|
||||
result_payload[module_path] = module_result
|
||||
continue
|
||||
|
||||
summary = payload.get("summary", {})
|
||||
percent = float(summary.get("percent_covered", 0.0))
|
||||
module_result["actual_percent"] = percent
|
||||
module_result["meets_threshold"] = percent >= threshold
|
||||
|
||||
module_file = (repo_root / module_path).resolve()
|
||||
try:
|
||||
executed_lines = {int(x) for x in payload.get("executed_lines", [])}
|
||||
functions = _compute_function_coverage(module_file, executed_lines)
|
||||
except Exception as exc: # pragma: no cover - defensive report path
|
||||
module_result["error"] = f"function coverage analysis error: {exc}"
|
||||
functions = []
|
||||
|
||||
uncovered = [
|
||||
{
|
||||
"name": fn.name,
|
||||
"line": fn.start_line,
|
||||
}
|
||||
for fn in functions
|
||||
if not fn.covered
|
||||
]
|
||||
module_result["uncovered_functions"] = uncovered
|
||||
|
||||
status = "PASS" if module_result["meets_threshold"] else "FAIL"
|
||||
print(f"- {status} {module_path}: {percent:.2f}% (threshold {threshold:.2f}%)")
|
||||
if uncovered:
|
||||
preview = ", ".join(
|
||||
f"{item['name']}@L{item['line']}" for item in uncovered[:8]
|
||||
)
|
||||
suffix = " ..." if len(uncovered) > 8 else ""
|
||||
print(f" uncovered functions: {preview}{suffix}")
|
||||
|
||||
if not module_result["meets_threshold"]:
|
||||
failing_modules.append(module_path)
|
||||
result_payload[module_path] = module_result
|
||||
|
||||
output = {
|
||||
"status": "passed" if not failing_modules else "failed",
|
||||
"failing_modules": failing_modules,
|
||||
"modules": result_payload,
|
||||
"coverage_json": str(args.coverage_json.resolve()),
|
||||
}
|
||||
if args.json_output:
|
||||
args.json_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.json_output.write_text(json.dumps(output, indent=2), encoding="utf-8")
|
||||
|
||||
if failing_modules:
|
||||
print("\nCoverage gate failed for:")
|
||||
for module in failing_modules:
|
||||
print(f"- {module}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
BRANCH="${1:-beta}"
|
||||
MODE="${2:-tui}"
|
||||
|
||||
case "$MODE" in
|
||||
tui|gui|both) ;;
|
||||
*)
|
||||
echo "[ERROR] MODE must be one of: tui, gui, both" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "[ERROR] git is required for installer smoke test" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
TMP_HOME="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_HOME"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
export HOME="$TMP_HOME"
|
||||
unset DISPLAY || true
|
||||
unset WAYLAND_DISPLAY || true
|
||||
|
||||
echo "[INFO] Smoke install start: branch=$BRANCH mode=$MODE home=$HOME"
|
||||
|
||||
bash scripts/install.sh -b "$BRANCH" -m "$MODE"
|
||||
|
||||
LAUNCHER="$HOME/.local/bin/seedpass"
|
||||
APP_DIR="$HOME/.seedpass/app"
|
||||
if [[ ! -x "$LAUNCHER" ]]; then
|
||||
echo "[ERROR] launcher missing: $LAUNCHER" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "$APP_DIR/.git" ]]; then
|
||||
echo "[ERROR] install dir missing git checkout: $APP_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
actual_branch="$(git -C "$APP_DIR" rev-parse --abbrev-ref HEAD)"
|
||||
if [[ "$actual_branch" != "$BRANCH" ]]; then
|
||||
echo "[ERROR] expected branch '$BRANCH', got '$actual_branch'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$LAUNCHER" --help >/tmp/seedpass-installer-smoke-help.txt
|
||||
|
||||
echo "[INFO] Re-running installer to validate idempotence"
|
||||
bash scripts/install.sh -b "$BRANCH" -m "$MODE"
|
||||
"$LAUNCHER" --help >/tmp/seedpass-installer-smoke-help-2.txt
|
||||
|
||||
echo "[SUCCESS] Installer smoke test passed (branch=$BRANCH mode=$MODE)"
|
||||
@@ -0,0 +1,58 @@
|
||||
param(
|
||||
[string]$Branch = "beta",
|
||||
[ValidateSet("tui", "gui", "both")]
|
||||
[string]$Mode = "tui"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
throw "git is required for installer smoke test"
|
||||
}
|
||||
|
||||
$TempHome = Join-Path $env:RUNNER_TEMP ("seedpass-installer-home-" + [guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Path $TempHome -Force | Out-Null
|
||||
|
||||
try {
|
||||
$env:USERPROFILE = $TempHome
|
||||
$env:HOME = $TempHome
|
||||
|
||||
Write-Host "[INFO] Smoke install start: branch=$Branch mode=$Mode home=$TempHome"
|
||||
|
||||
if ($Mode -eq "tui") {
|
||||
& "$PSScriptRoot\install.ps1" -Branch $Branch
|
||||
} else {
|
||||
& "$PSScriptRoot\install.ps1" -Branch $Branch -IncludeGui
|
||||
}
|
||||
|
||||
$LauncherPath = Join-Path $env:USERPROFILE ".seedpass\app\bin\seedpass.cmd"
|
||||
$AppDir = Join-Path $env:USERPROFILE ".seedpass\app"
|
||||
if (-not (Test-Path $LauncherPath)) {
|
||||
throw "launcher missing: $LauncherPath"
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $AppDir ".git"))) {
|
||||
throw "install dir missing git checkout: $AppDir"
|
||||
}
|
||||
|
||||
$ActualBranch = (git -C $AppDir rev-parse --abbrev-ref HEAD).Trim()
|
||||
if ($ActualBranch -ne $Branch) {
|
||||
throw "expected branch '$Branch', got '$ActualBranch'"
|
||||
}
|
||||
|
||||
cmd /c "`"$LauncherPath`" --help" | Out-Null
|
||||
|
||||
Write-Host "[INFO] Re-running installer to validate idempotence"
|
||||
if ($Mode -eq "tui") {
|
||||
& "$PSScriptRoot\install.ps1" -Branch $Branch
|
||||
} else {
|
||||
& "$PSScriptRoot\install.ps1" -Branch $Branch -IncludeGui
|
||||
}
|
||||
cmd /c "`"$LauncherPath`" --help" | Out-Null
|
||||
|
||||
Write-Host "[SUCCESS] Installer smoke test passed (branch=$Branch mode=$Mode)"
|
||||
}
|
||||
finally {
|
||||
if (Test-Path $TempHome) {
|
||||
Remove-Item -Recurse -Force $TempHome
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
mkdir -p artifacts/coverage
|
||||
|
||||
pytest_args=(-vv)
|
||||
if [[ -n "${STRESS_ARGS:-}" ]]; then
|
||||
pytest_args+=(${STRESS_ARGS})
|
||||
@@ -8,7 +10,14 @@ fi
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
|
||||
pytest_args+=(-n 1)
|
||||
fi
|
||||
pytest_args+=(--cov=src --cov-report=xml --cov-report=term-missing --cov-fail-under=20 src/tests)
|
||||
pytest_args+=(
|
||||
--cov=src
|
||||
--cov-report=xml
|
||||
--cov-report=term-missing
|
||||
--cov-report=json:artifacts/coverage/coverage.json
|
||||
--cov-fail-under=20
|
||||
src/tests
|
||||
)
|
||||
|
||||
timeout_bin="timeout"
|
||||
if ! command -v "$timeout_bin" >/dev/null 2>&1; then
|
||||
@@ -33,4 +42,7 @@ if [[ $status -eq 124 ]]; then
|
||||
tail -n 20 pytest.log
|
||||
exit 1
|
||||
fi
|
||||
if [[ $status -eq 0 && "${CRITICAL_COVERAGE_GATE:-0}" == "1" ]]; then
|
||||
python scripts/check_critical_coverage.py artifacts/coverage/coverage.json
|
||||
fi
|
||||
exit $status
|
||||
@@ -37,7 +37,7 @@ deterministic_totp_option = typer.Option(
|
||||
)
|
||||
|
||||
# Sub command groups
|
||||
from . import entry, vault, nostr, config, fingerprint, util, api
|
||||
from . import entry, vault, nostr, config, fingerprint, util, api, agent
|
||||
|
||||
app.add_typer(entry.app, name="entry")
|
||||
app.add_typer(vault.app, name="vault")
|
||||
@@ -46,6 +46,7 @@ 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")
|
||||
app.add_typer(agent.app, name="agent")
|
||||
|
||||
|
||||
def run() -> None:
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import click
|
||||
import typer
|
||||
from bip_utils import Bip39Languages, Bip39MnemonicGenerator, Bip39WordsNum
|
||||
from mnemonic import Mnemonic
|
||||
|
||||
from constants import APP_DIR, initialize_app
|
||||
from seedpass.core.api import EntryService
|
||||
from seedpass.core.entry_types import ALL_ENTRY_TYPES, EntryType
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||
|
||||
app = typer.Typer(help="Agent-first non-interactive workflows")
|
||||
|
||||
PRIVATE_KINDS = {
|
||||
EntryType.SEED.value,
|
||||
EntryType.SSH.value,
|
||||
EntryType.PGP.value,
|
||||
EntryType.NOSTR.value,
|
||||
EntryType.MANAGED_ACCOUNT.value,
|
||||
}
|
||||
DEFAULT_POLICY = {
|
||||
"allow_kinds": [
|
||||
EntryType.PASSWORD.value,
|
||||
EntryType.TOTP.value,
|
||||
EntryType.KEY_VALUE.value,
|
||||
],
|
||||
"deny_private_reveal": sorted(PRIVATE_KINDS),
|
||||
"allow_export_import": False,
|
||||
}
|
||||
|
||||
|
||||
def _policy_path() -> Path:
|
||||
return APP_DIR / "agent_policy.json"
|
||||
|
||||
|
||||
def _load_policy() -> dict:
|
||||
path = _policy_path()
|
||||
if not path.exists():
|
||||
return dict(DEFAULT_POLICY)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return dict(DEFAULT_POLICY)
|
||||
policy = dict(DEFAULT_POLICY)
|
||||
if isinstance(data.get("allow_kinds"), list):
|
||||
policy["allow_kinds"] = [str(v) for v in data["allow_kinds"]]
|
||||
if isinstance(data.get("deny_private_reveal"), list):
|
||||
policy["deny_private_reveal"] = [str(v) for v in data["deny_private_reveal"]]
|
||||
if isinstance(data.get("allow_export_import"), bool):
|
||||
policy["allow_export_import"] = data["allow_export_import"]
|
||||
return policy
|
||||
|
||||
|
||||
def _save_policy(policy: dict) -> None:
|
||||
path = _policy_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(policy, indent=2), encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def _password_from_env(var_name: str) -> str:
|
||||
value = os.getenv(var_name)
|
||||
if not value:
|
||||
raise typer.BadParameter(
|
||||
f"Missing password env var '{var_name}'. Export it before running this command."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _seed_from_inputs(
|
||||
seed: Optional[str],
|
||||
seed_file: Optional[Path],
|
||||
generate_seed: bool,
|
||||
) -> tuple[str, bool]:
|
||||
choices = int(bool(seed)) + int(bool(seed_file)) + int(bool(generate_seed))
|
||||
if choices != 1:
|
||||
raise typer.BadParameter(
|
||||
"Provide exactly one of --seed, --seed-file, or --generate-seed."
|
||||
)
|
||||
if seed_file:
|
||||
seed = seed_file.read_text(encoding="utf-8").strip()
|
||||
elif generate_seed:
|
||||
seed = (
|
||||
Bip39MnemonicGenerator(Bip39Languages.ENGLISH)
|
||||
.FromWordsNumber(Bip39WordsNum.WORDS_NUM_12)
|
||||
.ToStr()
|
||||
)
|
||||
assert seed is not None
|
||||
if not Mnemonic("english").check(seed):
|
||||
raise typer.BadParameter("Invalid BIP-39 seed phrase.")
|
||||
return seed, bool(generate_seed)
|
||||
|
||||
|
||||
@app.command("init")
|
||||
def agent_init(
|
||||
seed: Optional[str] = typer.Option(
|
||||
None, "--seed", help="BIP-39 seed phrase (avoid shell history for secrets)"
|
||||
),
|
||||
seed_file: Optional[Path] = typer.Option(
|
||||
None, "--seed-file", help="Path to a file containing the seed phrase"
|
||||
),
|
||||
generate_seed: bool = typer.Option(
|
||||
False, "--generate-seed", help="Generate a fresh 12-word seed"
|
||||
),
|
||||
password_env: str = typer.Option(
|
||||
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
|
||||
),
|
||||
switch_existing: bool = typer.Option(
|
||||
False,
|
||||
"--switch-existing",
|
||||
help="Switch to existing profile if the seed fingerprint already exists",
|
||||
),
|
||||
kdf_iterations: int = typer.Option(
|
||||
100_000, "--kdf-iterations", help="PBKDF2 iterations for profile setup"
|
||||
),
|
||||
print_seed: bool = typer.Option(
|
||||
False, "--print-seed", help="Include generated seed in JSON output"
|
||||
),
|
||||
) -> None:
|
||||
"""Initialize a profile non-interactively for agent workflows."""
|
||||
initialize_app()
|
||||
password = _password_from_env(password_env)
|
||||
seed_phrase, generated = _seed_from_inputs(seed, seed_file, generate_seed)
|
||||
|
||||
fp_mgr = FingerprintManager(APP_DIR)
|
||||
fingerprint = None
|
||||
created = False
|
||||
try:
|
||||
fingerprint = fp_mgr.add_fingerprint(seed_phrase)
|
||||
created = True
|
||||
except ValueError:
|
||||
fingerprint = generate_fingerprint(seed_phrase)
|
||||
if not switch_existing:
|
||||
raise typer.BadParameter(
|
||||
"Seed profile already exists. Use --switch-existing to reuse it."
|
||||
)
|
||||
|
||||
assert fingerprint is not None
|
||||
profile_dir = fp_mgr.get_fingerprint_directory(fingerprint)
|
||||
if profile_dir is None:
|
||||
raise typer.BadParameter("Could not resolve profile directory for fingerprint.")
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
seed_key = derive_key_from_password(
|
||||
password, fingerprint, iterations=kdf_iterations
|
||||
)
|
||||
seed_mgr = EncryptionManager(seed_key, profile_dir)
|
||||
seed_mgr.encrypt_parent_seed(seed_phrase)
|
||||
|
||||
index_key = derive_index_key(seed_phrase)
|
||||
enc_mgr = EncryptionManager(index_key, profile_dir)
|
||||
vault = Vault(enc_mgr, profile_dir)
|
||||
cfg = ConfigManager(vault=vault, fingerprint_dir=profile_dir)
|
||||
cfg.set_password_hash(bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode())
|
||||
cfg.set_kdf_iterations(kdf_iterations)
|
||||
cfg.set_offline_mode(True)
|
||||
cfg.set_secret_mode_enabled(False)
|
||||
cfg.set_quick_unlock(False)
|
||||
|
||||
if not _policy_path().exists():
|
||||
_save_policy(dict(DEFAULT_POLICY))
|
||||
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"created": created,
|
||||
"fingerprint": fingerprint,
|
||||
"profile_dir": str(profile_dir),
|
||||
"generated_seed": seed_phrase if generated and print_seed else None,
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
@app.command("policy-show")
|
||||
def agent_policy_show() -> None:
|
||||
"""Show the active agent policy."""
|
||||
typer.echo(json.dumps(_load_policy(), indent=2))
|
||||
|
||||
|
||||
@app.command("policy-set")
|
||||
def agent_policy_set(
|
||||
allow_kind: list[str] = typer.Option(
|
||||
None,
|
||||
"--allow-kind",
|
||||
help="Allowed entry kind for agent retrieval (repeatable)",
|
||||
click_type=click.Choice(ALL_ENTRY_TYPES),
|
||||
),
|
||||
deny_private_kind: list[str] = typer.Option(
|
||||
None,
|
||||
"--deny-private-kind",
|
||||
help="Kind denied from agent reveal (repeatable)",
|
||||
click_type=click.Choice(ALL_ENTRY_TYPES),
|
||||
),
|
||||
allow_export_import: bool = typer.Option(
|
||||
False,
|
||||
"--allow-export-import",
|
||||
help="Allow export/import operations in agent mode",
|
||||
),
|
||||
) -> None:
|
||||
"""Set policy flags used by agent commands."""
|
||||
policy = _load_policy()
|
||||
if allow_kind:
|
||||
policy["allow_kinds"] = list(allow_kind)
|
||||
if deny_private_kind:
|
||||
policy["deny_private_reveal"] = list(deny_private_kind)
|
||||
policy["allow_export_import"] = bool(allow_export_import)
|
||||
_save_policy(policy)
|
||||
typer.echo(json.dumps(policy, indent=2))
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def agent_get(
|
||||
ctx: typer.Context,
|
||||
query: str = typer.Argument(..., help="Entry label or index query"),
|
||||
password_env: str = typer.Option(
|
||||
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
|
||||
),
|
||||
ttl: int = typer.Option(
|
||||
30,
|
||||
"--ttl",
|
||||
min=1,
|
||||
help="Lease time in seconds for the returned secret metadata",
|
||||
),
|
||||
) -> None:
|
||||
"""Retrieve one secret as JSON with policy enforcement."""
|
||||
fingerprint = (ctx.obj or {}).get("fingerprint")
|
||||
if not fingerprint:
|
||||
raise typer.BadParameter("Specify target profile with --fingerprint.")
|
||||
password = _password_from_env(password_env)
|
||||
policy = _load_policy()
|
||||
|
||||
pm = PasswordManager(fingerprint=fingerprint, password=password)
|
||||
service = EntryService(pm)
|
||||
matches = service.search_entries(query)
|
||||
if len(matches) != 1:
|
||||
payload = {
|
||||
"status": "error",
|
||||
"reason": "ambiguous_or_missing",
|
||||
"match_count": len(matches),
|
||||
"matches": [
|
||||
{
|
||||
"index": idx,
|
||||
"label": label,
|
||||
"kind": etype.value,
|
||||
"username": username,
|
||||
"url": url,
|
||||
"archived": archived,
|
||||
}
|
||||
for idx, label, username, url, archived, etype in matches
|
||||
],
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
index = matches[0][0]
|
||||
entry = service.retrieve_entry(index)
|
||||
kind = entry.get("type", entry.get("kind"))
|
||||
if kind is None:
|
||||
raise typer.BadParameter("Entry kind missing.")
|
||||
|
||||
allow_kinds = set(policy.get("allow_kinds", []))
|
||||
deny_private = set(policy.get("deny_private_reveal", []))
|
||||
if allow_kinds and kind not in allow_kinds:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "denied",
|
||||
"reason": "kind_not_allowed",
|
||||
"kind": kind,
|
||||
"policy": policy,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if kind in deny_private:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "denied",
|
||||
"reason": "private_kind_blocked",
|
||||
"kind": kind,
|
||||
"policy": policy,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
secret: Optional[str] = None
|
||||
if kind == EntryType.PASSWORD.value:
|
||||
secret = service.generate_password(int(entry.get("length", 12)), index)
|
||||
elif kind == EntryType.TOTP.value:
|
||||
secret = service.get_totp_code(index)
|
||||
elif kind == EntryType.KEY_VALUE.value:
|
||||
secret = entry.get("value")
|
||||
else:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unsupported_kind_for_agent_get",
|
||||
"kind": kind,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"fingerprint": fingerprint,
|
||||
"index": index,
|
||||
"kind": kind,
|
||||
"label": entry.get("label"),
|
||||
"lease_ttl_sec": ttl,
|
||||
"issued_at_utc": now.isoformat(),
|
||||
"expires_at_utc": (now + timedelta(seconds=ttl)).isoformat(),
|
||||
"secret": secret,
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_harness_module():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
script_path = repo_root / "scripts" / "ai_tui_agent_test.py"
|
||||
spec = importlib.util.spec_from_file_location("ai_tui_agent_test", script_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
harness = _load_harness_module()
|
||||
|
||||
|
||||
def test_run_step_records_passed_result():
|
||||
results = []
|
||||
called = {"value": False}
|
||||
|
||||
def _ok():
|
||||
called["value"] = True
|
||||
|
||||
harness._run_step("step_a", "happy path", _ok, results, verbose=False)
|
||||
|
||||
assert called["value"] is True
|
||||
assert len(results) == 1
|
||||
assert results[0].step_id == "step_a"
|
||||
assert results[0].status == "passed"
|
||||
assert results[0].detail is None
|
||||
|
||||
|
||||
def test_run_step_records_failed_result_and_reraises():
|
||||
results = []
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
harness._run_step("step_b", "failing path", _boom, results, verbose=False)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].step_id == "step_b"
|
||||
assert results[0].status == "failed"
|
||||
assert results[0].detail == "boom"
|
||||
|
||||
|
||||
def test_add_entry_menu_open_waits_when_choice_omitted():
|
||||
class Runner:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.waited = []
|
||||
|
||||
def sendline(self, value=""):
|
||||
self.sent.append(value)
|
||||
|
||||
def wait_for(self, pattern, timeout):
|
||||
self.waited.append((pattern, timeout))
|
||||
|
||||
runner = Runner()
|
||||
harness._add_entry_menu_open(runner, timeout=5.0, choice=None)
|
||||
assert runner.sent == ["1"]
|
||||
assert len(runner.waited) == 1
|
||||
assert "Select entry type or press Enter to go back:" in runner.waited[0][0]
|
||||
|
||||
|
||||
def test_add_entry_menu_open_sends_choice_when_provided():
|
||||
class Runner:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.expect_calls = []
|
||||
|
||||
def sendline(self, value=""):
|
||||
self.sent.append(value)
|
||||
|
||||
def expect_and_send(self, pattern, value, timeout):
|
||||
self.expect_calls.append((pattern, value, timeout))
|
||||
|
||||
runner = Runner()
|
||||
harness._add_entry_menu_open(runner, timeout=5.0, choice="7")
|
||||
assert runner.sent == ["1"]
|
||||
assert runner.expect_calls == [
|
||||
("Select entry type or press Enter to go back:", "7", 5.0)
|
||||
]
|
||||
|
||||
|
||||
def test_complete_onboarding_reaches_main_menu_and_sends_password():
|
||||
class Runner:
|
||||
def __init__(self):
|
||||
self.calls = [
|
||||
(r"Enter your choice \(1-8\) or press Enter to exit:", False),
|
||||
(r"Enter a new password:", True),
|
||||
(r"Enter your choice \(1-8\) or press Enter to exit:", False),
|
||||
(r"Enter a new password:", False),
|
||||
(r"Confirm your password:", True),
|
||||
(r"Enter your choice \(1-8\) or press Enter to exit:", True),
|
||||
]
|
||||
self.sent = []
|
||||
|
||||
def try_wait_for(self, pattern, timeout):
|
||||
expected_pattern, ret = self.calls.pop(0)
|
||||
assert pattern == expected_pattern
|
||||
return ret
|
||||
|
||||
def sendline(self, value=""):
|
||||
self.sent.append(value)
|
||||
|
||||
def pump(self, duration):
|
||||
return None
|
||||
|
||||
runner = Runner()
|
||||
harness._complete_onboarding_to_main_menu(runner, timeout=1.0, password="pw123")
|
||||
assert runner.sent == ["pw123", "pw123"]
|
||||
assert runner.calls == []
|
||||
|
||||
|
||||
def test_complete_onboarding_times_out_without_expected_prompts():
|
||||
class Runner:
|
||||
def try_wait_for(self, pattern, timeout):
|
||||
return False
|
||||
|
||||
def sendline(self, value=""):
|
||||
return None
|
||||
|
||||
def pump(self, duration):
|
||||
return None
|
||||
|
||||
runner = Runner()
|
||||
with pytest.raises(harness.TUIHarnessError, match="Onboarding did not reach main"):
|
||||
harness._complete_onboarding_to_main_menu(runner, timeout=0.0, password="pw")
|
||||
|
||||
|
||||
def test_drain_to_main_menu_uses_helper_prompt_then_returns():
|
||||
class Runner:
|
||||
def __init__(self):
|
||||
self.main_attempts = 0
|
||||
self.helper_calls = []
|
||||
|
||||
def wait_for(self, pattern, timeout):
|
||||
self.main_attempts += 1
|
||||
if self.main_attempts == 1:
|
||||
raise harness.TUIHarnessError("not yet")
|
||||
return None
|
||||
|
||||
def expect_and_send(self, pattern, value, timeout):
|
||||
self.helper_calls.append((pattern, value, timeout))
|
||||
return None
|
||||
|
||||
def pump(self, duration):
|
||||
return None
|
||||
|
||||
runner = Runner()
|
||||
harness._drain_to_main_menu(runner, timeout=1.0)
|
||||
assert runner.main_attempts == 2
|
||||
assert len(runner.helper_calls) == 1
|
||||
assert runner.helper_calls[0][1] == ""
|
||||
|
||||
|
||||
def test_drain_to_main_menu_times_out_when_no_prompt_progress(monkeypatch):
|
||||
class Runner:
|
||||
def wait_for(self, pattern, timeout):
|
||||
raise harness.TUIHarnessError("missing")
|
||||
|
||||
def expect_and_send(self, pattern, value, timeout):
|
||||
raise harness.TUIHarnessError("missing helper")
|
||||
|
||||
def pump(self, duration):
|
||||
return None
|
||||
|
||||
timeline = iter([0.0, 0.05, 0.15])
|
||||
monkeypatch.setattr(harness.time, "monotonic", lambda: next(timeline))
|
||||
|
||||
with pytest.raises(
|
||||
harness.TUIHarnessError, match="Unable to return to main menu within timeout"
|
||||
):
|
||||
harness._drain_to_main_menu(Runner(), timeout=0.1)
|
||||
@@ -0,0 +1,58 @@
|
||||
import asyncio
|
||||
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from seedpass.core import manager as manager_module
|
||||
|
||||
|
||||
def test_start_background_vault_sync_thread_worker_path(monkeypatch):
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.offline_mode = False
|
||||
|
||||
events = []
|
||||
|
||||
async def fake_sync_vault_async(*, alt_summary=None):
|
||||
return {"alt_summary": alt_summary}
|
||||
|
||||
monkeypatch.setattr(pm, "sync_vault_async", fake_sync_vault_async)
|
||||
monkeypatch.setattr(
|
||||
manager_module.bus, "publish", lambda *args: events.append(args)
|
||||
)
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, target, daemon):
|
||||
self._target = target
|
||||
self.daemon = daemon
|
||||
|
||||
def start(self):
|
||||
self._target()
|
||||
|
||||
monkeypatch.setattr(manager_module.threading, "Thread", ImmediateThread)
|
||||
|
||||
pm.start_background_vault_sync("thread-path")
|
||||
|
||||
assert ("sync_started",) in events
|
||||
assert ("sync_finished", {"alt_summary": "thread-path"}) in events
|
||||
|
||||
|
||||
def test_start_background_vault_sync_async_worker_path(monkeypatch):
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.offline_mode = False
|
||||
|
||||
events = []
|
||||
|
||||
async def fake_sync_vault_async(*, alt_summary=None):
|
||||
return {"alt_summary": alt_summary}
|
||||
|
||||
monkeypatch.setattr(pm, "sync_vault_async", fake_sync_vault_async)
|
||||
monkeypatch.setattr(
|
||||
manager_module.bus, "publish", lambda *args: events.append(args)
|
||||
)
|
||||
|
||||
async def runner():
|
||||
pm.start_background_vault_sync("async-path")
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
asyncio.run(runner())
|
||||
|
||||
assert ("sync_started",) in events
|
||||
assert ("sync_finished", {"alt_summary": "async-path"}) in events
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_module():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
script_path = repo_root / "scripts" / "check_critical_coverage.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"check_critical_coverage", script_path
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
coverage_gate = _load_module()
|
||||
|
||||
|
||||
def test_parse_threshold_overrides_valid():
|
||||
parsed = coverage_gate._parse_threshold_overrides(
|
||||
["src/main.py=40", "src/seedpass/core/manager.py=55.5"]
|
||||
)
|
||||
assert parsed["src/main.py"] == 40.0
|
||||
assert parsed["src/seedpass/core/manager.py"] == 55.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"src/main.py",
|
||||
"src/main.py=abc",
|
||||
"src/main.py=-1",
|
||||
"src/main.py=101",
|
||||
"=50",
|
||||
],
|
||||
)
|
||||
def test_parse_threshold_overrides_invalid(value):
|
||||
with pytest.raises(ValueError):
|
||||
coverage_gate._parse_threshold_overrides([value])
|
||||
|
||||
|
||||
def test_find_file_payload_accepts_absolute_path_suffix():
|
||||
files_payload = {
|
||||
"/tmp/work/src/main.py": {"summary": {"percent_covered": 50.0}},
|
||||
"/tmp/work/src/seedpass/core/manager.py": {
|
||||
"summary": {"percent_covered": 60.0}
|
||||
},
|
||||
}
|
||||
payload = coverage_gate._find_file_payload(files_payload, "src/main.py")
|
||||
assert payload == {"summary": {"percent_covered": 50.0}}
|
||||
|
||||
|
||||
def test_compute_function_coverage_reports_uncovered(tmp_path):
|
||||
module_file = tmp_path / "example.py"
|
||||
module_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"def covered():",
|
||||
" return 1",
|
||||
"",
|
||||
"def uncovered():",
|
||||
" return 2",
|
||||
"",
|
||||
"class A:",
|
||||
" def method(self):",
|
||||
" return 3",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
coverage = coverage_gate._compute_function_coverage(
|
||||
module_file, executed_lines={1, 2}
|
||||
)
|
||||
by_name = {item.name: item for item in coverage}
|
||||
assert by_name["covered"].covered is True
|
||||
assert by_name["uncovered"].covered is False
|
||||
assert by_name["A.method"].covered is False
|
||||
@@ -0,0 +1,149 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from seedpass.cli import app
|
||||
from seedpass.cli import agent as agent_cli
|
||||
from seedpass.core.entry_types import EntryType
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_agent_policy_show_defaults(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
result = runner.invoke(app, ["agent", "policy-show"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["allow_export_import"] is False
|
||||
assert EntryType.PASSWORD.value in payload["allow_kinds"]
|
||||
|
||||
|
||||
def test_agent_policy_set_persists(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"agent",
|
||||
"policy-set",
|
||||
"--allow-kind",
|
||||
"password",
|
||||
"--allow-kind",
|
||||
"totp",
|
||||
"--deny-private-kind",
|
||||
"seed",
|
||||
"--allow-export-import",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["allow_kinds"] == ["password", "totp"]
|
||||
assert payload["deny_private_reveal"] == ["seed"]
|
||||
assert payload["allow_export_import"] is True
|
||||
|
||||
|
||||
def test_agent_init_creates_profile(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "AgentPass123!")
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"agent",
|
||||
"init",
|
||||
"--seed",
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["fingerprint"]
|
||||
profile_dir = Path(payload["profile_dir"])
|
||||
assert profile_dir.exists()
|
||||
assert (profile_dir / "parent_seed.enc").exists()
|
||||
|
||||
|
||||
def test_agent_get_requires_fingerprint(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
result = runner.invoke(app, ["agent", "get", "example"])
|
||||
assert result.exit_code == 2
|
||||
|
||||
|
||||
def test_agent_get_password_json(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def search_entries(self, _q):
|
||||
return [(2, "Example", "", "", False, EntryType.PASSWORD)]
|
||||
|
||||
def retrieve_entry(self, _i):
|
||||
return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12}
|
||||
|
||||
def generate_password(self, _length, _index):
|
||||
return "secret-pass"
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda: {"allow_kinds": ["password"], "deny_private_reveal": []},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["fingerprint"] == "ABC123"
|
||||
assert payload["secret"] == "secret-pass"
|
||||
assert payload["kind"] == "password"
|
||||
|
||||
|
||||
def test_agent_get_denied_by_policy(monkeypatch):
|
||||
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"PasswordManager",
|
||||
lambda fingerprint, password: SimpleNamespace(
|
||||
fingerprint=fingerprint, password=password
|
||||
),
|
||||
)
|
||||
|
||||
class DummyEntryService:
|
||||
def __init__(self, _pm):
|
||||
pass
|
||||
|
||||
def search_entries(self, _q):
|
||||
return [(4, "Seed", "", "", False, EntryType.SEED)]
|
||||
|
||||
def retrieve_entry(self, _i):
|
||||
return {"kind": EntryType.SEED.value, "label": "Seed"}
|
||||
|
||||
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
|
||||
monkeypatch.setattr(
|
||||
agent_cli,
|
||||
"_load_policy",
|
||||
lambda: {
|
||||
"allow_kinds": ["password", "totp", "key_value"],
|
||||
"deny_private_reveal": ["seed"],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "seed"])
|
||||
assert result.exit_code == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "denied"
|
||||
assert payload["reason"] in {"kind_not_allowed", "private_kind_blocked"}
|
||||
Reference in new issue
Block a user