362 lines
12 KiB
Go
362 lines
12 KiB
Go
package browserbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
|
)
|
|
|
|
func TestReadRequestAndWriteResponse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var input bytes.Buffer
|
|
body, err := json.Marshal(Request{
|
|
Action: "find-logins",
|
|
GRPCAddress: "127.0.0.1:47777",
|
|
BearerToken: "secret",
|
|
URL: "https://example.invalid/login",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
if err := binary.Write(&input, binary.LittleEndian, uint32(len(body))); err != nil {
|
|
t.Fatalf("binary.Write() error = %v", err)
|
|
}
|
|
if _, err := input.Write(body); err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
|
|
req, err := ReadRequest(&input)
|
|
if err != nil {
|
|
t.Fatalf("ReadRequest() error = %v", err)
|
|
}
|
|
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
|
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
|
}
|
|
if conn, err := req.Connection(); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
|
t.Fatalf("req.Connection() = (%#v, %v), want explicit tcp address preserved", conn, err)
|
|
}
|
|
|
|
var output bytes.Buffer
|
|
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
|
|
t.Fatalf("WriteResponse() error = %v", err)
|
|
}
|
|
var size uint32
|
|
if err := binary.Read(&output, binary.LittleEndian, &size); err != nil {
|
|
t.Fatalf("binary.Read() error = %v", err)
|
|
}
|
|
payload := make([]byte, size)
|
|
if _, err := output.Read(payload); err != nil {
|
|
t.Fatalf("Read() payload error = %v", err)
|
|
}
|
|
var resp Response
|
|
if err := json.Unmarshal(payload, &resp); err != nil {
|
|
t.Fatalf("Unmarshal() error = %v", err)
|
|
}
|
|
if !resp.Success || resp.Version != "1" {
|
|
t.Fatalf("response = %#v, want success version 1", resp)
|
|
}
|
|
}
|
|
|
|
func TestHandleRequestFindLogins(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := fakeClient{
|
|
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 2},
|
|
matches: []*keepassgov1.BrowserLoginMatch{
|
|
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
|
},
|
|
}
|
|
resp := HandleRequest(context.Background(), Request{
|
|
Action: "find-logins",
|
|
BearerToken: "secret",
|
|
URL: "https://vault.example.invalid/login",
|
|
}, client)
|
|
if !resp.Success {
|
|
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
|
}
|
|
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
|
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
|
|
}
|
|
}
|
|
|
|
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := fakeClient{
|
|
status: &keepassgov1.GetSessionStatusResponse{
|
|
Locked: false,
|
|
EntryCount: 2,
|
|
PendingApprovalCount: 3,
|
|
TokenPendingApprovalCount: 1,
|
|
},
|
|
}
|
|
resp := HandleRequest(context.Background(), Request{
|
|
Action: "status",
|
|
BearerToken: "secret",
|
|
}, client)
|
|
if !resp.Success {
|
|
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
|
|
}
|
|
if resp.Status == nil {
|
|
t.Fatal("HandleRequest(status).Status = nil, want status")
|
|
}
|
|
if got := resp.Status.PendingApprovalCount; got != 3 {
|
|
t.Fatalf("HandleRequest(status).PendingApprovalCount = %d, want 3", got)
|
|
}
|
|
if got := resp.Status.TokenPendingApprovalCount; got != 1 {
|
|
t.Fatalf("HandleRequest(status).TokenPendingApprovalCount = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleRequestGetLogin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := fakeClient{
|
|
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 1},
|
|
credential: &keepassgov1.GetBrowserCredentialResponse{
|
|
Id: "vault-console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
Url: "https://vault.example.invalid",
|
|
},
|
|
}
|
|
resp := HandleRequest(context.Background(), Request{
|
|
Action: "get-login",
|
|
BearerToken: "secret",
|
|
EntryID: "vault-console",
|
|
URL: "https://vault.example.invalid/login",
|
|
}, client)
|
|
if !resp.Success {
|
|
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
|
}
|
|
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
|
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
|
|
}
|
|
}
|
|
|
|
func TestHandleRequestRequiresBearerToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resp := HandleRequest(context.Background(), Request{Action: "status"}, fakeClient{})
|
|
if resp.Success {
|
|
t.Fatal("HandleRequest().Success = true, want false without token")
|
|
}
|
|
}
|
|
|
|
func TestRequestConnectionDefaultsAddress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := Request{Action: "status", BearerToken: "secret"}
|
|
conn, err := req.Connection()
|
|
if err != nil {
|
|
t.Fatalf("Connection() error = %v", err)
|
|
}
|
|
if conn.GRPCAddress == "" {
|
|
t.Fatal("Connection().GRPCAddress = empty, want default address")
|
|
}
|
|
if runtime.GOOS != "windows" && !strings.HasPrefix(conn.GRPCAddress, "unix://") && conn.GRPCAddress != "off" {
|
|
t.Fatalf("Connection().GRPCAddress = %q, want unix socket default on this platform", conn.GRPCAddress)
|
|
}
|
|
}
|
|
|
|
func TestInstallManifest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmp := t.TempDir()
|
|
binaryPath := filepath.Join(tmp, "keepassgo-browser-bridge")
|
|
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile(binary) error = %v", err)
|
|
}
|
|
|
|
path, err := InstallManifest(BrowserFirefox, binaryPath, "", filepath.Join(tmp, "firefox-host.json"))
|
|
if err != nil {
|
|
t.Fatalf("InstallManifest() error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error = %v", err)
|
|
}
|
|
var manifest NativeHostManifest
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
t.Fatalf("Unmarshal() error = %v", err)
|
|
}
|
|
if manifest.Path != binaryPath {
|
|
t.Fatalf("manifest.Path = %q, want %q", manifest.Path, binaryPath)
|
|
}
|
|
if len(manifest.AllowedExtensions) != 1 || manifest.AllowedExtensions[0] != DefaultFirefoxExtensionID() {
|
|
t.Fatalf("manifest.AllowedExtensions = %#v, want default firefox extension id", manifest.AllowedExtensions)
|
|
}
|
|
}
|
|
|
|
func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMfW0u1k4K5A0uN2s0aH7uQKpM3x5Hf8mZfY1xVh0m7E2mJ7M8GiV4m0g0I2w9U9D1yqGQ6w8jzH5v8t7qB2RjMCAwEAAQ=="
|
|
got, err := ChromiumExtensionIDFromManifestKey(publicKey)
|
|
if err != nil {
|
|
t.Fatalf("ChromiumExtensionIDFromManifestKey() error = %v", err)
|
|
}
|
|
if got != "okcdfigpojphpoecpglkkmkjmiaefmpd" {
|
|
t.Fatalf("ChromiumExtensionIDFromManifestKey() = %q, want %q", got, "okcdfigpojphpoecpglkkmkjmiaefmpd")
|
|
}
|
|
}
|
|
|
|
func TestManifestSetChromiumIncludesAllOrigins(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
manifest, err := ManifestSet(BrowserChromium, "/tmp/keepassgo-browser-bridge", []string{
|
|
"mjlnpdomnblnbblhacolncflebbgafhj",
|
|
"ddfbfpcgdjkffmjnialjpookcoedahcn",
|
|
"mjlnpdomnblnbblhacolncflebbgafhj",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ManifestSet() error = %v", err)
|
|
}
|
|
want := []string{
|
|
"chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/",
|
|
"chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/",
|
|
}
|
|
if !slices.Equal(manifest.AllowedOrigins, want) {
|
|
t.Fatalf("ManifestSet().AllowedOrigins = %#v, want %#v", manifest.AllowedOrigins, want)
|
|
}
|
|
}
|
|
|
|
func TestDiscoverInstalledExtensionIDsInRoot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
writeExtensionManifest(t, filepath.Join(root, "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
|
|
writeExtensionManifest(t, filepath.Join(root, "Profile 1", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.2.0", "manifest.json"), browserExtensionName)
|
|
writeExtensionManifest(t, filepath.Join(root, "Profile 2", "Extensions", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "3.4.5", "manifest.json"), "Bellagio Notes")
|
|
writeExtensionManifest(t, filepath.Join(root, "Profile 3", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.1.0", "manifest.json"), browserExtensionName)
|
|
|
|
got, err := DiscoverInstalledExtensionIDsInRoot(root)
|
|
if err != nil {
|
|
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() error = %v", err)
|
|
}
|
|
want := []string{
|
|
"ddfbfpcgdjkffmjnialjpookcoedahcn",
|
|
"mjlnpdomnblnbblhacolncflebbgafhj",
|
|
}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("DiscoverInstalledExtensionIDsInRoot() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv("HOME", filepath.Join(tmp, "home"))
|
|
appDir := filepath.Join(tmp, "app")
|
|
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(appDir) error = %v", err)
|
|
}
|
|
appBinaryPath := filepath.Join(appDir, "keepassgo")
|
|
if err := os.WriteFile(appBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile(appBinaryPath) error = %v", err)
|
|
}
|
|
bridgeBinaryPath := filepath.Join(appDir, "keepassgo-browser-bridge")
|
|
if err := os.WriteFile(bridgeBinaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile(bridgeBinaryPath) error = %v", err)
|
|
}
|
|
home := filepath.Join(tmp, "home")
|
|
writeExtensionManifest(t, filepath.Join(home, ".config", "chromium", "Default", "Extensions", "mjlnpdomnblnbblhacolncflebbgafhj", "1.0.0", "manifest.json"), browserExtensionName)
|
|
writeExtensionManifest(t, filepath.Join(home, ".config", "google-chrome", "Profile 7", "Extensions", "ddfbfpcgdjkffmjnialjpookcoedahcn", "1.0.0", "manifest.json"), browserExtensionName)
|
|
|
|
if err := EnsureNativeHostManifests(appBinaryPath); err != nil {
|
|
t.Fatalf("EnsureNativeHostManifests() error = %v", err)
|
|
}
|
|
|
|
assertManifestContainsExtension(t, filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), "allowed_extensions", DefaultFirefoxExtensionID())
|
|
assertManifestContainsExtension(t, filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://mjlnpdomnblnbblhacolncflebbgafhj/")
|
|
assertManifestContainsExtension(t, filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), "allowed_origins", "chrome-extension://ddfbfpcgdjkffmjnialjpookcoedahcn/")
|
|
}
|
|
|
|
type fakeClient struct {
|
|
status *keepassgov1.GetSessionStatusResponse
|
|
matches []*keepassgov1.BrowserLoginMatch
|
|
credential *keepassgov1.GetBrowserCredentialResponse
|
|
err error
|
|
}
|
|
|
|
func writeExtensionManifest(t *testing.T, path, name string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
|
|
}
|
|
data, err := json.Marshal(map[string]string{"name": name})
|
|
if err != nil {
|
|
t.Fatalf("Marshal(manifest %q) error = %v", path, err)
|
|
}
|
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
|
}
|
|
}
|
|
|
|
func assertManifestContainsExtension(t *testing.T, path, field, want string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
|
}
|
|
var manifest map[string]any
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
t.Fatalf("Unmarshal(%q) error = %v", path, err)
|
|
}
|
|
valuesAny, ok := manifest[field]
|
|
if !ok {
|
|
t.Fatalf("manifest %q missing field %q", path, field)
|
|
}
|
|
valuesRaw, ok := valuesAny.([]any)
|
|
if !ok {
|
|
t.Fatalf("manifest %q field %q = %#v, want []any", path, field, valuesAny)
|
|
}
|
|
values := make([]string, 0, len(valuesRaw))
|
|
for _, raw := range valuesRaw {
|
|
text, ok := raw.(string)
|
|
if !ok {
|
|
t.Fatalf("manifest %q field %q value = %#v, want string", path, field, raw)
|
|
}
|
|
values = append(values, text)
|
|
}
|
|
if !slices.Contains(values, want) {
|
|
t.Fatalf("manifest %q field %q = %#v, want to contain %q", path, field, values, want)
|
|
}
|
|
}
|
|
|
|
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
if f.status == nil {
|
|
return &keepassgov1.GetSessionStatusResponse{}, nil
|
|
}
|
|
return f.status, nil
|
|
}
|
|
|
|
func (f fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.matches, nil
|
|
}
|
|
|
|
func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
if f.credential == nil {
|
|
return &keepassgov1.GetBrowserCredentialResponse{}, nil
|
|
}
|
|
return f.credential, nil
|
|
}
|