Files
keepassgo/api/server_test.go
T
2026-03-29 11:04:38 -07:00

631 lines
20 KiB
Go

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: "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 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 != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
}
}
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: "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 := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
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",
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")
}
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: "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 := 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: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
Url: "https://bellagio.example.invalid",
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)
}
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: "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 := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
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)
}
}
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: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
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"},
},
},
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
}