mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-05 22:41:30 +02:00
[client, ios] Migrate switft profile manager to go (#6528)
* [client] Add iOS NetBirdSDK profile manager binding Mirror the Android profile manager in the iOS gomobile binding so the core's ID-based profilemanager.ServiceManager owns profile state on iOS too, instead of a parallel Swift reimplementation. Adds client/ios/NetBirdSDK/profile_manager.go (//go:build ios): an ID-based ProfileManager wrapping ServiceManager with iOS-specific path handling (default profile at the container-root netbird.cfg, others as profiles/<id>.json) and a gomobile-friendly API: List/Add/Switch/Rename/ Logout/Remove plus active config/state path accessors. The default profile keeps the reserved "default" id and is never assigned a hex id. * fix(ios): preserve profile name when saving config during auth NewAuth built a fresh in-memory config from only the management URL, so the SSO/setup-key save (DirectWriteOutConfig) overwrote the profile config file the profile manager had just written, wiping the display name to "" and forcing the UI to fall back to the profile ID. Load the existing config when present and override only the management URL, keeping the name and keys. * [client] Extract the mobile profile manager into client/mobile The Android and iOS gomobile bindings carried two near-identical copies of the profile manager. Move the shared implementation into a new client/mobile package and reduce both bindings to thin adapters that only translate to gomobile-friendly types (gomobile binds per package, so the Profile / ProfileArray wrappers have to stay platform-side). Also bring the account-email layer over to the shared package: an SSO login records the account under <stem>.account.json so the next login can pass it as an OIDC login_hint. Logout keeps it, profile removal drops it. The suffix deliberately differs from .state.json, which the engine's state manager owns in the same directory on mobile. Adds profilemanager.Prefs (namespaced per-profile preference store) and its cleanup in ServiceManager.RemoveProfile, exposed through the shared manager as ProfilePrefs.
This commit is contained in:
294
client/mobile/profile_manager.go
Normal file
294
client/mobile/profile_manager.go
Normal file
@@ -0,0 +1,294 @@
|
||||
// Package mobile holds the profile manager implementation shared by the
|
||||
// Android and iOS gomobile bindings. The platform packages (client/android,
|
||||
// client/ios/NetBirdSDK) only adapt this API to gomobile-friendly types.
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
// Config filename of the default profile, stored at the configDir root.
|
||||
// Both platforms use netbird.cfg (matching the desktop netbird.cfg rather
|
||||
// than default.json); the app-side path constants must match.
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory of configDir holding non-default profiles.
|
||||
profilesSubdir = "profiles"
|
||||
)
|
||||
|
||||
/*
|
||||
|
||||
<configDir>/ ← app-writable config root
|
||||
├── netbird.cfg ← Default profile config
|
||||
├── netbird.account.json ← Default profile account email (see profile_state.go)
|
||||
├── state.json ← Default profile state
|
||||
├── active_profile.json ← Active profile tracker (JSON with ID + Username)
|
||||
└── profiles/ ← Subdirectory for non-default profiles
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← Profile config (filename = ID)
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← Profile state
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.account.json ← Profile account email
|
||||
└── 4c5f5c8198c3989cffb5b5394f5a7ae0.prefs.json ← Profile preferences
|
||||
*/
|
||||
|
||||
// Profile is the platform-independent profile view handed to the bindings.
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login. Kept across logouts; cleared when the profile is
|
||||
// removed. See profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
// ProfileManager manages profiles for the mobile platforms. It wraps the
|
||||
// internal profilemanager.ServiceManager with mobile-specific path handling.
|
||||
// All profile identity is ID-based; the human-readable name lives inside the
|
||||
// profile config's Name field.
|
||||
type ProfileManager struct {
|
||||
configDir string
|
||||
username string
|
||||
serviceMgr *profilemanager.ServiceManager
|
||||
}
|
||||
|
||||
// NewProfileManager creates a profile manager rooted at configDir, the
|
||||
// app-writable directory that every process of the app can reach. username is
|
||||
// the platform's fixed single-user context (a non-empty username is required
|
||||
// by ServiceManager for non-default profiles).
|
||||
func NewProfileManager(configDir, username string) *ProfileManager {
|
||||
// The default profile is stored in the root configDir, not under profiles/.
|
||||
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
|
||||
|
||||
// Point the package globals at the app-provided directory, overriding the
|
||||
// desktop defaults set in profilemanager's init().
|
||||
profilemanager.DefaultConfigPathDir = configDir
|
||||
profilemanager.DefaultConfigPath = defaultConfigPath
|
||||
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
|
||||
|
||||
// Non-default profiles live in the profiles/ subdirectory. Passing it
|
||||
// explicitly avoids touching the global config-dir override.
|
||||
profilesDir := filepath.Join(configDir, profilesSubdir)
|
||||
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
|
||||
|
||||
return &ProfileManager{
|
||||
configDir: configDir,
|
||||
username: username,
|
||||
serviceMgr: serviceMgr,
|
||||
}
|
||||
}
|
||||
|
||||
// ListProfiles returns all available profiles, including the default profile,
|
||||
// with their active status set.
|
||||
func (pm *ProfileManager) ListProfiles() ([]Profile, error) {
|
||||
internalProfiles, err := pm.serviceMgr.ListProfiles(pm.username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list profiles: %w", err)
|
||||
}
|
||||
|
||||
profiles := make([]Profile, 0, len(internalProfiles))
|
||||
for _, p := range internalProfiles {
|
||||
profiles = append(profiles, Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Email: pm.profileEmail(p.ID.String()),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the currently active profile, resolving its ID to
|
||||
// the full profile so callers get the real display name.
|
||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
|
||||
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), pm.username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{
|
||||
ID: prof.ID.String(),
|
||||
Name: prof.Name,
|
||||
Email: pm.profileEmail(prof.ID.String()),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SwitchProfile records the given profile ID as the active profile. The caller
|
||||
// must stop the VPN tunnel before switching.
|
||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: profilemanager.ID(id),
|
||||
Username: pm.username,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("switch profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("switched to profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddProfile creates a new profile with the given display name and a
|
||||
// generated ID. It returns the created profile so the caller learns the ID.
|
||||
func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
|
||||
profile, err := pm.serviceMgr.AddProfile(displayName, pm.username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("created new profile: %s", profile.ID)
|
||||
return &Profile{ID: profile.ID.String(), Name: profile.Name, IsActive: false}, nil
|
||||
}
|
||||
|
||||
// RenameProfile changes the display name of the profile identified by id. The
|
||||
// on-disk filename (the ID) is left unchanged.
|
||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil {
|
||||
return fmt.Errorf("rename profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("renamed profile %s to %q", id, newName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogoutProfile clears authentication data for a profile by removing its
|
||||
// private key and SSH key from the config, forcing a re-login. The management
|
||||
// URL and other settings are preserved.
|
||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("profile %q does not exist", id)
|
||||
}
|
||||
|
||||
config, err := profilemanager.ReadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read profile config: %w", err)
|
||||
}
|
||||
|
||||
config.PrivateKey = ""
|
||||
config.SSHKey = ""
|
||||
|
||||
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
// The stored account email is kept on purpose, matching the desktop and CLI
|
||||
// logout semantics: the next login passes it as the login_hint so the IdP
|
||||
// preselects the account. Removing the profile is what deletes it.
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProfile deletes a profile. The default profile and the active profile
|
||||
// cannot be removed.
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), pm.username); err != nil {
|
||||
return fmt.Errorf("remove profile: %w", err)
|
||||
}
|
||||
|
||||
// The account file is this package's, not the ServiceManager's, so it must
|
||||
// go here. The default profile has a fixed filename, so a recreated one
|
||||
// would otherwise inherit the deleted profile's email as its login_hint.
|
||||
// Not fatal: the profile itself is gone.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to remove stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("removed profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProfilePrefs returns the namespaced per-profile preference store of the
|
||||
// profile identified by id.
|
||||
func (pm *ProfileManager) ProfilePrefs(id string) (*profilemanager.Prefs, error) {
|
||||
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), pm.username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve profile prefs: %w", err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// GetConfigPath returns the config file path for the given profile ID. The
|
||||
// platform code should call this instead of constructing paths itself.
|
||||
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
||||
return pm.getProfileConfigPath(id)
|
||||
}
|
||||
|
||||
// GetStateFilePath returns the state file path for the given profile ID.
|
||||
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
||||
if id == "" || id == profilemanager.DefaultProfileName {
|
||||
return filepath.Join(pm.configDir, "state.json"), nil
|
||||
}
|
||||
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".state.json"), nil
|
||||
}
|
||||
|
||||
// GetActiveConfigPath returns the config file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
return pm.GetConfigPath(activeProfile.ID)
|
||||
}
|
||||
|
||||
// GetActiveStateFilePath returns the state file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
return pm.GetStateFilePath(activeProfile.ID)
|
||||
}
|
||||
|
||||
// profileEmail returns the account email recorded for a profile. Display-only,
|
||||
// so an unresolvable path degrades to "" rather than an error.
|
||||
func (pm *ProfileManager) profileEmail(id string) string {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ReadProfileEmail(configPath)
|
||||
}
|
||||
|
||||
// getProfileConfigPath returns the config file path for a profile ID. The
|
||||
// default profile uses netbird.cfg in the root configDir; other profiles use
|
||||
// <id>.json in the profiles/ subdirectory.
|
||||
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
if id == profilemanager.DefaultProfileName {
|
||||
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".json"), nil
|
||||
}
|
||||
104
client/mobile/profile_state.go
Normal file
104
client/mobile/profile_state.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// profileAccountSuffix names the file holding the profile's account email.
|
||||
// Deliberately not ".state.json", which desktop uses for the same data:
|
||||
// there the email and the engine's state manager live in different
|
||||
// directories, but on mobile both resolve under configDir, so sharing the
|
||||
// name would have the two overwrite each other — the state manager rewrites
|
||||
// the whole file from its own keys (see statemanager.Manager.PersistState),
|
||||
// and this package's writer does the same in reverse.
|
||||
profileAccountSuffix = ".account.json"
|
||||
)
|
||||
|
||||
// profileAccountPathFor derives the account file path from a profile's config
|
||||
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
|
||||
//
|
||||
// Deriving from the config path rather than resolving the active profile keeps
|
||||
// the write on the profile the login actually ran for: login flows run in a
|
||||
// goroutine, so the active profile can change under a flow already in flight.
|
||||
func profileAccountPathFor(configPath string) (string, error) {
|
||||
if configPath == "" {
|
||||
return "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if stem == "" || stem == "." {
|
||||
return "", fmt.Errorf("config path %q has no filename stem", configPath)
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||
}
|
||||
|
||||
// ReadProfileEmail returns the account email stored for the profile whose
|
||||
// config lives at configPath. A missing or unreadable file yields "", which
|
||||
// leaves the account choice to the IdP.
|
||||
func ReadProfileEmail(configPath string) string {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
log.Debugf("no profile account path for login hint: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var state profilemanager.ProfileState
|
||||
if _, err := util.ReadJson(accountPath, &state); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Debugf("failed to read profile account for login hint: %v", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return state.Email
|
||||
}
|
||||
|
||||
// WriteProfileEmail records the account email for the profile whose config
|
||||
// lives at configPath, so later logins can pass it as an OIDC login_hint. An
|
||||
// empty email is ignored rather than blanking what is already stored.
|
||||
func WriteProfileEmail(configPath string, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
state := profilemanager.ProfileState{Email: email}
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
|
||||
return fmt.Errorf("write profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeProfileEmail drops the stored account email. Called on profile removal,
|
||||
// not on logout: a logged-out profile keeps its email so the next login passes
|
||||
// it as the login_hint, matching the desktop and CLI semantics. Mirrors the
|
||||
// desktop UI's RemoveProfileState call.
|
||||
func removeProfileEmail(configPath string) error {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
161
client/mobile/profile_state_test.go
Normal file
161
client/mobile/profile_state_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProfileAccountPathFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default profile",
|
||||
configPath: "/data/netbird/files/netbird.cfg",
|
||||
want: filepath.FromSlash("/data/netbird/files/netbird.account.json"),
|
||||
},
|
||||
{
|
||||
name: "id profile",
|
||||
configPath: "/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
|
||||
want: filepath.FromSlash("/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
|
||||
},
|
||||
{
|
||||
name: "legacy name-keyed profile is handled the same way",
|
||||
configPath: "/data/netbird/files/profiles/work.json",
|
||||
want: filepath.FromSlash("/data/netbird/files/profiles/work.account.json"),
|
||||
},
|
||||
{
|
||||
name: "empty path is rejected",
|
||||
configPath: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := profileAccountPathFor(tt.configPath)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
|
||||
root := "/data/netbird/files"
|
||||
|
||||
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("default profile: %v", err)
|
||||
}
|
||||
|
||||
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("id profile: %v", err)
|
||||
}
|
||||
|
||||
if defaultAccount == idAccount {
|
||||
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
|
||||
}
|
||||
}
|
||||
|
||||
// The account file must never land on the engine state file: on mobile both
|
||||
// resolve under configDir, and the state manager rewrites the whole file from
|
||||
// its own keys, so sharing a path would have the two overwrite each other. The
|
||||
// expected names here mirror ProfileManager.GetStateFilePath.
|
||||
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
|
||||
root := "/data/netbird/files"
|
||||
|
||||
cases := []struct {
|
||||
configPath string
|
||||
engineState string
|
||||
}{
|
||||
{
|
||||
configPath: filepath.Join(root, defaultConfigFilename),
|
||||
engineState: filepath.Join(root, "state.json"),
|
||||
},
|
||||
{
|
||||
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
|
||||
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
account, err := profileAccountPathFor(c.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.configPath, err)
|
||||
}
|
||||
if account == c.engineState {
|
||||
t.Errorf("account file collides with the engine state file: %q", account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteThenReadProfileEmail(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
if got := ReadProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email before a login, got %q", got)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := WriteProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
if got := ReadProfileEmail(configPath); got != email {
|
||||
t.Errorf("got %q, want %q", got, email)
|
||||
}
|
||||
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if got := ReadProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email after removal, got %q", got)
|
||||
}
|
||||
|
||||
// Removal may run on a never-logged-in profile, so a second remove must pass.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("second remove should be a no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := WriteProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := WriteProfileEmail(configPath, ""); err != nil {
|
||||
t.Fatalf("write empty: %v", err)
|
||||
}
|
||||
|
||||
if got := ReadProfileEmail(configPath); got != email {
|
||||
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirFor(t *testing.T, path string) error {
|
||||
t.Helper()
|
||||
return os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
}
|
||||
Reference in New Issue
Block a user