Compare commits
95 Commits
v0.0.1
..
8be0aad14d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8be0aad14d | |||
| eef209ae43 | |||
| 322dc84917 | |||
| 3d53ff8e95 | |||
| 0a6b7fe5c0 | |||
| 82bfd694b6 | |||
| d9c2d48ff5 | |||
| 8495beb8dc | |||
| 3e471366ce | |||
| f9b58367da | |||
| 20f84ac58e | |||
| 2db8a71704 | |||
| b7a4742ee6 | |||
| 9afddd7a93 | |||
| 84c188129e | |||
| 980d30f6c2 | |||
| e6b33134c9 | |||
| a269217c60 | |||
| 722d2eefa0 | |||
| dba0bf1f2c | |||
| 47942014d7 | |||
| ed2118223f | |||
| 9e30f654b2 | |||
| 1ecad1fae9 | |||
| fc34508689 | |||
| 9dea7a4a34 | |||
| e5904dd224 | |||
| da769a9728 | |||
| f47682f3a1 | |||
| 9dcc5f1d6f | |||
| ee9b00bd85 | |||
| 8ce924e08f | |||
| bda28eef4b | |||
| bf56e38bc5 | |||
| d137018d3a | |||
| fcc0291dd9 | |||
| cc127a013e | |||
| b2f1d9a66d | |||
| b391cea295 | |||
| fba135ff09 | |||
| 71b383114d | |||
| 75dc6453f7 | |||
| 72b413a11a | |||
| 75002a4c47 | |||
| e98256709d | |||
| 5e15ad3265 | |||
| d492743eb1 | |||
| 4a629c16bd | |||
| 62fc343ecf | |||
| 1e51eff76e | |||
| 4509634a15 | |||
| 47bbbdb05e | |||
| 4fbaee3970 | |||
| d2b49ca0ad | |||
| fedeab2fc1 | |||
| b56401b5c6 | |||
| 01559a3a2b | |||
| 16dc1de3c8 | |||
| bd9674a1f5 | |||
| e2e38a97a0 | |||
| 34a6e0150e | |||
| ec063ac81f | |||
| ac5b6894cf | |||
| d6bc213474 | |||
| 706a72e135 | |||
| f8111f626f | |||
| 0bfa30de91 | |||
| 24dcf9c8ad | |||
| ea5c8b3c31 | |||
| c355eebed5 | |||
| 7651b3b237 | |||
| da942b41d7 | |||
| 1c0cd20bcc | |||
| cffe05af82 | |||
| 8deccced9e | |||
| 4fe912b41f | |||
| 55ca6352b4 | |||
| 0cfccb58d6 | |||
| b043ecdc83 | |||
| d3be07f252 | |||
| 0f30c5b1c5 | |||
| 985150c36f | |||
| f39bdcd3be | |||
| 48ffa78fa2 | |||
| 1349fe6e38 | |||
| 19ccab1d8d | |||
| ae1921506d | |||
| 1f51904567 | |||
| ef867adfa1 | |||
| 5eb2068d3e | |||
| 77407e0510 | |||
| dd0b0b6f6e | |||
| 566256ab79 | |||
| a4b3d5c1e1 | |||
| ce01c47256 |
@@ -1,116 +0,0 @@
|
||||
---
|
||||
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 user’s 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 user’s 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
|
||||
@@ -1,173 +0,0 @@
|
||||
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"
|
||||
@@ -14,8 +14,6 @@ These instructions apply to all future work in this repository.
|
||||
## Skills
|
||||
|
||||
- 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:
|
||||
`go-code-review`,
|
||||
`go-concurrency`,
|
||||
@@ -128,20 +126,6 @@ These features are product requirements, not “nice to have” ideas.
|
||||
- Keep `golangci-lint` passing.
|
||||
- Keep `go test ./...` passing.
|
||||
- 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 user’s 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 user’s 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 user’s 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.
|
||||
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
|
||||
|
||||
@@ -41,24 +41,6 @@ Desktop 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
|
||||
|
||||
KeePassGO uses Gio, so Android packaging is done with `gogio`.
|
||||
|
||||
@@ -6,102 +6,6 @@ These segments are intended to be independently executable wherever possible.
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
These segments define the work for programmatic access control over gRPC.
|
||||
@@ -545,21 +449,6 @@ Exit criteria:
|
||||
- Focus and accessibility states are visible and intentional.
|
||||
- `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
|
||||
|
||||
Scope:
|
||||
|
||||
Binary file not shown.
@@ -35,26 +35,20 @@ final class AutofillCacheStore {
|
||||
if (entries.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
NormalizedTarget target = normalizeURL(webDomain);
|
||||
if (target.host.isEmpty()) {
|
||||
return null;
|
||||
String normalizedDomain = normalizeHost(webDomain);
|
||||
if (normalizedDomain.isEmpty()) {
|
||||
return entries.get(0);
|
||||
}
|
||||
List<Entry> exactHost = new ArrayList<>();
|
||||
List<Entry> parentHost = new ArrayList<>();
|
||||
Entry fallback = null;
|
||||
for (Entry entry : entries) {
|
||||
if (entryMatchesHost(entry, target.host)) {
|
||||
exactHost.add(entry);
|
||||
continue;
|
||||
if (entry.host.equals(normalizedDomain)) {
|
||||
return entry;
|
||||
}
|
||||
if (entryMatchesParentHost(entry, target.host)) {
|
||||
parentHost.add(entry);
|
||||
if (fallback == null && normalizedDomain.endsWith("." + entry.host)) {
|
||||
fallback = entry;
|
||||
}
|
||||
}
|
||||
Entry matched = chooseEntry(target, exactHost);
|
||||
if (matched != null) {
|
||||
return matched;
|
||||
}
|
||||
return chooseEntry(target, parentHost);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static File findCacheFile(Context context) {
|
||||
@@ -107,8 +101,6 @@ final class AutofillCacheStore {
|
||||
String username = "";
|
||||
String password = "";
|
||||
String host = "";
|
||||
String url = "";
|
||||
List<String> targets = new ArrayList<>();
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String name = reader.nextName();
|
||||
@@ -122,26 +114,16 @@ final class AutofillCacheStore {
|
||||
case "password":
|
||||
password = nextString(reader);
|
||||
break;
|
||||
case "url":
|
||||
url = nextString(reader);
|
||||
break;
|
||||
case "host":
|
||||
host = normalizeHost(nextString(reader));
|
||||
break;
|
||||
case "targets":
|
||||
reader.beginArray();
|
||||
while (reader.hasNext()) {
|
||||
targets.add(nextString(reader));
|
||||
}
|
||||
reader.endArray();
|
||||
break;
|
||||
default:
|
||||
reader.skipValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
reader.endObject();
|
||||
return new Entry(title, username, password, host, url, targets);
|
||||
return new Entry(title, username, password, host);
|
||||
}
|
||||
|
||||
private static String nextString(JsonReader reader) throws IOException {
|
||||
@@ -153,12 +135,8 @@ final class AutofillCacheStore {
|
||||
}
|
||||
|
||||
private static String normalizeHost(String raw) {
|
||||
return normalizeURL(raw).host;
|
||||
}
|
||||
|
||||
private static NormalizedTarget normalizeURL(String raw) {
|
||||
if (raw == null) {
|
||||
return new NormalizedTarget("", "", "");
|
||||
return "";
|
||||
}
|
||||
String value = raw.trim().toLowerCase(Locale.US);
|
||||
if (value.startsWith("http://")) {
|
||||
@@ -174,161 +152,20 @@ final class AutofillCacheStore {
|
||||
if (colon >= 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
final String title;
|
||||
final String username;
|
||||
final String password;
|
||||
final String host;
|
||||
final String url;
|
||||
final List<String> targets;
|
||||
|
||||
Entry(String title, String username, String password, String host, String url, List<String> targets) {
|
||||
Entry(String title, String username, String password, String host) {
|
||||
this.title = title;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
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,7 +21,6 @@ import java.util.List;
|
||||
|
||||
public final class KeePassGOAutofillService extends AutofillService {
|
||||
private static final String TAG = "KeePassGOAutofill";
|
||||
private static final String APP_SCHEME = "androidapp://";
|
||||
|
||||
@Override
|
||||
public void onConnected() {
|
||||
@@ -52,21 +51,15 @@ public final class KeePassGOAutofillService extends AutofillService {
|
||||
|
||||
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
|
||||
ParsedFields fields = new ParsedFields();
|
||||
ParsedTarget target = parseWindow(structure, fields);
|
||||
Log.i(
|
||||
TAG,
|
||||
"parsed target=" + target.matchTarget
|
||||
+ " package=" + target.packageName
|
||||
+ " usernameId=" + fields.usernameId
|
||||
+ " passwordId=" + fields.passwordId
|
||||
);
|
||||
String webDomain = parseWindow(structure, fields);
|
||||
Log.i(TAG, "parsed domain=" + webDomain + " usernameId=" + fields.usernameId + " passwordId=" + fields.passwordId);
|
||||
if (fields.passwordId == null) {
|
||||
Log.i(TAG, "no password field found");
|
||||
callback.onSuccess(null);
|
||||
return;
|
||||
}
|
||||
|
||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget);
|
||||
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, webDomain);
|
||||
if (entry == null) {
|
||||
Log.i(TAG, "no autofill cache match");
|
||||
callback.onSuccess(null);
|
||||
@@ -103,7 +96,7 @@ public final class KeePassGOAutofillService extends AutofillService {
|
||||
callback.onSuccess();
|
||||
}
|
||||
|
||||
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
|
||||
private static String parseWindow(AssistStructure structure, ParsedFields fields) {
|
||||
String domain = "";
|
||||
final int windowCount = structure.getWindowNodeCount();
|
||||
for (int i = 0; i < windowCount; i++) {
|
||||
@@ -113,17 +106,7 @@ public final class KeePassGOAutofillService extends AutofillService {
|
||||
domain = next;
|
||||
}
|
||||
}
|
||||
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("", "");
|
||||
return domain;
|
||||
}
|
||||
|
||||
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
|
||||
@@ -202,14 +185,4 @@ public final class KeePassGOAutofillService extends AutofillService {
|
||||
AutofillId usernameId;
|
||||
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
@@ -20,7 +20,7 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
||||
if err := lifecycle.Create(vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t),
|
||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
|
||||
+69
-69
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
token.Entry([]string{"Root", "API Tokens"}),
|
||||
@@ -78,22 +78,22 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
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: "vault-console", 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"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
|
||||
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"})
|
||||
if 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{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t),
|
||||
@@ -139,8 +139,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
}
|
||||
if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
|
||||
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +150,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t),
|
||||
@@ -196,7 +196,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
@@ -282,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -454,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
||||
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
|
||||
BaseUrl: "https://dav.example.com",
|
||||
Path: "vaults/main.kdbx",
|
||||
Username: "rustyryan",
|
||||
Username: "jjulian",
|
||||
Password: "dav-token",
|
||||
MasterPassword: "correct horse battery staple",
|
||||
}); err != nil {
|
||||
@@ -618,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
||||
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
|
||||
}
|
||||
|
||||
if resp.Entries[0].Title != "Vault Console" {
|
||||
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
|
||||
if resp.Entries[0].Title != "Git Server" {
|
||||
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
|
||||
}
|
||||
if got := resp.Entries[0].Fields["X-Role"]; 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)
|
||||
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
|
||||
Id: "vault-console",
|
||||
Id: "git-server",
|
||||
Target: "password",
|
||||
}); err != nil {
|
||||
t.Fatalf("CopyEntryField() error = %v", err)
|
||||
@@ -767,11 +767,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
|
||||
Entry: &keepassgov1.Entry{
|
||||
Id: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Id: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Url: "https://surveillance.crew.example.invalid",
|
||||
Url: "https://lights.julianfamily.org",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "lights-admin",
|
||||
},
|
||||
@@ -782,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
||||
t.Fatalf("UpsertEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if upserted.Entry.Title != "Surveillance Console" {
|
||||
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
|
||||
if upserted.Entry.Title != "Home Assistant (Codex)" {
|
||||
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
|
||||
}
|
||||
if got := upserted.Entry.Fields["X-Role"]; 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()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
|
||||
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
|
||||
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if restored.Entry.Title != "Vault Console" {
|
||||
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
|
||||
if restored.Entry.Title != "Git Server" {
|
||||
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
|
||||
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
||||
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
|
||||
TemplateId: "website-login",
|
||||
Overrides: &keepassgov1.Entry{
|
||||
Id: "bellagio",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Id: "dynadot",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "hunter2",
|
||||
Url: "https://bellagio.example.invalid",
|
||||
Url: "https://www.dynadot.com",
|
||||
Fields: map[string]string{
|
||||
"Environment": "staging",
|
||||
},
|
||||
@@ -879,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
||||
t.Fatalf("InstantiateTemplate() error = %v", err)
|
||||
}
|
||||
|
||||
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
||||
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
|
||||
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
||||
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
|
||||
}
|
||||
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
|
||||
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
|
||||
@@ -956,7 +956,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
|
||||
defer cleanup()
|
||||
|
||||
ctx := tokenContext(defaultTestTokenSecret)
|
||||
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
|
||||
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListEntryHistory() error = %v", err)
|
||||
}
|
||||
@@ -965,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
|
||||
}
|
||||
|
||||
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
|
||||
Id: "vault-console",
|
||||
Id: "git-server",
|
||||
HistoryIndex: 0,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -986,14 +986,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
uploaded := []byte("attachment-content")
|
||||
|
||||
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
|
||||
EntryId: "vault-console",
|
||||
EntryId: "git-server",
|
||||
Name: "token.txt",
|
||||
Content: uploaded,
|
||||
}); err != nil {
|
||||
t.Fatalf("UploadAttachment() error = %v", err)
|
||||
}
|
||||
|
||||
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
|
||||
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAttachments() error = %v", err)
|
||||
}
|
||||
@@ -1002,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
}
|
||||
|
||||
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
|
||||
EntryId: "vault-console",
|
||||
EntryId: "git-server",
|
||||
Name: "token.txt",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1013,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
|
||||
}
|
||||
|
||||
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
|
||||
EntryId: "vault-console",
|
||||
EntryId: "git-server",
|
||||
Name: "token.txt",
|
||||
}); err != nil {
|
||||
t.Fatalf("DeleteAttachment() error = %v", err)
|
||||
}
|
||||
|
||||
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
|
||||
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAttachments() error = %v", err)
|
||||
}
|
||||
@@ -1055,32 +1055,32 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Fields: map[string]string{
|
||||
"X-Role": "automation",
|
||||
},
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console-h1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server-h1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-0",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
ID: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
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.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.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: "vault-console", 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"}}},
|
||||
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.OperationCopyUsername, 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: "git-server", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
|
||||
@@ -16,9 +16,6 @@ const (
|
||||
EventApprovalDenied EventType = "approval_denied"
|
||||
EventApprovalCanceled EventType = "approval_canceled"
|
||||
EventApprovalTimedOut EventType = "approval_timed_out"
|
||||
EventAutofillFound EventType = "autofill_found"
|
||||
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
||||
EventAutofillBlocked EventType = "autofill_blocked"
|
||||
EventAuthRejected EventType = "auth_rejected"
|
||||
)
|
||||
|
||||
|
||||
@@ -47,22 +47,3 @@ func TestLogPreservesRecordedMetadata(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-35
@@ -61,7 +61,6 @@ type SynchronizableSession interface {
|
||||
type AdvancedSynchronizableSession interface {
|
||||
CurrentSession
|
||||
SynchronizeFromLocal(string) error
|
||||
SynchronizeFromLocalBytes(string, []byte) error
|
||||
SynchronizeToLocal(string) error
|
||||
SynchronizeFromRemote(webdav.Client, string) error
|
||||
SynchronizeToRemote(webdav.Client, string) error
|
||||
@@ -303,7 +302,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
||||
}
|
||||
|
||||
if s.Section == SectionEntries {
|
||||
return entriesInPath(model.Entries, s.CurrentPath), nil
|
||||
return model.EntriesInPath(s.CurrentPath), nil
|
||||
}
|
||||
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
||||
return entries, nil
|
||||
@@ -723,18 +722,6 @@ func (s *State) SynchronizeFromLocal(path string) error {
|
||||
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 {
|
||||
session, ok := s.Session.(AdvancedSynchronizableSession)
|
||||
if !ok {
|
||||
@@ -850,27 +837,6 @@ func (s *State) CreateGroup(name string) error {
|
||||
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 {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
|
||||
+82
-220
@@ -20,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{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"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "Internet"},
|
||||
CurrentPath: []string{"Joe", "Internet"},
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
@@ -39,39 +39,8 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
titles = append(titles, entry.Title)
|
||||
}
|
||||
|
||||
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
|
||||
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)
|
||||
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) {
|
||||
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,14 +140,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
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"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "Internet"},
|
||||
SearchQuery: "surveillance",
|
||||
CurrentPath: []string{"Joe", "Internet"},
|
||||
SearchQuery: "lights",
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
@@ -186,46 +155,11 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 || got[0].Title != "Surveillance Console" {
|
||||
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -349,7 +283,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
RecycleBin: []vault.Entry{
|
||||
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
|
||||
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}},
|
||||
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||
},
|
||||
},
|
||||
@@ -418,13 +352,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew"},
|
||||
CurrentPath: []string{"Joe"},
|
||||
}
|
||||
|
||||
got, err := state.ChildGroups()
|
||||
@@ -470,19 +404,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "Internet"},
|
||||
CurrentPath: []string{"Joe", "Internet"},
|
||||
}
|
||||
|
||||
if err := state.SelectVisibleIndex(1); err != nil {
|
||||
t.Fatalf("SelectVisibleIndex() error = %v", err)
|
||||
}
|
||||
if got := state.SelectedEntryID; got != "vault-console" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
|
||||
if got := state.SelectedEntryID; got != "git-server" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
||||
}
|
||||
|
||||
if err := state.ToggleVisibleIndex(1); err != nil {
|
||||
@@ -524,14 +458,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
|
||||
sess := &mutableStubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
if err := state.DeleteSelectedEntry(); err != nil {
|
||||
@@ -546,8 +480,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
|
||||
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
|
||||
}
|
||||
|
||||
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
|
||||
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
|
||||
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
|
||||
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -561,7 +495,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
||||
sess := &mutableStubSession{
|
||||
model: vault.Model{
|
||||
RecycleBin: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -570,7 +504,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
}
|
||||
|
||||
if err := state.RestoreEntry("vault-console"); err != nil {
|
||||
if err := state.RestoreEntry("git-server"); err != nil {
|
||||
t.Fatalf("RestoreEntry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -579,8 +513,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 || got[0].ID != "vault-console" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
|
||||
if len(got) != 1 || got[0].ID != "git-server" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -600,19 +534,19 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
}
|
||||
|
||||
entry := vault.Entry{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}
|
||||
if err := state.UpsertEntry(entry); err != nil {
|
||||
t.Fatalf("UpsertEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if got := state.SelectedEntryID; got != "vault-console" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
|
||||
if got := state.SelectedEntryID; got != "git-server" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
@@ -620,7 +554,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Password != "token-1" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
||||
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -652,11 +586,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
|
||||
}
|
||||
|
||||
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
|
||||
ID: "bellagio",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
ID: "dynadot",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "hunter2",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -667,16 +601,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
|
||||
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
|
||||
}
|
||||
|
||||
if got := state.SelectedEntryID; got != "bellagio" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
|
||||
if got := state.SelectedEntryID; got != "dynadot" {
|
||||
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "bellagio" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
|
||||
if len(got) != 1 || got[0].ID != "dynadot" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -743,24 +677,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
}}
|
||||
state := State{
|
||||
Session: sess,
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
|
||||
duplicate, err := state.DuplicateSelectedEntry("git-server-copy")
|
||||
if err != nil {
|
||||
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
|
||||
}
|
||||
|
||||
if duplicate.ID != "vault-console-copy" {
|
||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
|
||||
if duplicate.ID != "git-server-copy" {
|
||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
|
||||
}
|
||||
if state.SelectedEntryID != "vault-console-copy" {
|
||||
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
|
||||
if state.SelectedEntryID != "git-server-copy" {
|
||||
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
|
||||
}
|
||||
if len(sess.model.Entries) != 2 {
|
||||
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
|
||||
@@ -772,13 +706,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
|
||||
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
}}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
|
||||
@@ -791,8 +725,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
|
||||
}
|
||||
|
||||
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
|
||||
if len(newPath) != 1 || newPath[0].ID != "vault-console" {
|
||||
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
|
||||
if len(newPath) != 1 || newPath[0].ID != "git-server" {
|
||||
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
|
||||
}
|
||||
|
||||
if !state.Dirty {
|
||||
@@ -806,19 +740,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
|
||||
sess := &mutableStubSession{model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Password: "new-token",
|
||||
Path: []string{"Root", "Internet"},
|
||||
History: []vault.Entry{
|
||||
{ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
state := State{
|
||||
Session: sess,
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
if err := state.RestoreSelectedEntryVersion(0); err != nil {
|
||||
@@ -862,7 +796,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
@@ -891,7 +825,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
@@ -942,7 +876,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
@@ -970,14 +904,14 @@ func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
||||
sess := &lockableStubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
if err := state.Lock(); err != nil {
|
||||
@@ -1000,7 +934,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
|
||||
sess := &lockableStubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
},
|
||||
locked: true,
|
||||
@@ -1018,8 +952,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "vault-console" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
|
||||
if len(got) != 1 || got[0].ID != "git-server" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1048,7 +982,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
|
||||
state := State{
|
||||
Section: SectionEntries,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
SearchQuery: "git",
|
||||
}
|
||||
|
||||
@@ -1084,7 +1018,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
ErrorMessage: "previous error",
|
||||
}
|
||||
|
||||
@@ -1128,7 +1062,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
|
||||
|
||||
state := State{
|
||||
CurrentPath: []string{"Root"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
state.EnterGroup("Internet")
|
||||
@@ -1146,7 +1080,7 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
||||
|
||||
state := State{
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "vault-console",
|
||||
SelectedEntryID: "git-server",
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
@@ -1216,27 +1150,6 @@ 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1297,7 +1210,7 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
||||
@@ -1313,45 +1226,14 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
|
||||
if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
|
||||
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1359,7 +1241,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
|
||||
@@ -1387,7 +1269,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
|
||||
@@ -1413,7 +1295,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
|
||||
@@ -1439,7 +1321,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
|
||||
@@ -1457,7 +1339,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
|
||||
@@ -1483,7 +1365,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
|
||||
state := State{
|
||||
Session: sess,
|
||||
CurrentPath: []string{"Root", "Internet"},
|
||||
SelectedEntryID: "bellagio",
|
||||
SelectedEntryID: "dynadot",
|
||||
}
|
||||
|
||||
err := state.DeleteAttachmentFromSelectedEntry("token.txt")
|
||||
@@ -1511,8 +1393,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
|
||||
func testVaultModel() vault.Model {
|
||||
return vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
|
||||
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
|
||||
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1584,26 +1466,6 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
|
||||
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 {
|
||||
s.changedKey = key
|
||||
return nil
|
||||
|
||||
+3
-212
@@ -5,7 +5,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,7 +18,6 @@ type Entry struct {
|
||||
Password string `json:"password"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
Targets []string `json:"targets,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
@@ -28,62 +26,10 @@ type File struct {
|
||||
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 {
|
||||
entries := make([]Entry, 0, len(model.Entries))
|
||||
for _, item := range model.Entries {
|
||||
targets := collectTargets(item)
|
||||
host := normalizeHost(item.URL)
|
||||
if host == "" {
|
||||
for _, target := range targets {
|
||||
host = normalizeHost(target)
|
||||
if host != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
@@ -97,7 +43,6 @@ func Build(model vault.Model, now time.Time) File {
|
||||
Password: item.Password,
|
||||
URL: item.URL,
|
||||
Host: host,
|
||||
Targets: targets,
|
||||
Path: append([]string(nil), item.Path...),
|
||||
})
|
||||
}
|
||||
@@ -126,171 +71,17 @@ func Clear(path string) error {
|
||||
}
|
||||
|
||||
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)
|
||||
if value == "" {
|
||||
return normalizedTarget{}
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(value, "://") {
|
||||
value = "https://" + value
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return normalizedTarget{}
|
||||
return ""
|
||||
}
|
||||
host := strings.TrimSpace(parsed.Hostname())
|
||||
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
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
+4
-255
@@ -22,7 +22,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
Username: "joe",
|
||||
Password: "secret",
|
||||
URL: "https://10.0.2.2:8443/login",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
Path: []string{"Joe", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "two",
|
||||
@@ -35,11 +35,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
Title: "Bare Host",
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
URL: "surveillance.crew.example.invalid",
|
||||
Fields: map[string]string{
|
||||
"AndroidApp1": "androidapp://com.lights.mobile",
|
||||
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
|
||||
},
|
||||
URL: "lights.julianfamily.org",
|
||||
},
|
||||
},
|
||||
}, now)
|
||||
@@ -50,11 +46,8 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
|
||||
if got.Entries[0].Host != "10.0.2.2" {
|
||||
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
|
||||
}
|
||||
if got.Entries[1].Host != "surveillance.crew.example.invalid" {
|
||||
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.Entries[1].Host != "lights.julianfamily.org" {
|
||||
t.Fatalf("second host = %q, want lights.julianfamily.org", got.Entries[1].Host)
|
||||
}
|
||||
if got.UpdatedAt != "2026-03-31T12:00:00Z" {
|
||||
t.Fatalf("updatedAt = %q", got.UpdatedAt)
|
||||
@@ -93,247 +86,3 @@ func TestWriteAndClear(t *testing.T) {
|
||||
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
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
target Target
|
||||
want string
|
||||
}{
|
||||
{name: "username", target: TargetUsername, want: "dannyocean"},
|
||||
{name: "username", target: TargetUsername, want: "joejulian"},
|
||||
{name: "password", target: TargetPassword, want: "token-1"},
|
||||
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
|
||||
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
|
||||
var writer memoryWriter
|
||||
service := Service{Writer: &writer}
|
||||
|
||||
if err := service.Copy(model, "vault-console", tt.target); err != nil {
|
||||
if err := service.Copy(model, "git-server", tt.target); err != nil {
|
||||
t.Fatalf("Copy() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
|
||||
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
|
||||
}
|
||||
err = service.Copy(model, "vault-console", Target("unsupported"))
|
||||
err = service.Copy(model, "git-server", Target("unsupported"))
|
||||
if !errors.Is(err, ErrUnsupportedTarget) {
|
||||
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")}}
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Password: "token-1"},
|
||||
{ID: "git-server", Password: "token-1"},
|
||||
},
|
||||
}
|
||||
|
||||
err := service.Copy(model, "vault-console", TargetPassword)
|
||||
err := service.Copy(model, "git-server", TargetPassword)
|
||||
if !errors.Is(err, ErrWriteFailed) {
|
||||
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
|
||||
- list navigation
|
||||
- search focus
|
||||
- new-entry focus transitions
|
||||
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
|
||||
- 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).
|
||||
|
||||
## Current screen-reader boundary
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ replace gioui.org/cmd => ./third_party/gioui-cmd
|
||||
|
||||
require (
|
||||
gioui.org v0.8.0
|
||||
gioui.org/x v0.8.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/tobischo/gokeepasslib/v3 v3.6.2
|
||||
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
|
||||
@@ -19,7 +18,6 @@ require (
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
gioui.org/cmd v0.8.0 // 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/Abirdcfly/dupword v0.1.3 // indirect
|
||||
github.com/Antonboom/errname v1.0.0 // indirect
|
||||
@@ -76,7 +74,6 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.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/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
@@ -195,7 +192,7 @@ require (
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/zap v1.24.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||
golang.org/x/image v0.37.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
|
||||
@@ -42,10 +42,6 @@ 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/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
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/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
||||
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
||||
@@ -228,8 +224,6 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
||||
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/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/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@@ -675,8 +669,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-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-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
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/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
|
||||
+238
-2486
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
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
|
||||
@@ -1,63 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[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
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/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())
|
||||
+40
-151
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -33,33 +32,6 @@ type Manager struct {
|
||||
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 {
|
||||
return vault.DetectSecuritySettings(m.config)
|
||||
}
|
||||
@@ -93,10 +65,6 @@ func (m *Manager) HasVault() bool {
|
||||
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 {
|
||||
return m.locked
|
||||
}
|
||||
@@ -106,11 +74,23 @@ func (m *Manager) IsRemote() bool {
|
||||
}
|
||||
|
||||
func (m *Manager) Open(path string, key vault.MasterKey) error {
|
||||
prepared, err := PrepareLocalOpen(path, key)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("read %s: %w", path, 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
|
||||
}
|
||||
|
||||
@@ -127,11 +107,25 @@ func (m *Manager) Save() error {
|
||||
}
|
||||
|
||||
func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error {
|
||||
prepared, err := PrepareRemoteOpen(client, path, key)
|
||||
content, version, err := client.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("open remote %s: %w", path, 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
|
||||
}
|
||||
|
||||
@@ -178,18 +172,6 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
|
||||
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 {
|
||||
other, config, err := loadLocalSourceOrEmpty(path, m.key)
|
||||
if err != nil {
|
||||
@@ -283,101 +265,19 @@ func (m *Manager) Lock() error {
|
||||
}
|
||||
|
||||
func (m *Manager) Unlock(key vault.MasterKey) error {
|
||||
prepared, err := PrepareUnlock(m.encoded, key)
|
||||
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("unlock vault: %w", err)
|
||||
}
|
||||
m.ApplyPreparedUnlock(prepared)
|
||||
|
||||
m.model = model
|
||||
m.config = config
|
||||
m.key = key
|
||||
m.vaultRoot = detectSingleVaultRoot(model)
|
||||
m.locked = false
|
||||
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 {
|
||||
var (
|
||||
model vault.Model
|
||||
@@ -921,17 +821,6 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
|
||||
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) {
|
||||
model, config, err := loadLocalSource(path, key)
|
||||
if err == nil {
|
||||
|
||||
+83
-140
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
|
||||
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
||||
updated := model
|
||||
updated.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
sess.Replace(updated)
|
||||
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"keepass", "Crew", "Internet"},
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"keepass", "Joe", "Internet"},
|
||||
},
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Mail",
|
||||
Username: "dannyocean",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://dispatch.crew.example.invalid",
|
||||
Path: []string{"keepass", "Crew", "eMail"},
|
||||
URL: "https://mail.julianfamily.org",
|
||||
Path: []string{"keepass", "Joe", "eMail"},
|
||||
},
|
||||
},
|
||||
}, key.Password); err != nil {
|
||||
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
current.Entries[0].Path = []string{"Crew", "Internet"}
|
||||
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
|
||||
current.Entries[0].Path = []string{"Joe", "Internet"}
|
||||
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
|
||||
sess.Replace(current)
|
||||
|
||||
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)
|
||||
}
|
||||
rootGroups := db.Content.Root.Groups[0].Groups
|
||||
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
|
||||
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
|
||||
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
|
||||
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
|
||||
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-1",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
|
||||
}
|
||||
current.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
})
|
||||
sess.Replace(current)
|
||||
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
|
||||
}
|
||||
current.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
sess.Replace(current)
|
||||
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Vault Console" {
|
||||
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
|
||||
if len(got) != 1 || got[0].Title != "Git Server" {
|
||||
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got)
|
||||
}
|
||||
|
||||
var reopened Manager
|
||||
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
||||
{
|
||||
UUID: gokeepasslib.NewUUID(),
|
||||
Values: []gokeepasslib.ValueData{
|
||||
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
|
||||
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
|
||||
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
|
||||
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
|
||||
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
|
||||
{Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
|
||||
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
||||
}
|
||||
current.UpsertEntry(vault.Entry{
|
||||
ID: current.Entries[0].ID,
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: current.Entries[0].Path,
|
||||
})
|
||||
sess.Replace(current)
|
||||
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
"token.txt": []byte("secret attachment contents"),
|
||||
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||
History: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
}
|
||||
firstCurrent.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "remote-token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Notes: "updated remotely first",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
}
|
||||
secondCurrent.UpsertEntry(vault.Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "local-token-2",
|
||||
URL: "https://vault.crew.example.invalid/security/badges",
|
||||
URL: "https://git.julianfamily.org/settings/tokens",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
second.Replace(secondCurrent)
|
||||
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
|
||||
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" {
|
||||
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
|
||||
}
|
||||
if len(got[0].History) == 0 {
|
||||
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-current",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-current",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-other",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "token-other",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -953,63 +953,6 @@ 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1021,10 +964,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-current",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-current",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -1033,10 +976,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-other",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "token-other",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -1078,10 +1021,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-current",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-current",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -1090,10 +1033,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-remote",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "token-remote",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-1
@@ -471,7 +471,7 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe
|
||||
<uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" />
|
||||
{{range .Permissions}} <uses-permission android:name="{{.}}"/>
|
||||
{{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/>
|
||||
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}" android:enableOnBackInvokedCallback="false">
|
||||
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}">
|
||||
{{.AppSnip}}
|
||||
<activity android:name="org.gioui.GioActivity"
|
||||
android:label="{{.AppName}}"
|
||||
|
||||
+2
-25
@@ -19,49 +19,26 @@ type focusAppearance struct {
|
||||
MinHeight int
|
||||
}
|
||||
|
||||
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
|
||||
appearance := focusAppearance{
|
||||
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||
OutlineColor: color.NRGBA{A: 0},
|
||||
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||
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 {
|
||||
appearance.BorderColor = accentColor
|
||||
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||
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
|
||||
}
|
||||
|
||||
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||
prefs = normalizeAccessibilityPreferences(prefs)
|
||||
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,130 +27,6 @@ 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 {
|
||||
tokens, err := u.state.APITokens()
|
||||
if err != nil {
|
||||
@@ -369,57 +244,6 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
||||
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 {
|
||||
token, ok := u.selectedAPIToken()
|
||||
if !ok {
|
||||
@@ -450,7 +274,15 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
|
||||
}
|
||||
filtered := make([]apiaudit.Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
haystack := apiAuditEventSearchTerms(event)
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
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) {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
@@ -461,58 +293,16 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
|
||||
return filtered
|
||||
}
|
||||
|
||||
func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) {
|
||||
effect := strings.ToUpper(string(rule.Effect))
|
||||
operation := string(rule.Operation)
|
||||
resource := "Vault root"
|
||||
func formatAPIPolicyRule(rule apitokens.PolicyRule) string {
|
||||
scope := strings.Join(rule.Resource.Path, " / ")
|
||||
if rule.Resource.Kind == apitokens.ResourceEntry {
|
||||
resource = "Entry: " + rule.Resource.EntryID
|
||||
} else if len(rule.Resource.Path) > 0 {
|
||||
resource = strings.Join(rule.Resource.Path, " / ")
|
||||
scope = "entry " + rule.Resource.EntryID
|
||||
}
|
||||
return effect, operation, resource
|
||||
}
|
||||
|
||||
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)
|
||||
return strings.TrimSpace(strings.Join([]string{
|
||||
strings.ToUpper(string(rule.Effect)),
|
||||
string(rule.Operation),
|
||||
scope,
|
||||
}, " "))
|
||||
}
|
||||
|
||||
func uiHasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
|
||||
@@ -546,21 +336,17 @@ func (u *ui) apiTokenRow(gtx layout.Context, click *widget.Clickable, idx int, t
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), token.ClientName)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), apiTokenStatusSummary(token))
|
||||
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(11), "ID "+token.ID)
|
||||
text := "Non-expiring"
|
||||
if token.ExpiresAt != nil {
|
||||
text = "Expires " + token.ExpiresAt.Local().Format(time.RFC3339)
|
||||
}
|
||||
lbl := material.Label(u.theme, unit.Sp(12), text)
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
@@ -606,11 +392,6 @@ func (u *ui) apiAuditRow(gtx layout.Context, click *widget.Clickable, idx int, e
|
||||
lbl.Color = accentColor
|
||||
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 {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), event.At.Local().Format(time.RFC3339))
|
||||
lbl.Color = mutedColor
|
||||
@@ -634,46 +415,11 @@ func (u *ui) apiAuditRow(gtx layout.Context, click *widget.Clickable, idx int, e
|
||||
func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
|
||||
tokens := u.apiTokens()
|
||||
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 {
|
||||
if len(tokens) == 0 {
|
||||
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
|
||||
lbl := material.Label(u.theme, unit.Sp(14), "No API tokens match the current filter.")
|
||||
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 u.apiTokenRow(gtx, &u.apiTokenClicks[i], i, tokens[i])
|
||||
@@ -684,11 +430,6 @@ func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
|
||||
|
||||
func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
|
||||
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,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
text := "Local gRPC audit history"
|
||||
@@ -700,19 +441,9 @@ func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
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 {
|
||||
if len(events) == 0 {
|
||||
lbl := material.Label(u.theme, unit.Sp(14), u.listEmptyMessage())
|
||||
lbl := material.Label(u.theme, unit.Sp(14), "No audit events yet.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
}
|
||||
@@ -723,109 +454,6 @@ 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 {
|
||||
token, ok := u.selectedAPIToken()
|
||||
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
||||
@@ -834,269 +462,140 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||
}
|
||||
rows := []layout.Widget{
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return card(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(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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
lbl := material.Label(u.theme, unit.Sp(20), "API Token")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
},
|
||||
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
||||
labeledEditor(u.theme, "Name", &u.apiTokenName, false),
|
||||
layout.Spacer{Height: unit.Dp(6)}.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 {
|
||||
return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx)
|
||||
},
|
||||
}
|
||||
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,
|
||||
layout.Spacer{Height: unit.Dp(10)}.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(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), "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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
rows = append(rows,
|
||||
layout.Spacer{Height: unit.Dp(10)}.Layout,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
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...)
|
||||
})
|
||||
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{Width: unit.Dp(6)}.Layout),
|
||||
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.Spacer{Height: unit.Dp(14)}.Layout,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(16), "Policy Rules")
|
||||
lbl.Color = accentColor
|
||||
return lbl.Layout(gtx)
|
||||
},
|
||||
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
||||
)
|
||||
if ok {
|
||||
rows = append(rows,
|
||||
layout.Spacer{Height: unit.Dp(10)}.Layout,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return card(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(14), "Lifecycle")
|
||||
lbl.Color = accentColor
|
||||
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.")
|
||||
if ok && len(token.Policies) > 0 {
|
||||
for i, rule := range token.Policies {
|
||||
index := i
|
||||
ruleText := formatAPIPolicyRule(rule)
|
||||
rows = append(rows,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(13), ruleText)
|
||||
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(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.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,
|
||||
)
|
||||
}),
|
||||
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")
|
||||
}),
|
||||
)
|
||||
},
|
||||
layout.Spacer{Height: unit.Dp(6)}.Layout,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
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.")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
})
|
||||
}
|
||||
rows = append(rows,
|
||||
layout.Spacer{Height: unit.Dp(10)}.Layout,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return card(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(14), "Policy Composer")
|
||||
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), "Rules are evaluated per operation. Explicit deny rules override allow rules.")
|
||||
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 material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
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.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.apiPolicyGroupScopeW.Value {
|
||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(detailLine(u.theme, "Group Path", u.apiPolicyGroupPathSummary())),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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.useCurrentGroupForPolicy, "Use Current Group")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
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.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.useSelectedEntryForPolicy, "Use Selected Entry")
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
|
||||
}),
|
||||
)
|
||||
})
|
||||
return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx)
|
||||
},
|
||||
layout.Spacer{Height: unit.Dp(6)}.Layout,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
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 {
|
||||
@@ -1127,28 +626,16 @@ func (u *ui) apiAuditDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||
event := events[u.selectedAuditIndex]
|
||||
rows = append(rows,
|
||||
layout.Spacer{Height: unit.Dp(12)}.Layout,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(12), "Selected event")
|
||||
lbl.Color = mutedColor
|
||||
return lbl.Layout(gtx)
|
||||
},
|
||||
detailLine(u.theme, "Type", string(event.Type)),
|
||||
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)),
|
||||
layout.Spacer{Height: unit.Dp(6)}.Layout,
|
||||
detailLine(u.theme, "Token", event.TokenName),
|
||||
layout.Spacer{Height: unit.Dp(6)}.Layout,
|
||||
detailLine(u.theme, "Client", event.ClientName),
|
||||
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)),
|
||||
layout.Spacer{Height: unit.Dp(6)}.Layout,
|
||||
detailLine(u.theme, "Message", event.Message),
|
||||
|
||||
@@ -41,7 +41,6 @@ func (u *ui) attachmentInput() (string, []byte, error) {
|
||||
|
||||
func (u *ui) loadSelectedEntryIntoEditor() {
|
||||
u.resetPasswordPeek()
|
||||
u.clearGeneratedPasswordDraft()
|
||||
u.selectedHistoryIndex = -1
|
||||
u.historyIndex.SetText("")
|
||||
|
||||
@@ -176,7 +175,6 @@ func (u *ui) saveEntryAction() error {
|
||||
return err
|
||||
}
|
||||
u.editingEntry = false
|
||||
u.clearGeneratedPasswordDraft()
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
@@ -231,7 +229,6 @@ func (u *ui) saveTemplateAction() error {
|
||||
return err
|
||||
}
|
||||
u.editingEntry = false
|
||||
u.clearGeneratedPasswordDraft()
|
||||
u.filter()
|
||||
return nil
|
||||
}
|
||||
@@ -254,14 +251,6 @@ func (u *ui) createGroupAction() error {
|
||||
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 {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
||||
@@ -411,7 +400,6 @@ func (u *ui) generatePasswordAction() error {
|
||||
return err
|
||||
}
|
||||
u.entryPassword.SetText(password)
|
||||
u.generatedPasswordDraft = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -493,27 +481,3 @@ func marshalFields(fields map[string]string) string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
+258
-1159
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -46,14 +46,14 @@ func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
||||
return true
|
||||
}
|
||||
if u.isVaultLocked() && name == key.NameReturn {
|
||||
u.startUnlockAction()
|
||||
u.runAction("unlock vault", u.unlockAction)
|
||||
return true
|
||||
}
|
||||
if u.shouldShowLifecycleSetup() && name == key.NameReturn {
|
||||
if u.lifecycleMode == "remote" {
|
||||
u.startOpenRemoteAction()
|
||||
u.runAction("open remote vault", u.openRemoteAction)
|
||||
} else {
|
||||
u.startOpenVaultAction()
|
||||
u.runAction("open vault", u.openVaultAction)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -37,8 +37,6 @@ func (u *ui) processShortcuts(gtx layout.Context) {
|
||||
key.Filter{Name: key.NameUpArrow},
|
||||
key.Filter{Name: key.NameDownArrow},
|
||||
key.Filter{Name: key.NameReturn},
|
||||
key.Filter{Name: key.NameBack},
|
||||
key.Filter{Name: key.NameEscape},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
@@ -50,9 +48,6 @@ func (u *ui) processShortcuts(gtx layout.Context) {
|
||||
}
|
||||
|
||||
u.handleKeyPress(ke.Name, ke.Modifiers)
|
||||
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
|
||||
_ = u.handlePhoneBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-28
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "old-token",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Notes: "Original note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
|
||||
}
|
||||
|
||||
model.UpsertEntry(Entry{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "new-token",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Notes: "Updated note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
})
|
||||
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
ID: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.DeleteEntry("surveillance-console"); err != nil {
|
||||
if err := model.DeleteEntry("ha-codex"); err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
if model.RecycleBin[0].Title != "Surveillance Console" {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
|
||||
if model.RecycleBin[0].Title != "Home Assistant (Codex)" {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
|
||||
model := Model{
|
||||
RecycleBin: []Entry{
|
||||
{
|
||||
ID: "bellagio",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
ID: "dynadot",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "token-3",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.RestoreEntry("bellagio"); err != nil {
|
||||
if err := model.RestoreEntry("dynadot"); err != nil {
|
||||
t.Fatalf("RestoreEntry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Title != "Bellagio" {
|
||||
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
|
||||
if got[0].Title != "Dynadot" {
|
||||
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot")
|
||||
}
|
||||
|
||||
if len(model.RecycleBin) != 0 {
|
||||
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "new-token",
|
||||
Notes: "Current note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
History: []Entry{
|
||||
{
|
||||
ID: "vault-console-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server-history-1",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "old-token",
|
||||
Notes: "Previous note",
|
||||
Path: []string{"Root", "Internet"},
|
||||
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
|
||||
},
|
||||
}
|
||||
|
||||
if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
|
||||
if err := model.RestoreEntryVersion("git-server", 0); err != nil {
|
||||
t.Fatalf("RestoreEntryVersion() error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
+46
-46
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root",
|
||||
mustGroup("Internet",
|
||||
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
||||
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
|
||||
mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"),
|
||||
mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"),
|
||||
),
|
||||
mustGroup("Home Assistant",
|
||||
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
|
||||
mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"),
|
||||
),
|
||||
),
|
||||
},
|
||||
@@ -53,11 +53,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" {
|
||||
if got[0].Title != "Dynadot" || got[0].Username != "jjulian" || got[0].URL != "https://www.dynadot.com" {
|
||||
t.Fatalf("unexpected first entry: %#v", got[0])
|
||||
}
|
||||
|
||||
if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" {
|
||||
if got[1].Title != "Git Server" || got[1].Username != "joejulian" || got[1].URL != "https://git.julianfamily.org" {
|
||||
t.Fatalf("unexpected second entry: %#v", got[1])
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
|
||||
entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2")
|
||||
entry.Tags = "automation; home"
|
||||
entry.Values = append(entry.Values,
|
||||
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
||||
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Notes: "Personal git server token entry used for automation and CLI auth.",
|
||||
Tags: []string{"git", "infra"},
|
||||
Fields: map[string]string{
|
||||
@@ -146,10 +146,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
},
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Surveillance Console",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Notes: "Long-lived token used by Codex for home automation tasks.",
|
||||
Tags: []string{"automation", "home"},
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("LoadKDBX() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.Search("vault")
|
||||
got := loaded.Search("git")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
|
||||
}
|
||||
@@ -245,18 +245,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "new-token",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
History: []Entry{
|
||||
{
|
||||
ID: "entry-1-old",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "old-token",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Notes: "Original version",
|
||||
},
|
||||
@@ -296,10 +296,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
RecycleBin: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
@@ -319,8 +319,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
|
||||
}
|
||||
|
||||
if loaded.RecycleBin[0].Title != "Surveillance Console" {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
|
||||
if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" {
|
||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Home Assistant (Codex)")
|
||||
}
|
||||
|
||||
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(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -378,9 +378,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.Search("vault")
|
||||
got := model.Search("git")
|
||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving git entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +413,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
|
||||
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -452,7 +452,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
Groups: []gokeepasslib.Group{
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
||||
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -490,11 +490,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
},
|
||||
@@ -510,9 +510,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.Search("vault")
|
||||
got := loaded.Search("git")
|
||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
||||
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
|
||||
t.Fatalf("round-trip with key file = %#v, want git entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,11 +533,11 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
ID: "ha-codex",
|
||||
Title: "Home Assistant (Codex)",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
URL: "https://lights.julianfamily.org",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
@@ -570,11 +570,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
"token.txt": []byte("secret attachment contents"),
|
||||
@@ -610,10 +610,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Notes: "Current credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
@@ -622,10 +622,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
History: []Entry{
|
||||
{
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Notes: "Original credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
|
||||
+5
-89
@@ -96,27 +96,6 @@ func (m Model) EntriesInPath(path []string) []Entry {
|
||||
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 {
|
||||
query = strings.TrimSpace(strings.ToLower(query))
|
||||
if query == "" {
|
||||
@@ -277,14 +256,13 @@ func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
|
||||
}
|
||||
|
||||
func (m *Model) CreateGroup(parent []string, name string) {
|
||||
groupPath := append([]string(nil), parent...)
|
||||
for _, part := range splitGroupPath(name) {
|
||||
groupPath = append(groupPath, part)
|
||||
if groupPathExists(m.Groups, groupPath) {
|
||||
continue
|
||||
groupPath := append(append([]string(nil), parent...), name)
|
||||
for _, existing := range m.Groups {
|
||||
if slices.Equal(existing, groupPath) {
|
||||
return
|
||||
}
|
||||
m.Groups = append(m.Groups, append([]string(nil), groupPath...))
|
||||
}
|
||||
m.Groups = append(m.Groups, groupPath)
|
||||
}
|
||||
|
||||
func (m *Model) RenameGroup(path []string, newName string) error {
|
||||
@@ -332,47 +310,6 @@ func (m *Model) MoveEntry(id string, path []string) error {
|
||||
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 {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != id {
|
||||
@@ -412,27 +349,6 @@ func hasPathPrefix(path, prefix []string) bool {
|
||||
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 {
|
||||
entry := cloneEntry(template)
|
||||
|
||||
|
||||
+36
-87
@@ -9,9 +9,9 @@ import (
|
||||
func testModel() Model {
|
||||
return Model{
|
||||
Entries: []Entry{
|
||||
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
||||
{ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
||||
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
||||
{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) {
|
||||
model := testModel()
|
||||
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
got := model.ChildGroups([]string{"Joe"})
|
||||
want := []string{"Home Assistant", "Internet"}
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
@@ -31,43 +31,30 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
|
||||
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
got := model.EntriesInPath([]string{"Crew", "Internet"})
|
||||
got := model.EntriesInPath([]string{"Joe", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
|
||||
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" {
|
||||
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) {
|
||||
model := testModel()
|
||||
|
||||
got := model.Search("vault")
|
||||
got := model.Search("git")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(Search()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Entry.Title != "Vault Console" {
|
||||
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
|
||||
if got[0].Entry.Title != "Git Server" {
|
||||
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server")
|
||||
}
|
||||
|
||||
if got[0].Path != "Crew / Internet" {
|
||||
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
|
||||
if got[0].Path != "Joe / Internet" {
|
||||
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +106,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
||||
|
||||
entry, err := model.InstantiateTemplate("tpl-1", Entry{
|
||||
ID: "entry-1",
|
||||
Title: "Bellagio",
|
||||
Username: "rustyryan",
|
||||
Title: "Dynadot",
|
||||
Username: "jjulian",
|
||||
Password: "hunter2",
|
||||
URL: "https://bellagio.example.invalid",
|
||||
Path: []string{"Crew", "Internet"},
|
||||
URL: "https://www.dynadot.com",
|
||||
Path: []string{"Joe", "Internet"},
|
||||
Tags: []string{"dns"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -134,11 +121,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
||||
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
|
||||
}
|
||||
|
||||
if entry.Title != "Bellagio" {
|
||||
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
|
||||
if entry.Title != "Dynadot" {
|
||||
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot")
|
||||
}
|
||||
|
||||
if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
|
||||
if entry.Username != "jjulian" || entry.Password != "hunter2" || entry.URL != "https://www.dynadot.com" {
|
||||
t.Fatalf("entry credentials = %#v, want override values", entry)
|
||||
}
|
||||
|
||||
@@ -154,9 +141,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
|
||||
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Crew", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Bellagio" {
|
||||
t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
|
||||
got := model.EntriesInPath([]string{"Joe", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Dynadot" {
|
||||
t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +165,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
},
|
||||
Templates: []Entry{
|
||||
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
|
||||
@@ -222,8 +209,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
@@ -238,8 +225,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
||||
if duplicate.ID != "entry-2" {
|
||||
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
|
||||
}
|
||||
if duplicate.Title != "Vault Console (Copy)" {
|
||||
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
|
||||
if duplicate.Title != "Git Server (Copy)" {
|
||||
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)")
|
||||
}
|
||||
got := model.EntriesInPath([]string{"Root", "Internet"})
|
||||
if len(got) != 2 {
|
||||
@@ -250,45 +237,29 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
|
||||
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
|
||||
model := testModel()
|
||||
|
||||
model.CreateGroup([]string{"Crew"}, "Finance")
|
||||
model.CreateGroup([]string{"Joe"}, "Finance")
|
||||
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
got := model.ChildGroups([]string{"Joe"})
|
||||
want := []string{"Finance", "Home Assistant", "Internet"}
|
||||
if !slices.Equal(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) {
|
||||
model := testModel()
|
||||
|
||||
if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
|
||||
if err := model.RenameGroup([]string{"Joe", "Internet"}, "Infra"); err != nil {
|
||||
t.Fatalf("RenameGroup() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Crew", "Infra"})
|
||||
got := model.EntriesInPath([]string{"Joe", "Infra"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
|
||||
t.Fatalf("len(EntriesInPath(Joe/Infra)) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
|
||||
t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
|
||||
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 {
|
||||
t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,37 +276,15 @@ 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) {
|
||||
model := testModel()
|
||||
|
||||
model.CreateGroup([]string{"Crew"}, "Finance")
|
||||
if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
|
||||
model.CreateGroup([]string{"Joe"}, "Finance")
|
||||
if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil {
|
||||
t.Fatalf("DeleteGroup() error = %v", err)
|
||||
}
|
||||
|
||||
got := model.ChildGroups([]string{"Crew"})
|
||||
got := model.ChildGroups([]string{"Joe"})
|
||||
want := []string{"Home Assistant", "Internet"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("ChildGroups() = %v, want %v", got, want)
|
||||
|
||||
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
|
||||
t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
|
||||
if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" {
|
||||
t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok)
|
||||
}
|
||||
w.Header().Set("ETag", `"etag-1"`)
|
||||
_, _ = io.WriteString(w, "vault-bytes")
|
||||
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
|
||||
client := Client{
|
||||
HTTPClient: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
Username: "rustyryan",
|
||||
Username: "jjulian",
|
||||
Password: "secret",
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
|
||||
client := Client{
|
||||
HTTPClient: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
Username: "rustyryan",
|
||||
Username: "jjulian",
|
||||
Password: "secret",
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
|
||||
client := Client{
|
||||
HTTPClient: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
Username: "rustyryan",
|
||||
Username: "jjulian",
|
||||
Password: "secret",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user