package api import ( "bytes" "context" "crypto/sha256" "encoding/hex" "net" "os" "slices" "testing" "time" "git.julianfamily.org/keepassgo/internal/apiapproval" "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/webdav" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" ) func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() _, err := client.ListEntries(context.Background(), &keepassgov1.ListEntriesRequest{}) if status.Code(err) != codes.Unauthenticated { t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated) } } func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) { t.Parallel() expiredAt := time.Date(2026, 3, 29, 11, 59, 0, 0, time.UTC) token, _, err := apitokens.Issue("Expired", "grpc-test", &expiredAt, time.Date(2026, 3, 29, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("Issue() error = %v", err) } token.SecretHash = hashSecretForTest(defaultTestTokenSecret) client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, token.Entry([]string{"Root", "API Tokens"}), }, }) defer cleanup() oldNow := timeNow timeNow = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) } defer func() { timeNow = oldNow }() _, err = client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if status.Code(err) != codes.Unauthenticated { t.Fatalf("ListEntries() code = %v, want %v for expired token", status.Code(err), codes.Unauthenticated) } } func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) { t.Parallel() client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", 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"}}}, ), }, }) defer cleanup() _, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"}) if status.Code(err) != codes.PermissionDenied { t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied) } } func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) { t.Parallel() client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, ), }, }) defer cleanup() _, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{}) if status.Code(err) != codes.PermissionDenied { t.Fatalf("GetSessionStatus() code = %v, want %v", status.Code(err), codes.PermissionDenied) } } func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) { t.Parallel() client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}}, apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}}, ), }, Templates: []vault.Entry{ {ID: "website-login", Title: "Website Login", Path: []string{"Templates"}}, }, }) defer cleanup() _, err := client.UpsertTemplate(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertTemplateRequest{ Template: &keepassgov1.Entry{Id: "website-login", Title: "Updated"}, }) if status.Code(err) != codes.PermissionDenied { t.Fatalf("UpsertTemplate() code = %v, want %v", status.Code(err), codes.PermissionDenied) } } func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) { t.Parallel() client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, ), }, }) defer cleanup() _, err := client.GeneratePassword(tokenContext(defaultTestTokenSecret), &keepassgov1.GeneratePasswordRequest{Profile: "strong"}) if status.Code(err) != codes.PermissionDenied { t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.PermissionDenied) } } func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) resp, err := client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{ PageUrl: "https://vault.crew.example.invalid/login", }) if err != nil { t.Fatalf("FindBrowserLogins() error = %v", err) } if len(resp.Matches) != 1 { t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches)) } if resp.Matches[0].Id != "vault-console" { t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want vault-console", resp.Matches[0].Id) } if resp.Matches[0].Quality != "exact-host" { t.Fatalf("FindBrowserLogins().Matches[0].Quality = %q, want exact-host", resp.Matches[0].Quality) } } func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) { t.Parallel() client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ { ID: "codex-nextcloud", Title: "Nextcloud (codex)", Username: "jjulian", Password: "secret-1", URL: "https://nextcloud.example.invalid", Path: []string{"keepass", "Joe", "codex"}, }, { ID: "joe-nextcloud", Title: "Nextcloud", Username: "jjulian", Password: "secret-2", URL: "https://nextcloud.example.invalid", Path: []string{"keepass", "Joe", "Internet"}, }, testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, ), }, }) defer cleanup() resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{ PageUrl: "https://nextcloud.example.invalid/login", }) if err != nil { t.Fatalf("FindBrowserLogins() error = %v", err) } if len(resp.Matches) != 1 { t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches)) } if resp.Matches[0].Id != "codex-nextcloud" { t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want codex-nextcloud", resp.Matches[0].Id) } } func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) { t.Parallel() client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ { ID: "codex-nextcloud", Title: "Nextcloud (codex)", Username: "jjulian", Password: "secret-1", URL: "https://nextcloud.example.invalid", Path: []string{"keepass", "Joe", "codex"}, }, testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, ), }, Groups: [][]string{ {"keepass"}, {"keepass", "Joe"}, {"keepass", "Joe", "codex"}, }, }) defer cleanup() resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{ Path: []string{"Joe", "codex"}, }) if err != nil { t.Fatalf("ListEntries() error = %v", err) } if len(resp.Entries) != 1 { t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries)) } if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) { t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got) } } func TestVaultServiceListGroupsHidesSingleInternalVaultRoot(t *testing.T) { t.Parallel() client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}}, ), }, Groups: [][]string{ {"keepass"}, {"keepass", "Joe"}, {"keepass", "Shared"}, }, }) defer cleanup() resp, err := client.ListGroups(tokenContext(defaultTestTokenSecret), &keepassgov1.ListGroupsRequest{}) if err != nil { t.Fatalf("ListGroups() error = %v", err) } if !slices.Equal(resp.Names, []string{"Joe", "Shared"}) { t.Fatalf("ListGroups().Names = %v, want [Joe Shared]", resp.Names) } } func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) resp, err := client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{ Id: "vault-console", PageUrl: "https://vault.crew.example.invalid/login", }) if err != nil { t.Fatalf("GetBrowserCredential() error = %v", err) } if resp.Id != "vault-console" { t.Fatalf("GetBrowserCredential().Id = %q, want vault-console", resp.Id) } if resp.Password != "token-1" { t.Fatalf("GetBrowserCredential().Password = %q, want token-1", resp.Password) } } func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) { t.Parallel() client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, 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.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, ), }, }) defer cleanup() _, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{ Id: "vault-console", PageUrl: "https://vault.crew.example.invalid/login", }) if status.Code(err) != codes.PermissionDenied { t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.PermissionDenied) } } func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, testAPITokenEntry(t), }, } client, _, service, cleanup := newTestHarnessForModel(t, model) defer cleanup() service.approvals = apiapproval.NewBroker(time.Minute) respCh := make(chan *keepassgov1.ListEntriesResponse, 1) errCh := make(chan error, 1) go func() { resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) respCh <- resp errCh <- err }() pending := waitForServerPendingApproval(t, service, 1)[0] if pending.Operation != apitokens.OperationListEntries { t.Fatalf("pending.Operation = %q, want %q", pending.Operation, apitokens.OperationListEntries) } if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil { t.Fatalf("ResolveApproval(allow) error = %v", err) } resp := <-respCh 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) } } func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, testAPITokenEntry(t), }, } client, _, service, cleanup := newTestHarnessForModel(t, model) defer cleanup() service.approvals = apiapproval.NewBroker(time.Minute) errCh := make(chan error, 1) go func() { _, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) errCh <- err }() pending := waitForServerPendingApproval(t, service, 1)[0] if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeDenyPermanent); err != nil { t.Fatalf("ResolveApproval(deny permanent) error = %v", err) } if err := <-errCh; status.Code(err) != codes.PermissionDenied { t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.PermissionDenied) } service.mu.RLock() tokens, err := apitokens.Entries(service.model) service.mu.RUnlock() if err != nil { t.Fatalf("Entries() error = %v", err) } decision := apitokens.Evaluate(tokens[0], apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}) if decision != apitokens.DecisionDeny { t.Fatalf("Evaluate() after permanent deny = %q, want %q", decision, apitokens.DecisionDeny) } } func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, testAPITokenEntry(t), }, } client, _, service, cleanup := newTestHarnessForModel(t, model) defer cleanup() service.approvals = apiapproval.NewBroker(time.Minute) errCh := make(chan error, 1) go func() { _, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) errCh <- err }() pending := waitForServerPendingApproval(t, service, 1)[0] if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil { t.Fatalf("ResolveApproval(cancel) error = %v", err) } if err := <-errCh; status.Code(err) != codes.Unauthenticated { t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated) } } func TestVaultServiceTimesOutPendingApproval(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, testAPITokenEntry(t), }, } client, _, service, cleanup := newTestHarnessForModel(t, model) defer cleanup() service.approvals = apiapproval.NewBroker(20 * time.Millisecond) _, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if status.Code(err) != codes.DeadlineExceeded { t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.DeadlineExceeded) } } func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, testAPITokenEntry(t), }, } client, _, service, cleanup := newTestHarnessForModel(t, model) defer cleanup() service.approvals = apiapproval.NewBroker(time.Minute) errCh := make(chan error, 1) go func() { _, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) errCh <- err }() pending := waitForServerPendingApproval(t, service, 1)[0] if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowPermanent); err != nil { t.Fatalf("ResolveApproval(allow permanent) error = %v", err) } if err := <-errCh; err != nil { t.Fatalf("ListEntries() error = %v", err) } events := service.AuditLog().Events() if len(events) < 2 { t.Fatalf("len(AuditLog().Events()) = %d, want at least 2", len(events)) } if events[0].Type != apiaudit.EventApprovalAllowed || events[1].Type != apiaudit.EventApprovalRequested { t.Fatalf("AuditLog().Events() = %#v, want allowed then requested", events[:2]) } } func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { t.Parallel() lifecycle := &stubLifecycle{ model: vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, }, }, } client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) if err != nil { t.Fatalf("GetSessionStatus() error = %v", err) } if statusResp.Locked { t.Fatal("GetSessionStatus().Locked = true, want false at startup") } if statusResp.EntryCount == 0 { t.Fatal("GetSessionStatus().EntryCount = 0, want non-zero") } if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil { t.Fatalf("LockVault() error = %v", err) } statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) if err != nil { t.Fatalf("GetSessionStatus() after lock error = %v", err) } if !statusResp.Locked { t.Fatal("GetSessionStatus().Locked = false, want true after lock") } if _, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}); status.Code(err) != codes.FailedPrecondition { t.Fatalf("ListEntries() code = %v, want FailedPrecondition while locked", status.Code(err)) } if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{}); err != nil { t.Fatalf("UnlockVault() error = %v", err) } statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) if err != nil { t.Fatalf("GetSessionStatus() after unlock error = %v", err) } if statusResp.Locked { t.Fatal("GetSessionStatus().Locked = true, want false after unlock") } } func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) { t.Parallel() lifecycle := &stubLifecycle{ model: vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}}, }, }, unlockPassword: "correct horse battery staple", unlockKeyFile: []byte("key-material"), } client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{ Path: "/tmp/test.kdbx", Password: lifecycle.unlockPassword, KeyFileData: lifecycle.unlockKeyFile, }); err != nil { t.Fatalf("OpenVault() error = %v", err) } if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil { t.Fatalf("LockVault() error = %v", err) } if !lifecycle.locked { t.Fatal("LockVault() did not lock lifecycle backend") } statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) if err != nil { t.Fatalf("GetSessionStatus() after lock error = %v", err) } if !statusResp.Locked { t.Fatal("GetSessionStatus().Locked = false, want true after lock") } if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{ Password: "wrong password", KeyFileData: lifecycle.unlockKeyFile, }); status.Code(err) != codes.InvalidArgument { t.Fatalf("UnlockVault(wrong password) code = %v, want %v", status.Code(err), codes.InvalidArgument) } statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) if err != nil { t.Fatalf("GetSessionStatus() after failed unlock error = %v", err) } if !statusResp.Locked { t.Fatal("GetSessionStatus().Locked = false, want true after failed unlock") } if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{ Password: lifecycle.unlockPassword, KeyFileData: lifecycle.unlockKeyFile, }); err != nil { t.Fatalf("UnlockVault() error = %v", err) } if lifecycle.lastUnlockKey.Password != lifecycle.unlockPassword { t.Fatalf("UnlockVault() password = %q, want %q", lifecycle.lastUnlockKey.Password, lifecycle.unlockPassword) } if !bytes.Equal(lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile) { t.Fatalf("UnlockVault() key data = %q, want %q", lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile) } listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if err != nil { t.Fatalf("ListEntries() after unlock error = %v", err) } if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" { t.Fatalf("ListEntries().Entries = %#v, want Remote Git after unlock", listed.Entries) } } func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) { t.Parallel() lifecycle := &stubLifecycle{ model: vault.Model{ Entries: []vault.Entry{ {ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}}, }, }, } client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{ Path: "/tmp/test.kdbx", Password: "correct horse battery staple", }); err != nil { t.Fatalf("OpenVault() error = %v", err) } if lifecycle.openPath != "/tmp/test.kdbx" { t.Fatalf("openPath = %q, want /tmp/test.kdbx", lifecycle.openPath) } listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if err != nil { t.Fatalf("ListEntries() after open error = %v", err) } if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" { t.Fatalf("ListEntries().Entries = %#v, want Remote Git after open", listed.Entries) } if _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}); err != nil { t.Fatalf("SaveVault() error = %v", err) } if !lifecycle.saved { t.Fatal("SaveVault() did not call lifecycle Save") } if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{ BaseUrl: "https://dav.example.com", Path: "vaults/main.kdbx", Username: "rustyryan", Password: "dav-token", MasterPassword: "correct horse battery staple", }); err != nil { t.Fatalf("OpenRemoteVault() error = %v", err) } if lifecycle.remoteBaseURL != "https://dav.example.com" || lifecycle.remotePath != "vaults/main.kdbx" { t.Fatalf("remote open = %q %q, want dav.example.com vaults/main.kdbx", lifecycle.remoteBaseURL, lifecycle.remotePath) } } func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) testCases := []struct { name string call func() error }{ { name: "open", call: func() error { _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"}) return err }, }, { name: "open_remote", call: func() error { _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{ BaseUrl: "https://dav.example.com", Path: "vaults/main.kdbx", }) return err }, }, { name: "save", call: func() error { _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}) return err }, }, { name: "lock", call: func() error { _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}) return err }, }, { name: "unlock", call: func() error { _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{}) return err }, }, } for _, tt := range testCases { tt := tt t.Run(tt.name, func(t *testing.T) { err := tt.call() if status.Code(err) != codes.FailedPrecondition { t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), codes.FailedPrecondition) } }) } } func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) { t.Parallel() testCases := []struct { name string call func(keepassgov1.VaultServiceClient, context.Context) error err error want codes.Code }{ { name: "open not found", call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/missing.kdbx"}) return err }, err: os.ErrNotExist, want: codes.NotFound, }, { name: "open invalid master key", call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"}) return err }, err: vault.ErrInvalidMasterKey, want: codes.InvalidArgument, }, { name: "save no path", call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}) return err }, err: session.ErrNoPath, want: codes.FailedPrecondition, }, { name: "lock already locked", call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}) return err }, err: session.ErrLocked, want: codes.FailedPrecondition, }, { name: "unlock invalid master key", call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{Password: "wrong"}) return err }, err: vault.ErrInvalidMasterKey, want: codes.InvalidArgument, }, } for _, tt := range testCases { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() lifecycle := &stubLifecycle{err: tt.err} client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) err := tt.call(client, ctx) if status.Code(err) != tt.want { t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want) } }) } } func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if err != nil { t.Fatalf("ListEntries() error = %v", err) } if len(resp.Entries) != 1 { 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 got := resp.Entries[0].Fields["X-Role"]; got != "automation" { t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation") } } func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}}) if err != nil { t.Fatalf("ListGroups() error = %v", err) } if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" { t.Fatalf("ListGroups().Names = %#v, want [Home Assistant Internet]", listed.Names) } if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{ ParentPath: []string{"Root"}, Name: "Finance", }); err != nil { t.Fatalf("CreateGroup() error = %v", err) } listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}}) if err != nil { t.Fatalf("ListGroups() error = %v", err) } if len(listed.Names) != 3 || listed.Names[0] != "Finance" { t.Fatalf("ListGroups().Names = %#v, want Finance present after create", listed.Names) } if _, err := client.RenameGroup(ctx, &keepassgov1.RenameGroupRequest{ Path: []string{"Root", "Internet"}, NewName: "Infra", }); err != nil { t.Fatalf("RenameGroup() error = %v", err) } listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}}) if err != nil { t.Fatalf("ListGroups() error = %v", err) } if len(listed.Names) != 3 || listed.Names[2] != "Infra" { t.Fatalf("ListGroups().Names = %#v, want Infra after rename", listed.Names) } } func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{ ParentPath: []string{"Root"}, Name: "Finance", }); err != nil { t.Fatalf("CreateGroup() error = %v", err) } if _, err := client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{ Path: []string{"Root", "Finance"}, }); err != nil { t.Fatalf("DeleteGroup() error = %v, want success for empty group", err) } listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}}) if err != nil { t.Fatalf("ListGroups() error = %v", err) } if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" { t.Fatalf("ListGroups().Names = %#v, want empty Finance group removed", listed.Names) } _, err = client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{ Path: []string{"Root", "Internet"}, }) if status.Code(err) != codes.FailedPrecondition { t.Fatalf("DeleteGroup() code = %v, want %v for non-empty group", status.Code(err), codes.FailedPrecondition) } } func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"}) if err != nil { t.Fatalf("GeneratePassword() error = %v", err) } if len(resp.Password) < passwords.DefaultProfiles()["strong"].Length { t.Fatalf("len(GeneratePassword().Password) = %d, want at least %d", len(resp.Password), passwords.DefaultProfiles()["strong"].Length) } } func TestVaultServiceGeneratePasswordRejectsUnknownProfiles(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) _, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"}) if status.Code(err) != codes.InvalidArgument { t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument) } } func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) { t.Parallel() client, clipboardWriter, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{ Id: "vault-console", Target: "password", }); err != nil { t.Fatalf("CopyEntryField() error = %v", err) } if clipboardWriter.content != "token-1" { t.Fatalf("clipboard content = %q, want %q", clipboardWriter.content, "token-1") } } func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{ Entry: &keepassgov1.Entry{ Id: "surveillance-console", Title: "Surveillance Console", Username: "codex", Password: "token-2", Url: "https://surveillance.crew.example.invalid", Fields: map[string]string{ "X-Role": "lights-admin", }, Path: []string{"Root", "Home Assistant"}, }, }) if err != nil { 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 got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" { t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin") } listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}}) if err != nil { t.Fatalf("ListEntries() error = %v", err) } if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" { t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries) } if got := listed.Entries[0].Fields["X-Role"]; got != "lights-admin" { t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "lights-admin") } } func TestVaultServiceUpsertEntryUpdatesLifecycleModel(t *testing.T) { t.Parallel() model := vault.Model{ Entries: []vault.Entry{ testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, ), }, } lifecycle := &stubLifecycle{model: model} listener := bufconn.Listen(1024 * 1024) clipboardWriter := &memoryClipboardWriter{} service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle) server := grpc.NewServer() keepassgov1.RegisterVaultServiceServer(server, service) go func() { _ = server.Serve(listener) }() t.Cleanup(func() { server.Stop() _ = listener.Close() }) conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return listener.DialContext(ctx) }), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatalf("NewClient() error = %v", err) } t.Cleanup(func() { _ = conn.Close() }) client := keepassgov1.NewVaultServiceClient(conn) _, err = client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{ Entry: &keepassgov1.Entry{ Id: "lifecycle-visible", Title: "Lifecycle Visible", Path: []string{"Root", "Internet"}, }, }) if err != nil { t.Fatalf("UpsertEntry() error = %v", err) } current, err := lifecycle.Current() if err != nil { t.Fatalf("Current() error = %v", err) } if _, err := current.EntryByID("lifecycle-visible"); err != nil { t.Fatalf("Current().EntryByID() error = %v, want persisted lifecycle-visible entry", err) } } func TestVaultServiceUpsertsNewEntryWithinAuthorizedGroupScope(t *testing.T) { t.Parallel() client, _, cleanup := newTestClientForModel(t, vault.Model{ Entries: []vault.Entry{ testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, ), }, Groups: [][]string{ {"keepass"}, {"keepass", "Joe"}, {"keepass", "Joe", "codex"}, }, }) defer cleanup() upserted, err := client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{ Entry: &keepassgov1.Entry{ Id: "codex-created", Title: "Codex Created", Path: []string{"Joe", "codex"}, }, }) if err != nil { t.Fatalf("UpsertEntry() error = %v", err) } if got := upserted.Entry.Path; !slices.Equal(got, []string{"Joe", "codex"}) { t.Fatalf("UpsertEntry().Entry.Path = %v, want [Joe codex]", got) } listed, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{ Path: []string{"Joe", "codex"}, }) if err != nil { t.Fatalf("ListEntries() error = %v", err) } if len(listed.Entries) != 1 || listed.Entries[0].Id != "codex-created" { t.Fatalf("ListEntries().Entries = %#v, want created codex entry", listed.Entries) } } func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil { t.Fatalf("DeleteEntry() error = %v", err) } listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if err != nil { t.Fatalf("ListEntries() error = %v", err) } if len(listed.Entries) != 0 { t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries)) } restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"}) 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") } listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if err != nil { 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) } } func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{}) if err != nil { t.Fatalf("ListTemplates() error = %v", err) } if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" { t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates) } if got := templates.Templates[0].Fields["Environment"]; got != "prod" { t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "prod") } instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{ TemplateId: "website-login", Overrides: &keepassgov1.Entry{ Id: "bellagio", Title: "Bellagio", Username: "rustyryan", Password: "hunter2", Url: "https://bellagio.example.invalid", Fields: map[string]string{ "Environment": "staging", }, Path: []string{"Root", "Internet"}, Tags: []string{"dns"}, }, }) if err != nil { 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 got := instantiated.Entry.Fields["Environment"]; got != "staging" { t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging") } listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if err != nil { t.Fatalf("ListEntries() error = %v", err) } if len(listed.Entries) != 2 { t.Fatalf("len(ListEntries().Entries) = %d, want 2 after template instantiation", len(listed.Entries)) } } func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{ Template: &keepassgov1.Entry{ Id: "website-login", Title: "Website Login Updated", Username: "template-user", Password: "template-password", Fields: map[string]string{ "Environment": "dev", }, Path: []string{"Templates", "Web"}, }, }) if err != nil { t.Fatalf("UpsertTemplate() error = %v", err) } if upserted.Template.Title != "Website Login Updated" { t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title) } if got := upserted.Template.Fields["Environment"]; got != "dev" { t.Fatalf("UpsertTemplate().Template.Fields[Environment] = %q, want %q", got, "dev") } listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{}) if err != nil { t.Fatalf("ListTemplates() error = %v", err) } if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" { t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates) } if got := listed.Templates[0].Fields["Environment"]; got != "dev" { t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "dev") } if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil { t.Fatalf("DeleteTemplate() error = %v", err) } listed, err = client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{}) if err != nil { t.Fatalf("ListTemplates() error = %v", err) } if len(listed.Templates) != 0 { t.Fatalf("ListTemplates().Templates = %#v, want empty after delete", listed.Templates) } } func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"}) if err != nil { t.Fatalf("ListEntryHistory() error = %v", err) } if len(history.Entries) != 1 || history.Entries[0].Password != "token-0" { t.Fatalf("ListEntryHistory().Entries = %#v, want old token entry", history.Entries) } restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{ Id: "vault-console", HistoryIndex: 0, }) if err != nil { t.Fatalf("RestoreEntryHistory() error = %v", err) } if restored.Entry.Password != "token-0" { t.Fatalf("RestoreEntryHistory().Entry.Password = %q, want token-0", restored.Entry.Password) } } func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := tokenContext(defaultTestTokenSecret) uploaded := []byte("attachment-content") if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{ EntryId: "vault-console", Name: "token.txt", Content: uploaded, }); err != nil { t.Fatalf("UploadAttachment() error = %v", err) } listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"}) if err != nil { t.Fatalf("ListAttachments() error = %v", err) } if len(listed.Names) != 1 || listed.Names[0] != "token.txt" { t.Fatalf("ListAttachments().Names = %#v, want [token.txt]", listed.Names) } downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{ EntryId: "vault-console", Name: "token.txt", }) if err != nil { t.Fatalf("DownloadAttachment() error = %v", err) } if !bytes.Equal(downloaded.Content, uploaded) { t.Fatalf("DownloadAttachment().Content = %q, want %q", downloaded.Content, uploaded) } if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{ EntryId: "vault-console", Name: "token.txt", }); err != nil { t.Fatalf("DeleteAttachment() error = %v", err) } listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"}) if err != nil { t.Fatalf("ListAttachments() error = %v", err) } if len(listed.Names) != 0 { t.Fatalf("ListAttachments().Names = %#v, want empty after delete", listed.Names) } } const defaultTestTokenSecret = "test-token" func tokenContext(secret string) context.Context { return metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer "+secret) } func hashSecretForTest(secret string) string { sum := sha256.Sum256([]byte(secret)) return hex.EncodeToString(sum[:]) } func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry { t.Helper() token, _, err := apitokens.Issue("Test Client", "grpc-test", nil, time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("Issue() error = %v", err) } token.SecretHash = hashSecretForTest(defaultTestTokenSecret) token.Policies = append([]apitokens.PolicyRule(nil), rules...) return token.Entry([]string{"Root", "API Tokens"}) } func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { t.Helper() model := vault.Model{ Entries: []vault.Entry{ { ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Fields: map[string]string{ "X-Role": "automation", }, History: []vault.Entry{ { ID: "vault-console-h1", Title: "Vault Console", Username: "dannyocean", Password: "token-0", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}, }, }, Path: []string{"Root", "Internet"}, }, { ID: "surveillance-console", Title: "Surveillance Console", Username: "codex", Password: "token-2", URL: "https://surveillance.crew.example.invalid", Path: []string{"Root", "Home Assistant"}, }, testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}}, 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.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationGeneratePassword, 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"}}}, ), }, Templates: []vault.Entry{ { ID: "website-login", Title: "Website Login", Username: "template-user", Password: "template-password", URL: "https://example.com", Notes: "Reusable template for website accounts.", Fields: map[string]string{ "Environment": "prod", }, Tags: []string{"template", "web"}, Path: []string{"Templates"}, }, }, } return newTestClientForModel(t, model) } func newTestClientForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { client, clipboardWriter, _, cleanup := newTestHarnessForModel(t, model) return client, clipboardWriter, cleanup } func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) { t.Helper() listener := bufconn.Listen(1024 * 1024) clipboardWriter := &memoryClipboardWriter{} service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter) server := grpc.NewServer() keepassgov1.RegisterVaultServiceServer(server, service) go func() { _ = server.Serve(listener) }() conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatalf("grpc.NewClient() error = %v", err) } cleanup := func() { _ = conn.Close() server.Stop() } return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup } func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { client, clipboardWriter, _, cleanup := newTestHarnessWithLifecycle(t, lifecycle) return client, clipboardWriter, cleanup } func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) { t.Helper() listener := bufconn.Listen(1024 * 1024) clipboardWriter := &memoryClipboardWriter{} model := lifecycle.model model.Entries = append(model.Entries, testAPITokenEntry(t, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, 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"}}}, )) lifecycle.model = model service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle) server := grpc.NewServer() keepassgov1.RegisterVaultServiceServer(server, service) go func() { _ = server.Serve(listener) }() conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatalf("grpc.NewClient() error = %v", err) } cleanup := func() { _ = conn.Close() server.Stop() } return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup } type memoryClipboardWriter struct { content string } func (w *memoryClipboardWriter) WriteText(text string) error { w.content = text return nil } func waitForServerPendingApproval(t *testing.T, server *Server, want int) []apiapproval.Request { t.Helper() deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { pending := server.ApprovalBroker().Pending() if len(pending) == want { return pending } time.Sleep(5 * time.Millisecond) } t.Fatalf("server pending approvals never reached len %d", want) return nil } type stubLifecycle struct { model vault.Model openPath string remoteBaseURL string remotePath string saved bool locked bool err error unlockPassword string unlockKeyFile []byte lastUnlockKey vault.MasterKey } func (s *stubLifecycle) Current() (vault.Model, error) { if s.locked { return vault.Model{}, session.ErrLocked } return s.model, nil } func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error { if s.err != nil { return s.err } s.openPath = path s.locked = false return nil } func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { if s.err != nil { return s.err } s.remoteBaseURL = client.BaseURL s.remotePath = path s.locked = false return nil } func (s *stubLifecycle) Save() error { if s.err != nil { return s.err } s.saved = true return nil } func (s *stubLifecycle) Lock() error { if s.err != nil { return s.err } s.locked = true return nil } func (s *stubLifecycle) Unlock(key vault.MasterKey) error { if s.err != nil { return s.err } if s.unlockPassword != "" && key.Password != s.unlockPassword { return vault.ErrInvalidMasterKey } if s.unlockKeyFile != nil && !bytes.Equal(key.KeyFileData, s.unlockKeyFile) { return vault.ErrInvalidMasterKey } s.lastUnlockKey = vault.MasterKey{ Password: key.Password, KeyFileData: append([]byte(nil), key.KeyFileData...), } s.locked = false return nil } func (s *stubLifecycle) Replace(model vault.Model) { s.model = model }