mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-22 13:29:08 +02:00
Merge remote-tracking branch 'origin/main' into embedded-vnc
# Conflicts: # client/cmd/up.go # client/internal/engine.go # client/internal/profilemanager/config.go # client/proto/daemon.pb.go # client/server/mdm.go # client/server/server.go # client/status/status.go # client/ui/main.go # client/ui/services/windowmanager.go # management/internals/shared/grpc/conversion.go # management/server/store/sql_store.go # management/server/store/sql_store_test.go # shared/management/networkmap/envelope.go # shared/management/proto/management.pb.go
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTokenInfoMatchesAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token TokenInfo
|
||||
hint string
|
||||
match bool
|
||||
}{
|
||||
{
|
||||
name: "same account",
|
||||
token: TokenInfo{EmailClaim: "user@example.com"},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "different account",
|
||||
token: TokenInfo{EmailClaim: "other@example.com"},
|
||||
hint: "user@example.com",
|
||||
match: false,
|
||||
},
|
||||
{
|
||||
name: "case differences are the same account",
|
||||
token: TokenInfo{EmailClaim: "User@Example.com"},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "no hint leaves the choice to the IdP",
|
||||
token: TokenInfo{EmailClaim: "other@example.com"},
|
||||
hint: "",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "token without an email claim is not judged",
|
||||
token: TokenInfo{EmailClaim: ""},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
{
|
||||
name: "name fallback does not trigger matching",
|
||||
token: TokenInfo{Email: "Some One"},
|
||||
hint: "user@example.com",
|
||||
match: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.match, tc.token.MatchesAccount(tc.hint))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmailFromIDToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
claims map[string]interface{}
|
||||
wantValue string
|
||||
wantFromEmail bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "email claim",
|
||||
claims: map[string]interface{}{"email": "user@example.com", "name": "Some One"},
|
||||
wantValue: "user@example.com",
|
||||
wantFromEmail: true,
|
||||
},
|
||||
{
|
||||
name: "name fallback",
|
||||
claims: map[string]interface{}{"name": "Some One"},
|
||||
wantValue: "Some One",
|
||||
},
|
||||
{
|
||||
name: "neither claim present",
|
||||
claims: map[string]interface{}{"sub": "abc"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
value, fromEmailClaim, err := parseEmailFromIDToken(idTokenWithClaims(t, tc.claims))
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantValue, value)
|
||||
assert.Equal(t, tc.wantFromEmail, fromEmailClaim)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryFlowForAccountUnsupportedFlow(t *testing.T) {
|
||||
assert.Nil(t, RetryFlowForAccount(&DeviceAuthorizationFlow{}))
|
||||
}
|
||||
|
||||
func idTokenWithClaims(t *testing.T, claims map[string]interface{}) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(claims)
|
||||
require.NoError(t, err)
|
||||
return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
||||
|
||||
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||
// Try PKCE flow first
|
||||
_, err := a.getPKCEFlow(client)
|
||||
_, err := a.getPKCEFlow(client, false)
|
||||
if err == nil {
|
||||
supportsSSO = true
|
||||
return nil
|
||||
@@ -136,9 +136,13 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
||||
return supportsSSO, err
|
||||
}
|
||||
|
||||
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
|
||||
// This avoids creating a new connection to the management server
|
||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) {
|
||||
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection.
|
||||
// This avoids creating a new connection to the management server.
|
||||
//
|
||||
// sessionExtend marks the flow as renewing an existing peer's session rather than
|
||||
// logging one in; the server needs it to rule out a silent authorization that the
|
||||
// IdP could answer from another account. See PKCEAuthorizationFlowRequest.
|
||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, sessionExtend bool, hint string) (OAuthFlow, error) {
|
||||
var flow OAuthFlow
|
||||
|
||||
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||
@@ -153,7 +157,7 @@ func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint stri
|
||||
}
|
||||
|
||||
// Try PKCE flow first
|
||||
pkceFlow, err := a.getPKCEFlow(client)
|
||||
pkceFlow, err := a.getPKCEFlow(client, sessionExtend)
|
||||
if err != nil {
|
||||
// If PKCE not supported, try Device flow
|
||||
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
|
||||
@@ -240,8 +244,8 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
||||
}
|
||||
|
||||
// getPKCEFlow retrieves PKCE authorization flow configuration and creates a flow instance
|
||||
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, error) {
|
||||
protoFlow, err := client.GetPKCEAuthorizationFlow()
|
||||
func (a *Auth) getPKCEFlow(client *mgm.GrpcClient, sessionExtend bool) (*PKCEAuthorizationFlow, error) {
|
||||
protoFlow, err := client.GetPKCEAuthorizationFlow(sessionExtend)
|
||||
if err != nil {
|
||||
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
|
||||
log.Warnf("server couldn't find pkce flow, contact admin: %v", err)
|
||||
|
||||
@@ -308,10 +308,13 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
||||
// callers store to send back as the login_hint. Without it a client
|
||||
// driven through the device flow — Android TV and tvOS — never binds
|
||||
// an account to its profile and every later login goes out blind.
|
||||
if email, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
|
||||
if email, fromEmailClaim, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
|
||||
log.Warnf("failed to parse email from ID token: %v", err)
|
||||
} else {
|
||||
tokenInfo.Email = email
|
||||
if fromEmailClaim {
|
||||
tokenInfo.EmailClaim = email
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -25,6 +26,14 @@ type HTTPClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// accountPromptForcer is implemented by the PKCE flow only. The device code
|
||||
// flow has no equivalent: RFC 8628 defines no prompt parameter, and the user
|
||||
// confirms the code on a page that shows which account signs in, so a silent
|
||||
// wrong-account answer is not the failure mode there.
|
||||
type accountPromptForcer interface {
|
||||
ForceAccountPrompt()
|
||||
}
|
||||
|
||||
// AuthFlowInfo holds information for the OAuth 2.0 authorization flow
|
||||
type AuthFlowInfo struct { //nolint:revive
|
||||
DeviceCode string `json:"device_code"`
|
||||
@@ -49,6 +58,23 @@ type TokenInfo struct {
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
UseIDToken bool `json:"-"`
|
||||
Email string `json:"-"`
|
||||
EmailClaim string `json:"-"`
|
||||
}
|
||||
|
||||
// MatchesAccount reports whether the token belongs to the account a profile is
|
||||
// bound to. A hint the IdP could not have acted on — no hint stored, or a token
|
||||
// that carried no email claim — is reported as a match: the check exists to catch a
|
||||
// login answered from the wrong account, not to block one it cannot judge.
|
||||
//
|
||||
// The comparison is case-insensitive. Local-parts are case-sensitive per RFC
|
||||
// 5321, but no IdP in practice issues two accounts differing only in case, and
|
||||
// an IdP that echoes a differently-cased address would otherwise fail every
|
||||
// login.
|
||||
func (t TokenInfo) MatchesAccount(hint string) bool {
|
||||
if hint == "" || t.EmailClaim == "" {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(t.EmailClaim, hint)
|
||||
}
|
||||
|
||||
// GetTokenToUse returns either the access or id token based on UseIDToken field
|
||||
@@ -63,19 +89,22 @@ func shouldUseDeviceFlow(force bool, isUnixDesktopClient bool) bool {
|
||||
return force || (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !isUnixDesktopClient
|
||||
}
|
||||
|
||||
// NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration
|
||||
// NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration.
|
||||
//
|
||||
// It starts by initializing the PKCE.If this process fails, it resorts to the Device Code Flow,
|
||||
// and if that also fails, the authentication process is deemed unsuccessful
|
||||
//
|
||||
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow
|
||||
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV)
|
||||
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string) (OAuthFlow, error) {
|
||||
// On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow.
|
||||
// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV).
|
||||
//
|
||||
// sessionExtend marks the flow as renewing an existing peer's session rather than
|
||||
// logging one in. See PKCEAuthorizationFlowRequest for what the server makes of it.
|
||||
func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string, sessionExtend bool) (OAuthFlow, error) {
|
||||
if shouldUseDeviceFlow(forceDeviceCodeFlow, isUnixDesktopClient) {
|
||||
return authenticateWithDeviceCodeFlow(ctx, config, hint)
|
||||
}
|
||||
|
||||
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint)
|
||||
pkceFlow, err := authenticateWithPKCEFlow(ctx, config, hint, sessionExtend)
|
||||
if err != nil {
|
||||
log.Debugf("failed to initialize pkce authentication with error: %v\n", err)
|
||||
log.Debug("falling back to device code flow")
|
||||
@@ -85,14 +114,14 @@ func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesk
|
||||
}
|
||||
|
||||
// authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow
|
||||
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) {
|
||||
func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string, sessionExtend bool) (OAuthFlow, error) {
|
||||
authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create auth client: %v", err)
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client)
|
||||
pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client, sessionExtend)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
||||
}
|
||||
@@ -129,3 +158,21 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
|
||||
|
||||
return deviceFlowInfo, nil
|
||||
}
|
||||
|
||||
// RetryFlowForAccount returns a flow that asks the IdP to re-authenticate, for
|
||||
// a login answered with an account other than the one hinted. Returns nil when
|
||||
// the flow cannot ask — the caller then proceeds with the token it has.
|
||||
//
|
||||
// Proceeding rather than failing is deliberate. The hint is an email that may
|
||||
// simply have changed since it was stored, and refusing the login would lock a
|
||||
// user out of their own profile over a rename. The retry gives the account a
|
||||
// chance to be corrected; the server still rejects a token that does not own
|
||||
// the peer.
|
||||
func RetryFlowForAccount(flow OAuthFlow) OAuthFlow {
|
||||
forcer, ok := flow.(accountPromptForcer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
forcer.ForceAccountPrompt()
|
||||
return flow
|
||||
}
|
||||
|
||||
@@ -87,10 +87,11 @@ func validatePKCEConfig(config *PKCEAuthProviderConfig) error {
|
||||
// PKCEAuthorizationFlow implements the OAuthFlow interface for
|
||||
// the Authorization Code Flow with PKCE.
|
||||
type PKCEAuthorizationFlow struct {
|
||||
providerConfig PKCEAuthProviderConfig
|
||||
state string
|
||||
codeVerifier string
|
||||
oAuthConfig *oauth2.Config
|
||||
providerConfig PKCEAuthProviderConfig
|
||||
state string
|
||||
codeVerifier string
|
||||
oAuthConfig *oauth2.Config
|
||||
forceAccountPrompt bool
|
||||
}
|
||||
|
||||
// NewPKCEAuthorizationFlow returns new PKCE authorization code flow.
|
||||
@@ -153,11 +154,16 @@ func (p *PKCEAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlowIn
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("audience", p.providerConfig.Audience),
|
||||
}
|
||||
forceAccountPrompt := p.forceAccountPrompt
|
||||
p.forceAccountPrompt = false
|
||||
|
||||
if !p.providerConfig.DisablePromptLogin {
|
||||
switch p.providerConfig.LoginFlag {
|
||||
case common.LoginFlagPromptLogin:
|
||||
switch {
|
||||
case forceAccountPrompt:
|
||||
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
|
||||
case common.LoginFlagMaxAge0:
|
||||
case p.providerConfig.LoginFlag == common.LoginFlagPromptLogin:
|
||||
params = append(params, oauth2.SetAuthURLParam("prompt", "login"))
|
||||
case p.providerConfig.LoginFlag == common.LoginFlagMaxAge0:
|
||||
params = append(params, oauth2.SetAuthURLParam("max_age", "0"))
|
||||
}
|
||||
}
|
||||
@@ -178,6 +184,20 @@ func (p *PKCEAuthorizationFlow) SetLoginHint(hint string) {
|
||||
p.providerConfig.LoginHint = hint
|
||||
}
|
||||
|
||||
// ForceAccountPrompt makes the next authorization request ask the IdP to
|
||||
// re-authenticate instead of answering from the session it already holds. Used
|
||||
// to retry a login that came back for an account other than the one hinted.
|
||||
//
|
||||
// The next RequestAuthInfo consumes the flag, so a flow that outlives its retry
|
||||
// goes back to the configured behaviour instead of re-authenticating forever.
|
||||
//
|
||||
// DisablePromptLogin still wins: it is set for IdPs that break on prompt=login,
|
||||
// where retrying with it would replace a wrong-account login with one that
|
||||
// cannot complete at all.
|
||||
func (p *PKCEAuthorizationFlow) ForceAccountPrompt() {
|
||||
p.forceAccountPrompt = true
|
||||
}
|
||||
|
||||
// WaitToken waits for the OAuth token in the PKCE Authorization Flow.
|
||||
// It starts an HTTP server to receive the OAuth token callback and waits for the token or an error.
|
||||
// Once the token is received, it is converted to TokenInfo and validated before returning.
|
||||
@@ -310,49 +330,48 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
||||
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
|
||||
}
|
||||
|
||||
email, err := parseEmailFromIDToken(tokenInfo.IDToken)
|
||||
email, fromEmailClaim, err := parseEmailFromIDToken(tokenInfo.IDToken)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse email from ID token: %v", err)
|
||||
} else {
|
||||
tokenInfo.Email = email
|
||||
if fromEmailClaim {
|
||||
tokenInfo.EmailClaim = email
|
||||
}
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
|
||||
// without verifying its signature. The value is best-effort and used only as a
|
||||
// UX convenience (login hint prefill and display); it never drives an
|
||||
// authorization decision. The authoritative identity is established server-side
|
||||
// from the signature-verified token.
|
||||
func parseEmailFromIDToken(token string) (string, error) {
|
||||
// without verifying its signature. The value is best-effort: it prefills the
|
||||
// login hint and is displayed. Account matching (see MatchesAccount) only uses
|
||||
// it when it came from the email claim, which fromEmailClaim reports. It never
|
||||
// grants anything — the authoritative identity is established server-side from
|
||||
// the signature-verified token.
|
||||
func parseEmailFromIDToken(token string) (value string, fromEmailClaim bool, err error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("invalid token format")
|
||||
return "", false, fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode payload: %w", err)
|
||||
return "", false, fmt.Errorf("failed to decode payload: %w", err)
|
||||
}
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(data, &claims); err != nil {
|
||||
return "", fmt.Errorf("json unmarshal error: %w", err)
|
||||
return "", false, fmt.Errorf("json unmarshal error: %w", err)
|
||||
}
|
||||
|
||||
var email string
|
||||
if emailValue, ok := claims["email"].(string); ok {
|
||||
email = emailValue
|
||||
} else {
|
||||
val, ok := claims["name"].(string)
|
||||
if ok {
|
||||
email = val
|
||||
} else {
|
||||
return "", fmt.Errorf("email or name field not found in token payload")
|
||||
}
|
||||
if email, ok := claims["email"].(string); ok {
|
||||
return email, true, nil
|
||||
}
|
||||
if name, ok := claims["name"].(string); ok {
|
||||
return name, false, nil
|
||||
}
|
||||
|
||||
return email, nil
|
||||
return "", false, fmt.Errorf("email or name field not found in token payload")
|
||||
}
|
||||
|
||||
func createCodeChallenge(codeVerifier string) string {
|
||||
|
||||
@@ -76,6 +76,32 @@ func TestPromptLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceAccountPromptAppliesOnlyToTheRetry(t *testing.T) {
|
||||
config := PKCEAuthProviderConfig{
|
||||
ClientID: "test-client-id",
|
||||
Audience: "test-audience",
|
||||
TokenEndpoint: "https://test-token-endpoint.com/token",
|
||||
Scope: "openid email profile",
|
||||
AuthorizationEndpoint: "https://test-auth-endpoint.com/authorize",
|
||||
RedirectURLs: []string{"http://127.0.0.1:33992/"},
|
||||
UseIDToken: true,
|
||||
LoginFlag: mgm.LoginFlagNone,
|
||||
}
|
||||
pkce, err := NewPKCEAuthorizationFlow(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkce.ForceAccountPrompt()
|
||||
|
||||
retry, err := pkce.RequestAuthInfo(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, retry.VerificationURIComplete, "prompt=login")
|
||||
|
||||
next, err := pkce.RequestAuthInfo(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, next.VerificationURIComplete, "prompt=login",
|
||||
"the forced prompt outlived the retry it was armed for")
|
||||
}
|
||||
|
||||
func TestIsPortInExcludedRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -4,12 +4,20 @@
|
||||
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
||||
// fallback dialog.
|
||||
//
|
||||
// The deadline is an absolute wall-clock instant, so the watcher compares
|
||||
// it against the wall clock on a ticker rather than arming a relative
|
||||
// timer for it. A relative timer runs on the monotonic clock, which does
|
||||
// not advance while a device is suspended: it fires once that much awake
|
||||
// time has passed, which can be long after the deadline, and nothing
|
||||
// re-evaluates when the device wakes up. Polling makes every tick after a
|
||||
// resume (or after an NTP correction) see the real remaining time.
|
||||
//
|
||||
// The watcher is idempotent: Update may be called as often as the network
|
||||
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
||||
// deadline reschedules the timers and arms a fresh warning cycle.
|
||||
// deadline starts a fresh warning cycle.
|
||||
//
|
||||
// Warning firing is edge-detected. Each unique deadline value fires each
|
||||
// warning callback at most once.
|
||||
// Warning firing is edge-detected. Each unique deadline value publishes
|
||||
// each warning at most once.
|
||||
package sessionwatch
|
||||
|
||||
import (
|
||||
@@ -28,9 +36,15 @@ const (
|
||||
|
||||
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||
// glitch, and silently arming a 100-year timer would hide the bug.
|
||||
// glitch, and silently tracking a 100-year deadline would hide the bug.
|
||||
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
|
||||
|
||||
// defaultEvalInterval is how often the tracked deadline is compared
|
||||
// against the wall clock. The leads are minutes, so a coarse tick costs
|
||||
// nothing in accuracy and keeps the wakeup cheap on battery-powered
|
||||
// devices.
|
||||
defaultEvalInterval = 10 * time.Second
|
||||
|
||||
// WarningLead is how far before expiry the first (interactive)
|
||||
// warning fires. Drives the T-10 OS notification with
|
||||
// Extend/Dismiss actions.
|
||||
@@ -92,18 +106,23 @@ type StatusRecorder interface {
|
||||
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
||||
// the first one for the same deadline). Safe for concurrent use.
|
||||
type Watcher struct {
|
||||
lead time.Duration
|
||||
finalLead time.Duration
|
||||
lead time.Duration
|
||||
finalLead time.Duration
|
||||
interval time.Duration
|
||||
deadlineOnly bool // record the deadline, leave the warnings to the caller
|
||||
|
||||
mu sync.Mutex
|
||||
current time.Time
|
||||
timer *time.Timer
|
||||
finalTimer *time.Timer
|
||||
firedAt time.Time // deadline value the T-WarningLead callback last fired against
|
||||
finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
|
||||
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
|
||||
firedAt time.Time // deadline value the T-WarningLead warning last published for
|
||||
finalFiredAt time.Time // deadline value the T-FinalWarningLead warning last published for
|
||||
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates the final warning
|
||||
announcedAt time.Time // deadline value the recorder has been told about; gates publishing
|
||||
closed bool
|
||||
recorder StatusRecorder
|
||||
nowFn func() time.Time
|
||||
stop chan struct{} // closed to stop the evaluation loop; nil while it is not running
|
||||
done chan struct{} // closed by the loop on its way out
|
||||
wake chan struct{} // buffered nudge asking the loop to evaluate before its next tick
|
||||
}
|
||||
|
||||
// New returns a watcher with the package defaults WarningLead and
|
||||
@@ -115,27 +134,40 @@ func New(recorder StatusRecorder) *Watcher {
|
||||
}
|
||||
|
||||
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
|
||||
// final must be strictly less than lead; otherwise both timers fire in the
|
||||
// wrong order or simultaneously and the UI flow breaks. A zero final lead
|
||||
// disables the final-warning timer entirely (see armTimerLocked) so a
|
||||
// millisecond-scale deadline doesn't flush both timers in one tick.
|
||||
// final must be strictly less than lead; otherwise the final warning takes
|
||||
// over the whole warning window and the interactive notification never
|
||||
// shows. A zero final lead disables the final warning entirely (see
|
||||
// evaluate), leaving the interactive one as the only warning.
|
||||
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||
return &Watcher{
|
||||
lead: lead,
|
||||
finalLead: final,
|
||||
interval: defaultEvalInterval,
|
||||
recorder: recorder,
|
||||
nowFn: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// NewDeadlineOnly returns a watcher that validates and records deadlines
|
||||
// but publishes no warnings about them, and runs no evaluation loop to
|
||||
// decide. Used where the deadline is handed on to something that schedules
|
||||
// the warnings itself, such as the Android app.
|
||||
func NewDeadlineOnly(recorder StatusRecorder) *Watcher {
|
||||
w := New(recorder)
|
||||
w.deadlineOnly = true
|
||||
return w
|
||||
}
|
||||
|
||||
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
||||
// a Sync push from the server omits the field because login expiration
|
||||
// was disabled).
|
||||
//
|
||||
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||
// pending timer, resets the "already fired" guards, and — when the
|
||||
// deadline lies in the future — arms fresh warning timers. A deadline
|
||||
// already in the past (within maxPastHorizon) is recorded as-is with no
|
||||
// timers: the session has expired and consumers render it that way.
|
||||
// Same-value updates are no-ops. A different non-zero value resets the
|
||||
// "already fired" guards and starts a fresh warning cycle, evaluated
|
||||
// immediately so a deadline that already sits inside a warning window
|
||||
// warns without waiting for the next tick. A deadline already in the past
|
||||
// (within maxPastHorizon) is recorded as-is and warns nothing: the session
|
||||
// has expired and consumers render it that way.
|
||||
//
|
||||
// Returns one of the sentinel Err* values when the deadline fails the
|
||||
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
|
||||
@@ -155,7 +187,7 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
now := w.nowFn()
|
||||
switch {
|
||||
case deadline.Before(time.Unix(0, 0)):
|
||||
w.clearLocked()
|
||||
@@ -173,18 +205,22 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
w.stopTimerLocked()
|
||||
w.current = deadline
|
||||
// Reset every per-deadline guard so a refreshed deadline arms a fresh
|
||||
// Reset every per-deadline guard so a refreshed deadline starts a fresh
|
||||
// warning cycle: both edge triggers and the user Dismiss decision
|
||||
// (the user agreed to the old deadline expiring; a new deadline
|
||||
// restarts the contract).
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
w.announcedAt = time.Time{}
|
||||
|
||||
if deadline.After(now) {
|
||||
w.armTimerLocked(deadline)
|
||||
// Poll every accepted deadline, including one that reads as already
|
||||
// expired: the clock may be running ahead of real time and get
|
||||
// corrected later, and evaluate ignores a deadline that has genuinely
|
||||
// passed anyway.
|
||||
if !w.deadlineOnly {
|
||||
w.startPollLocked()
|
||||
}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
@@ -192,6 +228,28 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
recorder.SetSessionExpiresAt(deadline)
|
||||
}
|
||||
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
||||
|
||||
// Open the gate only once the recorder knows the new deadline, so a
|
||||
// warning that refers to it can never reach consumers before the state
|
||||
// change itself. A tick landing in between finds the gate shut.
|
||||
w.mu.Lock()
|
||||
if w.closed || !w.current.Equal(deadline) {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
w.announcedAt = deadline
|
||||
wake := w.wake
|
||||
w.mu.Unlock()
|
||||
|
||||
// Hand the evaluation to the loop rather than running it here, so a
|
||||
// deadline that already sits inside a warning window is published at
|
||||
// once without this goroutine ever touching the recorder.
|
||||
if wake != nil {
|
||||
select {
|
||||
case wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -204,9 +262,9 @@ func (w *Watcher) Deadline() time.Time {
|
||||
}
|
||||
|
||||
// Dismiss records the user's "Dismiss" action against the current deadline
|
||||
// and suppresses the upcoming final-warning callback for that deadline.
|
||||
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
|
||||
// deadline resets the dismissal so the final-warning cycle re-arms.
|
||||
// and suppresses the final warning for that deadline. Idempotent: repeated
|
||||
// calls are no-ops. A subsequent Update with a fresh deadline resets the
|
||||
// dismissal so the final-warning cycle starts over.
|
||||
//
|
||||
// No-op when the watcher holds no deadline or has been closed.
|
||||
func (w *Watcher) Dismiss() {
|
||||
@@ -219,35 +277,48 @@ func (w *Watcher) Dismiss() {
|
||||
return
|
||||
}
|
||||
w.dismissedAt = w.current
|
||||
// Cancel the armed final-warning timer eagerly. fireFinal would also
|
||||
// gate on dismissedAt, but stopping the timer avoids a wakeup with
|
||||
// nothing to do and makes the intent visible.
|
||||
if w.finalTimer != nil {
|
||||
w.finalTimer.Stop()
|
||||
w.finalTimer = nil
|
||||
}
|
||||
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Close stops any pending timer. Update calls after Close are ignored.
|
||||
// The recorder keeps its deadline: the watcher is engine-scoped and closes
|
||||
// on every engine restart (network change, sleep/wake, stream errors)
|
||||
// while the SSO deadline stays valid across those, so clearing here would
|
||||
// blank the UI's "expires in" row on every transient reconnect. The
|
||||
// client run loop clears the server-scoped recorder when it exits for
|
||||
// real (Down, profile switch, permanent login failure).
|
||||
// Close stops the evaluation loop and waits for it to exit. Update calls
|
||||
// after Close are ignored. The recorder keeps its deadline: the watcher is
|
||||
// engine-scoped and closes on every engine restart (network change,
|
||||
// sleep/wake, stream errors) while the SSO deadline stays valid across
|
||||
// those, so clearing here would blank the UI's "expires in" row on every
|
||||
// transient reconnect. The client run loop clears the server-scoped
|
||||
// recorder when it exits for real (Down, profile switch, permanent login
|
||||
// failure).
|
||||
func (w *Watcher) Close() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
// A concurrent Close is already tearing down. w.done outlives it,
|
||||
// so this caller waits for the same loop rather than returning
|
||||
// while a warning is still on its way to the recorder.
|
||||
done := w.done
|
||||
w.mu.Unlock()
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
w.stopTimerLocked()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
w.announcedAt = time.Time{}
|
||||
// Copy the channels out before releasing the lock: the loop takes w.mu
|
||||
// on every tick, so waiting for it while holding the lock would
|
||||
// deadlock. w.done stays on the receiver for the branch above.
|
||||
stop, done := w.stop, w.done
|
||||
w.stop, w.wake = nil, nil
|
||||
w.mu.Unlock()
|
||||
|
||||
if stop == nil {
|
||||
return
|
||||
}
|
||||
close(stop)
|
||||
<-done
|
||||
}
|
||||
|
||||
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||
@@ -259,11 +330,11 @@ func (w *Watcher) clearLocked() {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.stopTimerLocked()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
w.announcedAt = time.Time{}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil {
|
||||
@@ -272,87 +343,121 @@ func (w *Watcher) clearLocked() {
|
||||
log.Infof("auth session deadline cleared")
|
||||
}
|
||||
|
||||
func (w *Watcher) stopTimerLocked() {
|
||||
if w.timer != nil {
|
||||
w.timer.Stop()
|
||||
w.timer = nil
|
||||
// startPollLocked starts the evaluation loop unless it is already
|
||||
// running. The loop starts lazily on the first accepted deadline, so a
|
||||
// client whose server never publishes a session expiry never pays for a
|
||||
// ticker, and it runs until Close: the watcher is engine-scoped, and a
|
||||
// cleared deadline is normally followed by a fresh one on the next sync.
|
||||
// Caller must hold w.mu.
|
||||
func (w *Watcher) startPollLocked() {
|
||||
if w.stop != nil {
|
||||
return
|
||||
}
|
||||
if w.finalTimer != nil {
|
||||
w.finalTimer.Stop()
|
||||
w.finalTimer = nil
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
wake := make(chan struct{}, 1)
|
||||
w.stop, w.done, w.wake = stop, done, wake
|
||||
go w.poll(stop, done, wake, w.interval)
|
||||
}
|
||||
|
||||
// poll re-evaluates the tracked deadline every interval, and as soon as a
|
||||
// new deadline is announced. It is the only caller of evaluate, so Close
|
||||
// waiting for it to exit is enough to know no warning is still on its way
|
||||
// to the recorder. Its channels and interval are passed in rather than
|
||||
// read off the receiver, so Close can clear them without racing it.
|
||||
func (w *Watcher) poll(stop <-chan struct{}, done chan<- struct{}, wake <-chan struct{}, interval time.Duration) {
|
||||
defer close(done)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-wake:
|
||||
w.evaluate()
|
||||
case <-ticker.C:
|
||||
w.evaluate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) armTimerLocked(deadline time.Time) {
|
||||
w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
|
||||
// finalLead <= 0 disables the final-warning timer entirely. Used by
|
||||
// tests that predate the final-warning fallback so a millisecond-scale
|
||||
// deadline does not flush both timers at once.
|
||||
if w.finalLead > 0 {
|
||||
w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) fire(armedFor time.Time) {
|
||||
// evaluate compares the tracked deadline against the wall clock and
|
||||
// publishes whichever warning the remaining time calls for. Inside the
|
||||
// final-warning window the interactive warning is stale, so the final one
|
||||
// is published in its place: a device that resumes there never saw the
|
||||
// T-WarningLead notification.
|
||||
func (w *Watcher) evaluate() {
|
||||
w.mu.Lock()
|
||||
if w.closed || !w.current.Equal(armedFor) {
|
||||
// Deadline moved while we were waiting (e.g. a successful extend).
|
||||
// The reschedule path armed a fresh timer; this one is stale.
|
||||
if w.closed || w.deadlineOnly || w.current.IsZero() {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
|
||||
|
||||
deadline := w.current
|
||||
if !w.announcedAt.Equal(deadline) {
|
||||
// Update is still on its way to the recorder with this deadline.
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.firedAt = armedFor
|
||||
// Round(0) strips the monotonic reading so the comparison is wall
|
||||
// clock on both sides, whether the deadline came off the wire or from
|
||||
// a caller that derived it from time.Now.
|
||||
remaining := deadline.Round(0).Sub(w.nowFn().Round(0))
|
||||
|
||||
switch {
|
||||
case remaining <= 0:
|
||||
// Already expired: the post-mortem SessionExpired flow owns it.
|
||||
w.mu.Unlock()
|
||||
case w.finalLead > 0 && remaining <= w.finalLead:
|
||||
w.publishFinalLocked(deadline, remaining)
|
||||
case remaining <= w.lead:
|
||||
w.publishWarningLocked(deadline, remaining)
|
||||
default:
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// publishWarningLocked emits the interactive T-WarningLead warning, at
|
||||
// most once per deadline value. Caller must hold w.mu; this helper
|
||||
// releases it.
|
||||
func (w *Watcher) publishWarningLocked(deadline time.Time, remaining time.Duration) {
|
||||
if w.firedAt.Equal(deadline) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.firedAt = deadline
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
log.Infof("auth session expiry soon warning fired")
|
||||
publishWarning(recorder, armedFor, false)
|
||||
log.Infof("auth session expiry soon warning fired for deadline %s (in %s)",
|
||||
deadline.Format(time.RFC3339), remaining.Round(time.Second))
|
||||
publishWarning(recorder, deadline, false)
|
||||
}
|
||||
|
||||
// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
|
||||
// dismiss-gate: if the user dismissed the T-WarningLead notification for
|
||||
// this deadline, the final warning is suppressed entirely.
|
||||
func (w *Watcher) fireFinal(armedFor time.Time) {
|
||||
w.mu.Lock()
|
||||
if w.closed || !w.current.Equal(armedFor) {
|
||||
// publishFinalLocked emits the final warning, at most once per deadline
|
||||
// value and never once the user dismissed that deadline. It marks the
|
||||
// interactive warning as handled too: the final window is open, so a
|
||||
// "expires in WarningLead minutes" notification would be wrong. Caller
|
||||
// must hold w.mu; this helper releases it.
|
||||
func (w *Watcher) publishFinalLocked(deadline time.Time, remaining time.Duration) {
|
||||
if w.finalFiredAt.Equal(deadline) || w.dismissedAt.Equal(deadline) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if w.dismissedAt.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
log.Infof("auth session final-warning skipped (dismissed by user)")
|
||||
return
|
||||
}
|
||||
w.finalFiredAt = armedFor
|
||||
w.firedAt = deadline
|
||||
w.finalFiredAt = deadline
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
log.Infof("auth session final-warning fired")
|
||||
publishWarning(recorder, armedFor, true)
|
||||
}
|
||||
|
||||
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
|
||||
// past it dispatches on the next scheduler tick so a state-change recorder
|
||||
// notification (invoked after w.mu is released) lands first. Caller must
|
||||
// hold w.mu.
|
||||
func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
|
||||
delay := time.Until(fireAt)
|
||||
if delay <= 0 {
|
||||
return time.AfterFunc(0, cb)
|
||||
}
|
||||
return time.AfterFunc(delay, cb)
|
||||
log.Infof("auth session final-warning fired for deadline %s (in %s)",
|
||||
deadline.Format(time.RFC3339), remaining.Round(time.Second))
|
||||
publishWarning(recorder, deadline, true)
|
||||
}
|
||||
|
||||
// publishWarning composes the SystemEvent for a watcher-fired warning and
|
||||
|
||||
@@ -19,6 +19,9 @@ type fakeRecorder struct {
|
||||
mu sync.Mutex
|
||||
events []event
|
||||
lastDeadline time.Time
|
||||
// setDelay stalls SetSessionExpiresAt, widening the window in which a
|
||||
// concurrent evaluation could publish a warning out of order.
|
||||
setDelay time.Duration
|
||||
}
|
||||
|
||||
type eventKind int
|
||||
@@ -43,6 +46,12 @@ type event struct {
|
||||
// is the zero time, so an initial clear before any deadline is set emits
|
||||
// nothing — matching the real recorder.
|
||||
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
||||
r.mu.Lock()
|
||||
delay := r.setDelay
|
||||
r.mu.Unlock()
|
||||
// Stall without the lock held, so a concurrent publish can still record.
|
||||
time.Sleep(delay)
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.lastDeadline.Equal(deadline) {
|
||||
@@ -117,10 +126,54 @@ func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
||||
return nil
|
||||
}
|
||||
|
||||
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
||||
// testInterval keeps the ticker-driven tests fast; the leads they use are
|
||||
// in the same millisecond scale.
|
||||
const testInterval = 2 * time.Millisecond
|
||||
|
||||
// newWatcher builds a watcher with the final warning disabled (finalLead=0),
|
||||
// matching the lead-only behaviour the pre-final-warning tests assume.
|
||||
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
||||
return NewWithLeads(lead, 0, r)
|
||||
return newWatcherWithLeads(lead, 0, r)
|
||||
}
|
||||
|
||||
// newWatcherWithLeads builds a watcher that evaluates on testInterval. The
|
||||
// interval is set before Update, so the evaluation loop does not exist yet
|
||||
// and the write cannot race it.
|
||||
func newWatcherWithLeads(lead, final time.Duration, r StatusRecorder) *Watcher {
|
||||
w := NewWithLeads(lead, final, r)
|
||||
w.interval = testInterval
|
||||
return w
|
||||
}
|
||||
|
||||
// fakeClock is the watcher's wall clock under test. Reads come from the
|
||||
// evaluation goroutine while the test writes, so both go through the mutex.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func newFakeClock(t time.Time) *fakeClock {
|
||||
return &fakeClock{t: t}
|
||||
}
|
||||
|
||||
func (c *fakeClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
// set jumps the clock, standing in for a resume from suspension or for an
|
||||
// NTP correction.
|
||||
func (c *fakeClock) set(t time.Time) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = t
|
||||
}
|
||||
|
||||
// settle waits out a handful of evaluation ticks so a test can assert that
|
||||
// nothing was published.
|
||||
func settle() {
|
||||
time.Sleep(20 * testInterval)
|
||||
}
|
||||
|
||||
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
||||
@@ -411,7 +464,7 @@ func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
|
||||
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
// Warning fires at deadline-80ms, final at deadline-30ms.
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
@@ -447,7 +500,7 @@ func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||
|
||||
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
@@ -478,7 +531,7 @@ func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||
|
||||
func TestDismissResetByNewDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(100 * time.Millisecond)
|
||||
@@ -508,7 +561,7 @@ func TestDismissResetByNewDeadline(t *testing.T) {
|
||||
|
||||
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
w := newWatcherWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
||||
@@ -528,3 +581,340 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||
}
|
||||
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
||||
}
|
||||
|
||||
// The tests below drive the watcher's wall clock directly. The deadline sits
|
||||
// an hour out in real time and the fake clock jumps, standing in for a device
|
||||
// that was suspended and resumed somewhere inside — or past — the warning
|
||||
// windows. The evaluation loop is what reacts to the jump, so these exercise
|
||||
// the same path production takes on a resume.
|
||||
func newResumeWatcher(t *testing.T, r *fakeRecorder) (*Watcher, *fakeClock, time.Time) {
|
||||
t.Helper()
|
||||
|
||||
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||
start := time.Now()
|
||||
clock := newFakeClock(start)
|
||||
// Set before Update: the evaluation loop does not exist yet.
|
||||
w.nowFn = clock.now
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
deadline := start.Add(time.Hour).Round(0)
|
||||
if err := w.Update(deadline); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
return w, clock, deadline
|
||||
}
|
||||
|
||||
func TestResumeInsideWarningWindowWarns(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
_, clock, deadline := newResumeWatcher(t, r)
|
||||
|
||||
clock.set(deadline.Add(-5 * time.Minute))
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if !events[1].isWarning() {
|
||||
t.Fatalf("expected the interactive warning after the resume, got %+v", events[1])
|
||||
}
|
||||
if n := countWhere(events, event.isFinalWarning); n != 0 {
|
||||
t.Fatalf("final-warning must wait for its own window, got %d: %+v", n, events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeInsideFinalWindowSendsFinalWarningOnly(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
_, clock, deadline := newResumeWatcher(t, r)
|
||||
|
||||
clock.set(deadline.Add(-time.Minute))
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if !events[1].isFinalWarning() {
|
||||
t.Fatalf("expected the final warning after the resume, got %+v", events[1])
|
||||
}
|
||||
settle()
|
||||
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
||||
t.Fatalf("the interactive warning is stale inside the final window, got %d: %+v", n, r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumePastDeadlinePublishesNothing(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
_, clock, deadline := newResumeWatcher(t, r)
|
||||
|
||||
clock.set(deadline.Add(time.Minute))
|
||||
|
||||
settle()
|
||||
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||
t.Fatalf("an expired session must not warn, got %d publishes: %+v", n, r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningPublishesOncePerDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
_, clock, deadline := newResumeWatcher(t, r)
|
||||
|
||||
clock.set(deadline.Add(-5 * time.Minute))
|
||||
waitForEvents(t, r, 2)
|
||||
|
||||
// Many ticks pass inside the same window.
|
||||
settle()
|
||||
if n := countWhere(r.snapshot(), event.isWarning); n != 1 {
|
||||
t.Fatalf("expected exactly 1 warning publish across ticks, got %d: %+v", n, r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarningRecoversFromAClockRunningAhead covers the device that boots
|
||||
// before NTP has corrected it: the deadline looks long gone, nothing is
|
||||
// published, and the warning still arrives once the clock is fixed.
|
||||
func TestWarningRecoversFromAClockRunningAhead(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
_, clock, deadline := newResumeWatcher(t, r)
|
||||
|
||||
clock.set(deadline.Add(2 * time.Hour))
|
||||
settle()
|
||||
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||
t.Fatalf("a deadline that looks expired must not warn, got %d publishes: %+v", n, r.snapshot())
|
||||
}
|
||||
|
||||
clock.set(deadline.Add(-5 * time.Minute))
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if !events[1].isWarning() {
|
||||
t.Fatalf("expected the warning once the clock was corrected, got %+v", events[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeInsideFinalWindowRespectsDismiss(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w, clock, deadline := newResumeWatcher(t, r)
|
||||
|
||||
// The user dismissed the warning for this deadline before the device
|
||||
// was suspended; resuming inside the final window must not reopen it.
|
||||
w.Dismiss()
|
||||
clock.set(deadline.Add(-time.Minute))
|
||||
|
||||
settle()
|
||||
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||
t.Fatalf("a dismissed deadline must not warn on resume, got %d: %+v", n, r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseStopsTheEvaluationLoop(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||
start := time.Now()
|
||||
clock := newFakeClock(start)
|
||||
w.nowFn = clock.now
|
||||
|
||||
deadline := start.Add(time.Hour).Round(0)
|
||||
if err := w.Update(deadline); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
clock.set(deadline.Add(-5 * time.Minute))
|
||||
settle()
|
||||
|
||||
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||
t.Fatalf("a closed watcher must not publish, got %d: %+v", n, r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadlineOnlyRecordsDeadlineWithoutWarnings(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewDeadlineOnly(r)
|
||||
defer w.Close()
|
||||
|
||||
// With the default leads this deadline sits inside the final-warning
|
||||
// window, so a watcher that warns at all would publish on the spot.
|
||||
d := time.Now().Add(50 * time.Millisecond).Round(0)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("expected recorder deadline %v, got %v", d, got)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
events := r.snapshot()
|
||||
if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 {
|
||||
t.Fatalf("expected no publish in deadline-only mode, got %d: %+v", got, events)
|
||||
}
|
||||
w.mu.Lock()
|
||||
polling := w.stop != nil
|
||||
w.mu.Unlock()
|
||||
if polling {
|
||||
t.Fatal("expected no evaluation loop in deadline-only mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadlineOnlyStillRejectsOutOfRangeDeadlines(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewDeadlineOnly(r)
|
||||
defer w.Close()
|
||||
|
||||
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
err := w.Update(time.Now().Add(-maxPastHorizon - time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineInPast) {
|
||||
t.Fatalf("expected ErrDeadlineInPast, got %v", err)
|
||||
}
|
||||
if got := r.deadline(); !got.IsZero() {
|
||||
t.Fatalf("expected recorder cleared after rejection, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClockAheadAtUpdateStillWarnsOnceCorrected covers a client that connects
|
||||
// while its clock runs ahead of real time: the deadline reads as already
|
||||
// expired when it arrives, so nothing publishes, and the warning must still
|
||||
// come once NTP pulls the clock back.
|
||||
func TestClockAheadAtUpdateStillWarnsOnceCorrected(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||
deadline := time.Now().Add(time.Hour).Round(0)
|
||||
// Two hours past the deadline from the device's point of view, well
|
||||
// inside maxPastHorizon, so Update accepts and records it.
|
||||
clock := newFakeClock(deadline.Add(2 * time.Hour))
|
||||
w.nowFn = clock.now
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
if err := w.Update(deadline); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
settle()
|
||||
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||
t.Fatalf("a deadline that reads as expired must not warn, got %d: %+v", n, r.snapshot())
|
||||
}
|
||||
|
||||
clock.set(deadline.Add(-5 * time.Minute))
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if !events[1].isWarning() {
|
||||
t.Fatalf("expected the warning once the clock was corrected, got %+v", events[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarningNeverPrecedesTheDeadlineStateChange pins the ordering Update
|
||||
// documents: consumers learn the new deadline before they see a warning that
|
||||
// refers to it. The deadline here already sits inside the warning window, and
|
||||
// the recorder stalls, so the evaluation loop gets many chances to publish
|
||||
// while Update is still announcing.
|
||||
func TestWarningNeverPrecedesTheDeadlineStateChange(t *testing.T) {
|
||||
r := &fakeRecorder{setDelay: 50 * time.Millisecond}
|
||||
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||
deadline := time.Now().Add(time.Hour).Round(0)
|
||||
clock := newFakeClock(deadline.Add(-5 * time.Minute))
|
||||
w.nowFn = clock.now
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
if err := w.Update(deadline); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[0].kind != stateChange {
|
||||
t.Fatalf("event[0] should be the deadline state change, got %+v", events)
|
||||
}
|
||||
if !events[1].isWarning() {
|
||||
t.Fatalf("event[1] should be the warning, got %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateWakesTheLoopWithoutWaitingForATick pins the hand-off: Update
|
||||
// publishes nothing itself, it nudges the evaluation loop, so a deadline that
|
||||
// already sits inside a warning window is warned about at once even though the
|
||||
// ticker here would not fire for an hour.
|
||||
func TestUpdateWakesTheLoopWithoutWaitingForATick(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||
w.interval = time.Hour
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
d := time.Now().Add(5 * time.Minute).Round(0)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if !events[1].isWarning() {
|
||||
t.Fatalf("expected the warning on the wake-up, got %+v", events[1])
|
||||
}
|
||||
}
|
||||
|
||||
// blockingRecorder holds a publish open until the test releases it, so the
|
||||
// evaluation loop can be parked mid-publish while Close is called.
|
||||
type blockingRecorder struct {
|
||||
fakeRecorder
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (r *blockingRecorder) PublishEvent(
|
||||
severity cProto.SystemEvent_Severity,
|
||||
category cProto.SystemEvent_Category,
|
||||
message string,
|
||||
userMessage string,
|
||||
metadata map[string]string,
|
||||
) bool {
|
||||
select {
|
||||
case r.entered <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-r.release
|
||||
return r.fakeRecorder.PublishEvent(severity, category, message, userMessage, metadata)
|
||||
}
|
||||
|
||||
// TestConcurrentCloseWaitsForTheLoop pins the contract for the caller that
|
||||
// loses the race: Close returns only once the loop is done, whichever of the
|
||||
// two calls got there first.
|
||||
func TestConcurrentCloseWaitsForTheLoop(t *testing.T) {
|
||||
r := &blockingRecorder{entered: make(chan struct{}, 1), release: make(chan struct{})}
|
||||
var releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(r.release) }) }
|
||||
|
||||
w := newWatcherWithLeads(WarningLead, FinalWarningLead, r)
|
||||
w.interval = time.Hour
|
||||
// Order matters: Close waits for the loop, which stays parked in the
|
||||
// publish until the release, so a t.Fatal below would hang the cleanup
|
||||
// instead of reporting the failure.
|
||||
t.Cleanup(func() {
|
||||
release()
|
||||
w.Close()
|
||||
})
|
||||
|
||||
d := time.Now().Add(5 * time.Minute).Round(0)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
// The loop is now parked inside the warning publish.
|
||||
select {
|
||||
case <-r.entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the loop never reached the publish")
|
||||
}
|
||||
|
||||
first := make(chan struct{})
|
||||
second := make(chan struct{})
|
||||
go func() { w.Close(); close(first) }()
|
||||
go func() { w.Close(); close(second) }()
|
||||
|
||||
select {
|
||||
case <-first:
|
||||
t.Fatal("Close returned while the loop was still publishing")
|
||||
case <-second:
|
||||
t.Fatal("Close returned while the loop was still publishing")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
release()
|
||||
for _, done := range []chan struct{}{first, second} {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Close did not return after the publish completed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user