From 2eda73c016ad7d987c8872ac1c2bff333ae9dbaa Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 24 Jan 2026 22:59:17 -0700 Subject: [PATCH 1/3] feat(keychain): make Keychain default backend for secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Keychain is now the default backend instead of Bitwarden. This removes the need for `dot unlock` for basic secret operations. Backend Configuration: - FLOW_SECRET_BACKEND=keychain (default) - Keychain only, no unlock needed - FLOW_SECRET_BACKEND=bitwarden - Bitwarden only (legacy mode) - FLOW_SECRET_BACKEND=both - Both backends (Keychain primary, Bitwarden sync) New Commands: - dot secret status - Show backend configuration - dot secret sync - Sync Keychain ↔ Bitwarden - dot secret sync --to-bw - Push Keychain → Bitwarden - dot secret sync --from-bw - Pull Bitwarden → Keychain - dot secret sync --status - Show differences Token Workflows Updated: - dot token github/npm/pypi now respect backend config - Bitwarden checks are conditional on _dot_secret_needs_bitwarden() - Keychain storage is conditional on _dot_secret_uses_keychain() - Added missing Keychain storage to npm/pypi wizards Files Changed (7): - lib/core.zsh: Added backend configuration functions - lib/dispatchers/dot-dispatcher.zsh: Added status/sync commands - lib/keychain-helpers.zsh: Updated help with new commands - commands/secret-tutorial.zsh: Fixed auto-run bug - docs/reference/REFCARD-TOKEN-SECRETS.md: Updated with backend section - docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md: Feature spec - tests/test-keychain-default.zsh: 20 tests for backend switching Co-Authored-By: Claude Opus 4.5 --- commands/secret-tutorial.zsh | 11 +- docs/reference/REFCARD-TOKEN-SECRETS.md | 30 +- ...PEC-keychain-default-phase-1-2026-01-24.md | 341 ++++++++++ lib/core.zsh | 103 +++ lib/dispatchers/dot-dispatcher.zsh | 630 +++++++++++++++--- lib/keychain-helpers.zsh | 11 +- tests/test-keychain-default.zsh | 248 +++++++ 7 files changed, 1283 insertions(+), 91 deletions(-) create mode 100644 docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md create mode 100755 tests/test-keychain-default.zsh diff --git a/commands/secret-tutorial.zsh b/commands/secret-tutorial.zsh index 5e68dd8cc..2cbc81da7 100644 --- a/commands/secret-tutorial.zsh +++ b/commands/secret-tutorial.zsh @@ -688,7 +688,10 @@ _dot_secret_tutorial() { return 0 } -# Run tutorial if called directly -if [[ "${(%):-%x}" == "${0}" ]]; then - _dot_secret_tutorial "$@" -fi +# Note: This file is sourced by flow.plugin.zsh at startup. +# The tutorial is ONLY run when explicitly called via: +# - dot secret tutorial (dispatcher calls _dot_secret_tutorial) +# - Direct execution: ./secret-tutorial.zsh +# +# DO NOT auto-run on source - this caused issues with "dot secret status" +# See: feature/keychain-default-phase-1 for the fix diff --git a/docs/reference/REFCARD-TOKEN-SECRETS.md b/docs/reference/REFCARD-TOKEN-SECRETS.md index dcee3a659..ec155363f 100644 --- a/docs/reference/REFCARD-TOKEN-SECRETS.md +++ b/docs/reference/REFCARD-TOKEN-SECRETS.md @@ -5,6 +5,26 @@ --- +## Backend Configuration (NEW in v5.18.0) + +| Backend | Description | Requires Unlock | +|---------|-------------|-----------------| +| `keychain` (default) | macOS Keychain only - instant access | No | +| `bitwarden` | Bitwarden only - cloud backup | Yes | +| `both` | Both backends - local + cloud | Yes (for writes) | + +```bash +# Configure backend (add to .zshrc) +export FLOW_SECRET_BACKEND=keychain # Default - instant access +export FLOW_SECRET_BACKEND=bitwarden # Legacy mode +export FLOW_SECRET_BACKEND=both # Cloud backup enabled + +# Check current status +dot secret status +``` + +--- + ## Common Commands | Command | Action | Example | @@ -16,6 +36,9 @@ | `dot secret list` | List all secrets | Shows names only | | `dot secret add ` | Add arbitrary secret | Prompts for value | | `dot secret delete ` | Remove secret | Permanent deletion | +| `dot secret status` | Show backend config | Backend & secrets count | +| `dot secret sync` | Sync Keychain ↔ Bitwarden | Interactive wizard | +| `dot secret sync --status` | Show sync differences | Compare backends | | `dot token expiring` | Check expiration | Shows 7-day warnings | | `dot token rotate` | Rotate token | With backup | | `dot secret help` | Show help | Display all commands | @@ -35,9 +58,12 @@ dot token github # 2. Browser opens to GitHub token page # 3. Create token with recommended scopes # 4. Paste token when prompted -# 5. Token stored in both Bitwarden & Keychain +# 5. Token stored (based on FLOW_SECRET_BACKEND): +# - keychain: Keychain only (default - no unlock needed!) +# - bitwarden: Bitwarden only +# - both: Keychain + Bitwarden -# ✅ Token ready to use +# ✅ Token ready to use instantly ``` ### Use Token in Scripts diff --git a/docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md b/docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md new file mode 100644 index 000000000..696b81ae8 --- /dev/null +++ b/docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md @@ -0,0 +1,341 @@ +# SPEC: Keychain Default - Phase 1 + +**Date:** 2026-01-24 +**Status:** In Progress +**Branch:** feature/keychain-default-phase-1 +**Effort:** 🔧 Medium-High (3-4 hours) +**Risk:** Medium (Architectural change to secret storage) + +--- + +## Overview + +Make macOS Keychain the **default** secret backend, with Bitwarden as an **optional** sync/backup target. This removes the friction of `dot unlock` for basic secret operations while preserving cloud backup capabilities for users who want them. + +### Background + +The current dual-storage architecture stores secrets in BOTH backends: +- **Bitwarden:** Cloud backup, cross-device sync (requires unlock) +- **Keychain:** Local cache, instant access (Touch ID) + +**Problem:** All token operations require `dot unlock` first, even for local-only use. + +**Solution:** Flip the default - Keychain is primary, Bitwarden is optional sync target. + +--- + +## User Stories + +### US-1: Basic Secret Storage (No Bitwarden) +```bash +# Before: Required Bitwarden +dot unlock # ← REQUIRED +dot token github # Store in both backends + +# After: Just works +dot token github # Stores in Keychain only (default) +``` + +### US-2: Optional Cloud Backup +```bash +# Configure Bitwarden sync +export FLOW_SECRET_BACKEND="both" # or: flow config set secret_backend both + +# Now both backends are used +dot token github # Stores in Keychain + Bitwarden +``` + +### US-3: Manual Sync +```bash +dot secret sync # Sync Keychain ↔ Bitwarden +dot secret sync --to-bitwarden # Push Keychain → Bitwarden +dot secret sync --from-bitwarden # Pull Bitwarden → Keychain +``` + +### US-4: Backend Selection +```bash +# Check current backend +dot secret status +# Backend: keychain (default) +# Bitwarden: not configured + +# Configure backend +export FLOW_SECRET_BACKEND="both" +dot secret status +# Backend: keychain + bitwarden +# Bitwarden: unlocked (session active) +``` + +--- + +## Configuration + +### Environment Variable + +```bash +# Backend options: +export FLOW_SECRET_BACKEND="keychain" # Default - Keychain only (no Bitwarden) +export FLOW_SECRET_BACKEND="bitwarden" # Bitwarden only (legacy mode) +export FLOW_SECRET_BACKEND="both" # Both backends (sync mode) +``` + +### Priority Matrix + +| Backend | `dot secret add` | `dot secret get` | `dot token *` | Requires Unlock | +|---------|------------------|------------------|---------------|-----------------| +| `keychain` (default) | Keychain only | Keychain only | Keychain only | No | +| `bitwarden` | Bitwarden only | Bitwarden only | Bitwarden only | Yes | +| `both` | Both backends | Keychain first, fallback Bitwarden | Both backends | For write ops | + +### Configuration File (Future) + +```yaml +# ~/.config/flow/config.yml (future enhancement) +secrets: + backend: keychain + bitwarden_sync: false +``` + +--- + +## API Changes + +### New Functions + +#### `_dot_secret_backend()` +Returns the configured backend ("keychain", "bitwarden", or "both"). + +```zsh +_dot_secret_backend() { + echo "${FLOW_SECRET_BACKEND:-keychain}" +} +``` + +#### `_dot_secret_sync()` +Syncs secrets between Keychain and Bitwarden. + +```zsh +dot secret sync # Interactive sync +dot secret sync --to-bw # Push Keychain → Bitwarden +dot secret sync --from-bw # Pull Bitwarden → Keychain +dot secret sync --status # Show sync status +``` + +#### `_dot_secret_status()` +Shows current backend configuration and status. + +```zsh +_dot_secret_status() { + local backend=$(_dot_secret_backend) + echo "Backend: $backend" + + case $backend in + keychain) + echo "Keychain: $(security list-keychains | head -1)" + echo "Bitwarden: not configured" + ;; + bitwarden) + echo "Keychain: not used" + echo "Bitwarden: $([[ -n "$BW_SESSION" ]] && echo "unlocked" || echo "locked")" + ;; + both) + echo "Keychain: $(security list-keychains | head -1)" + echo "Bitwarden: $([[ -n "$BW_SESSION" ]] && echo "unlocked" || echo "locked")" + ;; + esac +} +``` + +### Modified Functions + +#### `_dot_token_add_impl()` (lines 2017-2190) +- Check backend config before Bitwarden operations +- Skip Bitwarden if backend is "keychain" +- Add Bitwarden operations if backend is "both" + +#### `_dot_token_github()`, `_dot_token_npm()`, `_dot_token_pypi()` +- Remove mandatory `bw` requirement check when backend is "keychain" +- Keep Bitwarden logic for "bitwarden" and "both" modes + +#### `_dot_secret()` dispatcher +- Add `status` subcommand +- Add `sync` subcommand +- Route based on backend configuration + +--- + +## Implementation Plan + +### Increment 1: Backend Configuration (30 min) + +**Files to modify:** +- `lib/core.zsh` - Add `_dot_secret_backend()` function +- `lib/dispatchers/dot-dispatcher.zsh` - Add backend check at start + +**Deliverables:** +- `FLOW_SECRET_BACKEND` environment variable support +- Default to "keychain" when not set +- Backend detection function + +### Increment 2: Refactor `dot secret` (1 hour) + +**Files to modify:** +- `lib/dispatchers/dot-dispatcher.zsh` - `_dot_secret()` function + +**Changes:** +- Add `status` subcommand +- Route `add`/`get`/`list`/`delete` based on backend +- For "keychain" backend: use `_dot_kc_*` functions directly +- For "bitwarden" backend: use existing `_dot_secret_bw_*` functions +- For "both" backend: use Keychain primary, sync to Bitwarden + +### Increment 3: Refactor Token Workflows (1.5 hours) + +**Files to modify:** +- `lib/dispatchers/dot-dispatcher.zsh` - Token functions + +**Changes to `_dot_token_github()` (lines 2017-2190):** +```zsh +# Before: Always requires Bitwarden +if ! _dot_require_tool "bw" "brew install bitwarden-cli"; then + return 1 +fi + +# After: Check backend first +local backend=$(_dot_secret_backend) +if [[ "$backend" == "bitwarden" ]] || [[ "$backend" == "both" ]]; then + if ! _dot_require_tool "bw" "brew install bitwarden-cli"; then + return 1 + fi +fi +``` + +**Similar changes for:** +- `_dot_token_npm()` (lines 2532-2700) +- `_dot_token_pypi()` (lines 2711-2870) +- `_dot_token_status()` (lines 2902-3000) +- `_dot_env_*()` functions (lines 3100-3500) + +### Increment 4: Add Sync Command (45 min) + +**Files to create/modify:** +- `lib/dispatchers/dot-dispatcher.zsh` - Add `_dot_secret_sync()` + +**Functionality:** +- `dot secret sync` - Interactive comparison and sync +- `dot secret sync --to-bw` - Push all Keychain secrets to Bitwarden +- `dot secret sync --from-bw` - Pull all Bitwarden secrets to Keychain +- `dot secret sync --status` - Show differences + +### Increment 5: Update Documentation (30 min) + +**Files to update:** +- `docs/reference/REFCARD-TOKEN-SECRETS.md` +- `docs/guides/TOKEN-MANAGEMENT-COMPLETE.md` +- `lib/keychain-helpers.zsh` (help text) + +### Increment 6: Tests (30 min) + +**Files to create/update:** +- `tests/test-keychain-default.zsh` - New test suite for backend switching +- `tests/test-dot-secret-keychain.zsh` - Update existing tests + +--- + +## Migration Path + +### For New Users +- No action needed - Keychain is default +- Optional: Set `FLOW_SECRET_BACKEND=both` for cloud backup + +### For Existing Users (Bitwarden) +1. Existing secrets remain in Bitwarden +2. Run `dot secret sync --from-bitwarden` to copy to Keychain +3. Set `FLOW_SECRET_BACKEND=keychain` (now default) +4. Optional: Keep `FLOW_SECRET_BACKEND=both` for continued sync + +### Backward Compatibility +- `FLOW_SECRET_BACKEND=bitwarden` preserves old behavior +- Existing `dot unlock` workflow still works +- No breaking changes to API + +--- + +## Testing Strategy + +### Unit Tests +- Backend configuration detection +- Routing based on backend +- Keychain-only operations + +### Integration Tests +- Token add with Keychain-only backend +- Token add with both backends +- Sync operations + +### Manual Testing +```bash +# Test 1: Keychain-only (default) +unset FLOW_SECRET_BACKEND +dot token github # Should NOT require bw unlock +dot secret list # Should show Keychain secrets + +# Test 2: Bitwarden-only +export FLOW_SECRET_BACKEND=bitwarden +dot unlock +dot token github # Should require bw unlock +dot secret list # Should show Bitwarden secrets + +# Test 3: Both backends +export FLOW_SECRET_BACKEND=both +dot unlock +dot token github # Stores in both +dot secret sync --status # Shows sync status +``` + +--- + +## Success Metrics + +1. ✅ `dot token github` works without `dot unlock` (default mode) +2. ✅ `FLOW_SECRET_BACKEND` environment variable configures behavior +3. ✅ `dot secret sync` syncs between backends +4. ✅ `dot secret status` shows current configuration +5. ✅ All existing tests pass +6. ✅ New tests for backend switching pass +7. ✅ Documentation updated + +--- + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Breaking existing workflows | High | `FLOW_SECRET_BACKEND=bitwarden` preserves old behavior | +| Token sync conflicts | Medium | Sync command shows diff before overwriting | +| Users confused by change | Low | Clear docs, migration guide, status command | + +--- + +## Out of Scope (Future Phases) + +1. Configuration file (`~/.config/flow/config.yml`) +2. Auto-sync on session start +3. Conflict resolution UI +4. Multi-device Keychain sync (iCloud Keychain integration) +5. Export/import for backup + +--- + +## Approval + +- [ ] Spec reviewed +- [ ] Implementation started +- [ ] Tests passing +- [ ] Documentation updated +- [ ] PR created to dev + +--- + +**Created:** 2026-01-24 +**Author:** Claude (Orchestrator) diff --git a/lib/core.zsh b/lib/core.zsh index dec5aa1fa..c33eca45e 100644 --- a/lib/core.zsh +++ b/lib/core.zsh @@ -562,3 +562,106 @@ _flow_get_config() { echo "$default" fi } + +# ============================================================================ +# SECRET BACKEND CONFIGURATION +# ============================================================================ + +# ============================================================================= +# Function: _dot_secret_backend +# Purpose: Get the configured secret storage backend +# ============================================================================= +# Arguments: +# None +# +# Returns: +# 0 - Always succeeds +# +# Output: +# stdout - Backend name: "keychain" (default), "bitwarden", or "both" +# +# Example: +# local backend=$(_dot_secret_backend) +# case "$backend" in +# keychain) echo "Using macOS Keychain only" ;; +# bitwarden) echo "Using Bitwarden only" ;; +# both) echo "Using both backends" ;; +# esac +# +# Environment: +# FLOW_SECRET_BACKEND - Override default backend +# "keychain" - macOS Keychain only (default, no unlock needed) +# "bitwarden" - Bitwarden only (requires dot unlock) +# "both" - Both backends (Keychain primary, Bitwarden sync) +# +# Notes: +# - Default is "keychain" for instant access without unlock +# - "bitwarden" mode preserves legacy behavior +# - "both" mode enables cloud backup with local performance +# ============================================================================= +_dot_secret_backend() { + local backend="${FLOW_SECRET_BACKEND:-keychain}" + + # Validate backend value + case "$backend" in + keychain|bitwarden|both) + echo "$backend" + ;; + *) + # Invalid value, fall back to default + _flow_log_warning "Invalid FLOW_SECRET_BACKEND='$backend', using 'keychain'" + echo "keychain" + ;; + esac +} + +# ============================================================================= +# Function: _dot_secret_needs_bitwarden +# Purpose: Check if current backend requires Bitwarden +# ============================================================================= +# Arguments: +# None +# +# Returns: +# 0 - Bitwarden is needed (backend is "bitwarden" or "both") +# 1 - Bitwarden not needed (backend is "keychain") +# +# Example: +# if _dot_secret_needs_bitwarden; then +# # Ensure Bitwarden is available and unlocked +# _dot_require_tool "bw" "brew install bitwarden-cli" +# fi +# +# Notes: +# - Use this to conditionally skip Bitwarden checks +# - Returns success (0) for "bitwarden" and "both" modes +# ============================================================================= +_dot_secret_needs_bitwarden() { + local backend=$(_dot_secret_backend) + [[ "$backend" == "bitwarden" ]] || [[ "$backend" == "both" ]] +} + +# ============================================================================= +# Function: _dot_secret_uses_keychain +# Purpose: Check if current backend uses Keychain +# ============================================================================= +# Arguments: +# None +# +# Returns: +# 0 - Keychain is used (backend is "keychain" or "both") +# 1 - Keychain not used (backend is "bitwarden") +# +# Example: +# if _dot_secret_uses_keychain; then +# _dot_kc_add "$name" +# fi +# +# Notes: +# - Use this to conditionally use Keychain storage +# - Returns success (0) for "keychain" and "both" modes +# ============================================================================= +_dot_secret_uses_keychain() { + local backend=$(_dot_secret_backend) + [[ "$backend" == "keychain" ]] || [[ "$backend" == "both" ]] +} diff --git a/lib/dispatchers/dot-dispatcher.zsh b/lib/dispatchers/dot-dispatcher.zsh index 79d328b51..2f2250048 100644 --- a/lib/dispatchers/dot-dispatcher.zsh +++ b/lib/dispatchers/dot-dispatcher.zsh @@ -1168,6 +1168,22 @@ _dot_secret() { return ;; + # ───────────────────────────────────────────────────────────── + # STATUS (Show current backend configuration) + # ───────────────────────────────────────────────────────────── + status) + _dot_secret_status "$@" + return + ;; + + # ───────────────────────────────────────────────────────────── + # SYNC (Sync between Keychain and Bitwarden) + # ───────────────────────────────────────────────────────────── + sync) + _dot_secret_sync "$@" + return + ;; + # ───────────────────────────────────────────────────────────── # HELP # ───────────────────────────────────────────────────────────── @@ -1298,6 +1314,402 @@ _dot_secret_bw_help() { echo "" } +# ============================================================================= +# Function: _dot_secret_status +# Purpose: Show current secret backend configuration and status +# ============================================================================= +# Arguments: +# None +# +# Returns: +# 0 - Always succeeds +# +# Output: +# stdout - Formatted status display with backend info +# +# Example: +# dot secret status +# # Backend: keychain (default) +# # Keychain: login.keychain-db +# # Bitwarden: not configured +# ============================================================================= +_dot_secret_status() { + local backend=$(_dot_secret_backend) + + echo "" + echo "${FLOW_COLORS[header]}Secret Backend Status${FLOW_COLORS[reset]}" + echo "" + + # Show current backend + case "$backend" in + keychain) + echo "${FLOW_COLORS[success]}●${FLOW_COLORS[reset]} Backend: ${FLOW_COLORS[bold]}keychain${FLOW_COLORS[reset]} (default)" + echo "" + echo "${FLOW_COLORS[info]}Keychain:${FLOW_COLORS[reset]}" + echo " • Status: ${FLOW_COLORS[success]}active${FLOW_COLORS[reset]}" + echo " • Location: $(security list-keychains 2>/dev/null | head -1 | tr -d ' \"')" + + # Count secrets + local secret_count=$(_dot_secret_count_keychain 2>/dev/null || echo "0") + echo " • Secrets: $secret_count" + echo "" + echo "${FLOW_COLORS[muted]}Bitwarden:${FLOW_COLORS[reset]}" + echo " • Status: ${FLOW_COLORS[muted]}not configured${FLOW_COLORS[reset]}" + echo " • Set FLOW_SECRET_BACKEND=both to enable sync" + ;; + + bitwarden) + echo "${FLOW_COLORS[warning]}●${FLOW_COLORS[reset]} Backend: ${FLOW_COLORS[bold]}bitwarden${FLOW_COLORS[reset]} (legacy mode)" + echo "" + echo "${FLOW_COLORS[muted]}Keychain:${FLOW_COLORS[reset]}" + echo " • Status: ${FLOW_COLORS[muted]}not used${FLOW_COLORS[reset]}" + echo "" + echo "${FLOW_COLORS[info]}Bitwarden:${FLOW_COLORS[reset]}" + if [[ -n "$BW_SESSION" ]]; then + echo " • Status: ${FLOW_COLORS[success]}unlocked${FLOW_COLORS[reset]}" + else + echo " • Status: ${FLOW_COLORS[warning]}locked${FLOW_COLORS[reset]} (run: dot unlock)" + fi + ;; + + both) + echo "${FLOW_COLORS[info]}●${FLOW_COLORS[reset]} Backend: ${FLOW_COLORS[bold]}both${FLOW_COLORS[reset]} (sync mode)" + echo "" + echo "${FLOW_COLORS[info]}Keychain:${FLOW_COLORS[reset]}" + echo " • Status: ${FLOW_COLORS[success]}active${FLOW_COLORS[reset]} (primary)" + echo " • Location: $(security list-keychains 2>/dev/null | head -1 | tr -d ' \"')" + local kc_count=$(_dot_secret_count_keychain 2>/dev/null || echo "0") + echo " • Secrets: $kc_count" + echo "" + echo "${FLOW_COLORS[info]}Bitwarden:${FLOW_COLORS[reset]}" + if [[ -n "$BW_SESSION" ]]; then + echo " • Status: ${FLOW_COLORS[success]}unlocked${FLOW_COLORS[reset]} (sync enabled)" + else + echo " • Status: ${FLOW_COLORS[warning]}locked${FLOW_COLORS[reset]} (sync disabled until: dot unlock)" + fi + ;; + esac + + echo "" + echo "${FLOW_COLORS[muted]}Configuration:${FLOW_COLORS[reset]}" + echo " FLOW_SECRET_BACKEND=${FLOW_SECRET_BACKEND:-}" + echo "" +} + +# Helper: Count Keychain secrets +_dot_secret_count_keychain() { + local dump_file + dump_file=$(mktemp) + security dump-keychain > "$dump_file" 2>/dev/null + + local count=0 + local svc_lines + svc_lines=$(grep -c "\"svce\"=\"$_DOT_KEYCHAIN_SERVICE\"" "$dump_file" 2>/dev/null || echo "0") + count=$svc_lines + + rm -f "$dump_file" + echo "$count" +} + +# ============================================================================= +# Function: _dot_secret_sync +# Purpose: Sync secrets between Keychain and Bitwarden +# ============================================================================= +# Arguments: +# $1 - (optional) Mode: --to-bw, --from-bw, --status (default: interactive) +# +# Returns: +# 0 - Sync successful +# 1 - Error (Bitwarden not available, sync failed) +# +# Example: +# dot secret sync # Interactive comparison and sync +# dot secret sync --status # Show differences +# dot secret sync --to-bw # Push Keychain → Bitwarden +# dot secret sync --from-bw # Pull Bitwarden → Keychain +# ============================================================================= +_dot_secret_sync() { + local mode="${1:-interactive}" + + # Check if Bitwarden CLI is available + if ! command -v bw &>/dev/null; then + _flow_log_error "Bitwarden CLI not installed" + _flow_log_info "Install: ${FLOW_COLORS[cmd]}brew install bitwarden-cli${FLOW_COLORS[reset]}" + return 1 + fi + + case "$mode" in + --status|-s|status) + _dot_secret_sync_status + return + ;; + + --to-bw|--to-bitwarden|push) + _dot_secret_sync_to_bitwarden + return + ;; + + --from-bw|--from-bitwarden|pull) + _dot_secret_sync_from_bitwarden + return + ;; + + --help|-h|help) + _dot_secret_sync_help + return + ;; + + interactive|"") + _dot_secret_sync_interactive + return + ;; + + *) + _flow_log_error "Unknown sync mode: $mode" + _dot_secret_sync_help + return 1 + ;; + esac +} + +# Sync help +_dot_secret_sync_help() { + echo "" + echo "${FLOW_COLORS[header]}dot secret sync${FLOW_COLORS[reset]} - Sync between Keychain and Bitwarden" + echo "" + echo "${FLOW_COLORS[warning]}Commands:${FLOW_COLORS[reset]}" + echo " dot secret sync Interactive sync wizard" + echo " dot secret sync --status Show differences between backends" + echo " dot secret sync --to-bw Push Keychain → Bitwarden" + echo " dot secret sync --from-bw Pull Bitwarden → Keychain" + echo "" + echo "${FLOW_COLORS[muted]}Requires Bitwarden unlocked: dot unlock${FLOW_COLORS[reset]}" + echo "" +} + +# Show sync status (differences) +_dot_secret_sync_status() { + echo "" + echo "${FLOW_COLORS[header]}Sync Status${FLOW_COLORS[reset]}" + echo "" + + # Check if Bitwarden is unlocked + if [[ -z "$BW_SESSION" ]]; then + _flow_log_warning "Bitwarden is locked" + _flow_log_info "Run: ${FLOW_COLORS[cmd]}dot unlock${FLOW_COLORS[reset]} to enable sync status" + echo "" + echo "${FLOW_COLORS[info]}Keychain secrets:${FLOW_COLORS[reset]}" + _dot_kc_list + return 0 + fi + + # Get Keychain secrets + echo "${FLOW_COLORS[info]}Keychain secrets:${FLOW_COLORS[reset]}" + local -a kc_secrets=() + local dump_file + dump_file=$(mktemp) + security dump-keychain > "$dump_file" 2>/dev/null + + local svc_lines + svc_lines=$(grep -n "\"svce\"=\"$_DOT_KEYCHAIN_SERVICE\"" "$dump_file" 2>/dev/null | cut -d: -f1) + + for line_num in ${(f)svc_lines}; do + local start_line=$((line_num - 20)) + [[ $start_line -lt 1 ]] && start_line=1 + + local acct_line + acct_line=$(sed -n "${start_line},${line_num}p" "$dump_file" | grep '"acct"="' | tail -1) + + if [[ -n "$acct_line" ]]; then + local account_name + account_name="${acct_line#*\"acct\"=\"}" + account_name="${account_name%%\"*}" + if [[ -n "$account_name" ]]; then + kc_secrets+=("$account_name") + echo " ${FLOW_COLORS[success]}●${FLOW_COLORS[reset]} $account_name" + fi + fi + done + + rm -f "$dump_file" + + if [[ ${#kc_secrets[@]} -eq 0 ]]; then + echo " ${FLOW_COLORS[muted]}(none)${FLOW_COLORS[reset]}" + fi + + echo "" + echo "${FLOW_COLORS[info]}Bitwarden secrets (flow-cli):${FLOW_COLORS[reset]}" + + # Get Bitwarden items + local items_json + items_json=$(bw list items --session "$BW_SESSION" 2>/dev/null) + + if [[ -z "$items_json" ]] || [[ "$items_json" == "[]" ]]; then + echo " ${FLOW_COLORS[muted]}(none)${FLOW_COLORS[reset]}" + else + echo "$items_json" | jq -r '.[] | .name' 2>/dev/null | while read -r name; do + if _flow_array_contains "$name" "${kc_secrets[@]}"; then + echo " ${FLOW_COLORS[success]}●${FLOW_COLORS[reset]} $name ${FLOW_COLORS[muted]}(synced)${FLOW_COLORS[reset]}" + else + echo " ${FLOW_COLORS[warning]}○${FLOW_COLORS[reset]} $name ${FLOW_COLORS[muted]}(not in Keychain)${FLOW_COLORS[reset]}" + fi + done + fi + + echo "" +} + +# Push Keychain → Bitwarden +_dot_secret_sync_to_bitwarden() { + if [[ -z "$BW_SESSION" ]]; then + _flow_log_error "Bitwarden is locked" + _flow_log_info "Run: ${FLOW_COLORS[cmd]}dot unlock${FLOW_COLORS[reset]}" + return 1 + fi + + _flow_log_info "Syncing Keychain → Bitwarden..." + + # Get Keychain secrets + local dump_file + dump_file=$(mktemp) + security dump-keychain > "$dump_file" 2>/dev/null + + local count=0 + local svc_lines + svc_lines=$(grep -n "\"svce\"=\"$_DOT_KEYCHAIN_SERVICE\"" "$dump_file" 2>/dev/null | cut -d: -f1) + + for line_num in ${(f)svc_lines}; do + local start_line=$((line_num - 20)) + [[ $start_line -lt 1 ]] && start_line=1 + + local acct_line + acct_line=$(sed -n "${start_line},${line_num}p" "$dump_file" | grep '"acct"="' | tail -1) + + if [[ -n "$acct_line" ]]; then + local secret_name + secret_name="${acct_line#*\"acct\"=\"}" + secret_name="${secret_name%%\"*}" + + if [[ -n "$secret_name" ]]; then + # Get secret value from Keychain + local secret_value + secret_value=$(security find-generic-password -a "$secret_name" -s "$_DOT_KEYCHAIN_SERVICE" -w 2>/dev/null) + + if [[ -n "$secret_value" ]]; then + # Check if exists in Bitwarden + local existing + existing=$(bw get item "$secret_name" --session "$BW_SESSION" 2>/dev/null) + + if [[ -n "$existing" ]]; then + # Update existing + local item_id + item_id=$(echo "$existing" | jq -r '.id') + local updated_item + updated_item=$(echo "$existing" | jq --arg pw "$secret_value" '.login.password = $pw') + echo "$updated_item" | bw encode | bw edit item "$item_id" --session "$BW_SESSION" >/dev/null 2>&1 + _flow_log_success "Updated: $secret_name" + else + # Create new + local new_item + new_item=$(jq -n \ + --arg name "$secret_name" \ + --arg pw "$secret_value" \ + '{type: 1, name: $name, login: {password: $pw}}') + echo "$new_item" | bw encode | bw create item --session "$BW_SESSION" >/dev/null 2>&1 + _flow_log_success "Created: $secret_name" + fi + ((count++)) + fi + fi + fi + done + + rm -f "$dump_file" + + if [[ $count -gt 0 ]]; then + bw sync --session "$BW_SESSION" >/dev/null 2>&1 + _flow_log_success "Synced $count secret(s) to Bitwarden" + else + _flow_log_muted "No secrets to sync" + fi +} + +# Pull Bitwarden → Keychain +_dot_secret_sync_from_bitwarden() { + if [[ -z "$BW_SESSION" ]]; then + _flow_log_error "Bitwarden is locked" + _flow_log_info "Run: ${FLOW_COLORS[cmd]}dot unlock${FLOW_COLORS[reset]}" + return 1 + fi + + _flow_log_info "Syncing Bitwarden → Keychain..." + + local items_json + items_json=$(bw list items --session "$BW_SESSION" 2>/dev/null) + + if [[ -z "$items_json" ]] || [[ "$items_json" == "[]" ]]; then + _flow_log_muted "No secrets in Bitwarden to sync" + return 0 + fi + + local count=0 + + echo "$items_json" | jq -c '.[]' 2>/dev/null | while read -r item; do + local name + local password + name=$(echo "$item" | jq -r '.name') + password=$(echo "$item" | jq -r '.login.password // .notes // empty') + + if [[ -n "$name" ]] && [[ -n "$password" ]]; then + security add-generic-password \ + -a "$name" \ + -s "$_DOT_KEYCHAIN_SERVICE" \ + -w "$password" \ + -U 2>/dev/null + _flow_log_success "Synced: $name" + ((count++)) + fi + done + + _flow_log_success "Synced secrets from Bitwarden to Keychain" +} + +# Interactive sync +_dot_secret_sync_interactive() { + echo "" + echo "${FLOW_COLORS[header]}Secret Sync Wizard${FLOW_COLORS[reset]}" + echo "" + + # Show status first + _dot_secret_sync_status + + if [[ -z "$BW_SESSION" ]]; then + _flow_log_warning "Unlock Bitwarden to enable sync: dot unlock" + return 0 + fi + + echo "${FLOW_COLORS[warning]}Choose sync direction:${FLOW_COLORS[reset]}" + echo " 1) Push Keychain → Bitwarden" + echo " 2) Pull Bitwarden → Keychain" + echo " 3) Cancel" + echo "" + echo -n "Choice [1-3]: " + local choice + read -r choice + + case "$choice" in + 1) + _dot_secret_sync_to_bitwarden + ;; + 2) + _dot_secret_sync_from_bitwarden + ;; + *) + _flow_log_muted "Cancelled" + ;; + esac +} + _dot_secret_bw_list() { if ! _dot_require_tool "bw" "brew install bitwarden-cli"; then return 1 @@ -2014,14 +2426,19 @@ _dot_token_refresh() { # ─────────────────────────────────────────────────────────────────── _dot_token_github() { - if ! _dot_require_tool "bw" "brew install bitwarden-cli"; then - return 1 - fi + local backend=$(_dot_secret_backend) - # Check if session is active - if ! _dot_bw_session_valid; then - _flow_log_info "Bitwarden vault is locked. Unlocking..." - _dot_unlock || return 1 + # Only require Bitwarden if backend needs it + if _dot_secret_needs_bitwarden; then + if ! _dot_require_tool "bw" "brew install bitwarden-cli"; then + return 1 + fi + + # Check if session is active + if ! _dot_bw_session_valid; then + _flow_log_info "Bitwarden vault is locked. Unlocking..." + _dot_unlock || return 1 + fi fi echo "" @@ -2133,24 +2550,34 @@ _dot_token_github() { [[ -n "$username" ]] && metadata="${metadata},\"github_user\":\"${username}\"" metadata="${metadata}}" - # Store in Bitwarden - _flow_log_info "Storing token in Bitwarden..." - - # Check if token already exists - local existing - existing=$(bw get item "$token_name" --session "$BW_SESSION" 2>/dev/null) + # ───────────────────────────────────────────────────────────────── + # STORAGE: Based on backend configuration + # ───────────────────────────────────────────────────────────────── + # Backend options (set via FLOW_SECRET_BACKEND): + # - "keychain" (default): Store only in Keychain, no Bitwarden + # - "bitwarden": Store only in Bitwarden (legacy mode) + # - "both": Store in both backends for cloud backup + # ───────────────────────────────────────────────────────────────── - if [[ -n "$existing" ]]; then - # Update existing - local item_id=$(echo "$existing" | jq -r '.id' 2>/dev/null) - if [[ -n "$item_id" && "$item_id" != "null" ]]; then - local updated_item=$(echo "$existing" | jq --arg pw "$token_value" --arg notes "$metadata" \ - '.login.password = $pw | .notes = $notes') - echo "$updated_item" | bw encode | bw edit item "$item_id" --session "$BW_SESSION" >/dev/null 2>&1 - fi - else - # Create new - local new_item=$(cat </dev/null) + + if [[ -n "$existing" ]]; then + # Update existing + local item_id=$(echo "$existing" | jq -r '.id' 2>/dev/null) + if [[ -n "$item_id" && "$item_id" != "null" ]]; then + local updated_item=$(echo "$existing" | jq --arg pw "$token_value" --arg notes "$metadata" \ + '.login.password = $pw | .notes = $notes') + echo "$updated_item" | bw encode | bw edit item "$item_id" --session "$BW_SESSION" >/dev/null 2>&1 + fi + else + # Create new + local new_item=$(cat </dev/null 2>&1 - fi + echo "$new_item" | bw encode | bw create item --session "$BW_SESSION" >/dev/null 2>&1 + fi - # Sync vault - bw sync --session "$BW_SESSION" >/dev/null 2>&1 + # Sync vault + bw sync --session "$BW_SESSION" >/dev/null 2>&1 + fi # ───────────────────────────────────────────────────────────────── # KEYCHAIN METADATA STORAGE @@ -2190,14 +2618,16 @@ EOF # "github_user":"username"} # ───────────────────────────────────────────────────────────────── - # ALSO store in Keychain with metadata for instant access - _flow_log_info "Adding to Keychain for instant access..." - security add-generic-password \ - -a "$token_name" \ # Account: token name (searchable) - -s "$_DOT_KEYCHAIN_SERVICE" \ # Service: flow-cli namespace - -w "$token_value" \ # Password: actual token (protected) - -j "$metadata" \ # JSON attrs: metadata (searchable) - -U 2>/dev/null # Update if exists + # Store in Keychain (if backend uses it - default) + if _dot_secret_uses_keychain; then + _flow_log_info "Storing in Keychain..." + security add-generic-password \ + -a "$token_name" \ # Account: token name (searchable) + -s "$_DOT_KEYCHAIN_SERVICE" \ # Service: flow-cli namespace + -w "$token_value" \ # Password: actual token (protected) + -j "$metadata" \ # JSON attrs: metadata (searchable) + -U 2>/dev/null # Update if exists + fi echo "" echo "${FLOW_COLORS[header]}╭───────────────────────────────────────────────────╮${FLOW_COLORS[reset]}" @@ -2529,14 +2959,19 @@ _dot_token_sync_gh() { # ─────────────────────────────────────────────────────────────────── _dot_token_npm() { - if ! _dot_require_tool "bw" "brew install bitwarden-cli"; then - return 1 - fi + local backend=$(_dot_secret_backend) - # Check if session is active - if ! _dot_bw_session_valid; then - _flow_log_info "Bitwarden vault is locked. Unlocking..." - _dot_unlock || return 1 + # Only require Bitwarden if backend needs it + if _dot_secret_needs_bitwarden; then + if ! _dot_require_tool "bw" "brew install bitwarden-cli"; then + return 1 + fi + + # Check if session is active + if ! _dot_bw_session_valid; then + _flow_log_info "Bitwarden vault is locked. Unlocking..." + _dot_unlock || return 1 + fi fi echo "" @@ -2649,22 +3084,23 @@ _dot_token_npm() { [[ -n "$expire_date" ]] && metadata="${metadata},\"expires\":\"${expire_date}\"" metadata="${metadata}}" - # Store in Bitwarden - _flow_log_info "Storing token in Bitwarden..." + # Store in Bitwarden (if backend requires it) + if _dot_secret_needs_bitwarden; then + _flow_log_info "Storing token in Bitwarden..." - # Check if token already exists - local existing - existing=$(bw get item "$token_name" --session "$BW_SESSION" 2>/dev/null) + # Check if token already exists + local existing + existing=$(bw get item "$token_name" --session "$BW_SESSION" 2>/dev/null) - if [[ -n "$existing" ]]; then - local item_id=$(echo "$existing" | jq -r '.id' 2>/dev/null) - if [[ -n "$item_id" && "$item_id" != "null" ]]; then - local updated_item=$(echo "$existing" | jq --arg pw "$token_value" --arg notes "$metadata" \ - '.login.password = $pw | .notes = $notes') - echo "$updated_item" | bw encode | bw edit item "$item_id" --session "$BW_SESSION" >/dev/null 2>&1 - fi - else - local new_item=$(cat </dev/null) + if [[ -n "$item_id" && "$item_id" != "null" ]]; then + local updated_item=$(echo "$existing" | jq --arg pw "$token_value" --arg notes "$metadata" \ + '.login.password = $pw | .notes = $notes') + echo "$updated_item" | bw encode | bw edit item "$item_id" --session "$BW_SESSION" >/dev/null 2>&1 + fi + else + local new_item=$(cat </dev/null 2>&1 + echo "$new_item" | bw encode | bw create item --session "$BW_SESSION" >/dev/null 2>&1 + fi + + # Sync vault + bw sync --session "$BW_SESSION" >/dev/null 2>&1 fi - # Sync vault - bw sync --session "$BW_SESSION" >/dev/null 2>&1 + # Store in Keychain (if backend uses it - default) + if _dot_secret_uses_keychain; then + _flow_log_info "Storing in Keychain..." + security add-generic-password \ + -a "$token_name" \ + -s "$_DOT_KEYCHAIN_SERVICE" \ + -w "$token_value" \ + -j "$metadata" \ + -U 2>/dev/null + fi echo "" echo "${FLOW_COLORS[header]}╭───────────────────────────────────────────────────╮${FLOW_COLORS[reset]}" @@ -2708,14 +3156,15 @@ EOF # ─────────────────────────────────────────────────────────────────── _dot_token_pypi() { - if ! _dot_require_tool "bw" "brew install bitwarden-cli"; then - return 1 - fi + local backend=$(_dot_secret_backend) - # Check if session is active - if ! _dot_bw_session_valid; then - _flow_log_info "Bitwarden vault is locked. Unlocking..." - _dot_unlock || return 1 + # Only require Bitwarden if backend needs it + if _dot_secret_needs_bitwarden; then + if ! _dot_require_tool "bw" "brew install bitwarden-cli"; then + return 1 + _flow_log_info "Bitwarden vault is locked. Unlocking..." + _dot_unlock || return 1 + fi fi echo "" @@ -2817,22 +3266,23 @@ _dot_token_pypi() { [[ -n "$expire_date" ]] && metadata="${metadata},\"expires\":\"${expire_date}\"" metadata="${metadata}}" - # Store in Bitwarden - _flow_log_info "Storing token in Bitwarden..." + # Store in Bitwarden (if backend requires it) + if _dot_secret_needs_bitwarden; then + _flow_log_info "Storing token in Bitwarden..." - # Check if token already exists - local existing - existing=$(bw get item "$token_name" --session "$BW_SESSION" 2>/dev/null) + # Check if token already exists + local existing + existing=$(bw get item "$token_name" --session "$BW_SESSION" 2>/dev/null) - if [[ -n "$existing" ]]; then - local item_id=$(echo "$existing" | jq -r '.id' 2>/dev/null) - if [[ -n "$item_id" && "$item_id" != "null" ]]; then - local updated_item=$(echo "$existing" | jq --arg pw "$token_value" --arg notes "$metadata" \ - '.login.password = $pw | .notes = $notes') - echo "$updated_item" | bw encode | bw edit item "$item_id" --session "$BW_SESSION" >/dev/null 2>&1 - fi - else - local new_item=$(cat </dev/null) + if [[ -n "$item_id" && "$item_id" != "null" ]]; then + local updated_item=$(echo "$existing" | jq --arg pw "$token_value" --arg notes "$metadata" \ + '.login.password = $pw | .notes = $notes') + echo "$updated_item" | bw encode | bw edit item "$item_id" --session "$BW_SESSION" >/dev/null 2>&1 + fi + else + local new_item=$(cat </dev/null 2>&1 + echo "$new_item" | bw encode | bw create item --session "$BW_SESSION" >/dev/null 2>&1 + fi + + # Sync vault + bw sync --session "$BW_SESSION" >/dev/null 2>&1 fi - # Sync vault - bw sync --session "$BW_SESSION" >/dev/null 2>&1 + # Store in Keychain (if backend uses it - default) + if _dot_secret_uses_keychain; then + _flow_log_info "Storing in Keychain..." + security add-generic-password \ + -a "$token_name" \ + -s "$_DOT_KEYCHAIN_SERVICE" \ + -w "$token_value" \ + -j "$metadata" \ + -U 2>/dev/null + fi echo "" echo "${FLOW_COLORS[header]}╭───────────────────────────────────────────────────╮${FLOW_COLORS[reset]}" diff --git a/lib/keychain-helpers.zsh b/lib/keychain-helpers.zsh index 186e3a4fe..c1208f7a8 100644 --- a/lib/keychain-helpers.zsh +++ b/lib/keychain-helpers.zsh @@ -363,19 +363,28 @@ ${FLOW_COLORS[warning]}Commands:${FLOW_COLORS[reset]} dot secret Shortcut for 'get' dot secret list List all stored secrets dot secret delete Remove a secret + dot secret status Show backend configuration + dot secret sync Sync with Bitwarden (--to-bw, --from-bw) dot secret import Import from Bitwarden (one-time) dot secret tutorial Interactive tutorial (10-15 min) +${FLOW_COLORS[warning]}Backend Configuration:${FLOW_COLORS[reset]} + export FLOW_SECRET_BACKEND=keychain # Default (Keychain only) + export FLOW_SECRET_BACKEND=bitwarden # Bitwarden only (legacy) + export FLOW_SECRET_BACKEND=both # Both backends (sync mode) + ${FLOW_COLORS[warning]}Examples:${FLOW_COLORS[reset]} dot secret add github-token # Store GitHub token dot secret github-token # Retrieve it dot secret list # See all secrets + dot secret status # Check backend config + dot secret sync --status # Compare Keychain vs Bitwarden ${FLOW_COLORS[warning]}Usage in scripts:${FLOW_COLORS[reset]} export GITHUB_TOKEN=\$(dot secret github-token) gh auth login --with-token <<< \$(dot secret github-token) -${FLOW_COLORS[warning]}Benefits:${FLOW_COLORS[reset]} +${FLOW_COLORS[warning]}Benefits (Keychain default):${FLOW_COLORS[reset]} ${FLOW_COLORS[success]}\342\200\242${FLOW_COLORS[reset]} Instant access (no unlock needed) ${FLOW_COLORS[success]}\342\200\242${FLOW_COLORS[reset]} Touch ID / Apple Watch support ${FLOW_COLORS[success]}\342\200\242${FLOW_COLORS[reset]} Auto-locks with screen lock diff --git a/tests/test-keychain-default.zsh b/tests/test-keychain-default.zsh new file mode 100755 index 000000000..f9691067e --- /dev/null +++ b/tests/test-keychain-default.zsh @@ -0,0 +1,248 @@ +#!/usr/bin/env zsh +# tests/test-keychain-default.zsh - Backend configuration tests +# Tests for the Keychain Default Phase 1 feature + +# ============================================================================ +# TEST SETUP +# ============================================================================ + +TEST_COUNT=0 +PASS_COUNT=0 +FAIL_COUNT=0 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +# Source the plugin +SCRIPT_DIR="${0:A:h}" +PROJECT_ROOT="${SCRIPT_DIR:h}" +source "$PROJECT_ROOT/flow.plugin.zsh" + +# ============================================================================ +# TEST HELPERS +# ============================================================================ + +test_pass() { + ((TEST_COUNT++)) + ((PASS_COUNT++)) + echo "${GREEN}✓${NC} $1" +} + +test_fail() { + ((TEST_COUNT++)) + ((FAIL_COUNT++)) + echo "${RED}✗${NC} $1" + [[ -n "$2" ]] && echo " Expected: $2" + [[ -n "$3" ]] && echo " Got: $3" +} + +assert_eq() { + local actual="$1" + local expected="$2" + local message="$3" + + if [[ "$actual" == "$expected" ]]; then + test_pass "$message" + else + test_fail "$message" "$expected" "$actual" + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local message="$3" + + if [[ "$haystack" == *"$needle"* ]]; then + test_pass "$message" + else + test_fail "$message" "contains '$needle'" "not found" + fi +} + +# ============================================================================ +# TEST: Backend Configuration +# ============================================================================ + +echo "" +echo "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" +echo "${YELLOW} Test Suite: Keychain Default Phase 1 ${NC}" +echo "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" +echo "" + +echo "${YELLOW}── Backend Configuration ──${NC}" + +# Test 1: Default backend is keychain +unset FLOW_SECRET_BACKEND +local result=$(_dot_secret_backend) +assert_eq "$result" "keychain" "Default backend is 'keychain'" + +# Test 2: Keychain backend via env var +export FLOW_SECRET_BACKEND="keychain" +result=$(_dot_secret_backend) +assert_eq "$result" "keychain" "FLOW_SECRET_BACKEND=keychain returns 'keychain'" + +# Test 3: Bitwarden backend via env var +export FLOW_SECRET_BACKEND="bitwarden" +result=$(_dot_secret_backend) +assert_eq "$result" "bitwarden" "FLOW_SECRET_BACKEND=bitwarden returns 'bitwarden'" + +# Test 4: Both backend via env var +export FLOW_SECRET_BACKEND="both" +result=$(_dot_secret_backend) +assert_eq "$result" "both" "FLOW_SECRET_BACKEND=both returns 'both'" + +# Test 5: Invalid backend falls back to keychain +export FLOW_SECRET_BACKEND="invalid" +# Capture just the last line (the actual return value) +result=$(_dot_secret_backend 2>/dev/null | tail -1) +assert_eq "$result" "keychain" "Invalid backend falls back to 'keychain'" + +# Reset +unset FLOW_SECRET_BACKEND + +# ============================================================================ +# TEST: Helper Functions +# ============================================================================ + +echo "" +echo "${YELLOW}── Helper Functions ──${NC}" + +# Test 6: _dot_secret_needs_bitwarden with keychain backend +unset FLOW_SECRET_BACKEND +if _dot_secret_needs_bitwarden; then + test_fail "keychain backend should not need Bitwarden" +else + test_pass "keychain backend does not need Bitwarden" +fi + +# Test 7: _dot_secret_needs_bitwarden with bitwarden backend +export FLOW_SECRET_BACKEND="bitwarden" +if _dot_secret_needs_bitwarden; then + test_pass "bitwarden backend needs Bitwarden" +else + test_fail "bitwarden backend should need Bitwarden" +fi + +# Test 8: _dot_secret_needs_bitwarden with both backend +export FLOW_SECRET_BACKEND="both" +if _dot_secret_needs_bitwarden; then + test_pass "both backend needs Bitwarden" +else + test_fail "both backend should need Bitwarden" +fi + +# Test 9: _dot_secret_uses_keychain with keychain backend +unset FLOW_SECRET_BACKEND +if _dot_secret_uses_keychain; then + test_pass "keychain backend uses Keychain" +else + test_fail "keychain backend should use Keychain" +fi + +# Test 10: _dot_secret_uses_keychain with bitwarden backend +export FLOW_SECRET_BACKEND="bitwarden" +if _dot_secret_uses_keychain; then + test_fail "bitwarden backend should not use Keychain" +else + test_pass "bitwarden backend does not use Keychain" +fi + +# Test 11: _dot_secret_uses_keychain with both backend +export FLOW_SECRET_BACKEND="both" +if _dot_secret_uses_keychain; then + test_pass "both backend uses Keychain" +else + test_fail "both backend should use Keychain" +fi + +# Reset +unset FLOW_SECRET_BACKEND + +# ============================================================================ +# TEST: Status Command +# ============================================================================ + +echo "" +echo "${YELLOW}── Status Command ──${NC}" + +# Test 12: Status command exists +if type _dot_secret_status &>/dev/null; then + test_pass "_dot_secret_status function exists" +else + test_fail "_dot_secret_status function should exist" +fi + +# Test 13: Status output contains backend info +unset FLOW_SECRET_BACKEND +local status_output=$(_dot_secret_status 2>/dev/null) +assert_contains "$status_output" "keychain" "Status shows keychain backend" + +# Test 14: Status output contains configuration +assert_contains "$status_output" "Configuration" "Status shows configuration section" + +# ============================================================================ +# TEST: Sync Command +# ============================================================================ + +echo "" +echo "${YELLOW}── Sync Command ──${NC}" + +# Test 15: Sync function exists +if type _dot_secret_sync &>/dev/null; then + test_pass "_dot_secret_sync function exists" +else + test_fail "_dot_secret_sync function should exist" +fi + +# Test 16: Sync help exists +local sync_help=$(_dot_secret_sync_help 2>/dev/null) +assert_contains "$sync_help" "sync" "Sync help mentions sync" + +# ============================================================================ +# TEST: Command Routing +# ============================================================================ + +echo "" +echo "${YELLOW}── Command Routing ──${NC}" + +# Test 17: dot secret dispatches correctly +if type _dot_secret &>/dev/null; then + test_pass "_dot_secret dispatcher exists" +else + test_fail "_dot_secret dispatcher should exist" +fi + +# Test 18: Help includes status command +local help_output=$(_dot_kc_help 2>/dev/null) +assert_contains "$help_output" "status" "Help mentions status command" + +# Test 19: Help includes sync command +assert_contains "$help_output" "sync" "Help mentions sync command" + +# Test 20: Help includes backend configuration +assert_contains "$help_output" "FLOW_SECRET_BACKEND" "Help mentions backend configuration" + +# ============================================================================ +# RESULTS +# ============================================================================ + +echo "" +echo "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" +echo "Results: ${PASS_COUNT}/${TEST_COUNT} passed" +if [[ $FAIL_COUNT -eq 0 ]]; then + echo "${GREEN}All tests passed!${NC}" +else + echo "${RED}${FAIL_COUNT} tests failed${NC}" +fi +echo "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" +echo "" + +# Clean up +unset FLOW_SECRET_BACKEND + +# Exit with appropriate code +[[ $FAIL_COUNT -eq 0 ]] From 0bd6c44a834edcc5c7d0af27148dbabeaaa6a069 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 24 Jan 2026 23:05:37 -0700 Subject: [PATCH 2/3] test(keychain-default): add comprehensive dogfooding test suites Add two test suites for the Keychain Default Phase 1 feature: 1. test-keychain-default-automated.zsh (47 tests) - CI-ready automated tests across 10 test groups - Function existence, backend configuration, helper behavior matrix - Status/sync command verification, help text, command routing - File structure validation, integration sanity checks 2. interactive-keychain-default-dogfooding.zsh (15 tests) - ADHD-friendly gamified interactive testing - Dog feeding mechanics (hunger, happiness tracking) - Visual output verification by user - Star rating system for completion progress All 47 automated tests passing. Co-Authored-By: Claude Opus 4.5 --- ...nteractive-keychain-default-dogfooding.zsh | 418 +++++++++++++++++ tests/test-keychain-default-automated.zsh | 442 ++++++++++++++++++ 2 files changed, 860 insertions(+) create mode 100755 tests/interactive-keychain-default-dogfooding.zsh create mode 100755 tests/test-keychain-default-automated.zsh diff --git a/tests/interactive-keychain-default-dogfooding.zsh b/tests/interactive-keychain-default-dogfooding.zsh new file mode 100755 index 000000000..a93ec03e0 --- /dev/null +++ b/tests/interactive-keychain-default-dogfooding.zsh @@ -0,0 +1,418 @@ +#!/usr/bin/env zsh +# ══════════════════════════════════════════════════════════════════════════════ +# INTERACTIVE DOG FEEDING TEST - KEYCHAIN DEFAULT EDITION +# ══════════════════════════════════════════════════════════════════════════════ +# +# Purpose: ADHD-friendly interactive test for the Keychain Default feature. +# Feed the dog by testing commands and verifying outputs! +# +# Usage: ./tests/interactive-keychain-default-dogfooding.zsh +# +# What it tests: +# - dot secret status (backend configuration display) +# - dot secret sync --status (sync comparison) +# - Backend switching (keychain, bitwarden, both) +# - Token workflow without Bitwarden (the main feature!) +# - Help text updates +# +# Controls: +# y - Test passed, feed the dog +# n - Test failed, dog gets hungry +# s - Skip this test +# q - Quit early +# +# ══════════════════════════════════════════════════════════════════════════════ + +# Determine paths +PLUGIN_DIR="${0:A:h:h}" +TEST_DIR="${0:A:h}" +LOG_DIR="${TEST_DIR}/logs" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +LOG_FILE="${LOG_DIR}/keychain-default-interactive-${TIMESTAMP}.log" + +mkdir -p "$LOG_DIR" + +# Colors and emojis +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +DOG='🐕' +FOOD='🥩' +BOWL='🥣' +HAPPY='😊' +SAD='😢' +STAR='⭐' +CHECK='✅' +CROSS='❌' +THINKING='🤔' +EYES='👀' +KEY='🔑' +LOCK='🔐' +UNLOCK='🔓' +SYNC='🔄' +CLOUD='☁️' +LOCAL='💻' + +# Game state +HUNGER=100 +HAPPINESS=50 +TASKS_COMPLETED=0 +TOTAL_TASKS=15 +PASSED=0 +FAILED=0 +SKIPPED=0 + +# Source the plugin +source "$PLUGIN_DIR/flow.plugin.zsh" 2>/dev/null + +# ══════════════════════════════════════════════════════════════════════════════ +# HELPER FUNCTIONS +# ══════════════════════════════════════════════════════════════════════════════ + +log() { + echo "[$(date +%H:%M:%S)] $*" >> "$LOG_FILE" +} + +print_banner() { + clear + echo "" + echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║${NC} ${DOG} ${BOLD}KEYCHAIN DEFAULT DOG FEEDING TEST${NC} ${KEY} ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} ${LOCK} Test the new Keychain-first secret backend! ${UNLOCK} ${BLUE}║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${DIM}Feature: Keychain is now the default backend (no unlock needed!)${NC}" + echo -e "${DIM}Log: $LOG_FILE${NC}" + echo "" +} + +print_dog_status() { + local mood + if [[ $HAPPINESS -gt 70 ]]; then + mood="${GREEN}${HAPPY} Very Happy${NC}" + elif [[ $HAPPINESS -gt 40 ]]; then + mood="${YELLOW}${THINKING} Okay${NC}" + else + mood="${RED}${SAD} Sad${NC}" + fi + + echo "" + echo -e "${CYAN}╭─ Dog Status ─────────────────────────────────────────────╮${NC}" + echo -e "${CYAN}│${NC} Hunger: ${YELLOW}$HUNGER%${NC}" + echo -e "${CYAN}│${NC} Happiness: $mood" + echo -e "${CYAN}│${NC} Tasks: ${GREEN}$TASKS_COMPLETED${NC}/${TOTAL_TASKS} completed" + + # Show rating + local stars=$((TASKS_COMPLETED * 5 / TOTAL_TASKS)) + local star_display="" + for ((i=1; i<=5; i++)); do + if [[ $i -le $stars ]]; then + star_display="${star_display}${STAR}" + else + star_display="${star_display}☆" + fi + done + echo -e "${CYAN}│${NC} Rating: $star_display" + echo -e "${CYAN}╰───────────────────────────────────────────────────────────╯${NC}" + echo "" +} + +feed_dog() { + ((HUNGER -= 8)) + ((HAPPINESS += 5)) + [[ $HUNGER -lt 0 ]] && HUNGER=0 + [[ $HAPPINESS -gt 100 ]] && HAPPINESS=100 + echo -e "\n${GREEN}${FOOD} Yum! Dog fed! ${DOG}${NC}\n" + log "Dog fed - Hunger: $HUNGER, Happiness: $HAPPINESS" +} + +hungry_dog() { + ((HUNGER += 5)) + ((HAPPINESS -= 10)) + [[ $HUNGER -gt 100 ]] && HUNGER=100 + [[ $HAPPINESS -lt 0 ]] && HAPPINESS=0 + echo -e "\n${RED}${SAD} Dog is disappointed... ${DOG}${NC}\n" + log "Test failed - Hunger: $HUNGER, Happiness: $HAPPINESS" +} + +run_interactive_test() { + local test_num="$1" + local test_title="$2" + local command="$3" + local expected="$4" + local notes="${5:-}" + + ((TASKS_COMPLETED++)) + + print_banner + print_dog_status + + echo -e "${YELLOW}╭─ Task $test_num/$TOTAL_TASKS ─────────────────────────────────────────╮${NC}" + echo -e "${YELLOW}│${NC} ${BOLD}$test_title${NC}" + echo -e "${YELLOW}╰───────────────────────────────────────────────────────────╯${NC}" + echo "" + + if [[ -n "$notes" ]]; then + echo -e "${DIM}$notes${NC}" + echo "" + fi + + echo -e "${CYAN}Command:${NC}" + echo -e " ${BOLD}$command${NC}" + echo "" + + echo -e "${CYAN}Expected:${NC}" + echo -e " $expected" + echo "" + + echo -e "${CYAN}Running...${NC}" + echo "" + + # Run the command + local output + output=$(eval "$command" 2>&1) + + echo -e "${CYAN}Actual Output:${NC}" + echo "$output" | head -20 + if [[ $(echo "$output" | wc -l) -gt 20 ]]; then + echo -e "${DIM}... (output truncated)${NC}" + fi + echo "" + + echo -e "${MAGENTA}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BOLD}Does the output match expected? ${NC}[y=pass, n=fail, s=skip, q=quit]" + + local response + read -k1 "response?" + echo "" + + case "$response" in + y|Y) + ((PASSED++)) + feed_dog + log "PASS: $test_title" + ;; + n|N) + ((FAILED++)) + hungry_dog + log "FAIL: $test_title" + ;; + s|S) + ((SKIPPED++)) + echo -e "\n${YELLOW}Skipped${NC}\n" + log "SKIP: $test_title" + ;; + q|Q) + print_summary + exit 0 + ;; + esac + + sleep 1 +} + +print_summary() { + clear + echo "" + echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║${NC} ${DOG} ${BOLD}KEYCHAIN DEFAULT - TEST COMPLETE${NC} ${KEY} ${BLUE}║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}" + echo "" + + print_dog_status + + echo -e "${CYAN}━━━ Test Results ━━━${NC}" + echo -e " ${GREEN}Passed:${NC} $PASSED" + echo -e " ${RED}Failed:${NC} $FAILED" + echo -e " ${YELLOW}Skipped:${NC} $SKIPPED" + echo "" + + if [[ $FAILED -eq 0 && $PASSED -gt 0 ]]; then + echo -e "${GREEN}${CHECK} All tests passed! The dog is very happy! ${DOG}${HAPPY}${NC}" + elif [[ $FAILED -gt 0 ]]; then + echo -e "${RED}${CROSS} Some tests failed. The dog needs more attention. ${DOG}${SAD}${NC}" + fi + + echo "" + echo -e "${DIM}Log saved to: $LOG_FILE${NC}" + echo "" + + log "SUMMARY: Passed=$PASSED, Failed=$FAILED, Skipped=$SKIPPED" +} + +# ══════════════════════════════════════════════════════════════════════════════ +# TESTS +# ══════════════════════════════════════════════════════════════════════════════ + +log "Starting interactive tests for Keychain Default Phase 1" + +# Save current backend setting +SAVED_BACKEND="$FLOW_SECRET_BACKEND" + +# ─── Test 1: Default Backend ─── +run_interactive_test 1 \ + "Check Default Backend" \ + "_dot_secret_backend" \ + "Should output: ${GREEN}keychain${NC}" \ + "The default backend is now Keychain (no FLOW_SECRET_BACKEND set)" + +# ─── Test 2: Status Command ─── +run_interactive_test 2 \ + "dot secret status (default)" \ + "unset FLOW_SECRET_BACKEND; dot secret status" \ + "Should show: + - Backend: ${GREEN}keychain (default)${NC} + - Keychain: active with location + - Bitwarden: not configured" \ + "Status command shows current backend configuration" + +# ─── Test 3: Status with Bitwarden Backend ─── +run_interactive_test 3 \ + "dot secret status (bitwarden mode)" \ + "export FLOW_SECRET_BACKEND=bitwarden; dot secret status" \ + "Should show: + - Backend: ${YELLOW}bitwarden (legacy mode)${NC} + - Keychain: not used + - Bitwarden: locked or unlocked" \ + "When set to bitwarden, shows legacy mode indicator" + +# ─── Test 4: Status with Both Backend ─── +run_interactive_test 4 \ + "dot secret status (both mode)" \ + "export FLOW_SECRET_BACKEND=both; dot secret status" \ + "Should show: + - Backend: ${CYAN}both (sync mode)${NC} + - Keychain: active (primary) + - Bitwarden: status shown" \ + "When set to both, shows sync mode with both backends" + +# Reset backend +unset FLOW_SECRET_BACKEND + +# ─── Test 5: Sync Help ─── +run_interactive_test 5 \ + "dot secret sync --help" \ + "dot secret sync --help" \ + "Should show sync commands: + - dot secret sync (interactive) + - dot secret sync --status + - dot secret sync --to-bw + - dot secret sync --from-bw" \ + "Sync help shows all available sync options" + +# ─── Test 6: Sync Status ─── +run_interactive_test 6 \ + "dot secret sync --status" \ + "dot secret sync --status" \ + "Should show: + - Sync Status header + - Keychain secrets listed + - Bitwarden note (locked if not unlocked)" \ + "Sync status compares Keychain and Bitwarden secrets" + +# ─── Test 7: Help Text Updated ─── +run_interactive_test 7 \ + "dot secret help includes new commands" \ + "dot secret help" \ + "Should include: + - ${KEY} dot secret status + - ${SYNC} dot secret sync + - ${LOCAL} FLOW_SECRET_BACKEND" \ + "Help text shows new backend configuration commands" + +# ─── Test 8: Keychain Helper - needs_bitwarden ─── +run_interactive_test 8 \ + "Keychain mode doesn't need Bitwarden" \ + "unset FLOW_SECRET_BACKEND; _dot_secret_needs_bitwarden && echo 'needs BW' || echo 'no BW needed'" \ + "Should output: ${GREEN}no BW needed${NC}" \ + "When in keychain mode, Bitwarden is NOT required" + +# ─── Test 9: Both Mode - needs_bitwarden ─── +run_interactive_test 9 \ + "Both mode needs Bitwarden" \ + "export FLOW_SECRET_BACKEND=both; _dot_secret_needs_bitwarden && echo 'needs BW' || echo 'no BW needed'" \ + "Should output: ${YELLOW}needs BW${NC}" \ + "When in both mode, Bitwarden IS required for writes" + +# Reset +unset FLOW_SECRET_BACKEND + +# ─── Test 10: Keychain uses_keychain ─── +run_interactive_test 10 \ + "Keychain mode uses Keychain" \ + "unset FLOW_SECRET_BACKEND; _dot_secret_uses_keychain && echo 'uses KC' || echo 'no KC'" \ + "Should output: ${GREEN}uses KC${NC}" \ + "Keychain mode uses macOS Keychain for storage" + +# ─── Test 11: Bitwarden mode no keychain ─── +run_interactive_test 11 \ + "Bitwarden mode doesn't use Keychain" \ + "export FLOW_SECRET_BACKEND=bitwarden; _dot_secret_uses_keychain && echo 'uses KC' || echo 'no KC'" \ + "Should output: ${RED}no KC${NC}" \ + "Legacy bitwarden mode doesn't use Keychain" + +# Reset +unset FLOW_SECRET_BACKEND + +# ─── Test 12: Secret List Works ─── +run_interactive_test 12 \ + "dot secret list (Keychain secrets)" \ + "dot secret list" \ + "Should show: + - Secrets in Keychain (flow-cli): + - List of secret names (if any) + - Or message if none" \ + "List shows secrets stored in Keychain" + +# ─── Test 13: Invalid Backend Fallback ─── +run_interactive_test 13 \ + "Invalid backend falls back to keychain" \ + "export FLOW_SECRET_BACKEND=invalid_xyz; _dot_secret_backend 2>&1 | tail -1" \ + "Should output: ${GREEN}keychain${NC} + (May also show warning about invalid value)" \ + "Invalid backend values fall back to safe default" + +# Reset +unset FLOW_SECRET_BACKEND + +# ─── Test 14: Tutorial Not Auto-Run ─── +run_interactive_test 14 \ + "dot secret status doesn't trigger tutorial" \ + "zsh -c 'source $PLUGIN_DIR/flow.plugin.zsh && dot secret status 2>&1 | head -5'" \ + "Should show Status output: + - Secret Backend Status + - ${GREEN}NOT${NC} Tutorial header + - ${GREEN}NOT${NC} Step 1/7" \ + "Bug fix: status command no longer triggers tutorial" + +# ─── Test 15: Quick Status Check ─── +run_interactive_test 15 \ + "Quick Backend Check" \ + "echo 'Backend:' \$(_dot_secret_backend); echo 'Needs BW:' \$(_dot_secret_needs_bitwarden && echo yes || echo no); echo 'Uses KC:' \$(_dot_secret_uses_keychain && echo yes || echo no)" \ + "Should output: + Backend: keychain + Needs BW: no + Uses KC: yes" \ + "Summary check of all helper functions" + +# ══════════════════════════════════════════════════════════════════════════════ +# CLEANUP & SUMMARY +# ══════════════════════════════════════════════════════════════════════════════ + +# Restore saved backend +[[ -n "$SAVED_BACKEND" ]] && export FLOW_SECRET_BACKEND="$SAVED_BACKEND" || unset FLOW_SECRET_BACKEND + +print_summary + +if [[ $FAILED -eq 0 && $PASSED -gt 0 ]]; then + exit 0 +else + exit 1 +fi diff --git a/tests/test-keychain-default-automated.zsh b/tests/test-keychain-default-automated.zsh new file mode 100755 index 000000000..2afc876ca --- /dev/null +++ b/tests/test-keychain-default-automated.zsh @@ -0,0 +1,442 @@ +#!/usr/bin/env zsh +# ══════════════════════════════════════════════════════════════════════════════ +# AUTOMATED TEST SUITE - KEYCHAIN DEFAULT PHASE 1 +# ══════════════════════════════════════════════════════════════════════════════ +# +# Purpose: CI-ready automated tests for the Keychain Default feature. +# No user interaction required. +# +# Usage: ./tests/test-keychain-default-automated.zsh +# +# What it tests: +# - Backend configuration (FLOW_SECRET_BACKEND) +# - Helper functions (_dot_secret_backend, _dot_secret_needs_bitwarden, etc.) +# - Status command (dot secret status) +# - Sync command structure (dot secret sync --help) +# - Token workflow routing (conditional Bitwarden checks) +# - Documentation updates +# +# Exit codes: +# 0 - All tests passed +# 1 - One or more tests failed +# +# ══════════════════════════════════════════════════════════════════════════════ + +set -o pipefail + +# ============================================================================ +# SETUP +# ============================================================================ + +PLUGIN_DIR="${0:A:h:h}" +TEST_DIR="${0:A:h}" +LOG_DIR="${TEST_DIR}/logs" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +LOG_FILE="${LOG_DIR}/keychain-default-automated-${TIMESTAMP}.log" + +mkdir -p "$LOG_DIR" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +# Counters +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +# Source the plugin +source "$PLUGIN_DIR/flow.plugin.zsh" 2>/dev/null + +# ============================================================================ +# TEST FRAMEWORK +# ============================================================================ + +log() { + echo "[$(date +%H:%M:%S)] $*" >> "$LOG_FILE" +} + +test_pass() { + ((TOTAL++)) + ((PASS++)) + echo -e " ${GREEN}✓${NC} $1" + log "PASS: $1" +} + +test_fail() { + ((TOTAL++)) + ((FAIL++)) + echo -e " ${RED}✗${NC} $1" + log "FAIL: $1" + [[ -n "$2" ]] && echo -e " ${DIM}Expected: $2${NC}" && log " Expected: $2" + [[ -n "$3" ]] && echo -e " ${DIM}Got: $3${NC}" && log " Got: $3" +} + +test_skip() { + ((TOTAL++)) + ((SKIP++)) + echo -e " ${YELLOW}○${NC} $1 (skipped)" + log "SKIP: $1" +} + +section() { + echo "" + echo -e "${CYAN}━━━ $1 ━━━${NC}" + log "=== $1 ===" +} + +assert_eq() { + local actual="$1" + local expected="$2" + local message="$3" + + if [[ "$actual" == "$expected" ]]; then + test_pass "$message" + return 0 + else + test_fail "$message" "$expected" "$actual" + return 1 + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local message="$3" + + if [[ "$haystack" == *"$needle"* ]]; then + test_pass "$message" + return 0 + else + test_fail "$message" "contains '$needle'" "not found in output" + return 1 + fi +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + local message="$3" + + if [[ "$haystack" != *"$needle"* ]]; then + test_pass "$message" + return 0 + else + test_fail "$message" "should not contain '$needle'" "found in output" + return 1 + fi +} + +assert_function_exists() { + local fn="$1" + local message="${2:-Function $fn exists}" + + if type "$fn" &>/dev/null; then + test_pass "$message" + return 0 + else + test_fail "$message" "function defined" "function not found" + return 1 + fi +} + +assert_exit_code() { + local expected="$1" + local actual="$2" + local message="$3" + + if [[ "$actual" -eq "$expected" ]]; then + test_pass "$message" + return 0 + else + test_fail "$message" "exit code $expected" "exit code $actual" + return 1 + fi +} + +# ============================================================================ +# BANNER +# ============================================================================ + +echo "" +echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║${NC} ${BOLD}KEYCHAIN DEFAULT PHASE 1 - AUTOMATED TEST SUITE${NC} ${BLUE}║${NC}" +echo -e "${BLUE}║${NC} ${DIM}CI-ready tests for backend configuration feature${NC} ${BLUE}║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}" +echo -e "${DIM}Log: $LOG_FILE${NC}" + +log "Starting automated tests for Keychain Default Phase 1" +log "Plugin directory: $PLUGIN_DIR" + +# ============================================================================ +# TEST GROUP 1: FUNCTION EXISTENCE +# ============================================================================ + +section "1. Function Existence" + +assert_function_exists "_dot_secret_backend" "Backend configuration function exists" +assert_function_exists "_dot_secret_needs_bitwarden" "Bitwarden check helper exists" +assert_function_exists "_dot_secret_uses_keychain" "Keychain check helper exists" +assert_function_exists "_dot_secret_status" "Status command function exists" +assert_function_exists "_dot_secret_sync" "Sync command function exists" +assert_function_exists "_dot_secret_sync_status" "Sync status function exists" +assert_function_exists "_dot_secret_sync_to_bitwarden" "Sync to Bitwarden function exists" +assert_function_exists "_dot_secret_sync_from_bitwarden" "Sync from Bitwarden function exists" +assert_function_exists "_dot_secret_sync_help" "Sync help function exists" +assert_function_exists "_dot_secret_count_keychain" "Keychain count helper exists" + +# ============================================================================ +# TEST GROUP 2: DEFAULT BACKEND CONFIGURATION +# ============================================================================ + +section "2. Default Backend Configuration" + +# Save current value +SAVED_BACKEND="$FLOW_SECRET_BACKEND" + +# Test: Default is keychain +unset FLOW_SECRET_BACKEND +result=$(_dot_secret_backend 2>/dev/null) +assert_eq "$result" "keychain" "Default backend is 'keychain'" + +# Test: Keychain does not need Bitwarden +unset FLOW_SECRET_BACKEND +if _dot_secret_needs_bitwarden 2>/dev/null; then + test_fail "Default backend should not need Bitwarden" +else + test_pass "Default backend does not require Bitwarden" +fi + +# Test: Keychain uses Keychain (tautology check) +unset FLOW_SECRET_BACKEND +if _dot_secret_uses_keychain 2>/dev/null; then + test_pass "Default backend uses Keychain" +else + test_fail "Default backend should use Keychain" +fi + +# Restore +[[ -n "$SAVED_BACKEND" ]] && export FLOW_SECRET_BACKEND="$SAVED_BACKEND" || unset FLOW_SECRET_BACKEND + +# ============================================================================ +# TEST GROUP 3: EXPLICIT BACKEND CONFIGURATION +# ============================================================================ + +section "3. Explicit Backend Configuration" + +# Test: Keychain explicit +export FLOW_SECRET_BACKEND="keychain" +result=$(_dot_secret_backend 2>/dev/null) +assert_eq "$result" "keychain" "FLOW_SECRET_BACKEND=keychain works" + +# Test: Bitwarden explicit +export FLOW_SECRET_BACKEND="bitwarden" +result=$(_dot_secret_backend 2>/dev/null) +assert_eq "$result" "bitwarden" "FLOW_SECRET_BACKEND=bitwarden works" + +# Test: Both explicit +export FLOW_SECRET_BACKEND="both" +result=$(_dot_secret_backend 2>/dev/null) +assert_eq "$result" "both" "FLOW_SECRET_BACKEND=both works" + +# Test: Invalid falls back to keychain +export FLOW_SECRET_BACKEND="invalid_value" +result=$(_dot_secret_backend 2>/dev/null | tail -1) +assert_eq "$result" "keychain" "Invalid backend falls back to 'keychain'" + +# Restore +unset FLOW_SECRET_BACKEND + +# ============================================================================ +# TEST GROUP 4: HELPER FUNCTION BEHAVIOR +# ============================================================================ + +section "4. Helper Function Behavior Matrix" + +# Test matrix for _dot_secret_needs_bitwarden +for backend in keychain bitwarden both; do + export FLOW_SECRET_BACKEND="$backend" + if _dot_secret_needs_bitwarden 2>/dev/null; then + needs_bw="yes" + else + needs_bw="no" + fi + + case "$backend" in + keychain) + [[ "$needs_bw" == "no" ]] && test_pass "keychain: needs_bitwarden=no" || test_fail "keychain: needs_bitwarden should be no" + ;; + bitwarden) + [[ "$needs_bw" == "yes" ]] && test_pass "bitwarden: needs_bitwarden=yes" || test_fail "bitwarden: needs_bitwarden should be yes" + ;; + both) + [[ "$needs_bw" == "yes" ]] && test_pass "both: needs_bitwarden=yes" || test_fail "both: needs_bitwarden should be yes" + ;; + esac +done + +# Test matrix for _dot_secret_uses_keychain +for backend in keychain bitwarden both; do + export FLOW_SECRET_BACKEND="$backend" + if _dot_secret_uses_keychain 2>/dev/null; then + uses_kc="yes" + else + uses_kc="no" + fi + + case "$backend" in + keychain) + [[ "$uses_kc" == "yes" ]] && test_pass "keychain: uses_keychain=yes" || test_fail "keychain: uses_keychain should be yes" + ;; + bitwarden) + [[ "$uses_kc" == "no" ]] && test_pass "bitwarden: uses_keychain=no" || test_fail "bitwarden: uses_keychain should be no" + ;; + both) + [[ "$uses_kc" == "yes" ]] && test_pass "both: uses_keychain=yes" || test_fail "both: uses_keychain should be yes" + ;; + esac +done + +unset FLOW_SECRET_BACKEND + +# ============================================================================ +# TEST GROUP 5: STATUS COMMAND +# ============================================================================ + +section "5. Status Command" + +# Test: Status output contains backend info +unset FLOW_SECRET_BACKEND +status_output=$(_dot_secret_status 2>/dev/null) +assert_contains "$status_output" "keychain" "Status shows 'keychain' backend" +assert_contains "$status_output" "Backend" "Status has 'Backend' section" +assert_contains "$status_output" "Configuration" "Status has 'Configuration' section" +assert_contains "$status_output" "Keychain" "Status shows Keychain info" + +# Test: Status with bitwarden backend +export FLOW_SECRET_BACKEND="bitwarden" +status_output=$(_dot_secret_status 2>/dev/null) +assert_contains "$status_output" "bitwarden" "Status shows 'bitwarden' when configured" +assert_contains "$status_output" "legacy" "Status mentions 'legacy mode'" +unset FLOW_SECRET_BACKEND + +# ============================================================================ +# TEST GROUP 6: SYNC COMMAND STRUCTURE +# ============================================================================ + +section "6. Sync Command Structure" + +# Test: Sync help exists and is useful +sync_help=$(_dot_secret_sync_help 2>/dev/null) +assert_contains "$sync_help" "sync" "Sync help mentions 'sync'" +assert_contains "$sync_help" "--status" "Sync help mentions '--status'" +assert_contains "$sync_help" "--to-bw" "Sync help mentions '--to-bw'" +assert_contains "$sync_help" "--from-bw" "Sync help mentions '--from-bw'" + +# Test: Sync status runs without error (when BW locked) +unset BW_SESSION +sync_status_output=$(_dot_secret_sync_status 2>/dev/null) +assert_contains "$sync_status_output" "Bitwarden" "Sync status mentions Bitwarden" + +# ============================================================================ +# TEST GROUP 7: HELP TEXT +# ============================================================================ + +section "7. Help Text Updates" + +# Test: Main help includes new commands +help_output=$(_dot_kc_help 2>/dev/null) +assert_contains "$help_output" "status" "Help mentions 'status' command" +assert_contains "$help_output" "sync" "Help mentions 'sync' command" +assert_contains "$help_output" "FLOW_SECRET_BACKEND" "Help mentions FLOW_SECRET_BACKEND" + +# ============================================================================ +# TEST GROUP 8: COMMAND ROUTING +# ============================================================================ + +section "8. Command Routing" + +# Test: dot secret status routes correctly +unset FLOW_SECRET_BACKEND +output=$(zsh -c "source '$PLUGIN_DIR/flow.plugin.zsh' && dot secret status 2>&1" 2>/dev/null | head -5) +assert_contains "$output" "Backend" "dot secret status routes to status function" +assert_not_contains "$output" "Tutorial" "dot secret status does not trigger tutorial" + +# Test: dot secret sync --help routes correctly +output=$(zsh -c "source '$PLUGIN_DIR/flow.plugin.zsh' && dot secret sync --help 2>&1" 2>/dev/null | head -5) +assert_contains "$output" "sync" "dot secret sync --help shows sync help" + +# ============================================================================ +# TEST GROUP 9: FILE STRUCTURE +# ============================================================================ + +section "9. File Structure" + +# Test: Spec file exists +if [[ -f "$PLUGIN_DIR/docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md" ]]; then + test_pass "Spec file exists" +else + test_fail "Spec file should exist" +fi + +# Test: REFCARD updated +refcard_content=$(cat "$PLUGIN_DIR/docs/reference/REFCARD-TOKEN-SECRETS.md" 2>/dev/null) +assert_contains "$refcard_content" "Backend Configuration" "REFCARD has Backend Configuration section" +assert_contains "$refcard_content" "dot secret status" "REFCARD documents status command" +assert_contains "$refcard_content" "dot secret sync" "REFCARD documents sync command" + +# ============================================================================ +# TEST GROUP 10: INTEGRATION SANITY +# ============================================================================ + +section "10. Integration Sanity Checks" + +# Test: Plugin loads without errors +load_output=$(zsh -c "source '$PLUGIN_DIR/flow.plugin.zsh' 2>&1 && echo 'LOAD_OK'" 2>/dev/null) +assert_contains "$load_output" "LOAD_OK" "Plugin loads without fatal errors" + +# Test: dot command exists after load +if zsh -c "source '$PLUGIN_DIR/flow.plugin.zsh' && type dot &>/dev/null" 2>/dev/null; then + test_pass "dot command available after load" +else + test_fail "dot command should be available" +fi + +# Test: _DOT_KEYCHAIN_SERVICE constant defined +if [[ -n "$_DOT_KEYCHAIN_SERVICE" ]]; then + test_pass "Keychain service constant defined: $_DOT_KEYCHAIN_SERVICE" +else + test_fail "Keychain service constant should be defined" +fi + +# ============================================================================ +# SUMMARY +# ============================================================================ + +echo "" +echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}" +echo -e "${BOLD}RESULTS${NC}" +echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}" +echo "" +echo -e " ${GREEN}Passed:${NC} $PASS" +echo -e " ${RED}Failed:${NC} $FAIL" +echo -e " ${YELLOW}Skipped:${NC} $SKIP" +echo -e " ${BOLD}Total:${NC} $TOTAL" +echo "" + +if [[ $FAIL -eq 0 ]]; then + echo -e "${GREEN}All tests passed!${NC}" + log "SUMMARY: All $TOTAL tests passed" + exit 0 +else + echo -e "${RED}$FAIL test(s) failed${NC}" + log "SUMMARY: $FAIL/$TOTAL tests failed" + exit 1 +fi From 0b4da67dae54111337711b6ef11b7aea26e55416 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 24 Jan 2026 23:13:32 -0700 Subject: [PATCH 3/3] docs(spec): mark keychain-default-phase-1 as complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update spec status to Complete with all approval checkboxes marked: - Spec reviewed ✓ - Implementation started ✓ - Tests passing (67 tests) ✓ - Documentation updated ✓ - PR #295 created ✓ Co-Authored-By: Claude Opus 4.5 --- .../SPEC-keychain-default-phase-1-2026-01-24.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md b/docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md index 696b81ae8..47687ff54 100644 --- a/docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md +++ b/docs/specs/SPEC-keychain-default-phase-1-2026-01-24.md @@ -1,7 +1,7 @@ # SPEC: Keychain Default - Phase 1 **Date:** 2026-01-24 -**Status:** In Progress +**Status:** Complete **Branch:** feature/keychain-default-phase-1 **Effort:** 🔧 Medium-High (3-4 hours) **Risk:** Medium (Architectural change to secret storage) @@ -329,11 +329,11 @@ dot secret sync --status # Shows sync status ## Approval -- [ ] Spec reviewed -- [ ] Implementation started -- [ ] Tests passing -- [ ] Documentation updated -- [ ] PR created to dev +- [x] Spec reviewed +- [x] Implementation started +- [x] Tests passing (67 tests: 20 unit + 47 automated) +- [x] Documentation updated +- [x] PR created to dev (PR #295) ---