Add serviceclientprefs, an optional feature that stores and loads the desktop clients' saved service launch preferences, one file per login profile. - Add GET|POST /localapi/v0/prefs/service-clients to load and save the current profile's service client prefs. - Add local client GetServiceClientPrefs and SetServiceClientPref that call the new local api endpoint. - Store the prefs with the ipn/store FileStore at TailscaleVarRoot()/profile-data/<profileID>/service-client-prefs/<hex-encoded-key>, so DeleteProfile cleans them up for free. Fall back to an in-memory store when there's no var root. - Register the feature and its local api route from build tagged files so the whole thing drops out under ts_omit_serviceclientprefs. - Add the serviceclient package holding Pref and Prefs (saved client, username, database name, and last used time), so the local api client and desktop apps can import the types without the feature machinery. Change-Id: I340a99c1b332d181fb1556fbf3e8003bb3b95a08 Updates: https://github.com/tailscale/tailscale/issues/20429 Signed-off-by: Rollie Ma <rollie@tailscale.com>
117 lines
3.5 KiB
Go
117 lines
3.5 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package serviceclientprefs
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"tailscale.com/client/tailscale/apitype"
|
|
"tailscale.com/feature/serviceclientprefs/serviceclient"
|
|
"tailscale.com/ipn"
|
|
)
|
|
|
|
func TestServiceClientPrefsSetAndGet(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requests []apitype.ServiceClientPrefRequest
|
|
want serviceclient.Prefs
|
|
}{
|
|
{
|
|
name: "records_a_single_launch",
|
|
requests: []apitype.ServiceClientPrefRequest{{Key: "ssh:22", Client: "terminal", Username: "rollie"}},
|
|
want: serviceclient.Prefs{"ssh:22": {Client: "terminal", Username: "rollie"}},
|
|
},
|
|
{
|
|
name: "partial_update_preserves_existing_fields",
|
|
requests: []apitype.ServiceClientPrefRequest{
|
|
{Key: "ssh:22", Client: "terminal", Username: "rollie"},
|
|
{Key: "ssh:22", Client: "iterm2"},
|
|
},
|
|
want: serviceclient.Prefs{"ssh:22": {Client: "iterm2", Username: "rollie"}},
|
|
},
|
|
{
|
|
name: "independent_services_kept_separate",
|
|
requests: []apitype.ServiceClientPrefRequest{
|
|
{Key: "ssh:22", Client: "terminal"},
|
|
{Key: "db:5432", Client: "psql", DatabaseName: "prod"},
|
|
},
|
|
want: serviceclient.Prefs{
|
|
"ssh:22": {Client: "terminal"},
|
|
"db:5432": {Client: "psql", DatabaseName: "prod"},
|
|
},
|
|
},
|
|
}
|
|
|
|
ignoreLastUsed := cmpopts.IgnoreFields(serviceclient.Pref{}, "LastUsed")
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
e := newTestExtension(t, "pid")
|
|
for _, req := range tt.requests {
|
|
if _, err := e.setServiceClientPref(req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got, err := e.serviceClientPrefs()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if diff := cmp.Diff(tt.want, got, ignoreLastUsed, cmpopts.EquateEmpty()); diff != "" {
|
|
t.Errorf("(-want +got):\n%s", diff)
|
|
}
|
|
for key := range tt.want {
|
|
if got[key].LastUsed.IsZero() {
|
|
t.Errorf("%s: LastUsed was not stamped", key)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceClientPrefsEmptyKeyRejected(t *testing.T) {
|
|
e := newTestExtension(t, "pid")
|
|
_, err := e.setServiceClientPref(apitype.ServiceClientPrefRequest{Client: "terminal"})
|
|
if !errors.Is(err, errInvalidServiceClientPref) {
|
|
t.Errorf("want errInvalidServiceClientPref for empty key, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceClientPrefsNoCurrentProfileReturnsEmpty(t *testing.T) {
|
|
e := newTestExtension(t, "") // no current profile
|
|
got, err := e.serviceClientPrefs()
|
|
if err != nil {
|
|
t.Fatalf("want nil error for no current profile, got %v", err)
|
|
}
|
|
if got == nil || len(got) != 0 {
|
|
t.Errorf("want empty non-nil map, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestServiceClientPrefsRepopulatedOnProfileSwitch(t *testing.T) {
|
|
e := newTestExtension(t, "pid0")
|
|
|
|
// Save a pref for pid0.
|
|
if _, err := e.setServiceClientPref(apitype.ServiceClientPrefRequest{Key: "ssh:22", Client: "terminal"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Switch to a different profile; it must not see pid0's data.
|
|
e.onChangeProfile((&ipn.LoginProfile{ID: "pid1"}).View(), ipn.PrefsView{}, false)
|
|
if got, _ := e.serviceClientPrefs(); len(got) != 0 {
|
|
t.Fatalf("pid1 unexpectedly saw pid0's prefs: %v", got)
|
|
}
|
|
|
|
// Switch back to pid0; its saved pref should be read back from disk into the cache.
|
|
e.onChangeProfile((&ipn.LoginProfile{ID: "pid0"}).View(), ipn.PrefsView{}, false)
|
|
got, err := e.serviceClientPrefs()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got["ssh:22"].Client != "terminal" {
|
|
t.Errorf("after switching back, got %v, want Client=terminal", got["ssh:22"])
|
|
}
|
|
}
|