package api import ( "bytes" "context" "net" "testing" "git.julianfamily.org/keepassgo/passwords" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/webdav" "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 TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") 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 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 := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") 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: "jjulian", 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 TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") 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 != "Git Server" { t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server") } } func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") 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 TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") 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 TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) { t.Parallel() client, clipboardWriter, cleanup := newTestClient(t) defer cleanup() ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{ Id: "git-server", 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 := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{ Entry: &keepassgov1.Entry{ Id: "ha-codex", Title: "Home Assistant (Codex)", Username: "codex", Password: "token-2", Url: "https://lights.julianfamily.org", Path: []string{"Root", "Home Assistant"}, }, }) if err != nil { t.Fatalf("UpsertEntry() error = %v", err) } if upserted.Entry.Title != "Home Assistant (Codex)" { t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)") } 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) } } func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); 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: "git-server"}) if err != nil { t.Fatalf("RestoreEntry() error = %v", err) } 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"}}) if err != nil { t.Fatalf("ListEntries() error = %v", err) } if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" { t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries) } } func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testing.T) { t.Parallel() client, _, cleanup := newTestClient(t) defer cleanup() ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") 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) } instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{ TemplateId: "website-login", Overrides: &keepassgov1.Entry{ Id: "dynadot", Title: "Dynadot", Username: "jjulian", Password: "hunter2", Url: "https://www.dynadot.com", Path: []string{"Root", "Internet"}, Tags: []string{"dns"}, }, }) if err != nil { t.Fatalf("InstantiateTemplate() error = %v", err) } 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) } 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 := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{ Template: &keepassgov1.Entry{ Id: "website-login", Title: "Website Login Updated", Username: "template-user", Password: "template-password", 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) } 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 _, 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 := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"}) 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: "git-server", 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 := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") uploaded := []byte("attachment-content") if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{ EntryId: "git-server", Name: "token.txt", Content: uploaded, }); err != nil { t.Fatalf("UploadAttachment() error = %v", err) } listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"}) 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: "git-server", 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: "git-server", Name: "token.txt", }); err != nil { t.Fatalf("DeleteAttachment() error = %v", err) } listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"}) 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) } } func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { t.Helper() listener := bufconn.Listen(1024 * 1024) server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token"))) clipboardWriter := &memoryClipboardWriter{} keepassgov1.RegisterVaultServiceServer(server, NewServer( vault.Model{ Entries: []vault.Entry{ { ID: "git-server", Title: "Git Server", Username: "joejulian", Password: "token-1", URL: "https://git.julianfamily.org", History: []vault.Entry{ { ID: "git-server-h1", Title: "Git Server", Username: "joejulian", Password: "token-0", URL: "https://git.julianfamily.org", Path: []string{"Root", "Internet"}, }, }, Path: []string{"Root", "Internet"}, }, { ID: "ha-codex", Title: "Home Assistant (Codex)", Username: "codex", Password: "token-2", URL: "https://lights.julianfamily.org", Path: []string{"Root", "Home Assistant"}, }, }, 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.", Tags: []string{"template", "web"}, Path: []string{"Templates"}, }, }, }, passwords.DefaultProfiles(), clipboardWriter, )) 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, cleanup } func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { t.Helper() listener := bufconn.Listen(1024 * 1024) server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token"))) clipboardWriter := &memoryClipboardWriter{} keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle( vault.Model{}, passwords.DefaultProfiles(), clipboardWriter, lifecycle, )) 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, cleanup } type memoryClipboardWriter struct { content string } func (w *memoryClipboardWriter) WriteText(text string) error { w.content = text return nil } type stubLifecycle struct { model vault.Model openPath string remoteBaseURL string remotePath string saved bool locked bool } 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 { s.openPath = path return nil } func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { s.remoteBaseURL = client.BaseURL s.remotePath = path return nil } func (s *stubLifecycle) Save() error { s.saved = true return nil }