Files
netbird/client/internal/profilemanager/service_test.go
T

646 lines
22 KiB
Go

package profilemanager
import (
"context"
"encoding/json"
"errors"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/util"
)
// withTestSM wires up patched globals + a clean config dir and returns a
// fully initialized ServiceManager plus the username we are scoped to.
func withTestSM(t *testing.T, fn func(sm *ServiceManager, id ipcauth.Identity)) {
t.Helper()
withTempConfigDir(t, func(configDir string) {
withPatchedGlobals(t, configDir, func() {
u, err := user.Current()
require.NoError(t, err)
sm := &ServiceManager{}
require.NoError(t, sm.CreateDefaultProfile())
uid, err := strconv.ParseUint(u.Uid, 10, 32)
require.NoError(t, err)
userID := ipcauth.Identity{UID: uint32(uid)}
userID = ipcauth.KnownForTest(userID)
fn(sm, userID)
})
})
}
func TestServiceProfile_ExactID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", nil)
require.NoError(t, err)
got, err := sm.ResolveProfile(created.ID.String(), userID)
require.NoError(t, err)
assert.Equal(t, created.ID, got.ID)
assert.Equal(t, "work", got.Name)
})
}
func TestServiceProfile_IDPrefix(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
prefix := created.ID[:4]
got, err := sm.ResolveProfile(prefix.String(), userID)
require.NoError(t, err)
assert.Equal(t, created.ID, got.ID)
})
}
func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
// Plant two profiles whose IDs share a known prefix by writing
// the files directly, since generated IDs are random.
user, err := user.Current()
require.NoError(t, err)
configDir, err := sm.getConfigDirLegacy(user.Username)
require.NoError(t, err)
for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} {
path := filepath.Join(configDir, id+".json")
require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id}))
}
_, err = sm.ResolveProfile("abcd", userID)
var amb *ErrAmbiguousHandle
require.ErrorAs(t, err, &amb)
assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind)
assert.Len(t, amb.Candidates, 2)
})
}
func TestServiceProfile_ExactNameUnique(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
_, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
got, err := sm.ResolveProfile("work", userID)
require.NoError(t, err)
assert.Equal(t, "work", got.Name)
})
}
func TestServiceProfile_AmbiguousName(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
_, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
_, err = sm.AddProfile("work", &userID)
require.NoError(t, err)
_, err = sm.ResolveProfile("work", userID)
var amb *ErrAmbiguousHandle
require.ErrorAs(t, err, &amb)
assert.Equal(t, AmbiguityKindName, amb.Kind)
assert.Len(t, amb.Candidates, 2)
})
}
func TestServiceProfile_NotFound(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
_, err := sm.ResolveProfile("nope", userID)
assert.ErrorIs(t, err, ErrProfileNotFound)
})
}
func TestServiceProfile_DefaultByExactID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
got, err := sm.ResolveProfile(defaultProfileName, userID)
require.NoError(t, err)
assert.Equal(t, defaultProfileName, got.ID.String())
})
}
func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
// Legacy profiles stored as <name>.json with no "name" JSON field
// should still be discoverable by name and removable by name.
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
user, err := user.Current()
require.NoError(t, err)
configDir, err := sm.getConfigDirLegacy(user.Username)
require.NoError(t, err)
path := filepath.Join(configDir, "legacy.json")
require.NoError(t, util.WriteJson(context.Background(), path, &Config{}))
got, err := sm.ResolveProfile("legacy", userID)
require.NoError(t, err)
assert.Equal(t, "legacy", got.ID.String())
// Name falls back to the filename stem when JSON omits it.
assert.Equal(t, "legacy", got.Name)
})
}
func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
first, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
second, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
assert.Equal(t, "work", second.Name)
})
}
func TestAddProfile_RejectsInvalidNames(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
cases := []string{
"", // empty
"\x00\x01", // only control chars (becomes empty)
strings.Repeat("a", maxProfileNameLen+1), // too long
}
for _, name := range cases {
_, err := sm.AddProfile(name, &userID)
assert.Error(t, err, "expected error for %q", name)
}
})
}
func TestRemoveProfile_RejectsInvalidID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
err := sm.RemoveProfile("../escape", userID)
assert.Error(t, err)
})
}
func TestSanitizeDisplayName(t *testing.T) {
cases := []struct {
in string
want string
wantErr bool
}{
{"work", "work", false},
{"My Work Account", "My Work Account", false},
{"emoji 🚀 ok", "emoji 🚀 ok", false},
{"漢字テスト", "漢字テスト", false},
{"with\x00null", "withnull", false},
{"\x01\x02\x03", "", true},
{"", "", true},
}
for _, tc := range cases {
got, err := sanitizeDisplayName(tc.in)
if tc.wantErr {
assert.Error(t, err, "case %q", tc.in)
continue
}
assert.NoError(t, err, "case %q", tc.in)
assert.Equal(t, tc.want, got, "case %q", tc.in)
}
}
func TestIsValidProfileFilenameStem(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"default", true},
{"abc123def456", true},
{"legacy-name", true},
{"legacy_name", true},
{"", false},
{"..", false},
{"../etc", false},
{"foo/bar", false},
{`foo\bar`, false},
{"with space", false},
{"with.dot", false},
{strings.Repeat("a", maxProfileIDLen+1), false},
}
for _, tc := range cases {
got := IsValidProfileFilenameStem(ID(tc.in))
assert.Equal(t, tc.want, got, "case %q", tc.in)
}
}
func TestRemoveProfile_DeletesStateFile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
user, err := user.Current()
require.NoError(t, err)
configDir, err := sm.getConfigDirLegacy(user.Username)
require.NoError(t, err)
statePath := filepath.Join(configDir, created.ID.String()+".state.json")
require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600))
require.NoError(t, sm.RemoveProfile(created.ID, userID))
_, err = os.Stat(statePath)
assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed")
})
}
// profileIDs is the set of profile IDs in a listing, for membership assertions
// that do not care about the default profile always being present.
func profileIDs(profiles []Profile) []string {
ids := make([]string, 0, len(profiles))
for _, p := range profiles {
ids = append(ids, p.ID.String())
}
return ids
}
func TestListProfiles_ScopedToOwner(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
// Two synthetic users rather than the current one: this process is its
// own daemon, and IsPrivilegedCaller delegates to a caller sharing an
// unprivileged daemon's identity, so the current user resolves
// unfiltered here.
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
bob := ipcauth.KnownForTest(ipcauth.Identity{UID: 4243})
hers, err := sm.AddProfile("hers", &alice)
require.NoError(t, err)
his, err := sm.AddProfile("his", &bob)
require.NoError(t, err)
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), hers.ID.String())
assert.NotContains(t, profileIDs(got), his.ID.String(),
"another user's profile must not be listed")
})
}
func TestListProfiles_PrivilegedResolvesUnfiltered(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
other := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
mine, err := sm.AddProfile("mine", &userID)
require.NoError(t, err)
theirs, err := sm.AddProfile("theirs", &other)
require.NoError(t, err)
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err := sm.ListProfiles(root)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), mine.ID.String())
assert.Contains(t, profileIDs(got), theirs.ID.String())
assert.Contains(t, profileIDs(got), defaultProfileName)
})
}
func TestListProfiles_OnlyTheDefaultFailsOpenWhenUnowned(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
unowned, err := sm.AddProfile("unowned", nil)
require.NoError(t, err)
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), defaultProfileName,
"a fresh install has to be usable before anything is claimed")
assert.NotContains(t, profileIDs(got), unowned.ID.String(),
"every other profile needs an owner before anyone can address it")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err = sm.ListProfiles(root)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), unowned.ID.String(),
"root still reaches it, which is how it gets assigned")
nobody, err := sm.ListProfiles(ipcauth.Identity{})
require.NoError(t, err)
assert.Empty(t, profileIDs(nobody), "an unattested caller reaches nothing")
})
}
// withLegacyLayout wires up the globals the way withTestSM does, without the
// identity, since these tests supply their own.
func withLegacyLayout(t *testing.T, fn func(sm *ServiceManager, configDir string)) {
t.Helper()
withTempConfigDir(t, func(configDir string) {
withPatchedGlobals(t, configDir, func() {
sm := &ServiceManager{}
require.NoError(t, sm.CreateDefaultProfile())
fn(sm, configDir)
})
})
}
// writeLegacyProfile drops a profile into a per-username directory, the layout
// every profile used before the ID-keyed one. It writes no name, since a legacy
// profile took its display name from the filename.
func writeLegacyProfile(t *testing.T, configDir, dirName, id string, fields map[string]any) string {
t.Helper()
dir := filepath.Join(configDir, dirName)
require.NoError(t, os.MkdirAll(dir, 0700))
doc := map[string]any{}
for k, v := range fields {
doc[k] = v
}
path := filepath.Join(dir, id+".json")
require.NoError(t, util.WriteJson(context.Background(), path, doc))
return path
}
// stubLegacyDir replaces the account lookup, so the claim path can be exercised
// without an account existing on the machine running the test.
func stubLegacyDir(t *testing.T, dir string) {
t.Helper()
orig := legacyDirForIdentity
legacyDirForIdentity = func(ipcauth.Identity) (string, bool) { return dir, dir != "" }
t.Cleanup(func() { legacyDirForIdentity = orig })
}
func readOwners(t *testing.T, path string) []string {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
var meta ownerMeta
require.NoError(t, json.Unmarshal(data, &meta))
return meta.Owners
}
func TestListProfiles_UnownedLegacyProfileIsPrivilegedOnly(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
stubLegacyDir(t, "bob")
bob := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(bob)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), "work",
"a profile sitting in someone else's directory is not free to take")
assert.Empty(t, readOwners(t, path), "and it is not claimed on the way past")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err = sm.ListProfiles(root)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), "work",
"root still reaches it, which is how it gets reassigned")
})
}
func TestListProfiles_ClaimsLegacyProfileForItsOwnAccount(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), "work",
"the claim lands before the listing is filtered, so the gap closes in one call")
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
// The claim is on disk now, so it is the owner check and not the
// directory name that keeps the next caller out.
stubLegacyDir(t, "alice")
other := ipcauth.KnownForTest(ipcauth.Identity{UID: 5252})
got, err = sm.ListProfiles(other)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), "work")
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path),
"a second caller does not overwrite a stamped owner")
})
}
func TestClaimLegacyProfile_LeavesTheProfileWhereItIs(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
for _, suffix := range []string{".state.json", prefsFileSuffix} {
require.NoError(t, os.WriteFile(filepath.Join(configDir, "alice", "work"+suffix), []byte("{}"), 0600))
}
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimed := ownedProfile(t, got, "uid:4242")
assert.Equal(t, ID("work"), claimed.ID, "claiming does not re-key the profile")
assert.Equal(t, path, claimed.Path)
assert.Equal(t, "alice", claimed.LegacyUserDir,
"the directory is a leftover now, not something the claim rewrites")
// The engine captures its state file path at connect time and writes
// back to whatever it captured, so nothing here may move that file.
assert.FileExists(t, filepath.Join(configDir, "alice", "work.state.json"))
assert.FileExists(t, filepath.Join(configDir, "alice", "work"+prefsFileSuffix))
assert.NoFileExists(t, filepath.Join(configDir, DefaultProfilePathDir, "work.json"))
})
}
func TestClaimLegacyProfile_NamesakeInAnotherDirectoryIsUntouched(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
writeLegacyProfile(t, configDir, "alice", "work", nil)
bobs := writeLegacyProfile(t, configDir, "bob", "work", nil)
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimed := ownedProfile(t, got, "uid:4242")
assert.Equal(t, filepath.Join(configDir, "alice", "work.json"), claimed.Path,
"a legacy ID is a display name two accounts can hold, so the path is what tells them apart")
assert.Empty(t, readOwners(t, bobs), "bob's namesake is not claimed")
})
}
func ownedProfile(t *testing.T, profiles []Profile, principal string) Profile {
t.Helper()
var found []Profile
for _, p := range profiles {
if len(p.Owners) == 1 && p.Owners[0].String() == principal {
found = append(found, p)
}
}
require.Len(t, found, 1, "exactly one profile is owned by %s", principal)
return found[0]
}
func TestClaimLegacyProfile_SkipsOneAlreadyOwnedInTheSameDirectory(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
// One unowned profile keeps the claim running over the directory, so
// the owned one beside it is reached and has to be left alone.
taken := writeLegacyProfile(t, configDir, "alice", "taken", map[string]any{
"Owners": []string{"uid:9999"},
})
free := writeLegacyProfile(t, configDir, "alice", "free", nil)
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Equal(t, []string{"uid:9999"}, readOwners(t, taken),
"a profile that already has an owner is not restamped")
assert.Equal(t, []string{"uid:4242"}, readOwners(t, free))
})
}
func TestRenameProfile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
require.NoError(t, sm.RenameProfile(created.ID, userID, "weekend"))
got, err := sm.ResolveProfile(created.ID.String(), userID)
require.NoError(t, err)
assert.Equal(t, "weekend", got.Name, "the new name is on disk")
assert.Equal(t, created.ID, got.ID, "renaming does not re-key the profile")
assert.Equal(t, created.Path, got.Path)
})
}
func TestRenameProfile_NotTheCallersProfile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
stranger := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
require.Error(t, sm.RenameProfile(created.ID, stranger, "weekend"),
"a profile the caller cannot address is not theirs to rename")
got, err := sm.ResolveProfile(created.ID.String(), userID)
require.NoError(t, err)
assert.Equal(t, "work", got.Name)
})
}
func TestResolveProfile_ClaimsOnTheWayThrough(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
stubLegacyDir(t, "alice")
// Resolution is what switching a profile goes through, so the claim has
// to land here and not only when something lists profiles.
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ResolveProfile("work", alice)
require.NoError(t, err)
assert.Equal(t, path, got.Path)
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
})
}
func TestListProfiles_PrivilegedCallerDoesNotClaim(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "root", "work", nil)
stubLegacyDir(t, "root")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err := sm.ListProfiles(root)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), "work")
assert.Empty(t, readOwners(t, path),
"root reaches every profile anyway, so a listing must not stamp one")
})
}
func TestStampOwner(t *testing.T) {
withLegacyLayout(t, func(_ *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", map[string]any{
"DisableAutoConnect": true,
})
require.NoError(t, StampOwner(path, ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})))
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
var fields map[string]any
data, err := os.ReadFile(path)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(data, &fields))
assert.Equal(t, true, fields["DisableAutoConnect"],
"the rest of the config survives the stamp")
require.NoError(t, StampOwner(path, ipcauth.KnownForTest(ipcauth.Identity{UID: 5252})))
assert.Equal(t, []string{"uid:5252"}, readOwners(t, path),
"stamping replaces whoever is recorded, the caller decides whether to")
})
}
func TestClaimLegacyProfile_LeavesAnOwnerItCannotParse(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
// A principal kind this build does not know, which is what a client
// newer than this one leaves behind after a downgrade. The loader
// drops such a profile rather than reporting it as unowned, which is
// what keeps the claim from treating it as free to take.
path := writeLegacyProfile(t, configDir, "alice", "work", map[string]any{
"Owners": []string{"group:devs"},
})
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), "work")
assert.Equal(t, []string{"group:devs"}, readOwners(t, path),
"the profile is not taken over, it waits for an explicit claim")
})
}
func TestResolveLegacyDir_SanitizesTheSameStringTheOldLayoutDid(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the windows path resolves a SID rather than a numeric uid")
}
u, err := user.Current()
require.NoError(t, err)
uid, err := strconv.ParseUint(u.Uid, 10, 32)
require.NoError(t, err)
got, ok := resolveLegacyDir(ipcauth.KnownForTest(ipcauth.Identity{UID: uint32(uid)}))
require.True(t, ok)
assert.Equal(t, sanitizeProfileName(u.Username), got,
"the directory has to come from the account name, sanitized the way the old layout did")
}
func TestListProfiles_UnreadableOwnersAreSkipped(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
configDir, err := sm.getConfigDir()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(configDir, 0700))
// An owner entry that parses as JSON but names no principal: the
// profile records an owner, so it is not unowned, but nothing can match
// it.
const tampered = "abcd1111aaaa"
path := filepath.Join(configDir, tampered+".json")
require.NoError(t, os.WriteFile(path, []byte(`{"Name":"tampered","Owners":["garbage"]}`), 0600))
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), tampered,
"a profile whose owners cannot be read must not fall back to unowned")
// Not even a privileged caller: the loader drops the profile before
// ownership is ever consulted, so corrupting the owner list hides the
// profile rather than unlocking it.
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err = sm.ListProfiles(root)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), tampered)
})
}
func TestListProfiles_UnidentifiedCallerGetsNothing(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
_, err := sm.AddProfile("mine", &userID)
require.NoError(t, err)
_, err = sm.AddProfile("unowned", nil)
require.NoError(t, err)
// The zero Identity carries uid 0, so an unidentified caller must be
// refused before privilege is ever considered.
got, err := sm.ListProfiles(ipcauth.Identity{})
require.NoError(t, err)
assert.Empty(t, got)
})
}