Compare commits

..

163 Commits

Author SHA1 Message Date
Joe Julian c19bdd48e2 Simplify desktop remote open flow
ci / lint-test (push) Successful in 1m14s
ci / build (push) Successful in 2m42s
2026-04-05 21:34:59 -07:00
Joe Julian e5f42924f8 Simplify desktop unlock flow
ci / lint-test (push) Successful in 1m16s
ci / build (push) Successful in 2m39s
2026-04-05 21:24:36 -07:00
Joe Julian 13eb9028c7 Add Arch package for Linux client
ci / lint-test (push) Successful in 1m21s
ci / build (push) Successful in 2m45s
2026-04-05 20:31:17 -07:00
Joe Julian 5fa79bdf08 Move unlock controls ahead of vault summary
ci / lint-test (push) Successful in 1m14s
ci / build (push) Successful in 2m39s
2026-04-05 18:35:42 -07:00
Joe Julian 1a11944423 Pin Gio for Android rendering
ci / lint-test (push) Successful in 1m47s
ci / build (push) Successful in 3m34s
2026-04-05 16:55:16 -07:00
Joe Julian b0842566c0 Document Android APK test workflow 2026-04-05 16:51:38 -07:00
Joe Julian eb6624cba5 Simplify recent vault open flow and Android local sync
ci / lint-test (push) Successful in 1m46s
ci / build (push) Successful in 3m44s
2026-04-05 16:37:43 -07:00
Joe Julian 37f1a0ef8f Use Gio clipboard commands on Android
ci / lint-test (push) Successful in 1m45s
ci / build (push) Has been cancelled
2026-04-04 09:41:28 -07:00
Joe Julian da4a8d4622 Trigger CI after Traefik TLS fix 2026-04-03 21:41:20 -07:00
Joe Julian 81df59ee15 Use system CA for CI artifact uploads 2026-04-03 20:37:27 -07:00
Joe Julian 69fac41003 Enable cgo for Linux desktop CI builds 2026-04-03 20:31:34 -07:00
Joe Julian 1ee916b863 Limit CI desktop builds to supported targets 2026-04-03 20:27:13 -07:00
Joe Julian 9c41b4814b Use dedicated Android CI runner 2026-04-03 18:04:53 -07:00
Joe Julian 2f4f016fe7 Match Gitea workflow runner labels 2026-04-03 17:23:38 -07:00
Joe Julian 832aa86a34 Add Gitea CI and release pipeline 2026-04-03 16:38:56 -07:00
Joe Julian a4933f2127 Route Android back through Gio settings flow 2026-04-03 11:21:17 -07:00
Joe Julian 6afdf49f5e Add API policy pickers and contextual search labels 2026-04-03 10:55:17 -07:00
Joe Julian 9269c4b488 Remove redundant recycle bin notice 2026-04-03 10:45:47 -07:00
Joe Julian 78313f130d Fix group tools disclosure icon 2026-04-03 10:43:07 -07:00
Joe Julian e75b3eb418 Harden phone entry click state 2026-04-03 10:38:14 -07:00
Joe Julian ae7483e929 Limit group view to direct entries 2026-04-03 08:51:19 -07:00
Joe Julian db0501fca0 Keep parent breadcrumb on phone 2026-04-03 08:35:09 -07:00
Joe Julian 6629ba2483 Snapshot phone entry list during redraw 2026-04-03 08:28:58 -07:00
Joe Julian 3dc0f13af9 Fix phone header spacer 2026-04-03 07:25:25 -07:00
Joe Julian b3db70db91 Autosave Android settings and refine header 2026-04-03 07:23:31 -07:00
Joe Julian e2addfc288 Right-align phone action row 2026-04-03 07:19:26 -07:00
Joe Julian e0fba15246 Align phone header actions right 2026-04-03 07:18:10 -07:00
Joe Julian a8bb5bf6b9 Tighten phone group and detail layout 2026-04-03 07:10:25 -07:00
Joe Julian b76cb7e1df Simplify phone vault screen and settings navigation 2026-04-02 21:50:36 -07:00
Joe Julian 3667fce5f4 Make phone entry pane fully scrollable 2026-04-02 21:38:51 -07:00
Joe Julian c2a4fa4087 Trim verbose group navigation copy 2026-04-02 21:32:07 -07:00
Joe Julian e4ebb2e723 Collapse phone subgroup browser after navigation 2026-04-02 09:41:33 -07:00
Joe Julian 7fc0661b32 Fix Android reopen crash and simplify group navigation 2026-04-02 07:28:55 -07:00
Joe Julian 5b6f7bb1de Fix Android local open flow 2026-04-01 21:42:19 -07:00
Joe Julian b030b69c0d Move remote preference help out of open flow 2026-04-01 19:22:56 -07:00
Joe Julian 25c972263f Integrate autofill privacy settings controls 2026-04-01 18:46:10 -07:00
Joe Julian 818cb2d70e Add accessibility settings preferences 2026-04-01 18:46:10 -07:00
Joe Julian c0afe96f56 Move sync defaults into vault settings 2026-04-01 18:46:10 -07:00
Joe Julian 6cfb5ea567 Stabilize settings persistence tests 2026-04-01 18:46:10 -07:00
Joe Julian ea1cf43cbf Add autofill privacy settings controls 2026-04-01 18:46:10 -07:00
Joe Julian 9a920f9a5a Add accessibility settings preferences 2026-04-01 18:46:10 -07:00
Joe Julian b813c8f221 Add notification feedback settings 2026-04-01 18:46:10 -07:00
Joe Julian 6f2b4d49b3 Add persisted UI settings preferences 2026-04-01 18:46:10 -07:00
Joe Julian 7ee969fb81 Move remote preference help out of open flow 2026-04-01 17:37:06 -07:00
Joe Julian 99a581674d Align audit UI tests with empty-state model 2026-04-01 17:21:16 -07:00
Joe Julian 004a0278a7 Refine audit UI and vault settings placement 2026-04-01 17:21:16 -07:00
Joe Julian 2aa859f7cb Improve local vault open usability 2026-04-01 17:17:37 -07:00
Joe Julian 75fbd340aa Refine entry detail and edit panels 2026-04-01 17:15:58 -07:00
Joe Julian 1a8113d43b Tighten mobile locked vault hierarchy 2026-04-01 17:15:20 -07:00
Joe Julian dafc696053 Improve autofill status feedback 2026-04-01 17:13:43 -07:00
Joe Julian f205e753e8 Refine API token management UI 2026-04-01 17:12:29 -07:00
Joe Julian 3324eec9ec Improve entries navigation readability 2026-04-01 17:12:17 -07:00
Joe Julian 8c339eb309 Refine remote open lifecycle flow 2026-04-01 17:10:26 -07:00
Joe Julian 9ec8ef12b0 Clarify group navigation and management 2026-04-01 17:10:04 -07:00
Joe Julian 468b2dc933 Refine recycle bin and sync UX 2026-04-01 17:09:51 -07:00
Joe Julian a1cbec85da Clarify lifecycle loading and target selection 2026-04-01 17:00:24 -07:00
Joe Julian 7de55d1041 Avoid blocking UI during vault open and unlock 2026-04-01 16:54:16 -07:00
Joe Julian 1468bd5c5a Improve recent targets and workflow context 2026-04-01 16:43:15 -07:00
Joe Julian a2a7cb431d Refine group, editor, and admin workflows 2026-04-01 16:34:24 -07:00
Joe Julian baf3f27656 Clarify start, locked, and recycle flows 2026-04-01 16:29:53 -07:00
Joe Julian 72e67039cf Tighten navigation and admin UI 2026-04-01 16:24:11 -07:00
Joe Julian b8b4ab9e1d Refine settings and empty-state affordances 2026-04-01 15:00:46 -07:00
Joe Julian 84fea5e5d7 Simplify lifecycle and section UI 2026-04-01 14:58:56 -07:00
Joe Julian 73ff0fb77d Add UI review follow-ups to TODO 2026-04-01 14:22:53 -07:00
Joe Julian bd99b928d9 Honor alternate autofill targets from entry fields 2026-04-01 10:36:08 -07:00
Joe Julian 06ea95f0b3 Fix entry and group navigation workflows 2026-04-01 10:24:57 -07:00
Joe Julian b0721e5af1 Support Android app targets in autofill service 2026-04-01 05:56:52 -07:00
Joe Julian d9d1cf134d Disambiguate same-host Android fill matches 2026-04-01 05:21:15 -07:00
Joe Julian cc7214b880 Add Android accessibility-based Chrome form fill 2026-04-01 01:24:28 -07:00
Joe Julian 4cad54a696 Add Android autofill service packaging 2026-03-31 23:13:10 -07:00
Joe Julian 8499b4304e Tighten Android lifecycle screen layout 2026-03-31 22:53:47 -07:00
Joe Julian eb73cab155 Add KeePassGO branding assets 2026-03-31 22:34:36 -07:00
Joe Julian beefd51b15 Fix Android rendering by pinning Gio v0.8 2026-03-31 22:23:45 -07:00
Joe Julian 0ab444b0b9 Align Android build guidance with Gio 2026-03-31 21:37:19 -07:00
Joe Julian 867aae5ffe Harden API token policy button state 2026-03-31 20:23:59 -07:00
Joe Julian ff7f84c386 Test Android packaging configuration 2026-03-31 20:15:48 -07:00
Joe Julian 2000c27324 Isolate test state and paint full frame 2026-03-31 20:12:54 -07:00
Joe Julian 182b469b0b Fix API token detail panel panic 2026-03-30 14:46:07 -07:00
Joe Julian 27fdd77aa1 Restore entries tab state and preload recent vault 2026-03-30 14:31:06 -07:00
Joe Julian 88151dc780 Fix hidden root section restore 2026-03-30 09:24:41 -07:00
Joe Julian b2c26622e8 Add configurable vault security settings 2026-03-30 07:58:27 -07:00
Joe Julian 84f39b99de Host the gRPC API and add token admin views 2026-03-30 07:50:34 -07:00
Joe Julian fca121f170 Fix Android startup defaults and window sizing 2026-03-30 07:30:06 -07:00
Joe Julian 794eed04d4 Record audit events for API token authorization 2026-03-29 23:19:54 -07:00
Joe Julian e9eca73336 Add token management helpers for API credentials 2026-03-29 23:17:34 -07:00
Joe Julian 74dfe3f3d0 Expose API approval prompts to app state and UI 2026-03-29 23:14:39 -07:00
Joe Julian f77a185e46 Add API approval broker for gRPC authorization prompts 2026-03-29 23:09:36 -07:00
Joe Julian 6a7594e128 Authorize gRPC requests with vault API tokens 2026-03-29 22:56:18 -07:00
Joe Julian 666252f18c Add API token domain model and policy evaluation 2026-03-29 22:46:44 -07:00
Joe Julian f87b3f1989 Add API token authorization TODO segments 2026-03-29 22:38:09 -07:00
Joe Julian caf4ec6266 Preserve single-root KDBX group trees 2026-03-29 22:17:40 -07:00
Joe Julian 47d0ccc7ce Set up Android SDK prerequisites for APK builds 2026-03-29 21:59:52 -07:00
Joe Julian 96ec583c7e Add advanced synchronize dialog and APK tooling 2026-03-29 21:38:46 -07:00
Joe Julian 6e2760c514 Preserve remote conflicts in entry history 2026-03-29 21:19:42 -07:00
Joe Julian 59905ccd98 Tighten banner and navigation behavior 2026-03-29 21:16:38 -07:00
Joe Julian 62bb20edb0 Keep recent remote passwords masked 2026-03-29 21:07:57 -07:00
Joe Julian 4e082c345a Scroll lifecycle screen and restore remote groups 2026-03-29 17:46:48 -07:00
Joe Julian 5bcd951e6a Collapse empty phone detail pane 2026-03-29 17:41:51 -07:00
Joe Julian c0747b62a0 Rebalance desktop open-vault layout 2026-03-29 17:36:41 -07:00
Joe Julian 3066442c1b Reset password peek across view changes 2026-03-29 17:27:19 -07:00
Joe Julian 4a3e52ec1c Remember recent remote vault connections 2026-03-29 17:05:42 -07:00
Joe Julian daecb5ff32 Tighten desktop vault navigation header 2026-03-29 16:58:21 -07:00
Joe Julian 99113aa26b Honor env state dir in default UI paths 2026-03-29 16:54:20 -07:00
Joe Julian 4b78a649b1 Simplify locked vault screen 2026-03-29 16:47:29 -07:00
Joe Julian 2e9e2aae5f Add collapsible group tools section 2026-03-29 16:39:27 -07:00
Joe Julian db06fcd385 Use structured custom fields in entry editor 2026-03-29 16:37:20 -07:00
Joe Julian d7026d5c0e Restore per-vault groups and scroll edit forms 2026-03-29 16:32:54 -07:00
Joe Julian 20d5b3ba3d Require isolated state dir for test runs 2026-03-29 16:26:21 -07:00
Joe Julian 1c4fb59960 Improve group deletion and status banner UX 2026-03-29 16:17:22 -07:00
Joe Julian fe3fa854bb Support KeePassGO state dir via flag and env 2026-03-29 15:41:44 -07:00
Joe Julian 37b83e654a Clear KeePassGO master password after use 2026-03-29 15:38:30 -07:00
Joe Julian 761fae9b9b Fix KeePassGO open screen field and banner UX 2026-03-29 15:35:32 -07:00
Joe Julian fc9580403d Default Enter to opening the selected vault 2026-03-29 15:33:41 -07:00
Joe Julian 92e8fce0e7 Persist recent KeePassGO vault list 2026-03-29 15:28:20 -07:00
Joe Julian cd21cc26c9 Improve KeePassGO vault navigation UX 2026-03-29 15:18:14 -07:00
Joe Julian fa75908552 Simplify KeePassGO desktop vault workflow 2026-03-29 14:50:33 -07:00
Joe Julian 7e2e396264 Refine lifecycle setup screen 2026-03-29 14:00:20 -07:00
Joe Julian 01e06deeef Document remaining AGENTS gaps 2026-03-29 13:53:27 -07:00
Joe Julian 97f6a34472 Update lifecycle tests for controller state 2026-03-29 13:46:30 -07:00
Joe Julian fc8630f894 Restore search path context helper 2026-03-29 13:46:30 -07:00
Joe Julian ef0fb7f5d9 Align keyboard and tests with controller state 2026-03-29 13:46:30 -07:00
Joe Julian 3d0a6bc43a Remove duplicate app state group deletion 2026-03-29 13:46:30 -07:00
Joe Julian e693aab13b Merge branch 'seg01-appstate' into merge-main-01-seg01-appstate 2026-03-29 13:46:30 -07:00
Joe Julian a2f1539027 Centralize app state ownership in controller 2026-03-29 13:46:30 -07:00
Joe Julian 508fe7abc1 Fix rebased main path tests 2026-03-29 13:44:05 -07:00
Joe Julian ed1e2c3e6b Add password generation UI profile workflow 2026-03-29 13:44:05 -07:00
Joe Julian 2b535a90e4 Add local vault lifecycle UI coverage 2026-03-29 13:43:13 -07:00
Joe Julian 3f29fae12f Drop obsolete UI path fallback after rebase 2026-03-29 13:41:44 -07:00
Joe Julian c050cb3e40 Sync UI path fallbacks during main landing 2026-03-29 13:40:57 -07:00
Joe Julian cfec0c6703 Align moved-entry test with controller navigation 2026-03-29 13:40:36 -07:00
Joe Julian 00717f32ce Add attachment replace workflow UI 2026-03-29 13:40:36 -07:00
Joe Julian fc90e91174 test: align rebased path coverage with controller state 2026-03-29 13:37:57 -07:00
Joe Julian c248b89fcd test: align keyboard navigation with controller paths 2026-03-29 13:37:00 -07:00
Joe Julian 73992a95cc test: align template UI coverage with controller navigation 2026-03-29 13:37:00 -07:00
Joe Julian ba945b5527 test: cover template CRUD UI flows 2026-03-29 13:37:00 -07:00
Joe Julian 43fc278fd1 Make group navigation controller-driven 2026-03-29 13:37:00 -07:00
Joe Julian 484448b51c Merge commit '7651b3b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:36:26 -07:00
Joe Julian a708e17b50 Merge commit 'cffe05a' into merge-main-13-seg13-copy-reveal
# Conflicts:
#	main.go
2026-03-29 13:35:59 -07:00
Joe Julian 17ff374be7 Resolve remote lifecycle landing conflicts 2026-03-29 13:35:18 -07:00
Joe Julian d05830335f Add WebDAV lifecycle UI feedback 2026-03-29 13:35:18 -07:00
Joe Julian 4848b09be1 Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:35:12 -07:00
Joe Julian 5a458c0c8c Add entry history browsing to UI 2026-03-29 13:35:12 -07:00
Joe Julian beda174819 Add keyboard-first accessibility behavior 2026-03-29 13:35:12 -07:00
Joe Julian 4e67328a09 Merge branch 'main' into merge-main-20-seg20-regression 2026-03-29 13:34:26 -07:00
Joe Julian c5670a9a36 Add secret-safe copy and reveal UX coverage 2026-03-29 13:34:25 -07:00
Joe Julian a14101e415 Remove dead deleted-object helper 2026-03-29 13:34:12 -07:00
Joe Julian c5e2df4ca7 Add regression coverage for KDBX reopen cycles 2026-03-29 13:34:12 -07:00
Joe Julian 43d253aa21 Add Segment 6 entry editor coverage 2026-03-29 13:34:12 -07:00
Joe Julian 6c5e9b42d3 Add gRPC vault lifecycle backend flow 2026-03-29 13:34:12 -07:00
Joe Julian 5a6ba8ff57 Add gRPC group deletion and custom entry fields 2026-03-29 13:33:52 -07:00
Joe Julian 6c1ccdad16 Complete search section behavior 2026-03-29 13:33:52 -07:00
Joe Julian 6748d31f87 Add keyboard-first accessibility behavior 2026-03-29 13:33:52 -07:00
Joe Julian 40fd1bfde9 Remove dead deleted-object helper 2026-03-29 13:33:30 -07:00
Joe Julian 57f6ae658d Add regression coverage for KDBX reopen cycles 2026-03-29 13:33:30 -07:00
Joe Julian a857f972ea Add Segment 6 entry editor coverage 2026-03-29 13:33:30 -07:00
Joe Julian f009573b4a Add gRPC vault lifecycle backend flow 2026-03-29 13:33:30 -07:00
Joe Julian 4b4696ce30 Merge branch 'seg09-search' into merge-main-09-seg09-search 2026-03-29 13:33:07 -07:00
Joe Julian afa6c8821f Add keyboard-first accessibility behavior 2026-03-29 13:33:07 -07:00
Joe Julian afe9680d7a Complete search section behavior 2026-03-29 13:33:07 -07:00
Joe Julian 422de535af Complete UI state and error surfaces 2026-03-29 13:32:28 -07:00
Joe Julian 44bba18149 Add UI master key setup and change flows 2026-03-29 11:23:54 -07:00
Joe Julian e15cfb1535 Break TODO into parallel work segments 2026-03-29 11:10:12 -07:00
Joe Julian a2a8fcbd14 Reconstruct KeePassGO repository 2026-03-29 11:04:38 -07:00
43 changed files with 10058 additions and 1587 deletions
+116
View File
@@ -0,0 +1,116 @@
---
name: keepassgo-apk-test
description: Build and run the KeePassGO Android APK for emulator testing. Use when work requires `make apk`, APK install/launch, emulator validation, or checking known KeePassGO Android regressions such as black screens, clipboard, open flow, or local sync behavior.
---
# KeePassGO APK Test
Use this skill together with the installed `android-emulator-debug` skill. That skill covers generic emulator and `adb` mechanics. This skill adds the KeePassGO-specific build, install, and validation requirements that have already been established in this repo.
## Use This Skill When
- Building `build/keepassgo.apk`.
- Installing or launching KeePassGO in the Android emulator.
- Verifying Android regressions such as black screen, clipboard, open flow, file picker, or Advanced Sync behavior.
- Checking whether a Gio or Android toolchain change broke runtime behavior.
## Known Working Environment
- Preferred emulator AVD: `KeepassGoAPI35`
- Package: `org.julianfamily.keepassgo`
- Activity: `org.gioui.GioActivity`
- Local APK build defaults:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-ndk`
`JAVA_HOME=/usr/lib/jvm/java-25-openjdk`
- CI APK build uses:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-sdk/ndk`
`JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`
## Known Regression
- `gioui.org v0.9.0` caused a black screen in the `KeepassGoAPI35` emulator.
- `gioui.org v0.8.0` rendered correctly in the same environment.
- If Android rendering regresses after a Gio change, treat Gio as a primary suspect and verify against this known-bad versus known-good history before guessing about app logic.
## Safety Rules
- Do not clobber the users real KeePassGO state while testing.
- For host-side validation, use isolated state such as:
`KEEPASSGO_STATE_DIR="$(mktemp -d)" go test ./...`
- Prefer sanitized demo or test vaults when seeding emulator tests.
- If a real vault is absolutely required to reproduce a problem, do not modify it and do not overwrite the users existing recent-vault state.
## Build Workflow
1. Verify the JDK/SDK paths match the known working environment.
2. Build with `make apk`.
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
Typical local build:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
## Emulator Workflow
1. Reuse an existing emulator session if one is already running.
2. Prefer the `KeepassGoAPI35` AVD.
3. Install with `adb install -r build/keepassgo.apk`.
4. Launch with:
```sh
adb shell monkey -p org.julianfamily.keepassgo -c android.intent.category.LAUNCHER 1
```
5. Confirm focus before drawing conclusions:
```sh
adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
```
6. Capture evidence before editing code:
screenshot
focused window
relevant `logcat`
## Validation Checklist
- APK builds successfully with `make apk`.
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
- Screenshot shows the expected screen, not just a black frame.
- `logcat` shows no app crash or Android runtime fatal error.
- If the change is about user interaction, perform the tap-through in the emulator instead of stopping at install success.
## Issue-Specific Checks
### Black Screen
- Confirm the app is focused.
- Capture a screenshot and `logcat` before changing anything.
- Compare the current Gio version against the known `v0.9.0` regression history.
- If needed, test whether a minimal Gio example renders in the same emulator before blaming KeePassGO layout code.
### Clipboard
- Verify behavior in the emulator, not just unit tests.
- Android clipboard writes must use the Gio-native command path, not desktop clipboard backends.
### File Picker Or Local Sync
- Verify the picker flow on Android itself.
- Prefer document-stream or content-URI based behavior over raw filesystem paths when Android permissions are involved.
## Report Back
When closing out work, state:
- the exact build command used
- whether the emulator session was reused or newly started
- whether install succeeded
- whether the app actually rendered
- what was verified manually in the emulator
- what remains unverified
+173
View File
@@ -0,0 +1,173 @@
name: ci
on:
push:
branches:
- main
tags:
- "v*"
- "release-*"
- "[0-9]+.[0-9]+.[0-9]+*"
permissions:
contents: write
env:
GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
DIST_DIR: dist
jobs:
lint-test:
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Lint
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --build-tags nox11,nowayland,novulkan ./...
- name: Test
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
build:
needs: lint-test
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Prepare dist directory
shell: bash
run: |
set -euo pipefail
mkdir -p "${DIST_DIR}"
- name: Build desktop binaries
shell: bash
run: |
set -euo pipefail
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
# Keep the CI matrix to targets this runner can build reproducibly.
for target in \
"linux amd64" \
"windows amd64" \
"windows arm64"
do
set -- ${target}
goos="$1"
goarch="$2"
ext=""
cgo_enabled=0
if [[ "${goos}" == "linux" ]]; then
cgo_enabled=1
fi
if [[ "${goos}" == "windows" ]]; then
ext=".exe"
fi
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" go build -o "${out}" .
done
- name: Build APK
shell: bash
run: |
set -euo pipefail
make apk
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
- name: Upload CI artifacts
uses: christopherhx/gitea-upload-artifact@v4
env:
NODE_OPTIONS: --use-system-ca
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
with:
name: keepassgo-${{ gitea.sha }}
path: |
dist/keepassgo-linux-amd64
dist/keepassgo-windows-amd64.exe
dist/keepassgo-windows-arm64.exe
dist/keepassgo.apk
retention-days: 30
- name: Publish release artifacts
if: startsWith(gitea.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
shell: bash
run: |
set -euo pipefail
python3 scripts/gitea_release.py \
--server "${GITEA_SERVER_URL}" \
--repo "${GITEA_REPOSITORY}" \
--token "${GITEA_TOKEN}" \
--tag "${GITEA_REF_NAME}" \
"${DIST_DIR}/keepassgo-linux-amd64" \
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
"${DIST_DIR}/keepassgo.apk"
+16
View File
@@ -14,6 +14,8 @@ These instructions apply to all future work in this repository.
## Skills ## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work. - Use the installed Go skills whenever they materially apply to the current slice of work.
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
- The available Go skills for this repository are: - The available Go skills for this repository are:
`go-code-review`, `go-code-review`,
`go-concurrency`, `go-concurrency`,
@@ -126,6 +128,20 @@ These features are product requirements, not “nice to have” ideas.
- Keep `golangci-lint` passing. - Keep `golangci-lint` passing.
- Keep `go test ./...` passing. - Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging. - Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- Keep the Android build requirements aligned with the known working setup:
`ANDROID_SDK_ROOT=/opt/android-sdk`,
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
AVD `KeepassGoAPI35`,
package `org.julianfamily.keepassgo`,
activity `org.gioui.GioActivity`.
- Do not run emulator/manual APK tests against the users real persisted app state.
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the users real vault files whenever possible.
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config. - When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config.
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory. - Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository. - Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
+18
View File
@@ -41,6 +41,24 @@ Desktop build:
go build ./... go build ./...
``` ```
## Arch Linux Package
An AUR-style package definition for the Linux desktop client lives under:
- `packaging/archlinux/keepassgo-git/`
From that directory you can build and install it with:
```bash
makepkg -si
```
The package installs:
- `/usr/bin/keepassgo`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
## Android Packaging ## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`. KeePassGO uses Gio, so Android packaging is done with `gogio`.
+111
View File
@@ -6,6 +6,102 @@ These segments are intended to be independently executable wherever possible.
Each segment has its own local exit criteria. Each segment has its own local exit criteria.
The product is not complete until the global exit criteria at the end of this file are also met. The product is not complete until the global exit criteria at the end of this file are also met.
## UI Review Follow-Ups
These items came from a hands-on emulator and desktop walkthrough.
They should be treated as usability work, not just polish.
### Primary Workflow Changes
These should remain in the main user flow rather than being hidden behind a settings gear.
- Local open flow:
make the start screen primarily about opening a vault, not configuring one.
- Local open flow:
keep recent vault selection visually obvious and clearly tappable.
- Local open flow:
once a recent vault is preselected, collapse the full path into a compact summary with a `Change...` affordance.
- Local open flow:
improve Android field focus and IME behavior so the master-password field reliably takes focus and summons the keyboard.
- Local open flow:
show an explicit progress state and allow cancel or retry while opening a vault, especially on Android.
- Remote open flow:
break the remote form into clearer sections such as `Location` and `Authentication`.
- Remote open flow:
make recent remote connections easier to scan with a friendlier label than raw URL and path.
- Locked screen:
show clear vault identity and target summary so the user knows what is being unlocked.
- Entries screen:
tighten the top strip on phone so tabs, breadcrumbs, and group controls do not fight for the same row.
- Entries screen:
make breadcrumbs compress more aggressively on phone.
- Entries screen:
improve entry-row hierarchy and selected-state contrast.
- Entries screen:
provide section-specific empty states for search, recycle bin, API tokens, and empty groups.
- Group navigation:
make the distinction between root, current group, and child groups more obvious.
- Group navigation:
separate navigation controls from group-management controls more clearly.
- Entry detail:
tighten field spacing and reduce unnecessary whitespace.
- Entry detail:
group password reveal and copy actions more clearly.
- Entry detail:
make attachments more visible and actionable.
- Entry edit:
break the editor into clearer subsections such as `Basics`, `Notes`, `Custom Fields`, `History`, and `Attachments`.
- Entry edit:
make add/remove affordances for custom fields more visually obvious.
- Entry edit:
make generated-password draft state more explicit before save.
- Recycle bin:
make it visually distinct from normal entry browsing.
- API tokens:
give token list, token detail, and policy editing a clearer dedicated management surface.
- API tokens:
make policy rows easier to scan by separating effect, operation, and resource visually.
- API audit:
improve empty-state guidance and provide quick filtering by token, decision, and operation.
- Synchronize:
keep the split-button pattern, but reduce the visual weight of the sync controls and make advanced sync affordances clearer.
- Synchronize:
avoid layout-shifting success banners and keep noncritical notifications ephemeral.
- Phone layout:
continue reducing header and control density so content appears sooner.
- Mobile reliability:
fix Android local-open ANR behavior before deeper mobile polish.
- Autofill UX:
surface whether a fill candidate was found, ambiguous, blocked, or awaiting approval.
### Settings Gear Candidates
These are important, but they should likely move behind a dedicated settings gear or advanced/settings surface instead of occupying first-run or day-to-day credential screens.
- Vault security:
move `Cipher` and `KDF` off the main local-open screen and into `Advanced` or `Vault Settings`.
- Vault security:
frame security settings as vault configuration rather than freeform text fields in the primary workflow.
- Remote preferences:
move remembered-auth behavior details and retention policy explanations into settings/help rather than the main open flow.
- UI preferences:
save and expose view preferences such as group-tools collapse state and any future dense/comfortable layout toggle under settings.
- Autofill behavior:
app and browser allowlists, package rules, and first-fill approval preferences should live under a settings/privacy area.
- Sync defaults:
source/direction defaults, conflict preferences, and any future background sync behavior should live under settings.
- Notification preferences:
banner timeout, ephemeral notices, and other noncritical UI feedback tuning should live under settings.
- Accessibility preferences:
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
### Exit Criteria
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
- Advanced vault/security and behavior preferences are no longer cluttering the primary open and browsing flows.
- Phone and desktop layouts both present a clear information hierarchy.
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
## API Token And gRPC Authorization Parallel Segments ## API Token And gRPC Authorization Parallel Segments
These segments define the work for programmatic access control over gRPC. These segments define the work for programmatic access control over gRPC.
@@ -449,6 +545,21 @@ Exit criteria:
- Focus and accessibility states are visible and intentional. - Focus and accessibility states are visible and intentional.
- `go test ./...` passes. - `go test ./...` passes.
### Segment 21: Accessibility Fill Generalization
Scope:
- Extend Android accessibility-based fill beyond the current Chrome demo path.
- Support package-specific rules so apps with stable package identities can have tailored matching behavior.
- Support view-id matching so custom login forms can be identified more reliably than by generic hints alone.
- Support app allowlists so only approved apps/packages are eligible for accessibility-based credential fill.
- Require an approval step before filling into a newly seen app/package unless the user has already made a persistent decision.
Exit criteria:
- The design for package-specific rules, view-id matching, app allowlists, and first-fill approval is implemented or broken into executable sub-slices.
- Accessibility fill no longer depends solely on Chrome-style generic username/password heuristics.
- New app/package fill attempts can be allowed, denied, or made persistent by the user.
- `go test ./...` passes.
### Segment 17: UI Completion And Error Surfaces ### Segment 17: UI Completion And Error Surfaces
Scope: Scope:
Binary file not shown.
@@ -35,20 +35,26 @@ final class AutofillCacheStore {
if (entries.isEmpty()) { if (entries.isEmpty()) {
return null; return null;
} }
String normalizedDomain = normalizeHost(webDomain); NormalizedTarget target = normalizeURL(webDomain);
if (normalizedDomain.isEmpty()) { if (target.host.isEmpty()) {
return entries.get(0); return null;
} }
Entry fallback = null; List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) { for (Entry entry : entries) {
if (entry.host.equals(normalizedDomain)) { if (entryMatchesHost(entry, target.host)) {
return entry; exactHost.add(entry);
continue;
} }
if (fallback == null && normalizedDomain.endsWith("." + entry.host)) { if (entryMatchesParentHost(entry, target.host)) {
fallback = entry; parentHost.add(entry);
} }
} }
return fallback; Entry matched = chooseEntry(target, exactHost);
if (matched != null) {
return matched;
}
return chooseEntry(target, parentHost);
} }
private static File findCacheFile(Context context) { private static File findCacheFile(Context context) {
@@ -101,6 +107,8 @@ final class AutofillCacheStore {
String username = ""; String username = "";
String password = ""; String password = "";
String host = ""; String host = "";
String url = "";
List<String> targets = new ArrayList<>();
reader.beginObject(); reader.beginObject();
while (reader.hasNext()) { while (reader.hasNext()) {
String name = reader.nextName(); String name = reader.nextName();
@@ -114,16 +122,26 @@ final class AutofillCacheStore {
case "password": case "password":
password = nextString(reader); password = nextString(reader);
break; break;
case "url":
url = nextString(reader);
break;
case "host": case "host":
host = normalizeHost(nextString(reader)); host = normalizeHost(nextString(reader));
break; break;
case "targets":
reader.beginArray();
while (reader.hasNext()) {
targets.add(nextString(reader));
}
reader.endArray();
break;
default: default:
reader.skipValue(); reader.skipValue();
break; break;
} }
} }
reader.endObject(); reader.endObject();
return new Entry(title, username, password, host); return new Entry(title, username, password, host, url, targets);
} }
private static String nextString(JsonReader reader) throws IOException { private static String nextString(JsonReader reader) throws IOException {
@@ -135,8 +153,12 @@ final class AutofillCacheStore {
} }
private static String normalizeHost(String raw) { private static String normalizeHost(String raw) {
return normalizeURL(raw).host;
}
private static NormalizedTarget normalizeURL(String raw) {
if (raw == null) { if (raw == null) {
return ""; return new NormalizedTarget("", "", "");
} }
String value = raw.trim().toLowerCase(Locale.US); String value = raw.trim().toLowerCase(Locale.US);
if (value.startsWith("http://")) { if (value.startsWith("http://")) {
@@ -152,20 +174,161 @@ final class AutofillCacheStore {
if (colon >= 0) { if (colon >= 0) {
value = value.substring(0, colon); value = value.substring(0, colon);
} }
String host = value;
String path = "/";
int schemeSep = raw.indexOf("://");
String original = raw.trim();
if (schemeSep < 0) {
original = "https://" + original;
}
try {
java.net.URI uri = java.net.URI.create(original);
if (uri.getHost() != null) {
host = uri.getHost().toLowerCase(Locale.US);
}
path = cleanPath(uri.getPath());
} catch (IllegalArgumentException ignored) {
path = "/";
}
return new NormalizedTarget(host, path, host + path);
}
private static String cleanPath(String raw) {
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
return "/";
}
String value = raw.trim();
while (value.endsWith("/") && value.length() > 1) {
value = value.substring(0, value.length() - 1);
}
if (!value.startsWith("/")) {
value = "/" + value;
}
return value; return value;
} }
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
if (entries.isEmpty()) {
return null;
}
if (entries.size() == 1) {
return entries.get(0);
}
List<Entry> exact = new ArrayList<>();
List<Entry> prefix = new ArrayList<>();
int bestPrefixLen = -1;
for (Entry entry : entries) {
MatchQuality quality = bestTargetMatch(entry, target);
if (quality.exact) {
exact.add(entry);
continue;
}
if (quality.prefixLength <= 0) {
continue;
}
if (quality.prefixLength > bestPrefixLen) {
prefix.clear();
prefix.add(entry);
bestPrefixLen = quality.prefixLength;
} else if (quality.prefixLength == bestPrefixLen) {
prefix.add(entry);
}
}
if (exact.size() == 1) {
return exact.get(0);
}
if (exact.size() > 1 || prefix.isEmpty()) {
return null;
}
return prefix.size() == 1 ? prefix.get(0) : null;
}
private static boolean entryMatchesHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (target.host.equals(host)) {
return true;
}
}
return false;
}
private static boolean entryMatchesParentHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
return true;
}
}
return false;
}
private static List<NormalizedTarget> entryTargets(Entry entry) {
List<String> rawTargets = entry.targets;
if (rawTargets.isEmpty()) {
rawTargets = new ArrayList<>();
rawTargets.add(entry.url);
}
List<NormalizedTarget> targets = new ArrayList<>();
for (String rawTarget : rawTargets) {
NormalizedTarget target = normalizeURL(rawTarget);
if (!target.host.isEmpty()) {
targets.add(target);
}
}
return targets;
}
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
int bestPrefixLen = -1;
for (NormalizedTarget entryTarget : entryTargets(entry)) {
if (entryTarget.url.equals(target.url)) {
return new MatchQuality(true, 0);
}
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
}
}
return new MatchQuality(false, bestPrefixLen);
}
static final class Entry { static final class Entry {
final String title; final String title;
final String username; final String username;
final String password; final String password;
final String host; final String host;
final String url;
final List<String> targets;
Entry(String title, String username, String password, String host) { Entry(String title, String username, String password, String host, String url, List<String> targets) {
this.title = title; this.title = title;
this.username = username; this.username = username;
this.password = password; this.password = password;
this.host = host; this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
}
}
private static final class MatchQuality {
final boolean exact;
final int prefixLength;
MatchQuality(boolean exact, int prefixLength) {
this.exact = exact;
this.prefixLength = prefixLength;
}
}
private static final class NormalizedTarget {
final String host;
final String path;
final String url;
NormalizedTarget(String host, String path, String url) {
this.host = host;
this.path = path;
this.url = url;
} }
} }
} }
@@ -21,6 +21,7 @@ import java.util.List;
public final class KeePassGOAutofillService extends AutofillService { public final class KeePassGOAutofillService extends AutofillService {
private static final String TAG = "KeePassGOAutofill"; private static final String TAG = "KeePassGOAutofill";
private static final String APP_SCHEME = "androidapp://";
@Override @Override
public void onConnected() { public void onConnected() {
@@ -51,15 +52,21 @@ public final class KeePassGOAutofillService extends AutofillService {
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure(); AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
ParsedFields fields = new ParsedFields(); ParsedFields fields = new ParsedFields();
String webDomain = parseWindow(structure, fields); ParsedTarget target = parseWindow(structure, fields);
Log.i(TAG, "parsed domain=" + webDomain + " usernameId=" + fields.usernameId + " passwordId=" + fields.passwordId); Log.i(
TAG,
"parsed target=" + target.matchTarget
+ " package=" + target.packageName
+ " usernameId=" + fields.usernameId
+ " passwordId=" + fields.passwordId
);
if (fields.passwordId == null) { if (fields.passwordId == null) {
Log.i(TAG, "no password field found"); Log.i(TAG, "no password field found");
callback.onSuccess(null); callback.onSuccess(null);
return; return;
} }
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, webDomain); AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
if (entry == null) { if (entry == null) {
Log.i(TAG, "no autofill cache match"); Log.i(TAG, "no autofill cache match");
callback.onSuccess(null); callback.onSuccess(null);
@@ -96,7 +103,7 @@ public final class KeePassGOAutofillService extends AutofillService {
callback.onSuccess(); callback.onSuccess();
} }
private static String parseWindow(AssistStructure structure, ParsedFields fields) { private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
String domain = ""; String domain = "";
final int windowCount = structure.getWindowNodeCount(); final int windowCount = structure.getWindowNodeCount();
for (int i = 0; i < windowCount; i++) { for (int i = 0; i < windowCount; i++) {
@@ -106,7 +113,17 @@ public final class KeePassGOAutofillService extends AutofillService {
domain = next; domain = next;
} }
} }
return domain; String packageName = "";
if (structure.getActivityComponent() != null) {
packageName = structure.getActivityComponent().getPackageName();
}
if (!domain.isEmpty()) {
return new ParsedTarget(domain, packageName);
}
if (packageName != null && !packageName.isEmpty()) {
return new ParsedTarget(APP_SCHEME + packageName, packageName);
}
return new ParsedTarget("", "");
} }
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) { private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
@@ -185,4 +202,14 @@ public final class KeePassGOAutofillService extends AutofillService {
AutofillId usernameId; AutofillId usernameId;
AutofillId passwordId; AutofillId passwordId;
} }
private static final class ParsedTarget {
final String matchTarget;
final String packageName;
ParsedTarget(String matchTarget, String packageName) {
this.matchTarget = matchTarget;
this.packageName = packageName;
}
}
} }
+1 -1
View File
@@ -20,7 +20,7 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
if err := lifecycle.Create(vault.Model{ if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t), testAPITokenEntry(t),
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { }, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err) t.Fatalf("Create() error = %v", err)
+69 -69
View File
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
token.Entry([]string{"Root", "API Tokens"}), token.Entry([]string{"Root", "API Tokens"}),
@@ -78,22 +78,22 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
), ),
}, },
}) })
defer cleanup() defer cleanup()
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"}) _, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
if status.Code(err) != codes.PermissionDenied { if status.Code(err) != codes.PermissionDenied {
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied) t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
} }
@@ -105,11 +105,11 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t), testAPITokenEntry(t),
@@ -139,8 +139,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
if err := <-errCh; err != nil { if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err) t.Fatalf("ListEntries() error = %v", err)
} }
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" { if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries) t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
} }
} }
@@ -150,11 +150,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t), testAPITokenEntry(t),
@@ -196,7 +196,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -225,7 +225,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -244,7 +244,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -282,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -454,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{ if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com", BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx", Path: "vaults/main.kdbx",
Username: "jjulian", Username: "rustyryan",
Password: "dav-token", Password: "dav-token",
MasterPassword: "correct horse battery staple", MasterPassword: "correct horse battery staple",
}); err != nil { }); err != nil {
@@ -618,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries)) t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
} }
if resp.Entries[0].Title != "Git Server" { if resp.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server") t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
} }
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" { if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation") t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
@@ -747,7 +747,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{ if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "git-server", Id: "vault-console",
Target: "password", Target: "password",
}); err != nil { }); err != nil {
t.Fatalf("CopyEntryField() error = %v", err) t.Fatalf("CopyEntryField() error = %v", err)
@@ -767,11 +767,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{ upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{ Entry: &keepassgov1.Entry{
Id: "ha-codex", Id: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
Url: "https://lights.julianfamily.org", Url: "https://surveillance.crew.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"X-Role": "lights-admin", "X-Role": "lights-admin",
}, },
@@ -782,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("UpsertEntry() error = %v", err) t.Fatalf("UpsertEntry() error = %v", err)
} }
if upserted.Entry.Title != "Home Assistant (Codex)" { if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)") t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
} }
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" { if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin") t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
@@ -809,7 +809,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
defer cleanup() defer cleanup()
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil { if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err) t.Fatalf("DeleteEntry() error = %v", err)
} }
@@ -822,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries)) t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
} }
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"}) restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
if restored.Entry.Title != "Git Server" { if restored.Entry.Title != "Vault Console" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server") t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
} }
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
@@ -836,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("ListEntries() error = %v", err) t.Fatalf("ListEntries() error = %v", err)
} }
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" { if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries) t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
} }
} }
@@ -863,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{ instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login", TemplateId: "website-login",
Overrides: &keepassgov1.Entry{ Overrides: &keepassgov1.Entry{
Id: "dynadot", Id: "bellagio",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "hunter2", Password: "hunter2",
Url: "https://www.dynadot.com", Url: "https://bellagio.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"Environment": "staging", "Environment": "staging",
}, },
@@ -879,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
t.Fatalf("InstantiateTemplate() error = %v", err) t.Fatalf("InstantiateTemplate() error = %v", err)
} }
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." { if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry) t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
} }
if got := instantiated.Entry.Fields["Environment"]; got != "staging" { if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging") t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
@@ -956,7 +956,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
defer cleanup() defer cleanup()
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"}) history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err) t.Fatalf("ListEntryHistory() error = %v", err)
} }
@@ -965,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
} }
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{ restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "git-server", Id: "vault-console",
HistoryIndex: 0, HistoryIndex: 0,
}) })
if err != nil { if err != nil {
@@ -986,14 +986,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
uploaded := []byte("attachment-content") uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{ if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "git-server", EntryId: "vault-console",
Name: "token.txt", Name: "token.txt",
Content: uploaded, Content: uploaded,
}); err != nil { }); err != nil {
t.Fatalf("UploadAttachment() error = %v", err) t.Fatalf("UploadAttachment() error = %v", err)
} }
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"}) listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("ListAttachments() error = %v", err) t.Fatalf("ListAttachments() error = %v", err)
} }
@@ -1002,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
} }
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{ downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "git-server", EntryId: "vault-console",
Name: "token.txt", Name: "token.txt",
}) })
if err != nil { if err != nil {
@@ -1013,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
} }
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{ if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "git-server", EntryId: "vault-console",
Name: "token.txt", Name: "token.txt",
}); err != nil { }); err != nil {
t.Fatalf("DeleteAttachment() error = %v", err) t.Fatalf("DeleteAttachment() error = %v", err)
} }
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"}) listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("ListAttachments() error = %v", err) t.Fatalf("ListAttachments() error = %v", err)
} }
@@ -1055,32 +1055,32 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"X-Role": "automation", "X-Role": "automation",
}, },
History: []vault.Entry{ History: []vault.Entry{
{ {
ID: "git-server-h1", ID: "vault-console-h1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-0", Password: "token-0",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
{ {
ID: "ha-codex", ID: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
@@ -1090,9 +1090,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
), ),
}, },
Templates: []vault.Entry{ Templates: []vault.Entry{
+3
View File
@@ -16,6 +16,9 @@ const (
EventApprovalDenied EventType = "approval_denied" EventApprovalDenied EventType = "approval_denied"
EventApprovalCanceled EventType = "approval_canceled" EventApprovalCanceled EventType = "approval_canceled"
EventApprovalTimedOut EventType = "approval_timed_out" EventApprovalTimedOut EventType = "approval_timed_out"
EventAutofillFound EventType = "autofill_found"
EventAutofillAmbiguous EventType = "autofill_ambiguous"
EventAutofillBlocked EventType = "autofill_blocked"
EventAuthRejected EventType = "auth_rejected" EventAuthRejected EventType = "auth_rejected"
) )
+19
View File
@@ -47,3 +47,22 @@ func TestLogPreservesRecordedMetadata(t *testing.T) {
t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0]) t.Fatalf("Events()[0] = %#v, want preserved metadata", events[0])
} }
} }
func TestLogStoresAutofillEventTypes(t *testing.T) {
t.Parallel()
log := New(5)
log.Record(Event{
Type: EventAutofillAmbiguous,
TokenName: "Browser Extension",
Message: "multiple matches for example.com",
})
events := log.Events()
if len(events) != 1 {
t.Fatalf("len(Events()) = %d, want 1", len(events))
}
if events[0].Type != EventAutofillAmbiguous {
t.Fatalf("Events()[0].Type = %q, want %q", events[0].Type, EventAutofillAmbiguous)
}
}
+35 -1
View File
@@ -61,6 +61,7 @@ type SynchronizableSession interface {
type AdvancedSynchronizableSession interface { type AdvancedSynchronizableSession interface {
CurrentSession CurrentSession
SynchronizeFromLocal(string) error SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error SynchronizeToRemote(webdav.Client, string) error
@@ -302,7 +303,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
} }
if s.Section == SectionEntries { if s.Section == SectionEntries {
return model.EntriesInPath(s.CurrentPath), nil return entriesInPath(model.Entries, s.CurrentPath), nil
} }
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil return entries, nil
@@ -722,6 +723,18 @@ func (s *State) SynchronizeFromLocal(path string) error {
return nil return nil
} }
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error { func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession) session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok { if !ok {
@@ -837,6 +850,27 @@ func (s *State) CreateGroup(name string) error {
return nil return nil
} }
func (s *State) MoveCurrentGroup(parent []string) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
current := append([]string(nil), s.CurrentPath...)
if err := model.MoveGroup(current, parent); err != nil {
return err
}
session.Replace(model)
if len(current) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
}
s.Dirty = true
return nil
}
func (s *State) RenameCurrentGroup(newName string) error { func (s *State) RenameCurrentGroup(newName string) error {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
+220 -82
View File
@@ -20,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe", "Internet"}, CurrentPath: []string{"Crew", "Internet"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -39,8 +39,39 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
titles = append(titles, entry.Title) titles = append(titles, entry.Title)
} }
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) { if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles) t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
}
}
func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Crew Note"}) {
t.Fatalf("visible titles = %v, want only direct entries from Crew", titles)
} }
} }
@@ -140,14 +171,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe", "Internet"}, CurrentPath: []string{"Crew", "Internet"},
SearchQuery: "lights", SearchQuery: "surveillance",
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -155,11 +186,46 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" { if len(got) != 1 || got[0].Title != "Surveillance Console" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got) t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
} }
} }
func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew"},
SearchQuery: "missing",
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() with search error = %v", err)
}
if len(got) != 0 {
t.Fatalf("VisibleEntries() with missing search = %#v, want empty", got)
}
state.SearchQuery = ""
got, err = state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
}
if len(got) != 0 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Crew", len(got))
}
}
func TestVisibleEntriesUsesTemplateSection(t *testing.T) { func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
t.Parallel() t.Parallel()
@@ -283,7 +349,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
RecycleBin: []vault.Entry{ RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}}, {ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
}, },
}, },
@@ -352,13 +418,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, {ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe"}, CurrentPath: []string{"Crew"},
} }
got, err := state.ChildGroups() got, err := state.ChildGroups()
@@ -404,19 +470,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe", "Internet"}, CurrentPath: []string{"Crew", "Internet"},
} }
if err := state.SelectVisibleIndex(1); err != nil { if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err) t.Fatalf("SelectVisibleIndex() error = %v", err)
} }
if got := state.SelectedEntryID; got != "git-server" { if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server") t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
} }
if err := state.ToggleVisibleIndex(1); err != nil { if err := state.ToggleVisibleIndex(1); err != nil {
@@ -458,14 +524,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
sess := &mutableStubSession{ sess := &mutableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
} }
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.DeleteSelectedEntry(); err != nil { if err := state.DeleteSelectedEntry(); err != nil {
@@ -480,8 +546,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries)) t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
} }
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" { if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin) t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
} }
if !state.Dirty { if !state.Dirty {
@@ -495,7 +561,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
sess := &mutableStubSession{ sess := &mutableStubSession{
model: vault.Model{ model: vault.Model{
RecycleBin: []vault.Entry{ RecycleBin: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
} }
@@ -504,7 +570,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
} }
if err := state.RestoreEntry("git-server"); err != nil { if err := state.RestoreEntry("vault-console"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
@@ -513,8 +579,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "git-server" { if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got) t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -534,19 +600,19 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
} }
entry := vault.Entry{ entry := vault.Entry{
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
} }
if err := state.UpsertEntry(entry); err != nil { if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err) t.Fatalf("UpsertEntry() error = %v", err)
} }
if got := state.SelectedEntryID; got != "git-server" { if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server") t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -554,7 +620,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got) t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -586,11 +652,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
} }
entry, err := state.InstantiateTemplate("website-login", vault.Entry{ entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "dynadot", ID: "bellagio",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "hunter2", Password: "hunter2",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
if err != nil { if err != nil {
@@ -601,16 +667,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes) t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
} }
if got := state.SelectedEntryID; got != "dynadot" { if got := state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot") t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "dynadot" { if len(got) != 1 || got[0].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got) t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -677,24 +743,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}} }}
state := State{ state := State{
Session: sess, Session: sess,
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
duplicate, err := state.DuplicateSelectedEntry("git-server-copy") duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
if err != nil { if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err) t.Fatalf("DuplicateSelectedEntry() error = %v", err)
} }
if duplicate.ID != "git-server-copy" { if duplicate.ID != "vault-console-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy") t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
} }
if state.SelectedEntryID != "git-server-copy" { if state.SelectedEntryID != "vault-console-copy" {
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID) t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
} }
if len(sess.model.Entries) != 2 { if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries)) t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
@@ -706,13 +772,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}} }}
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil { if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
@@ -725,8 +791,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
} }
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"}) newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "git-server" { if len(newPath) != 1 || newPath[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath) t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
} }
if !state.Dirty { if !state.Dirty {
@@ -740,19 +806,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Password: "new-token", Password: "new-token",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []vault.Entry{ History: []vault.Entry{
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}}, {ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
}, },
}, },
}, },
}} }}
state := State{ state := State{
Session: sess, Session: sess,
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.RestoreSelectedEntryVersion(0); err != nil { if err := state.RestoreSelectedEntryVersion(0); err != nil {
@@ -796,7 +862,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true, Dirty: true,
} }
@@ -825,7 +891,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true, Dirty: true,
} }
@@ -876,7 +942,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true, Dirty: true,
} }
@@ -904,14 +970,14 @@ func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
sess := &lockableStubSession{ sess := &lockableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
} }
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.Lock(); err != nil { if err := state.Lock(); err != nil {
@@ -934,7 +1000,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
sess := &lockableStubSession{ sess := &lockableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
locked: true, locked: true,
@@ -952,8 +1018,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "git-server" { if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got) t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
} }
} }
@@ -982,7 +1048,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
state := State{ state := State{
Section: SectionEntries, Section: SectionEntries,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
SearchQuery: "git", SearchQuery: "git",
} }
@@ -1018,7 +1084,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
t.Parallel() t.Parallel()
state := State{ state := State{
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
ErrorMessage: "previous error", ErrorMessage: "previous error",
} }
@@ -1062,7 +1128,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
state := State{ state := State{
CurrentPath: []string{"Root"}, CurrentPath: []string{"Root"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
state.EnterGroup("Internet") state.EnterGroup("Internet")
@@ -1080,7 +1146,7 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
state := State{ state := State{
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
state.NavigateToPath([]string{"Root", "Home Assistant"}) state.NavigateToPath([]string{"Root", "Home Assistant"})
@@ -1150,6 +1216,27 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
} }
} }
func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Parallel()
session := &mutableStubSession{model: vault.Model{}}
state := State{
Session: session,
CurrentPath: []string{"Root"},
}
if err := state.CreateGroup("Infrastructure / Prod"); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
}
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) { func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1210,7 +1297,7 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil { if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
@@ -1226,14 +1313,45 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got)) t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
} }
if got[0].ID != "dynadot" && got[1].ID != "dynadot" { if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got) t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
} }
if !state.Dirty { if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry") t.Fatal("Dirty = false, want true after MoveSelectedEntry")
} }
} }
func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
session := &mutableStubSession{model: model}
state := State{
Session: session,
CurrentPath: []string{"Root", "Internet"},
}
if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil {
t.Fatalf("MoveCurrentGroup() error = %v", err)
}
if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath)
}
if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
}
}
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) { func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1241,7 +1359,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil { if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
@@ -1269,7 +1387,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement")) err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
@@ -1295,7 +1413,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil { if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
@@ -1321,7 +1439,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")) err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
@@ -1339,7 +1457,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil { if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
@@ -1365,7 +1483,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
err := state.DeleteAttachmentFromSelectedEntry("token.txt") err := state.DeleteAttachmentFromSelectedEntry("token.txt")
@@ -1393,8 +1511,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
func testVaultModel() vault.Model { func testVaultModel() vault.Model {
return vault.Model{ return vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
}, },
} }
} }
@@ -1466,6 +1584,26 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
return nil return nil
} }
func (s *lifecycleStubSession) SynchronizeFromLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error { func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
s.changedKey = key s.changedKey = key
return nil return nil
+212 -3
View File
@@ -5,6 +5,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -18,6 +19,7 @@ type Entry struct {
Password string `json:"password"` Password string `json:"password"`
URL string `json:"url"` URL string `json:"url"`
Host string `json:"host"` Host string `json:"host"`
Targets []string `json:"targets,omitempty"`
Path []string `json:"path,omitempty"` Path []string `json:"path,omitempty"`
} }
@@ -26,10 +28,62 @@ type File struct {
Entries []Entry `json:"entries"` Entries []Entry `json:"entries"`
} }
type MatchStatus string
const (
MatchStatusNone MatchStatus = ""
MatchStatusFound MatchStatus = "found"
MatchStatusAmbiguous MatchStatus = "ambiguous"
MatchStatusMissing MatchStatus = "missing"
)
type MatchResult struct {
Status MatchStatus `json:"status"`
Entry Entry `json:"entry,omitempty"`
}
func Match(cache File, webURL string) (Entry, bool) {
result := Resolve(cache, webURL)
return result.Entry, result.Status == MatchStatusFound
}
func Resolve(cache File, webURL string) MatchResult {
target := normalizeURL(webURL)
if target.host == "" {
return MatchResult{Status: MatchStatusMissing}
}
exactHost := make([]Entry, 0)
parentHost := make([]Entry, 0)
for _, entry := range cache.Entries {
if entryMatchesHost(entry, target.host) {
exactHost = append(exactHost, entry)
continue
}
if entryMatchesParentHost(entry, target.host) {
parentHost = append(parentHost, entry)
}
}
if result := chooseEntry(target, exactHost); result.Status != MatchStatusMissing {
return result
}
return chooseEntry(target, parentHost)
}
func Build(model vault.Model, now time.Time) File { func Build(model vault.Model, now time.Time) File {
entries := make([]Entry, 0, len(model.Entries)) entries := make([]Entry, 0, len(model.Entries))
for _, item := range model.Entries { for _, item := range model.Entries {
targets := collectTargets(item)
host := normalizeHost(item.URL) host := normalizeHost(item.URL)
if host == "" {
for _, target := range targets {
host = normalizeHost(target)
if host != "" {
break
}
}
}
if host == "" { if host == "" {
continue continue
} }
@@ -43,6 +97,7 @@ func Build(model vault.Model, now time.Time) File {
Password: item.Password, Password: item.Password,
URL: item.URL, URL: item.URL,
Host: host, Host: host,
Targets: targets,
Path: append([]string(nil), item.Path...), Path: append([]string(nil), item.Path...),
}) })
} }
@@ -71,17 +126,171 @@ func Clear(path string) error {
} }
func normalizeHost(raw string) string { func normalizeHost(raw string) string {
return normalizeURL(raw).host
}
type normalizedTarget struct {
host string
path string
url string
}
func normalizeURL(raw string) normalizedTarget {
value := strings.TrimSpace(raw) value := strings.TrimSpace(raw)
if value == "" { if value == "" {
return "" return normalizedTarget{}
} }
if !strings.Contains(value, "://") { if !strings.Contains(value, "://") {
value = "https://" + value value = "https://" + value
} }
parsed, err := url.Parse(value) parsed, err := url.Parse(value)
if err != nil { if err != nil {
return "" return normalizedTarget{}
} }
host := strings.TrimSpace(parsed.Hostname()) host := strings.TrimSpace(parsed.Hostname())
return strings.ToLower(host) path := cleanPath(parsed.EscapedPath())
return normalizedTarget{
host: strings.ToLower(host),
path: path,
url: strings.ToLower(host) + path,
}
}
func cleanPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "/" {
return "/"
}
path = strings.TrimRight(path, "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func chooseEntry(target normalizedTarget, entries []Entry) MatchResult {
switch len(entries) {
case 0:
return MatchResult{Status: MatchStatusMissing}
case 1:
return MatchResult{Status: MatchStatusFound, Entry: entries[0]}
}
exact := make([]Entry, 0)
bestPrefixLen := -1
bestPrefix := make([]Entry, 0)
for _, entry := range entries {
exactMatch, prefixLen := bestTargetMatch(entry, target)
if exactMatch {
exact = append(exact, entry)
continue
}
if prefixLen <= 0 {
continue
}
switch {
case prefixLen > bestPrefixLen:
bestPrefixLen = prefixLen
bestPrefix = []Entry{entry}
case prefixLen == bestPrefixLen:
bestPrefix = append(bestPrefix, entry)
}
}
if len(exact) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: exact[0]}
}
if len(exact) > 1 {
return MatchResult{Status: MatchStatusAmbiguous}
}
if len(bestPrefix) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: bestPrefix[0]}
}
if len(bestPrefix) == 0 {
return MatchResult{Status: MatchStatusMissing}
}
return MatchResult{Status: MatchStatusAmbiguous}
}
func collectTargets(item vault.Entry) []string {
seen := make(map[string]struct{})
targets := make([]string, 0, 1+len(item.Fields))
appendTarget := func(raw string) {
value := strings.TrimSpace(raw)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
targets = append(targets, value)
}
appendTarget(item.URL)
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
upper := strings.ToUpper(strings.TrimSpace(key))
if strings.HasPrefix(upper, "ANDROIDAPP") || strings.HasPrefix(upper, "KP2A_URL") {
appendTarget(item.Fields[key])
}
}
return targets
}
func entryTargets(entry Entry) []normalizedTarget {
values := entry.Targets
if len(values) == 0 {
values = []string{entry.URL}
}
targets := make([]normalizedTarget, 0, len(values))
for _, value := range values {
target := normalizeURL(value)
if target.host == "" {
continue
}
targets = append(targets, target)
}
return targets
}
func entryMatchesHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host == host {
return true
}
}
return false
}
func entryMatchesParentHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host != "" && strings.HasSuffix(host, "."+target.host) {
return true
}
}
return false
}
func bestTargetMatch(entry Entry, target normalizedTarget) (bool, int) {
bestPrefixLen := -1
for _, candidate := range entryTargets(entry) {
if candidate.url == target.url {
return true, 0
}
if candidate.path != "/" && strings.HasPrefix(target.path, candidate.path) {
if pathLen := len(candidate.path); pathLen > bestPrefixLen {
bestPrefixLen = pathLen
}
}
}
return false, bestPrefixLen
} }
+255 -4
View File
@@ -22,7 +22,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Username: "joe", Username: "joe",
Password: "secret", Password: "secret",
URL: "https://10.0.2.2:8443/login", URL: "https://10.0.2.2:8443/login",
Path: []string{"Joe", "Internet"}, Path: []string{"Crew", "Internet"},
}, },
{ {
ID: "two", ID: "two",
@@ -35,7 +35,11 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Title: "Bare Host", Title: "Bare Host",
Username: "user", Username: "user",
Password: "pass", Password: "pass",
URL: "lights.julianfamily.org", URL: "surveillance.crew.example.invalid",
Fields: map[string]string{
"AndroidApp1": "androidapp://com.lights.mobile",
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
},
}, },
}, },
}, now) }, now)
@@ -46,8 +50,11 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
if got.Entries[0].Host != "10.0.2.2" { if got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host) t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
} }
if got.Entries[1].Host != "lights.julianfamily.org" { if got.Entries[1].Host != "surveillance.crew.example.invalid" {
t.Fatalf("second host = %q, want lights.julianfamily.org", got.Entries[1].Host) t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
}
if len(got.Entries[1].Targets) != 3 {
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
} }
if got.UpdatedAt != "2026-03-31T12:00:00Z" { if got.UpdatedAt != "2026-03-31T12:00:00Z" {
t.Fatalf("updatedAt = %q", got.UpdatedAt) t.Fatalf("updatedAt = %q", got.UpdatedAt)
@@ -86,3 +93,247 @@ func TestWriteAndClear(t *testing.T) {
t.Fatalf("cache path still exists, stat err = %v", err) t.Fatalf("cache path still exists, stat err = %v", err)
} }
} }
func TestMatchChoosesExactURLWhenHostsRepeat(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Primary Login",
Username: "first",
Password: "secret1",
URL: "https://10.0.2.2:8443/login/",
Host: "10.0.2.2",
},
{
ID: "two",
Title: "Alt Login",
Username: "second",
Password: "secret2",
URL: "https://10.0.2.2:8443/alt/",
Host: "10.0.2.2",
},
},
}
got, ok := Match(cache, "https://10.0.2.2:8443/alt/")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "two" {
t.Fatalf("Match() entry = %q, want two", got.ID)
}
}
func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Host A",
Username: "first",
Password: "secret1",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
},
{
ID: "two",
Title: "Host B",
Username: "second",
Password: "secret2",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
},
},
}
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
}
}
func TestResolveReportsFoundAmbiguousAndMissingStatuses(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Admin Login",
Username: "admin",
Password: "secret1",
URL: "https://example.com/admin",
Host: "example.com",
},
{
ID: "two",
Title: "Shared Login A",
Username: "shared-a",
Password: "secret2",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
{
ID: "three",
Title: "Shared Login B",
Username: "shared-b",
Password: "secret3",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
},
}
if got := Resolve(cache, "https://example.com/admin/login"); got.Status != MatchStatusFound || got.Entry.ID != "one" {
t.Fatalf("Resolve(found) = %#v, want found entry one", got)
}
if got := Resolve(cache, "https://shared.example.com"); got.Status != MatchStatusAmbiguous {
t.Fatalf("Resolve(ambiguous) = %#v, want ambiguous", got)
}
if got := Resolve(cache, "https://nowhere.invalid"); got.Status != MatchStatusMissing {
t.Fatalf("Resolve(missing) = %#v, want missing", got)
}
}
func TestMatchChoosesLongestPathPrefix(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Generic Login",
Username: "generic",
Password: "secret1",
URL: "https://example.com/",
Host: "example.com",
},
{
ID: "two",
Title: "Admin Login",
Username: "admin",
Password: "secret2",
URL: "https://example.com/admin",
Host: "example.com",
},
},
}
got, ok := Match(cache, "https://example.com/admin/login")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "two" {
t.Fatalf("Match() entry = %q, want two", got.ID)
}
}
func TestMatchSupportsAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird/login",
Host: "org.mozilla.thunderbird",
},
},
}
got, ok := Match(cache, "androidapp://org.mozilla.thunderbird")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchRejectsAmbiguousAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird Primary",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
{
ID: "two",
Title: "Thunderbird Secondary",
Username: "other-user",
Password: "secret2",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
},
}
if _, ok := Match(cache, "androidapp://org.mozilla.thunderbird"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous android app package target")
}
}
func TestMatchUsesAndroidAppCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://account.blinknetwork.com",
Host: "account.blinknetwork.com",
Targets: []string{"https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"},
},
},
}
got, ok := Match(cache, "androidapp://com.blinknetwork.mobile2")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchUsesKP2AURLCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://blinknetwork.com",
Host: "blinknetwork.com",
Targets: []string{"https://blinknetwork.com", "https://account.blinknetwork.com"},
},
},
}
got, ok := Match(cache, "https://account.blinknetwork.com")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
+11 -11
View File
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
}, },
}, },
} }
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
target Target target Target
want string want string
}{ }{
{name: "username", target: TargetUsername, want: "joejulian"}, {name: "username", target: TargetUsername, want: "dannyocean"},
{name: "password", target: TargetPassword, want: "token-1"}, {name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"}, {name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter var writer memoryWriter
service := Service{Writer: &writer} service := Service{Writer: &writer}
if err := service.Copy(model, "git-server", tt.target); err != nil { if err := service.Copy(model, "vault-console", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err) t.Fatalf("Copy() error = %v", err)
} }
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
} }
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}}, Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
} }
err = service.Copy(model, "git-server", Target("unsupported")) err = service.Copy(model, "vault-console", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) { if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err) t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
} }
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}} service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Password: "token-1"}, {ID: "vault-console", Password: "token-1"},
}, },
} }
err := service.Copy(model, "git-server", TargetPassword) err := service.Copy(model, "vault-console", TargetPassword)
if !errors.Is(err, ErrWriteFailed) { if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err) t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
} }
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"io"
"strings"
"sync"
gioclipboard "gioui.org/io/clipboard"
"gioui.org/layout"
appclipboard "git.julianfamily.org/keepassgo/clipboard"
)
type clipboardCommandWriter struct {
mu sync.Mutex
pending []string
invalidate func()
}
func newPlatformClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
if strings.EqualFold(goos, "android") {
return &clipboardCommandWriter{invalidate: invalidate}
}
return nil
}
func processClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
commandWriter, ok := writer.(*clipboardCommandWriter)
if !ok {
return
}
commandWriter.Process(gtx)
}
func (w *clipboardCommandWriter) WriteText(text string) error {
w.mu.Lock()
w.pending = append(w.pending, text)
w.mu.Unlock()
if w.invalidate != nil {
w.invalidate()
}
return nil
}
func (w *clipboardCommandWriter) Process(gtx layout.Context) {
for _, text := range w.drain() {
gtx.Execute(gioclipboard.WriteCmd{
Type: "application/text",
Data: io.NopCloser(strings.NewReader(text)),
})
}
}
func (w *clipboardCommandWriter) drain() []string {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.pending) == 0 {
return nil
}
pending := append([]string(nil), w.pending...)
w.pending = nil
return pending
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"slices"
"testing"
)
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
t.Parallel()
writer := newPlatformClipboardWriter("android", nil)
if _, ok := writer.(*clipboardCommandWriter); !ok {
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
}
}
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
t.Parallel()
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
}
}
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
t.Parallel()
writer := &clipboardCommandWriter{}
if err := writer.WriteText("username"); err != nil {
t.Fatalf("WriteText(username) error = %v", err)
}
if err := writer.WriteText("password"); err != nil {
t.Fatalf("WriteText(password) error = %v", err)
}
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
t.Fatalf("drain() = %v, want [username password]", got)
}
if got := writer.drain(); got != nil {
t.Fatalf("drain() after flush = %v, want nil", got)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
- list navigation - list navigation
- search focus - search focus
- new-entry focus transitions - new-entry focus transitions
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/jjulian/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go). - Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
## Current screen-reader boundary ## Current screen-reader boundary
+4 -1
View File
@@ -6,6 +6,7 @@ replace gioui.org/cmd => ./third_party/gioui-cmd
require ( require (
gioui.org v0.8.0 gioui.org v0.8.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2 github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -18,6 +19,7 @@ require (
4d63.com/gochecknoglobals v0.2.2 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.8.0 // indirect gioui.org/cmd v0.8.0 // indirect
gioui.org/shader v1.0.8 // indirect gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect github.com/Antonboom/errname v1.0.0 // indirect
@@ -74,6 +76,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -192,7 +195,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.33.0 // indirect
+8 -2
View File
@@ -42,6 +42,10 @@ gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -224,6 +228,8 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -669,8 +675,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
+3065 -509
View File
File diff suppressed because it is too large Load Diff
+2486 -238
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
pkgbase = keepassgo-git
pkgdesc = KeePass-compatible password manager written in Go
pkgver = r160.5fa79bd
pkgrel = 1
url = https://git.julianfamily.org/joejulian/keepassgo
arch = x86_64
arch = aarch64
license = custom
makedepends = git
makedepends = go
makedepends = pkgconf
depends = glibc
depends = hicolor-icon-theme
depends = libx11
depends = libxcursor
depends = libxfixes
depends = libxkbcommon
depends = libxkbcommon-x11
depends = mesa
depends = wayland
provides = keepassgo
conflicts = keepassgo
source = git+https://git.julianfamily.org/joejulian/keepassgo.git
sha256sums = SKIP
pkgname = keepassgo-git
@@ -0,0 +1,63 @@
pkgname=keepassgo-git
pkgver=r0.0000000
pkgrel=1
pkgdesc='KeePass-compatible password manager written in Go'
arch=('x86_64' 'aarch64')
url='https://git.julianfamily.org/joejulian/keepassgo'
license=('custom')
depends=(
'glibc'
'hicolor-icon-theme'
'libx11'
'libxcursor'
'libxfixes'
'libxkbcommon'
'libxkbcommon-x11'
'mesa'
'wayland'
)
makedepends=(
'git'
'go'
'pkgconf'
)
provides=('keepassgo')
conflicts=('keepassgo')
source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
sha256sums=('SKIP')
_repo_dir() {
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
printf '%s\n' "${srcdir}/keepassgo"
return
fi
cd "${startdir}/../../.." || exit 1
pwd
}
pkgver() {
cd "$(_repo_dir)"
printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$(_repo_dir)"
export CGO_ENABLED=1
export GOFLAGS="-trimpath"
go build -o keepassgo .
}
package() {
cd "$(_repo_dir)"
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm644 assets/keepassgo-icon.png \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
install -Dm644 assets/keepassgo-icon.svg \
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
"${pkgdir}/usr/share/applications/keepassgo.desktop"
install -Dm644 README.md \
"${pkgdir}/usr/share/licenses/${pkgname}/README.md"
}
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=KeePassGO
Comment=KeePass-compatible password manager
Exec=keepassgo
Icon=keepassgo
Terminal=false
Categories=Utility;Security;
Keywords=keepass;password;vault;kdbx;
StartupNotify=true
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
import argparse
import json
import mimetypes
import pathlib
import sys
import urllib.error
import urllib.parse
import urllib.request
def request_json(method: str, url: str, token: str, payload: dict | None = None) -> dict:
data = None
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
def request_no_content(method: str, url: str, token: str) -> None:
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
method=method,
)
with urllib.request.urlopen(req):
return
def get_or_create_release(server: str, repo: str, token: str, tag: str) -> dict:
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
tag_url = f"{base}/releases/tags/{urllib.parse.quote(tag, safe='')}"
try:
return request_json("GET", tag_url, token)
except urllib.error.HTTPError as err:
if err.code != 404:
raise
payload = {
"tag_name": tag,
"name": tag,
"draft": False,
"prerelease": False,
}
return request_json("POST", f"{base}/releases", token, payload)
def upload_asset(server: str, repo: str, token: str, release: dict, path: pathlib.Path) -> None:
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
assets = release.get("assets", [])
for asset in assets:
if asset.get("name") == path.name:
request_no_content("DELETE", f"{base}/releases/{release['id']}/assets/{asset['id']}", token)
query = urllib.parse.urlencode({"name": path.name})
url = f"{base}/releases/{release['id']}/assets?{query}"
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
req = urllib.request.Request(
url,
data=path.read_bytes(),
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
"Content-Type": content_type,
},
method="POST",
)
with urllib.request.urlopen(req):
return
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--server", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("artifacts", nargs="+")
args = parser.parse_args()
paths = [pathlib.Path(p) for p in args.artifacts]
missing = [str(p) for p in paths if not p.is_file()]
if missing:
print(f"missing artifacts: {', '.join(missing)}", file=sys.stderr)
return 1
release = get_or_create_release(args.server, args.repo, args.token, args.tag)
for path in paths:
upload_asset(args.server, args.repo, args.token, release, path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+151 -40
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -32,6 +33,33 @@ type Manager struct {
remoteVersion webdav.Version remoteVersion webdav.Version
} }
type PreparedLocalOpen struct {
Model vault.Model
Config *vault.KDBXConfig
Path string
Key vault.MasterKey
Encoded []byte
VaultRoot string
}
type PreparedRemoteOpen struct {
Model vault.Model
Config *vault.KDBXConfig
Client webdav.Client
Path string
Key vault.MasterKey
Encoded []byte
VaultRoot string
RemoteVersion webdav.Version
}
type PreparedUnlock struct {
Model vault.Model
Config *vault.KDBXConfig
Key vault.MasterKey
VaultRoot string
}
func (m *Manager) SecuritySettings() vault.SecuritySettings { func (m *Manager) SecuritySettings() vault.SecuritySettings {
return vault.DetectSecuritySettings(m.config) return vault.DetectSecuritySettings(m.config)
} }
@@ -65,6 +93,10 @@ func (m *Manager) HasVault() bool {
return len(m.encoded) > 0 || m.path != "" || m.remotePath != "" return len(m.encoded) > 0 || m.path != "" || m.remotePath != ""
} }
func (m *Manager) EncodedBytes() []byte {
return append([]byte(nil), m.encoded...)
}
func (m *Manager) IsLocked() bool { func (m *Manager) IsLocked() bool {
return m.locked return m.locked
} }
@@ -74,23 +106,11 @@ func (m *Manager) IsRemote() bool {
} }
func (m *Manager) Open(path string, key vault.MasterKey) error { func (m *Manager) Open(path string, key vault.MasterKey) error {
content, err := os.ReadFile(path) prepared, err := PrepareLocalOpen(path, key)
if err != nil { if err != nil {
return fmt.Errorf("read %s: %w", path, err) return err
} }
m.ApplyPreparedLocalOpen(prepared)
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
m.model = model
m.config = config
m.path = path
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
return nil return nil
} }
@@ -107,25 +127,11 @@ func (m *Manager) Save() error {
} }
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error { func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
content, version, err := client.Open(path) prepared, err := PrepareRemoteOpen(client, path, key)
if err != nil { if err != nil {
return fmt.Errorf("open remote %s: %w", path, err) return err
} }
m.ApplyPreparedRemoteOpen(prepared)
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return fmt.Errorf("decode remote %s: %w", path, err)
}
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.encoded = content
m.locked = false
m.remoteClient = &client
m.remotePath = path
m.remoteVersion = version
return nil return nil
} }
@@ -172,6 +178,18 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
return m.persistMergedToCurrentSource(merged) return m.persistMergedToCurrentSource(merged)
} }
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
other, _, err := loadLocalSourceBytes(name, content, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeToLocal(path string) error { func (m *Manager) SynchronizeToLocal(path string) error {
other, config, err := loadLocalSourceOrEmpty(path, m.key) other, config, err := loadLocalSourceOrEmpty(path, m.key)
if err != nil { if err != nil {
@@ -265,19 +283,101 @@ func (m *Manager) Lock() error {
} }
func (m *Manager) Unlock(key vault.MasterKey) error { func (m *Manager) Unlock(key vault.MasterKey) error {
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key) prepared, err := PrepareUnlock(m.encoded, key)
if err != nil { if err != nil {
return fmt.Errorf("unlock vault: %w", err) return err
} }
m.ApplyPreparedUnlock(prepared)
m.model = model
m.config = config
m.key = key
m.vaultRoot = detectSingleVaultRoot(model)
m.locked = false
return nil return nil
} }
func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, error) {
content, err := os.ReadFile(path)
if err != nil {
return PreparedLocalOpen{}, fmt.Errorf("read %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
}
return PreparedLocalOpen{
Model: model,
Config: config,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
}, nil
}
func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (PreparedRemoteOpen, error) {
content, version, err := client.Open(path)
if err != nil {
return PreparedRemoteOpen{}, fmt.Errorf("open remote %s: %w", path, err)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
}
return PreparedRemoteOpen{
Model: model,
Config: config,
Client: client,
Path: path,
Key: key,
Encoded: content,
VaultRoot: detectSingleVaultRoot(model),
RemoteVersion: version,
}, nil
}
func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error) {
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(encoded), key)
if err != nil {
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
}
return PreparedUnlock{
Model: model,
Config: config,
Key: key,
VaultRoot: detectSingleVaultRoot(model),
}, nil
}
func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.model = prepared.Model
m.config = prepared.Config
m.path = prepared.Path
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.encoded = prepared.Encoded
m.locked = false
m.remoteClient = nil
m.remotePath = ""
m.remoteVersion = webdav.Version{}
}
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.model = prepared.Model
m.config = prepared.Config
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.encoded = prepared.Encoded
m.locked = false
m.remoteClient = &prepared.Client
m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion
m.path = ""
}
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.model = prepared.Model
m.config = prepared.Config
m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot
m.locked = false
}
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error { func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
var ( var (
model vault.Model model vault.Model
@@ -821,6 +921,17 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
return model, config, nil return model, config, nil
} }
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
if len(content) == 0 {
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
}
return model, config, nil
}
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
model, config, err := loadLocalSource(path, key) model, config, err := loadLocalSource(path, key)
if err == nil { if err == nil {
+140 -83
View File
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" { if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got) t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
} }
} }
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
updated := model updated := model
updated.UpsertEntry(vault.Entry{ updated.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
sess.Replace(updated) sess.Replace(updated)
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Joe", "Internet"}, Path: []string{"keepass", "Crew", "Internet"},
}, },
{ {
ID: "entry-2", ID: "entry-2",
Title: "Mail", Title: "Mail",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://mail.julianfamily.org", URL: "https://dispatch.crew.example.invalid",
Path: []string{"keepass", "Joe", "eMail"}, Path: []string{"keepass", "Crew", "eMail"},
}, },
}, },
}, key.Password); err != nil { }, key.Password); err != nil {
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
current.Entries[0].Path = []string{"Joe", "Internet"} current.Entries[0].Path = []string{"Crew", "Internet"}
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"}) current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
sess.Replace(current) sess.Replace(current)
if err := sess.Save(); err != nil { if err := sess.Save(); err != nil {
@@ -244,8 +244,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups) t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
} }
rootGroups := db.Content.Root.Groups[0].Groups rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" { if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups) t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
} }
} }
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got) t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
} }
} }
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-1", Password: "token-1",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}) })
sess.Replace(current) sess.Replace(current)
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
sess.Replace(current) sess.Replace(current)
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" { if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got) t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
} }
var reopened Manager var reopened Manager
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
{ {
UUID: gokeepasslib.NewUUID(), UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{ Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}}, {Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}}, {Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}}, {Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}}, {Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
}, },
}, },
}, },
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: current.Entries[0].ID, ID: current.Entries[0].ID,
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: current.Entries[0].Path, Path: current.Entries[0].Path,
}) })
sess.Replace(current) sess.Replace(current)
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"), "token.txt": []byte("secret attachment contents"),
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
History: []vault.Entry{ History: []vault.Entry{
{ {
ID: "entry-1-history-1", ID: "entry-1-history-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
} }
firstCurrent.UpsertEntry(vault.Entry{ firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "remote-token-2", Password: "remote-token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "updated remotely first", Notes: "updated remotely first",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
} }
secondCurrent.UpsertEntry(vault.Entry{ secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "local-token-2", Password: "local-token-2",
URL: "https://git.julianfamily.org/settings/tokens", URL: "https://vault.crew.example.invalid/security/badges",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
second.Replace(secondCurrent) second.Replace(secondCurrent)
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
} }
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" { if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0]) t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
} }
if len(got[0].History) == 0 { if len(got[0].History) == 0 {
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-current", Password: "token-current",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-other", ID: "entry-other",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-other", Password: "token-other",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -953,6 +953,63 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
} }
} }
func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
otherModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
var other bytes.Buffer
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
t.Fatalf("SaveKDBX(other) error = %v", err)
}
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
}
var reopened Manager
if err := reopened.Open(currentPath, key); err != nil {
t.Fatalf("reopen Open(current) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) { func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Parallel() t.Parallel()
@@ -964,10 +1021,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-current", Password: "token-current",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -976,10 +1033,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-other", ID: "entry-other",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-other", Password: "token-other",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -1021,10 +1078,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-current", Password: "token-current",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -1033,10 +1090,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-remote", ID: "entry-remote",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-remote", Password: "token-remote",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
+1 -1
View File
@@ -471,7 +471,7 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe
<uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" /> <uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" />
{{range .Permissions}} <uses-permission android:name="{{.}}"/> {{range .Permissions}} <uses-permission android:name="{{.}}"/>
{{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/> {{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/>
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}"> {{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}" android:enableOnBackInvokedCallback="false">
{{.AppSnip}} {{.AppSnip}}
<activity android:name="org.gioui.GioActivity" <activity android:name="org.gioui.GioActivity"
android:label="{{.AppName}}" android:label="{{.AppName}}"
+25 -2
View File
@@ -19,26 +19,49 @@ type focusAppearance struct {
MinHeight int MinHeight int
} }
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance { func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
appearance := focusAppearance{ appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255}, BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0}, OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))), OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)), MinHeight: metric.Dp(unit.Dp(44)),
} }
if prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
if focused { if focused {
appearance.BorderColor = accentColor appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72} appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2))) appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
} }
return appearance return appearance
} }
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) { func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused { if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
}
} }
return background, text return background, text
} }
+654 -141
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"image/color"
"strings" "strings"
"time" "time"
@@ -27,6 +28,130 @@ func apiOperations() []apitokens.Operation {
} }
} }
type apiAuditQuickFilter struct {
Label string
Query string
}
func apiAuditDecisionLabel(eventType apiaudit.EventType) string {
switch eventType {
case apiaudit.EventApprovalRequested:
return "Requested"
case apiaudit.EventApprovalAllowed:
return "Allowed"
case apiaudit.EventApprovalDenied:
return "Denied"
case apiaudit.EventApprovalCanceled:
return "Canceled"
case apiaudit.EventApprovalTimedOut:
return "Timed Out"
case apiaudit.EventAuthRejected:
return "Auth Rejected"
default:
return strings.ReplaceAll(string(eventType), "_", " ")
}
}
func apiAuditOperationLabel(operation apitokens.Operation) string {
if strings.TrimSpace(string(operation)) == "" {
return "Other"
}
return strings.ReplaceAll(string(operation), "_", " ")
}
func compactAuditFilterLabel(label string) string {
label = strings.TrimSpace(label)
if len(label) <= 22 {
return label
}
return label[:19] + "..."
}
func apiAuditEventSearchTerms(event apiaudit.Event) string {
parts := []string{
string(event.Type),
apiAuditDecisionLabel(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
apiAuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}
switch event.Type {
case apiaudit.EventApprovalAllowed:
parts = append(parts, "allow approved")
case apiaudit.EventApprovalDenied:
parts = append(parts, "deny denied")
case apiaudit.EventApprovalRequested:
parts = append(parts, "prompt requested")
case apiaudit.EventApprovalCanceled:
parts = append(parts, "cancel canceled")
case apiaudit.EventApprovalTimedOut:
parts = append(parts, "timeout timed out")
case apiaudit.EventAuthRejected:
parts = append(parts, "rejected unauthorized")
}
return strings.ToLower(strings.Join(parts, " "))
}
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
if len(filters) == 0 {
*clicks = nil
return nil
}
if len(*clicks) < len(filters) {
next := make([]widget.Clickable, len(filters))
copy(next, *clicks)
*clicks = next
}
return (*clicks)[:len(filters)]
}
func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilter, []apiAuditQuickFilter, []apiAuditQuickFilter) {
tokenSeen := map[string]struct{}{}
decisionSeen := map[apiaudit.EventType]struct{}{}
operationSeen := map[apitokens.Operation]struct{}{}
var tokens []apiAuditQuickFilter
var decisions []apiAuditQuickFilter
var operations []apiAuditQuickFilter
for _, event := range events {
if name := strings.TrimSpace(event.TokenName); name != "" {
if _, ok := tokenSeen[name]; !ok {
tokenSeen[name] = struct{}{}
tokens = append(tokens, apiAuditQuickFilter{Label: name, Query: name})
}
}
if _, ok := decisionSeen[event.Type]; !ok {
decisionSeen[event.Type] = struct{}{}
label := apiAuditDecisionLabel(event.Type)
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
}
if strings.TrimSpace(string(event.Operation)) == "" {
continue
}
if _, ok := operationSeen[event.Operation]; ok {
continue
}
operationSeen[event.Operation] = struct{}{}
label := apiAuditOperationLabel(event.Operation)
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
}
if len(tokens) > 4 {
tokens = tokens[:4]
}
if len(decisions) > 5 {
decisions = decisions[:5]
}
if len(operations) > 4 {
operations = operations[:4]
}
return tokens, decisions, operations
}
func (u *ui) apiTokens() []apitokens.Token { func (u *ui) apiTokens() []apitokens.Token {
tokens, err := u.state.APITokens() tokens, err := u.state.APITokens()
if err != nil { if err != nil {
@@ -244,6 +369,57 @@ func (u *ui) addAPIPolicyRuleAction() error {
return nil return nil
} }
func (u *ui) apiPolicyGroupPathSummary() string {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return "No group selected"
}
return strings.Join(path, " / ")
}
func (u *ui) apiPolicyEntrySummary() string {
id := strings.TrimSpace(u.apiPolicyEntryID.Text())
if id == "" {
return "No entry selected"
}
if item, ok := u.selectedEntry(); ok && item.ID == id {
if strings.TrimSpace(item.Title) != "" {
return item.Title + " (" + id + ")"
}
}
return id
}
func (u *ui) useCurrentGroupForPolicyAction() error {
u.syncCurrentPath()
path := u.displayPath()
if len(path) == 0 {
return fmt.Errorf("navigate to a group first")
}
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(path, " / "))
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) useSelectedEntryForPolicyAction() error {
item, ok := u.selectedEntry()
if !ok || strings.TrimSpace(item.ID) == "" {
return fmt.Errorf("select an entry first")
}
u.apiPolicyGroupScope = false
u.apiPolicyGroupScopeW.Value = false
u.apiPolicyEntryID.SetText(item.ID)
return nil
}
func (u *ui) clearAPIPolicyTargetAction() error {
u.apiPolicyPath.SetText("")
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) removeAPIPolicyRuleAction(index int) error { func (u *ui) removeAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
if !ok { if !ok {
@@ -274,15 +450,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
} }
filtered := make([]apiaudit.Event, 0, len(events)) filtered := make([]apiaudit.Event, 0, len(events))
for _, event := range events { for _, event := range events {
haystack := strings.ToLower(strings.Join([]string{ haystack := apiAuditEventSearchTerms(event)
string(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}, " "))
if strings.Contains(haystack, query) { if strings.Contains(haystack, query) {
filtered = append(filtered, event) filtered = append(filtered, event)
} }
@@ -293,16 +461,58 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
return filtered return filtered
} }
func formatAPIPolicyRule(rule apitokens.PolicyRule) string { func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) {
scope := strings.Join(rule.Resource.Path, " / ") effect := strings.ToUpper(string(rule.Effect))
operation := string(rule.Operation)
resource := "Vault root"
if rule.Resource.Kind == apitokens.ResourceEntry { if rule.Resource.Kind == apitokens.ResourceEntry {
scope = "entry " + rule.Resource.EntryID resource = "Entry: " + rule.Resource.EntryID
} else if len(rule.Resource.Path) > 0 {
resource = strings.Join(rule.Resource.Path, " / ")
} }
return strings.TrimSpace(strings.Join([]string{ return effect, operation, resource
strings.ToUpper(string(rule.Effect)), }
string(rule.Operation),
scope, func apiTokenStatusSummary(token apitokens.Token) string {
}, " ")) parts := []string{"Active"}
if token.Disabled {
parts[0] = "Disabled"
}
if token.RevokedAt != nil {
parts[0] = "Revoked"
}
if token.ExpiresAt != nil {
parts = append(parts, "Expires "+token.ExpiresAt.Local().Format(time.RFC3339))
} else {
parts = append(parts, "No expiration")
}
if len(token.Policies) == 1 {
parts = append(parts, "1 policy rule")
} else {
parts = append(parts, fmt.Sprintf("%d policy rules", len(token.Policies)))
}
return strings.Join(parts, " · ")
}
func apiTokenManagementTitle(token apitokens.Token, ok bool) string {
if !ok {
return "Issue API Token"
}
if strings.TrimSpace(token.Name) != "" {
return token.Name
}
return "Unnamed API Token"
}
func apiTokenManagementSubtitle(token apitokens.Token, ok bool) string {
if !ok {
return "Create a scoped gRPC credential, then select it here to inspect identity, lifecycle, and policy rules."
}
client := strings.TrimSpace(token.ClientName)
if client == "" {
client = "No client name"
}
return client + " · " + apiTokenStatusSummary(token)
} }
func uiHasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool { func uiHasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
@@ -336,17 +546,21 @@ func (u *ui) apiTokenRow(gtx layout.Context, click *widget.Clickable, idx int, t
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), token.ClientName) lbl := material.Label(u.theme, unit.Sp(13), token.ClientName)
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := "Non-expiring" lbl := material.Label(u.theme, unit.Sp(12), apiTokenStatusSummary(token))
if token.ExpiresAt != nil { lbl.Color = mutedColor
text = "Expires " + token.ExpiresAt.Local().Format(time.RFC3339) return lbl.Layout(gtx)
} }),
lbl := material.Label(u.theme, unit.Sp(12), text) layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "ID "+token.ID)
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
@@ -392,6 +606,11 @@ func (u *ui) apiAuditRow(gtx layout.Context, click *widget.Clickable, idx int, e
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), string(event.Operation))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), event.At.Local().Format(time.RFC3339)) lbl := material.Label(u.theme, unit.Sp(12), event.At.Local().Format(time.RFC3339))
lbl.Color = mutedColor lbl.Color = mutedColor
@@ -415,11 +634,46 @@ func (u *ui) apiAuditRow(gtx layout.Context, click *widget.Clickable, idx int, e
func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions { func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
tokens := u.apiTokens() tokens := u.apiTokens()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
selected, ok := u.selectedAPIToken()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Token Directory")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Grant scoped gRPC access to external tools. Search matches token name, client, token id, and policy details.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
title := fmt.Sprintf("%d tokens managed", len(tokens))
if ok {
title = "Selected: " + apiTokenManagementTitle(selected, true)
}
lbl := material.Label(u.theme, unit.Sp(12), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !ok {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), apiTokenManagementSubtitle(selected, true))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(tokens) == 0 { if len(tokens) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), "No API tokens match the current filter.") return emptyStatePanel(gtx, u.theme, u.listEmptyState())
lbl.Color = mutedColor
return lbl.Layout(gtx)
} }
return material.List(u.theme, &u.list).Layout(gtx, len(tokens), func(gtx layout.Context, i int) layout.Dimensions { return material.List(u.theme, &u.list).Layout(gtx, len(tokens), func(gtx layout.Context, i int) layout.Dimensions {
return u.apiTokenRow(gtx, &u.apiTokenClicks[i], i, tokens[i]) return u.apiTokenRow(gtx, &u.apiTokenClicks[i], i, tokens[i])
@@ -430,6 +684,11 @@ func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions { func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
events := u.apiAuditEvents() events := u.apiAuditEvents()
allEvents := []apiaudit.Event(nil)
if u.auditLog != nil {
allEvents = u.auditLog.Events()
}
tokenFilters, decisionFilters, operationFilters := u.apiAuditQuickFilters(allEvents)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := "Local gRPC audit history" text := "Local gRPC audit history"
@@ -441,9 +700,19 @@ func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Filter by token, decision, or operation. Use the quick filters below or type a resource path in Search vault.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterPanel(gtx, tokenFilters, decisionFilters, operationFilters)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(events) == 0 { if len(events) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), "No audit events yet.") lbl := material.Label(u.theme, unit.Sp(14), u.listEmptyMessage())
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
} }
@@ -454,6 +723,109 @@ func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
) )
} }
func (u *ui) apiAuditQuickFilterPanel(gtx layout.Context, tokenFilters, decisionFilters, operationFilters []apiAuditQuickFilter) layout.Dimensions {
hasTokens := len(tokenFilters) > 0
hasDecisions := len(decisionFilters) > 0
hasOperations := len(operationFilters) > 0
query := strings.TrimSpace(u.search.Text())
if !hasTokens && !hasDecisions && !hasOperations && query == "" {
return layout.Dimensions{}
}
children := make([]layout.FlexChild, 0, 8)
if query != "" {
children = append(children,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, &u.clearAPIAuditFilters, "Clear filters", false, "")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
}
if hasTokens {
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterRow(gtx, "Tokens", tokenFilters, &u.apiAuditTokenFilters)
}))
}
if hasDecisions {
if len(children) > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterRow(gtx, "Decisions", decisionFilters, &u.apiAuditDecisionFilters)
}))
}
if hasOperations {
if len(children) > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterRow(gtx, "Operations", operationFilters, &u.apiAuditOperationFilters)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []apiAuditQuickFilter, clicks *[]widget.Clickable) layout.Dimensions {
buttons := apiAuditFilterButtons(clicks, filters)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) {
column := make([]layout.FlexChild, 0, len(filters)*2)
for i := range filters {
if i > 0 {
column = append(column, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
filter := filters[i]
click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
}
flexChildren := make([]layout.FlexChild, 0, len(filters)*2)
for i := range filters {
if i > 0 {
flexChildren = append(flexChildren, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
}
filter := filters[i]
click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
}),
)
}
func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable, label string, selected bool, query string) layout.Dimensions {
for click.Clicked(gtx) {
u.search.SetText(strings.TrimSpace(query))
u.filter()
}
btn := material.Button(u.theme, click, label)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(11)
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
if selected {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 248, B: 238, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 224, B: 214, A: 255}
btn.Color = accentColor
}
return btn.Layout(gtx)
}
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
removeClicks := u.ensureAPIPolicyRemoveClickables(0) removeClicks := u.ensureAPIPolicyRemoveClickables(0)
@@ -462,140 +834,269 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
} }
rows := []layout.Widget{ rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "API Token") return card(gtx, func(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
return lbl.Layout(gtx) layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "API Token Management")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), apiTokenManagementTitle(token, ok))
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), apiTokenManagementSubtitle(token, ok))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}, },
layout.Spacer{Height: unit.Dp(8)}.Layout, }
labeledEditor(u.theme, "Name", &u.apiTokenName, false), rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(10)}.Layout,
labeledEditor(u.theme, "Client Name", &u.apiTokenClientName, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Expires At", "Optional RFC3339 timestamp, for example 2026-04-01T15:04:05Z.", &u.apiTokenExpiresAt, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx) return card(gtx, func(gtx layout.Context) layout.Dimensions {
content := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Identity")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Name", &u.apiTokenName, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Client Name", &u.apiTokenClientName, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Expires At", "Optional RFC3339 timestamp, for example 2026-04-01T15:04:05Z.", &u.apiTokenExpiresAt, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx)
}),
}
if ok {
content = append(content,
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(detailLine(u.theme, "Token ID", token.ID)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Created", token.CreatedAt.Local().Format(time.RFC3339))),
)
if token.RevokedAt != nil {
content = append(content,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Revoked", token.RevokedAt.Local().Format(time.RFC3339))),
)
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, content...)
})
}, },
} )
if ok { if ok {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token ID", token.ID),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Created", token.CreatedAt.Local().Format(time.RFC3339)),
)
if token.RevokedAt != nil {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Revoked", token.RevokedAt.Local().Format(time.RFC3339)),
)
}
}
if strings.TrimSpace(u.apiTokenSecret) != "" {
rows = append(rows, rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout, layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return card(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "ONE-TIME SECRET") lbl := material.Label(u.theme, unit.Sp(14), "Lifecycle")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), u.apiTokenSecret)
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Save updates, rotate secret material, or shut a token down without leaving this surface.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.saveAPIToken, "Save Token")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.rotateAPIToken, "Rotate")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.disableAPIToken, "Disable")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyAPITokenSecret, "Copy Secret") return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.revokeAPIToken, "Revoke")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteAPIToken, "Delete")
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(u.apiTokenSecret) == "" {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "ONE-TIME SECRET")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), u.apiTokenSecret)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyAPITokenSecret, "Copy Secret")
}),
)
})
})
}), }),
) )
}) })
}, },
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions {
sectionRows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Policy Rules")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(4)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Effect, operation, and resource are separated so rules scan quickly while you review token scope.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(10)}.Layout,
}
if len(token.Policies) > 0 {
for i, rule := range token.Policies {
index := i
effect, operation, resource := policyRuleParts(rule)
sectionRows = append(sectionRows,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Operation", operation)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Resource", resource)),
)
})
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
)
}
} else {
sectionRows = append(sectionRows, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "No explicit rules yet. Approval prompts can create permanent rules.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
})
}
return material.List(u.theme, &u.apiPolicyList).Layout(gtx, len(sectionRows), func(gtx layout.Context, i int) layout.Dimensions {
return sectionRows[i](gtx)
})
})
},
layout.Spacer{Height: unit.Dp(10)}.Layout,
) )
} }
rows = append(rows, rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, return card(gtx, func(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
return tonedButton(gtx, u.theme, &u.saveAPIToken, "Save Token") layout.Rigid(func(gtx layout.Context) layout.Dimensions {
}), lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer")
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), lbl.Color = accentColor
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return lbl.Layout(gtx)
return tonedButton(gtx, u.theme, &u.rotateAPIToken, "Rotate") }),
}), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Rules are evaluated per operation. Explicit deny rules override allow rules.")
return tonedButton(gtx, u.theme, &u.disableAPIToken, "Disable") lbl.Color = mutedColor
}), return lbl.Layout(gtx)
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
return tonedButton(gtx, u.theme, &u.revokeAPIToken, "Revoke") layout.Rigid(func(gtx layout.Context) layout.Dimensions {
}), return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx)
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
return tonedButton(gtx, u.theme, &u.deleteAPIToken, "Delete") layout.Rigid(func(gtx layout.Context) layout.Dimensions {
}), return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
) }),
}, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Spacer{Height: unit.Dp(14)}.Layout, layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
func(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
lbl := material.Label(u.theme, unit.Sp(16), "Policy Rules") layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor if u.apiPolicyGroupScopeW.Value {
return lbl.Layout(gtx) return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
}, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Spacer{Height: unit.Dp(8)}.Layout, layout.Rigid(detailLine(u.theme, "Group Path", u.apiPolicyGroupPathSummary())),
) layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
if ok && len(token.Policies) > 0 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for i, rule := range token.Policies { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
index := i layout.Rigid(func(gtx layout.Context) layout.Dimensions {
ruleText := formatAPIPolicyRule(rule) return tonedButton(gtx, u.theme, &u.useCurrentGroupForPolicy, "Use Current Group")
rows = append(rows, }),
func(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
lbl := material.Label(u.theme, unit.Sp(13), ruleText) }),
lbl.Color = mutedColor )
return lbl.Layout(gtx) }),
}), )
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), })
layout.Rigid(func(gtx layout.Context) layout.Dimensions { }
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove") return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
}), return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
) layout.Rigid(detailLine(u.theme, "Entry", u.apiPolicyEntrySummary())),
}, layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
) return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
} layout.Rigid(func(gtx layout.Context) layout.Dimensions {
} else { return tonedButton(gtx, u.theme, &u.useSelectedEntryForPolicy, "Use Selected Entry")
rows = append(rows, func(gtx layout.Context) layout.Dimensions { }),
lbl := material.Label(u.theme, unit.Sp(13), "No explicit rules yet. Approval prompts can create permanent rules.") layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
lbl.Color = mutedColor layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return lbl.Layout(gtx) return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
}) }),
} )
rows = append(rows, }),
layout.Spacer{Height: unit.Dp(10)}.Layout, )
func(gtx layout.Context) layout.Dimensions { })
return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx) }),
}, layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx) }),
}, )
layout.Spacer{Height: unit.Dp(6)}.Layout, })
labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
}, },
) )
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
@@ -626,16 +1127,28 @@ func (u *ui) apiAuditDetailPanel(gtx layout.Context) layout.Dimensions {
event := events[u.selectedAuditIndex] event := events[u.selectedAuditIndex]
rows = append(rows, rows = append(rows,
layout.Spacer{Height: unit.Dp(12)}.Layout, layout.Spacer{Height: unit.Dp(12)}.Layout,
detailLine(u.theme, "Type", string(event.Type)), func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Selected event")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Type", string(event.Type))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(detailLine(u.theme, "Operation", string(event.Operation))),
)
})
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
detailLine(u.theme, "When", event.At.Local().Format(time.RFC3339)), detailLine(u.theme, "When", event.At.Local().Format(time.RFC3339)),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token", event.TokenName), detailLine(u.theme, "Token", event.TokenName),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Client", event.ClientName), detailLine(u.theme, "Client", event.ClientName),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Operation", string(event.Operation)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Resource", formatAuditResource(event.Resource)), detailLine(u.theme, "Resource", formatAuditResource(event.Resource)),
layout.Spacer{Height: unit.Dp(6)}.Layout, layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Message", event.Message), detailLine(u.theme, "Message", event.Message),
+36
View File
@@ -41,6 +41,7 @@ func (u *ui) attachmentInput() (string, []byte, error) {
func (u *ui) loadSelectedEntryIntoEditor() { func (u *ui) loadSelectedEntryIntoEditor() {
u.resetPasswordPeek() u.resetPasswordPeek()
u.clearGeneratedPasswordDraft()
u.selectedHistoryIndex = -1 u.selectedHistoryIndex = -1
u.historyIndex.SetText("") u.historyIndex.SetText("")
@@ -175,6 +176,7 @@ func (u *ui) saveEntryAction() error {
return err return err
} }
u.editingEntry = false u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter() u.filter()
return nil return nil
} }
@@ -229,6 +231,7 @@ func (u *ui) saveTemplateAction() error {
return err return err
} }
u.editingEntry = false u.editingEntry = false
u.clearGeneratedPasswordDraft()
u.filter() u.filter()
return nil return nil
} }
@@ -251,6 +254,14 @@ func (u *ui) createGroupAction() error {
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text())) return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
} }
func (u *ui) moveCurrentGroupAction() error {
u.clearDeleteGroupConfirmation()
if len(u.displayPath()) == 0 {
return fmt.Errorf("no current group selected")
}
return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text()))
}
func (u *ui) renameGroupAction() error { func (u *ui) renameGroupAction() error {
u.clearDeleteGroupConfirmation() u.clearDeleteGroupConfirmation()
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text())) return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
@@ -400,6 +411,7 @@ func (u *ui) generatePasswordAction() error {
return err return err
} }
u.entryPassword.SetText(password) u.entryPassword.SetText(password)
u.generatedPasswordDraft = true
return nil return nil
} }
@@ -481,3 +493,27 @@ func marshalFields(fields map[string]string) string {
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func (u *ui) clearGeneratedPasswordDraft() {
u.generatedPasswordDraft = false
}
func (u *ui) attachmentActionSummary() string {
items := u.selectedAttachmentItems()
if len(items) == 0 {
return "No attachments yet. Add one below to store a file with this entry."
}
name := strings.TrimSpace(u.attachmentName.Text())
if name == "" {
return "Select an attachment above, then replace it, export it, or remove it below."
}
for _, item := range items {
if item.Name == name {
return fmt.Sprintf("Selected attachment %q. Replace it, export it, or remove it below.", name)
}
}
return fmt.Sprintf("Attachment %q is not on this entry yet. Add it as new, or select an existing attachment above.", name)
}
+1159 -258
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -46,14 +46,14 @@ func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
return true return true
} }
if u.isVaultLocked() && name == key.NameReturn { if u.isVaultLocked() && name == key.NameReturn {
u.runAction("unlock vault", u.unlockAction) u.startUnlockAction()
return true return true
} }
if u.shouldShowLifecycleSetup() && name == key.NameReturn { if u.shouldShowLifecycleSetup() && name == key.NameReturn {
if u.lifecycleMode == "remote" { if u.lifecycleMode == "remote" {
u.runAction("open remote vault", u.openRemoteAction) u.startOpenRemoteAction()
} else { } else {
u.runAction("open vault", u.openVaultAction) u.startOpenVaultAction()
} }
return true return true
} }
+300
View File
@@ -0,0 +1,300 @@
package main
import (
"encoding/json"
"image/color"
"os"
"path/filepath"
"strings"
"time"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/vault"
)
const (
displayDensityDense = "dense"
displayDensityComfortable = "comfortable"
contrastStandard = "standard"
contrastHigh = "high"
keyboardFocusStandard = "standard"
keyboardFocusProminent = "prominent"
)
type accessibilityPreferences struct {
DisplayDensity string
Contrast string
ReducedMotion bool
KeyboardFocus string
}
type settingsFile struct {
Sync syncSettings `json:"sync,omitempty"`
}
type syncSettings struct {
SourceDefault string `json:"sourceDefault,omitempty"`
DirectionDefault string `json:"directionDefault,omitempty"`
}
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
}
type settingsDraft struct {
Accessibility accessibilityPreferences
Sync syncSettingsDraft
}
type legacySyncPreferences struct {
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
}
type choiceSpec struct {
Click *widget.Clickable
Label string
Active bool
}
func defaultAccessibilityPreferences() accessibilityPreferences {
return accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(true),
Contrast: contrastStandard,
KeyboardFocus: keyboardFocusStandard,
}
}
func displayDensityForDenseLayout(dense bool) string {
if dense {
return displayDensityDense
}
return displayDensityComfortable
}
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
normalized := defaultAccessibilityPreferences()
switch prefs.DisplayDensity {
case displayDensityDense, displayDensityComfortable:
normalized.DisplayDensity = prefs.DisplayDensity
}
switch prefs.Contrast {
case contrastStandard, contrastHigh:
normalized.Contrast = prefs.Contrast
}
switch prefs.KeyboardFocus {
case keyboardFocusStandard, keyboardFocusProminent:
normalized.KeyboardFocus = prefs.KeyboardFocus
}
normalized.ReducedMotion = prefs.ReducedMotion
return normalized
}
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
normalized := normalizeAccessibilityPreferences(prefs)
u.denseLayout = normalized.DisplayDensity == displayDensityDense
u.accessibilityPrefs = normalized
}
func (u *ui) loadSettingsDraft() {
u.settingsDraft = settingsDraft{
Accessibility: accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
Contrast: u.accessibilityPrefs.Contrast,
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
},
Sync: syncSettingsDraft{
SourceDefault: u.syncDefaultSourceMode,
DirectionDefault: u.syncDefaultDirection,
},
}
}
func (u *ui) saveSecuritySettingsAction() error {
if err := u.applySecuritySettingsLive(); err != nil {
return err
}
u.securityDialogOpen = false
return nil
}
func (u *ui) applySecuritySettingsLive() error {
settings := vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}
if err := u.state.ConfigureSecurity(settings); err != nil {
return err
}
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
}
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
u.applySettingsFormToPreferences()
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
u.saveSettings()
u.saveUIPreferences()
return nil
}
func (u *ui) loadSettings() {
u.syncDefaultSourceMode = syncSourceLocal
u.syncDefaultDirection = syncDirectionPull
if strings.TrimSpace(u.settingsPath) != "" {
content, err := os.ReadFile(u.settingsPath)
if err == nil {
var settings settingsFile
if json.Unmarshal(content, &settings) == nil {
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
return
}
}
}
u.loadLegacySyncDefaultsFromUIPreferences()
}
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
if strings.TrimSpace(u.uiPreferencesPath) == "" {
return
}
content, err := os.ReadFile(u.uiPreferencesPath)
if err != nil {
return
}
var prefs legacySyncPreferences
if err := json.Unmarshal(content, &prefs); err != nil {
return
}
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault))
}
func (u *ui) saveSettings() {
if strings.TrimSpace(u.settingsPath) == "" {
return
}
if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil {
return
}
content, err := json.MarshalIndent(settingsFile{
Sync: syncSettings{
SourceDefault: string(u.syncDefaultSourceMode),
DirectionDefault: string(u.syncDefaultDirection),
},
}, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.settingsPath, content, 0o600)
}
func (u *ui) showStatusMessage(message string) {
u.state.StatusMessage = message
if u.accessibilityPrefs.ReducedMotion {
u.statusExpiresAt = time.Time{}
return
}
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
}
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
return sectionCard(gtx, u.theme, title, detail, body)
}
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), body)
lbl.Color = th.Palette.Fg
return lbl.Layout(gtx)
}),
)
})
}
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
children := make([]layout.FlexChild, 0, len(choices)*2)
for i, choice := range choices {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
}
current := choice
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
}
type listRowColors struct {
Title color.NRGBA
Meta color.NRGBA
Secondary color.NRGBA
Divider color.NRGBA
Fill color.NRGBA
Edge color.NRGBA
}
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
colors := listRowColors{
Title: accentColor,
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
Secondary: mutedColor,
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
}
if selected {
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
}
if recycleBin {
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
}
if focused && !selected {
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
}
if u.accessibilityPrefs.Contrast == contrastHigh {
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
if focused || selected {
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
}
}
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
}
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
}
return colors
}
+5
View File
@@ -37,6 +37,8 @@ func (u *ui) processShortcuts(gtx layout.Context) {
key.Filter{Name: key.NameUpArrow}, key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow}, key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn}, key.Filter{Name: key.NameReturn},
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
) )
if !ok { if !ok {
break break
@@ -48,6 +50,9 @@ func (u *ui) processShortcuts(gtx layout.Context) {
} }
u.handleKeyPress(ke.Name, ke.Modifiers) u.handleKeyPress(ke.Name, ke.Modifiers)
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
_ = u.handlePhoneBack()
}
} }
} }
+28 -28
View File
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "old-token", Password: "old-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Original note", Notes: "Original note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
} }
model.UpsertEntry(Entry{ model.UpsertEntry(Entry{
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "new-token", Password: "new-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Updated note", Notes: "Updated note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "ha-codex", ID: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
} }
if err := model.DeleteEntry("ha-codex"); err != nil { if err := model.DeleteEntry("surveillance-console"); err != nil {
t.Fatalf("DeleteEntry() error = %v", err) t.Fatalf("DeleteEntry() error = %v", err)
} }
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin)) t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
} }
if model.RecycleBin[0].Title != "Home Assistant (Codex)" { if model.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)") t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
} }
} }
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
model := Model{ model := Model{
RecycleBin: []Entry{ RecycleBin: []Entry{
{ {
ID: "dynadot", ID: "bellagio",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-3", Password: "token-3",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
} }
if err := model.RestoreEntry("dynadot"); err != nil { if err := model.RestoreEntry("bellagio"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
} }
if got[0].Title != "Dynadot" { if got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot") t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
} }
if len(model.RecycleBin) != 0 { if len(model.RecycleBin) != 0 {
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "new-token", Password: "new-token",
Notes: "Current note", Notes: "Current note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []Entry{ History: []Entry{
{ {
ID: "git-server-history-1", ID: "vault-console-history-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "old-token", Password: "old-token",
Notes: "Previous note", Notes: "Previous note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
}, },
} }
if err := model.RestoreEntryVersion("git-server", 0); err != nil { if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
t.Fatalf("RestoreEntryVersion() error = %v", err) t.Fatalf("RestoreEntryVersion() error = %v", err)
} }
+46 -46
View File
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Root",
mustGroup("Internet", mustGroup("Internet",
mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"), mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"), mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
), ),
mustGroup("Home Assistant", mustGroup("Home Assistant",
mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"), mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
), ),
), ),
}, },
@@ -53,11 +53,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
} }
if got[0].Title != "Dynadot" || got[0].Username != "jjulian" || got[0].URL != "https://www.dynadot.com" { if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" {
t.Fatalf("unexpected first entry: %#v", got[0]) t.Fatalf("unexpected first entry: %#v", got[0])
} }
if got[1].Title != "Git Server" || got[1].Username != "joejulian" || got[1].URL != "https://git.julianfamily.org" { if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" {
t.Fatalf("unexpected second entry: %#v", got[1]) t.Fatalf("unexpected second entry: %#v", got[1])
} }
@@ -70,7 +70,7 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
func TestLoadKDBXPreservesEntryDetails(t *testing.T) { func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
t.Parallel() t.Parallel()
entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2") entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
entry.Tags = "automation; home" entry.Tags = "automation; home"
entry.Values = append(entry.Values, entry.Values = append(entry.Values,
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."), mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Personal git server token entry used for automation and CLI auth.", Notes: "Personal git server token entry used for automation and CLI auth.",
Tags: []string{"git", "infra"}, Tags: []string{"git", "infra"},
Fields: map[string]string{ Fields: map[string]string{
@@ -146,10 +146,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
}, },
{ {
ID: "entry-2", ID: "entry-2",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Notes: "Long-lived token used by Codex for home automation tasks.", Notes: "Long-lived token used by Codex for home automation tasks.",
Tags: []string{"automation", "home"}, Tags: []string{"automation", "home"},
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBX() error = %v", err) t.Fatalf("LoadKDBX() error = %v", err)
} }
got := loaded.Search("git") got := loaded.Search("vault")
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got)) t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
} }
@@ -245,18 +245,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "new-token", Password: "new-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []Entry{ History: []Entry{
{ {
ID: "entry-1-old", ID: "entry-1-old",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "old-token", Password: "old-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Notes: "Original version", Notes: "Original version",
}, },
@@ -296,10 +296,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
RecycleBin: []Entry{ RecycleBin: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -319,8 +319,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin)) t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
} }
if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" { if loaded.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Home Assistant (Codex)") t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
} }
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" { if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
@@ -358,7 +358,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))), mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
}, },
}, },
}, },
@@ -378,9 +378,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := model.Search("git") got := model.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-1" { if len(got) != 1 || got[0].Entry.Password != "token-1" {
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving git entry", got) t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
} }
} }
@@ -413,7 +413,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))), mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
}, },
}, },
}, },
@@ -452,7 +452,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))), mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
}, },
}, },
}, },
@@ -490,11 +490,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -510,9 +510,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := loaded.Search("git") got := loaded.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-1" { if len(got) != 1 || got[0].Entry.Password != "token-1" {
t.Fatalf("round-trip with key file = %#v, want git entry with password", got) t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
} }
} }
@@ -533,11 +533,11 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "ha-codex", ID: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -570,11 +570,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"), "token.txt": []byte("secret attachment contents"),
@@ -610,10 +610,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Current credential", Notes: "Current credential",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
@@ -622,10 +622,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
History: []Entry{ History: []Entry{
{ {
ID: "entry-1-history-1", ID: "entry-1-history-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Original credential", Notes: "Original credential",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
+89 -5
View File
@@ -96,6 +96,27 @@ func (m Model) EntriesInPath(path []string) []Entry {
return entries return entries
} }
func (m Model) EntriesUnderPath(path []string) []Entry {
var entries []Entry
for _, entry := range m.Entries {
if !hasPathPrefix(entry.Path, path) {
continue
}
entries = append(entries, entry)
}
slices.SortFunc(entries, func(a, b Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return entries
}
func (m Model) Search(query string) []SearchResult { func (m Model) Search(query string) []SearchResult {
query = strings.TrimSpace(strings.ToLower(query)) query = strings.TrimSpace(strings.ToLower(query))
if query == "" { if query == "" {
@@ -256,13 +277,14 @@ func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
} }
func (m *Model) CreateGroup(parent []string, name string) { func (m *Model) CreateGroup(parent []string, name string) {
groupPath := append(append([]string(nil), parent...), name) groupPath := append([]string(nil), parent...)
for _, existing := range m.Groups { for _, part := range splitGroupPath(name) {
if slices.Equal(existing, groupPath) { groupPath = append(groupPath, part)
return if groupPathExists(m.Groups, groupPath) {
continue
} }
m.Groups = append(m.Groups, append([]string(nil), groupPath...))
} }
m.Groups = append(m.Groups, groupPath)
} }
func (m *Model) RenameGroup(path []string, newName string) error { func (m *Model) RenameGroup(path []string, newName string) error {
@@ -310,6 +332,47 @@ func (m *Model) MoveEntry(id string, path []string) error {
return ErrEntryNotFound return ErrEntryNotFound
} }
func (m *Model) MoveGroup(path, parent []string) error {
if len(path) == 0 {
return ErrEntryNotFound
}
if hasPathPrefix(parent, path) {
return ErrEntryNotFound
}
groupName := path[len(path)-1]
newPath := append(append([]string(nil), parent...), groupName)
moved := false
for i := range m.Entries {
if !hasPathPrefix(m.Entries[i].Path, path) {
continue
}
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
moved = true
}
for i := range m.Templates {
if !hasPathPrefix(m.Templates[i].Path, path) {
continue
}
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
moved = true
}
for i := range m.Groups {
if !hasPathPrefix(m.Groups[i], path) {
continue
}
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
moved = true
}
if !moved {
return ErrEntryNotFound
}
if !groupPathExists(m.Groups, newPath) {
m.Groups = append(m.Groups, append([]string(nil), newPath...))
}
return nil
}
func (m *Model) MoveTemplate(id string, path []string) error { func (m *Model) MoveTemplate(id string, path []string) error {
for i := range m.Templates { for i := range m.Templates {
if m.Templates[i].ID != id { if m.Templates[i].ID != id {
@@ -349,6 +412,27 @@ func hasPathPrefix(path, prefix []string) bool {
return slices.Equal(path[:len(prefix)], prefix) return slices.Equal(path[:len(prefix)], prefix)
} }
func splitGroupPath(name string) []string {
var parts []string
for _, part := range strings.Split(name, "/") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
parts = append(parts, part)
}
return parts
}
func groupPathExists(groups [][]string, path []string) bool {
for _, existing := range groups {
if slices.Equal(existing, path) {
return true
}
}
return false
}
func mergeEntryTemplate(template, overrides Entry) Entry { func mergeEntryTemplate(template, overrides Entry) Entry {
entry := cloneEntry(template) entry := cloneEntry(template)
+87 -36
View File
@@ -9,9 +9,9 @@ import (
func testModel() Model { func testModel() Model {
return Model{ return Model{
Entries: []Entry{ Entries: []Entry{
{ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}}, {ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}}, {ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}}, {ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}}, {ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
}, },
} }
@@ -20,7 +20,7 @@ func testModel() Model {
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) { func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
model := testModel() model := testModel()
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"} want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
@@ -31,30 +31,43 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) { func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
model := testModel() model := testModel()
got := model.EntriesInPath([]string{"Joe", "Internet"}) got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
} }
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" { if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title) t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
} }
} }
func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
t.Parallel()
model := testModel()
got := model.EntriesUnderPath([]string{"Crew"})
if len(got) != 3 {
t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
}
if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
}
}
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) { func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
model := testModel() model := testModel()
got := model.Search("git") got := model.Search("vault")
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(Search()) = %d, want 1", len(got)) t.Fatalf("len(Search()) = %d, want 1", len(got))
} }
if got[0].Entry.Title != "Git Server" { if got[0].Entry.Title != "Vault Console" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server") t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
} }
if got[0].Path != "Joe / Internet" { if got[0].Path != "Crew / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet") t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
} }
} }
@@ -106,11 +119,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
entry, err := model.InstantiateTemplate("tpl-1", Entry{ entry, err := model.InstantiateTemplate("tpl-1", Entry{
ID: "entry-1", ID: "entry-1",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "hunter2", Password: "hunter2",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Joe", "Internet"}, Path: []string{"Crew", "Internet"},
Tags: []string{"dns"}, Tags: []string{"dns"},
}) })
if err != nil { if err != nil {
@@ -121,11 +134,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1") t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
} }
if entry.Title != "Dynadot" { if entry.Title != "Bellagio" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot") t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
} }
if entry.Username != "jjulian" || entry.Password != "hunter2" || entry.URL != "https://www.dynadot.com" { if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
t.Fatalf("entry credentials = %#v, want override values", entry) t.Fatalf("entry credentials = %#v, want override values", entry)
} }
@@ -141,9 +154,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod") t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
} }
got := model.EntriesInPath([]string{"Joe", "Internet"}) got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 1 || got[0].Title != "Dynadot" { if len(got) != 1 || got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got) t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
} }
} }
@@ -165,7 +178,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
Templates: []Entry{ Templates: []Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}}, {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
@@ -209,8 +222,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
@@ -225,8 +238,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
if duplicate.ID != "entry-2" { if duplicate.ID != "entry-2" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2") t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
} }
if duplicate.Title != "Git Server (Copy)" { if duplicate.Title != "Vault Console (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)") t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
} }
got := model.EntriesInPath([]string{"Root", "Internet"}) got := model.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 { if len(got) != 2 {
@@ -237,29 +250,45 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) { func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe"}, "Finance") model.CreateGroup([]string{"Crew"}, "Finance")
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
want := []string{"Finance", "Home Assistant", "Internet"} want := []string{"Finance", "Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want) t.Fatalf("ChildGroups() = %v, want %v", got, want)
} }
} }
func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
got := model.ChildGroups([]string{"Crew"})
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
}
got = model.ChildGroups([]string{"Crew", "Infrastructure"})
if !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) { func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
model := testModel() model := testModel()
if err := model.RenameGroup([]string{"Joe", "Internet"}, "Infra"); err != nil { if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
t.Fatalf("RenameGroup() error = %v", err) t.Fatalf("RenameGroup() error = %v", err)
} }
got := model.EntriesInPath([]string{"Joe", "Infra"}) got := model.EntriesInPath([]string{"Crew", "Infra"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Joe/Infra)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
} }
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 { if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename") t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
} }
} }
@@ -276,15 +305,37 @@ func TestMoveEntryChangesItsPath(t *testing.T) {
} }
} }
func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
t.Fatalf("MoveGroup() error = %v", err)
}
got := model.EntriesInPath([]string{"Tricia", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
}
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
}
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Tricia/Internet) = %v, want [Infrastructure]", gotGroups)
}
}
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) { func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe"}, "Finance") model.CreateGroup([]string{"Crew"}, "Finance")
if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil { if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
t.Fatalf("DeleteGroup() error = %v", err) t.Fatalf("DeleteGroup() error = %v", err)
} }
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"} want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want) t.Fatalf("ChildGroups() = %v, want %v", got, want)
+5 -5
View File
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method) t.Fatalf("method = %s, want GET", r.Method)
} }
if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" { if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok) t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
} }
w.Header().Set("ETag", `"etag-1"`) w.Header().Set("ETag", `"etag-1"`)
_, _ = io.WriteString(w, "vault-bytes") _, _ = io.WriteString(w, "vault-bytes")
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "jjulian", Username: "rustyryan",
Password: "secret", Password: "secret",
} }
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "jjulian", Username: "rustyryan",
Password: "secret", Password: "secret",
} }
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "jjulian", Username: "rustyryan",
Password: "secret", Password: "secret",
} }