diff --git a/control/controlclient/auto.go b/control/controlclient/auto.go index b087e1444..5b5b06def 100644 --- a/control/controlclient/auto.go +++ b/control/controlclient/auto.go @@ -444,16 +444,15 @@ func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) { c.mu.Lock() c.inMapPoll = true c.expiry = nm.SelfKeyExpiry() - stillAuthed := c.loggedIn - c.logf("[v1] mapRoutine: netmap received: loggedIn=%v inMapPoll=true", stillAuthed) + c.logf("[v1] mapRoutine: netmap received: loggedIn=%v inMapPoll=true", c.loggedIn) // Reset the backoff timer if we got a netmap. mrs.bo.Reset() c.mu.Unlock() - if stillAuthed { - c.sendStatus("mapRoutine-got-netmap", nil, "", nm) - } + // Always send status - sendStatus will check if we should forward the netmap + // based on loggedIn, hasNodeKey, and inMapPoll. + c.sendStatus("mapRoutine-got-netmap", nil, "", nm) } func (mrs mapRoutineState) UpdateNetmapDelta(muts []netmap.NodeMutation) bool { @@ -516,10 +515,16 @@ func (c *Auto) mapRoutine() { c.mu.Lock() loggedIn := c.loggedIn - c.logf("[v1] mapRoutine: loggedIn=%v", loggedIn) ctx := c.mapCtx c.mu.Unlock() + // Check if we have a valid node key that could receive updates. + // Even if !loggedIn (e.g., key expired, waiting for interactive auth), + // we should still poll if we have credentials, because the server + // might send us a key extension notification. + _, hasNodeKey := c.direct.GetPersist().PublicNodeKeyOK() + c.logf("[v1] mapRoutine: loggedIn=%v hasNodeKey=%v", loggedIn, hasNodeKey) + report := func(err error, msg string) { c.logf("[v1] %s: %v", msg, err) err = fmt.Errorf("%s: %w", msg, err) @@ -530,8 +535,8 @@ func (c *Auto) mapRoutine() { } } - if !loggedIn { - // Wait for something interesting to happen + if !loggedIn && !hasNodeKey { + // No credentials at all, wait for auth to complete. c.mu.Lock() c.inMapPoll = false c.mu.Unlock() @@ -622,14 +627,17 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM loginGoal := c.loginGoal c.mu.Unlock() - c.logf("[v1] sendStatus: %s: loggedIn=%v inMapPoll=%v", who, loggedIn, inMapPoll) + // Check if we have a valid node key - if so, we should forward the netmap + // even if !loggedIn, to allow the backend to see key expiry changes. + _, hasNodeKey := c.direct.GetPersist().PublicNodeKeyOK() + c.logf("[v1] sendStatus: %s: loggedIn=%v inMapPoll=%v hasNodeKey=%v", who, loggedIn, inMapPoll, hasNodeKey) var p persist.PersistView - if nm != nil && loggedIn && inMapPoll { + if nm != nil && (loggedIn || hasNodeKey) && inMapPoll { p = c.direct.GetPersist() } else { // don't send netmap status, as it's misleading when we're - // not logged in. + // not logged in and have no credentials. nm = nil } newSt := &Status{ @@ -744,7 +752,29 @@ func (c *Auto) Login(flags LoginFlags) { c.loginGoal = &LoginGoal{ flags: flags, } - c.cancelMapCtxLocked() + // If we have valid credentials (loggedIn=true) or a valid node key, + // don't cancel the map poll. This allows the client to continue receiving + // key extension notifications from the server while the auth flow proceeds + // in parallel. + // + // This is important for the "Extend key" feature: if the admin extends a + // key while the user has clicked "Login", we want the map poll to receive + // that notification and recover without requiring the user to complete the + // auth flow. + // + // The hasNodeKey check handles the case where a tsnet server restarts with + // an expired key: loggedIn is false (server returned AuthURL), but we have + // a valid node key that can still receive map updates including key extensions. + // + // "First successful flow wins": if a key extension arrives via map poll, + // the client recovers. If the auth flow completes first, that works too. + var hasNodeKey bool + if c.direct != nil { + _, hasNodeKey = c.direct.GetPersist().PublicNodeKeyOK() + } + if !c.loggedIn && !hasNodeKey { + c.cancelMapCtxLocked() + } c.cancelAuthCtxLocked() } diff --git a/control/controlclient/key_expiry_test.go b/control/controlclient/key_expiry_test.go new file mode 100644 index 000000000..1ada70368 --- /dev/null +++ b/control/controlclient/key_expiry_test.go @@ -0,0 +1,146 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package controlclient + +import ( + "context" + "testing" + + "tailscale.com/types/key" + "tailscale.com/types/persist" +) + +// TestLoginPreservesMapPollWhenLoggedIn tests the fix for the key extension bug. +// +// When a client has valid credentials (loggedIn=true) but needs to re-authenticate +// due to key expiry, calling Login() should NOT cancel the map poll. This allows +// the client to continue receiving key extension notifications from the server +// while the auth flow proceeds in parallel. +func TestLoginPreservesMapPollWhenLoggedIn(t *testing.T) { + // Create an Auto client that is already logged in + // This simulates a client with valid credentials but expired key + auto := &Auto{ + logf: t.Logf, + loggedIn: true, // Already authenticated (key expired, but creds valid) + closed: false, + } + auto.mapCtx, auto.mapCancel = context.WithCancel(context.Background()) + auto.authCtx, auto.authCancel = context.WithCancel(context.Background()) + + originalMapCtx := auto.mapCtx + + // Call Login() - this is what happens when user clicks "Login" after key expiry + auto.Login(LoginInteractive) + + // The fix: when loggedIn=true, mapCtx should NOT be cancelled + // This allows the map poll to continue receiving key extension notifications + select { + case <-originalMapCtx.Done(): + t.Error("Login() cancelled mapCtx even though loggedIn=true; key extension notifications would be lost") + default: + // Good - map context still active + } + + // Verify loginGoal was set (auth flow can proceed in parallel) + auto.mu.Lock() + hasLoginGoal := auto.loginGoal != nil + auto.mu.Unlock() + + if !hasLoginGoal { + t.Error("loginGoal should be set even though mapCtx wasn't cancelled") + } +} + +// TestLoginPreservesMapPollWithNodeKey tests the tsnet restart scenario. +// +// When a tsnet server restarts with an expired key: +// 1. The server has a valid node key stored in persist +// 2. Control returns an AuthURL (for interactive login) +// 3. loggedIn is false (because TryLogin returned a URL, not success) +// 4. But we should NOT cancel the map poll, because the server might send +// a key extension notification via the existing node key +// +// This test verifies that Login() preserves the map poll when we have a +// valid node key, even if loggedIn=false. +func TestLoginPreservesMapPollWithNodeKey(t *testing.T) { + // Create persist data with a valid node key (simulating stored credentials) + nodeKey := key.NewNode() + p := &persist.Persist{ + PrivateNodeKey: nodeKey, + } + + // Create a Direct client with the persist data + direct := &Direct{ + persist: p.View(), + } + + // Create an Auto client that is NOT logged in but HAS a valid node key + // This simulates a tsnet server restart with expired key: + // - loggedIn=false because control returned an AuthURL + // - but we have a valid node key that can receive map updates + auto := &Auto{ + logf: t.Logf, + loggedIn: false, // Control returned AuthURL, so not "logged in" yet + closed: false, + direct: direct, + } + auto.mapCtx, auto.mapCancel = context.WithCancel(context.Background()) + auto.authCtx, auto.authCancel = context.WithCancel(context.Background()) + + originalMapCtx := auto.mapCtx + + // Call Login() - this is what tsnet's StartLoginInteractive does + auto.Login(LoginInteractive) + + // The fix: even though loggedIn=false, we have a valid node key, + // so mapCtx should NOT be cancelled. This allows us to receive + // key extension notifications from the server. + select { + case <-originalMapCtx.Done(): + t.Error("Login() cancelled mapCtx even though we have a valid node key; " + + "key extension notifications would be lost in tsnet restart scenario") + default: + // Good - map context still active, can receive key extensions + } + + // Verify loginGoal was set (auth flow can proceed in parallel) + auto.mu.Lock() + hasLoginGoal := auto.loginGoal != nil + auto.mu.Unlock() + + if !hasLoginGoal { + t.Error("loginGoal should be set for the auth flow to proceed") + } +} + +// TestLoginCancelsMapPollWhenNoNodeKey verifies that when there's no node key +// at all (fresh install, never authenticated), Login() should cancel the map poll. +func TestLoginCancelsMapPollWhenNoNodeKey(t *testing.T) { + // Create a Direct client with empty persist (no node key) + direct := &Direct{ + persist: new(persist.Persist).View(), + } + + auto := &Auto{ + logf: t.Logf, + loggedIn: false, + closed: false, + direct: direct, + } + auto.mapCtx, auto.mapCancel = context.WithCancel(context.Background()) + auto.authCtx, auto.authCancel = context.WithCancel(context.Background()) + + originalMapCtx := auto.mapCtx + + // Call Login() + auto.Login(LoginInteractive) + + // When loggedIn=false AND no node key, mapCtx SHOULD be cancelled + select { + case <-originalMapCtx.Done(): + // Good - cancelled as expected for fresh login with no credentials + default: + t.Error("mapCtx should be cancelled when loggedIn=false and no node key") + } +} diff --git a/ipn/ipnlocal/key_extension_test.go b/ipn/ipnlocal/key_extension_test.go new file mode 100644 index 000000000..462c3cb03 --- /dev/null +++ b/ipn/ipnlocal/key_extension_test.go @@ -0,0 +1,465 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package ipnlocal + +import ( + "testing" + "time" + + qt "github.com/frankban/quicktest" + "tailscale.com/control/controlclient" + "tailscale.com/envknob" + "tailscale.com/ipn" + "tailscale.com/ipn/store/mem" + "tailscale.com/tailcfg" + "tailscale.com/tsd" + "tailscale.com/tstest" + "tailscale.com/types/key" + "tailscale.com/types/logid" + "tailscale.com/types/netmap" + "tailscale.com/wgengine" +) + +// TestKeyExtensionWakesUpExpiredClient verifies that when a client is in NeedsLogin +// state due to key expiry, receiving a netmap with an extended (future) KeyExpiry +// correctly transitions the client back to a working state. +// +// This tests the key recovery path: client has expired key -> admin extends key +// -> server sends updated netmap -> client should recover. +func TestKeyExtensionWakesUpExpiredClient(t *testing.T) { + envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1") + defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "") + + c := qt.New(t) + logf := tstest.WhileTestRunningLogger(t) + + // Setup test infrastructure + sys := tsd.NewSystem() + store := new(mem.Store) + sys.Set(store) + e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get()) + c.Assert(err, qt.IsNil) + t.Cleanup(e.Close) + sys.Set(e) + + b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) + c.Assert(err, qt.IsNil) + t.Cleanup(b.Shutdown) + + var cc *mockControl + b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) { + cc = newClient(t, opts) + return cc, nil + }) + + // Start the backend + c.Assert(b.Start(ipn.Options{}), qt.IsNil) + + // Simulate successful login and authenticated state + cc.populateKeys() + nodeKey := key.NewNode().Public() + now := time.Now() + + // First, get to a Running state with a valid key + futureExpiry := now.Add(1 * time.Hour) + cc.send(sendOpt{ + loginFinished: true, + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: futureExpiry, + }).View(), + }, + }) + + // Enable WantRunning - required for keyExpired to trigger NeedsLogin state + b.EditPrefs(&ipn.MaskedPrefs{ + WantRunningSet: true, + Prefs: ipn.Prefs{WantRunning: true}, + }) + + // Verify we're in a good state initially + b.mu.Lock() + c.Assert(b.keyExpired, qt.IsFalse, qt.Commentf("key should not be expired initially")) + b.mu.Unlock() + + // Now simulate key expiry by sending a netmap with past KeyExpiry + pastExpiry := now.Add(-1 * time.Hour) + cc.send(sendOpt{ + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: pastExpiry, + }).View(), + }, + }) + + // Verify the client detects key expiry + b.mu.Lock() + c.Assert(b.keyExpired, qt.IsTrue, qt.Commentf("key should be detected as expired")) + b.mu.Unlock() + + // Verify state is NeedsLogin (requires WantRunning=true) + state := b.State() + c.Assert(state, qt.Equals, ipn.NeedsLogin, qt.Commentf("state should be NeedsLogin when key is expired and WantRunning=true")) + + // Set blocked to true to simulate the engine being blocked (as would happen + // when entering NeedsLogin due to key expiry in real flow) + b.mu.Lock() + b.blocked = true + b.mu.Unlock() + + // Now simulate admin extending the key - server sends new netmap with extended expiry + extendedExpiry := now.Add(30 * time.Minute) + cc.send(sendOpt{ + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: extendedExpiry, + }).View(), + }, + }) + + // Verify the client recovers: + // 1. keyExpired should be false + b.mu.Lock() + c.Assert(b.keyExpired, qt.IsFalse, qt.Commentf("key should no longer be expired after extension")) + + // 2. blocked should be false (unblocked when key extended) + c.Assert(b.blocked, qt.IsFalse, qt.Commentf("engine should be unblocked after key extension")) + b.mu.Unlock() + + // 3. state should transition away from NeedsLogin + // Note: exact state depends on other factors (MachineAuthorized, etc.) + // but it should NOT be NeedsLogin anymore + state = b.State() + if state == ipn.NeedsLogin { + // Check if it's still NeedsLogin for a reason OTHER than key expiry + b.mu.Lock() + keyExp := b.keyExpired + b.mu.Unlock() + if !keyExp { + // Key is not expired, so NeedsLogin must be for another reason + // (which is acceptable in this test's context) + t.Logf("state is NeedsLogin but keyExpired=false, which is acceptable") + } else { + t.Errorf("state is still NeedsLogin with keyExpired=true after key extension") + } + } +} + +// TestKeyExpiredStateMachine verifies that when a key expires, the state machine +// correctly transitions to NeedsLogin and sets keyExpired=true. +func TestKeyExpiredStateMachine(t *testing.T) { + envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1") + defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "") + + c := qt.New(t) + logf := tstest.WhileTestRunningLogger(t) + + sys := tsd.NewSystem() + store := new(mem.Store) + sys.Set(store) + e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get()) + c.Assert(err, qt.IsNil) + t.Cleanup(e.Close) + sys.Set(e) + + b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) + c.Assert(err, qt.IsNil) + t.Cleanup(b.Shutdown) + + var cc *mockControl + b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) { + cc = newClient(t, opts) + return cc, nil + }) + + c.Assert(b.Start(ipn.Options{}), qt.IsNil) + + cc.populateKeys() + nodeKey := key.NewNode().Public() + now := time.Now() + + // Get to Running state with valid key + futureExpiry := now.Add(1 * time.Hour) + cc.send(sendOpt{ + loginFinished: true, + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: futureExpiry, + }).View(), + }, + }) + + // Enable WantRunning + b.EditPrefs(&ipn.MaskedPrefs{ + WantRunningSet: true, + Prefs: ipn.Prefs{WantRunning: true}, + }) + + // Verify initial state + b.mu.Lock() + c.Assert(b.keyExpired, qt.IsFalse) + b.mu.Unlock() + + // Now expire the key + pastExpiry := now.Add(-1 * time.Hour) + cc.send(sendOpt{ + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: pastExpiry, + }).View(), + }, + }) + + // Verify keyExpired is set + b.mu.Lock() + c.Assert(b.keyExpired, qt.IsTrue, qt.Commentf("keyExpired should be true after receiving expired KeyExpiry")) + b.mu.Unlock() + + // Verify state is NeedsLogin + c.Assert(b.State(), qt.Equals, ipn.NeedsLogin) +} + +// TestKeyExpiryExtendedUnblocksEngine verifies that when a key is extended, +// the engine is unblocked even if it was blocked due to key expiry. +func TestKeyExpiryExtendedUnblocksEngine(t *testing.T) { + envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1") + defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "") + + c := qt.New(t) + logf := tstest.WhileTestRunningLogger(t) + + sys := tsd.NewSystem() + store := new(mem.Store) + sys.Set(store) + e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get()) + c.Assert(err, qt.IsNil) + t.Cleanup(e.Close) + sys.Set(e) + + b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) + c.Assert(err, qt.IsNil) + t.Cleanup(b.Shutdown) + + var cc *mockControl + b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) { + cc = newClient(t, opts) + return cc, nil + }) + + c.Assert(b.Start(ipn.Options{}), qt.IsNil) + + cc.populateKeys() + nodeKey := key.NewNode().Public() + now := time.Now() + + // Get to authenticated state + cc.send(sendOpt{ + loginFinished: true, + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: now.Add(1 * time.Hour), + }).View(), + }, + }) + + // Simulate key expiry + pastExpiry := now.Add(-1 * time.Hour) + cc.send(sendOpt{ + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: pastExpiry, + }).View(), + }, + }) + + // Manually set blocked=true to simulate blocked state + b.mu.Lock() + b.blocked = true + wasBlocked := b.blocked + b.mu.Unlock() + c.Assert(wasBlocked, qt.IsTrue) + + // Extend the key + extendedExpiry := now.Add(30 * time.Minute) + cc.send(sendOpt{ + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: extendedExpiry, + }).View(), + }, + }) + + // Verify engine is unblocked + b.mu.Lock() + c.Assert(b.blocked, qt.IsFalse, qt.Commentf("engine should be unblocked after key extension")) + c.Assert(b.keyExpired, qt.IsFalse, qt.Commentf("keyExpired should be false after extension")) + b.mu.Unlock() +} + +// TestKeyExpiryZeroMeansNoExpiry verifies that a zero KeyExpiry (used for +// tagged nodes or nodes with expiry disabled) is not treated as expired. +func TestKeyExpiryZeroMeansNoExpiry(t *testing.T) { + envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1") + defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "") + + c := qt.New(t) + logf := tstest.WhileTestRunningLogger(t) + + sys := tsd.NewSystem() + store := new(mem.Store) + sys.Set(store) + e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get()) + c.Assert(err, qt.IsNil) + t.Cleanup(e.Close) + sys.Set(e) + + b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) + c.Assert(err, qt.IsNil) + t.Cleanup(b.Shutdown) + + var cc *mockControl + b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) { + cc = newClient(t, opts) + return cc, nil + }) + + c.Assert(b.Start(ipn.Options{}), qt.IsNil) + + cc.populateKeys() + nodeKey := key.NewNode().Public() + + // Send netmap with zero KeyExpiry (like a tagged node) + cc.send(sendOpt{ + loginFinished: true, + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: time.Time{}, // zero = no expiry + }).View(), + }, + }) + + // Verify key is NOT considered expired + b.mu.Lock() + c.Assert(b.keyExpired, qt.IsFalse, qt.Commentf("zero KeyExpiry should not be treated as expired")) + b.mu.Unlock() + + // State should not be NeedsLogin due to expiry + state := b.State() + c.Assert(state, qt.Not(qt.Equals), ipn.NeedsLogin, qt.Commentf("should not be in NeedsLogin with zero KeyExpiry")) +} + +// TestKeyExpiryWithNetMapUpdate verifies that key expiry detection works +// correctly across multiple netmap updates with varying expiry times. +func TestKeyExpiryWithNetMapUpdate(t *testing.T) { + envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1") + defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "") + + c := qt.New(t) + logf := tstest.WhileTestRunningLogger(t) + + sys := tsd.NewSystem() + store := new(mem.Store) + sys.Set(store) + e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get()) + c.Assert(err, qt.IsNil) + t.Cleanup(e.Close) + sys.Set(e) + + b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) + c.Assert(err, qt.IsNil) + t.Cleanup(b.Shutdown) + + var cc *mockControl + b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) { + cc = newClient(t, opts) + return cc, nil + }) + + c.Assert(b.Start(ipn.Options{}), qt.IsNil) + + cc.populateKeys() + nodeKey := key.NewNode().Public() + now := time.Now() + + // Initial login with future expiry + futureExpiry := now.Add(24 * time.Hour) + cc.send(sendOpt{ + loginFinished: true, + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: futureExpiry, + }).View(), + }, + }) + + b.mu.Lock() + c.Assert(b.keyExpired, qt.IsFalse) + b.mu.Unlock() + + // Simulate multiple netmap updates, tracking keyExpired state + testCases := []struct { + name string + expiry time.Time + wantExpired bool + }{ + {"still valid", now.Add(12 * time.Hour), false}, + {"expires soon", now.Add(5 * time.Minute), false}, + {"just expired", now.Add(-1 * time.Second), true}, + {"expired long ago", now.Add(-24 * time.Hour), true}, + {"extended again", now.Add(1 * time.Hour), false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc.send(sendOpt{ + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + Key: nodeKey, + MachineAuthorized: true, + KeyExpiry: tc.expiry, + }).View(), + }, + }) + + b.mu.Lock() + gotExpired := b.keyExpired + b.mu.Unlock() + + c.Assert(gotExpired, qt.Equals, tc.wantExpired, + qt.Commentf("%s: expiry=%v, keyExpired=%v, want=%v", + tc.name, tc.expiry, gotExpired, tc.wantExpired)) + }) + } +}