@@ -120,6 +120,213 @@ func TestGoogleSupervisorCredentialRepairControlsGenerationStart(t *testing.T) {
120120 }
121121}
122122
123+ func TestGoogleCredentialRepairMinInterval (t * testing.T ) {
124+ tests := []struct {
125+ name string
126+ value string
127+ want time.Duration
128+ }{
129+ {
130+ name : "unset" ,
131+ want : googleCredentialRepairDefaultMinInterval ,
132+ },
133+ {
134+ name : "invalid" ,
135+ value : "soon" ,
136+ want : googleCredentialRepairDefaultMinInterval ,
137+ },
138+ {
139+ name : "non-positive" ,
140+ value : "0s" ,
141+ want : googleCredentialRepairDefaultMinInterval ,
142+ },
143+ {
144+ name : "configured" ,
145+ value : "5m" ,
146+ want : 5 * time .Minute ,
147+ },
148+ }
149+
150+ for _ , tc := range tests {
151+ t .Run (tc .name , func (t * testing.T ) {
152+ t .Setenv (googleCredentialRepairMinIntervalEnv , tc .value )
153+ if got := googleCredentialRepairMinInterval (); got != tc .want {
154+ t .Fatalf ("googleCredentialRepairMinInterval() = %s, want %s" , got , tc .want )
155+ }
156+ })
157+ }
158+ }
159+
160+ func TestGoogleCredentialRepairCooldownFirstRepairProceedsImmediately (t * testing.T ) {
161+ clock := & googleCredentialRepairTestClock {}
162+ var refreshCalls atomic.Int32
163+ repairer := & googleCredentialRepairer {
164+ sessionPath : "session.json" ,
165+ canRepair : func () bool { return true },
166+ refresh : func (context.Context , string ) error {
167+ refreshCalls .Add (1 )
168+ return nil
169+ },
170+ minInterval : time .Hour ,
171+ now : clock .Now ,
172+ }
173+
174+ if err := repairer .RepairCredentials (context .Background (), "" , bridge.OpError {}); err != nil {
175+ t .Fatalf ("RepairCredentials() error = %v" , err )
176+ }
177+ if got := refreshCalls .Load (); got != 1 {
178+ t .Fatalf ("refresh calls = %d, want 1" , got )
179+ }
180+ if got := repairer .PacedRepairCount (); got != 0 {
181+ t .Fatalf ("paced repair count = %d, want 0" , got )
182+ }
183+ }
184+
185+ func TestGoogleCredentialRepairCooldownPacesTooSoonRepair (t * testing.T ) {
186+ const minInterval = 50 * time .Millisecond
187+ clock := & googleCredentialRepairTestClock {}
188+ var refreshMu sync.Mutex
189+ var refreshTimes []time.Time
190+ repairer := & googleCredentialRepairer {
191+ sessionPath : "session.json" ,
192+ canRepair : func () bool { return true },
193+ refresh : func (context.Context , string ) error {
194+ refreshMu .Lock ()
195+ refreshTimes = append (refreshTimes , clock .Now ())
196+ refreshMu .Unlock ()
197+ return nil
198+ },
199+ minInterval : minInterval ,
200+ now : clock .Now ,
201+ }
202+
203+ if err := repairer .RepairCredentials (context .Background (), "" , bridge.OpError {}); err != nil {
204+ t .Fatalf ("first RepairCredentials() error = %v" , err )
205+ }
206+ repairer .mu .Lock ()
207+ firstRepairAt := repairer .lastRepairAt
208+ repairer .mu .Unlock ()
209+
210+ if err := repairer .RepairCredentials (context .Background (), "" , bridge.OpError {}); err != nil {
211+ t .Fatalf ("second RepairCredentials() error = %v" , err )
212+ }
213+ refreshMu .Lock ()
214+ gotRefreshTimes := append ([]time.Time (nil ), refreshTimes ... )
215+ refreshMu .Unlock ()
216+ if len (gotRefreshTimes ) != 2 {
217+ t .Fatalf ("refresh calls = %d, want 2" , len (gotRefreshTimes ))
218+ }
219+ if elapsed := gotRefreshTimes [1 ].Sub (firstRepairAt ); elapsed < minInterval {
220+ t .Fatalf ("second refresh elapsed from first repair = %s, want >= %s" , elapsed , minInterval )
221+ }
222+ if got := repairer .PacedRepairCount (); got != 1 {
223+ t .Fatalf ("paced repair count = %d, want 1" , got )
224+ }
225+ }
226+
227+ func TestGoogleCredentialRepairCooldownAfterIntervalProceedsImmediately (t * testing.T ) {
228+ const minInterval = time .Minute
229+ clock := & googleCredentialRepairTestClock {}
230+ var refreshCalls atomic.Int32
231+ repairer := & googleCredentialRepairer {
232+ sessionPath : "session.json" ,
233+ canRepair : func () bool { return true },
234+ refresh : func (context.Context , string ) error {
235+ refreshCalls .Add (1 )
236+ return nil
237+ },
238+ minInterval : minInterval ,
239+ now : clock .Now ,
240+ }
241+
242+ if err := repairer .RepairCredentials (context .Background (), "" , bridge.OpError {}); err != nil {
243+ t .Fatalf ("first RepairCredentials() error = %v" , err )
244+ }
245+ clock .Advance (minInterval )
246+ ctx , cancel := context .WithTimeout (context .Background (), time .Second )
247+ defer cancel ()
248+ if err := repairer .RepairCredentials (ctx , "" , bridge.OpError {}); err != nil {
249+ t .Fatalf ("second RepairCredentials() after interval error = %v" , err )
250+ }
251+ if got := refreshCalls .Load (); got != 2 {
252+ t .Fatalf ("refresh calls = %d, want 2" , got )
253+ }
254+ if got := repairer .PacedRepairCount (); got != 0 {
255+ t .Fatalf ("paced repair count = %d, want 0" , got )
256+ }
257+ }
258+
259+ func TestGoogleCredentialRepairCooldownCancellationDoesNotFlagOrRefresh (t * testing.T ) {
260+ clock := & googleCredentialRepairTestClock {}
261+ var refreshCalls atomic.Int32
262+ var flagCalls atomic.Int32
263+ repairer := & googleCredentialRepairer {
264+ sessionPath : "session.json" ,
265+ canRepair : func () bool { return true },
266+ refresh : func (context.Context , string ) error {
267+ refreshCalls .Add (1 )
268+ return nil
269+ },
270+ flagRepair : func () { flagCalls .Add (1 ) },
271+ minInterval : time .Hour ,
272+ now : clock .Now ,
273+ }
274+
275+ if err := repairer .RepairCredentials (context .Background (), "" , bridge.OpError {}); err != nil {
276+ t .Fatalf ("first RepairCredentials() error = %v" , err )
277+ }
278+ ctx , cancel := context .WithCancel (context .Background ())
279+ errCh := make (chan error , 1 )
280+ go func () {
281+ errCh <- repairer .RepairCredentials (ctx , "" , bridge.OpError {})
282+ }()
283+ awaitGooglePacedRepairCount (t , repairer , 1 )
284+ if got := refreshCalls .Load (); got != 1 {
285+ t .Fatalf ("refresh calls before cancellation = %d, want 1" , got )
286+ }
287+
288+ queuedBaseCtx , queuedCancel := context .WithCancel (context .Background ())
289+ queuedCtx := & googleRepairObservedContext {
290+ Context : queuedBaseCtx ,
291+ doneCalled : make (chan struct {}),
292+ }
293+ queuedErrCh := make (chan error , 1 )
294+ go func () {
295+ queuedErrCh <- repairer .RepairCredentials (queuedCtx , "" , bridge.OpError {})
296+ }()
297+ select {
298+ case <- queuedCtx .doneCalled :
299+ case <- time .After (time .Second ):
300+ t .Fatal ("queued RepairCredentials() did not begin waiting for admission" )
301+ }
302+ queuedCancel ()
303+ select {
304+ case err := <- queuedErrCh :
305+ if ! errors .Is (err , context .Canceled ) {
306+ t .Fatalf ("queued RepairCredentials() error = %v, want context.Canceled" , err )
307+ }
308+ case <- time .After (time .Second ):
309+ t .Fatal ("queued RepairCredentials() did not return after cancellation" )
310+ }
311+
312+ cancel ()
313+
314+ select {
315+ case err := <- errCh :
316+ if ! errors .Is (err , context .Canceled ) {
317+ t .Fatalf ("RepairCredentials() error = %v, want context.Canceled" , err )
318+ }
319+ case <- time .After (time .Second ):
320+ t .Fatal ("RepairCredentials() did not return after cancellation" )
321+ }
322+ if got := refreshCalls .Load (); got != 1 {
323+ t .Fatalf ("refresh calls after cancellation = %d, want 1" , got )
324+ }
325+ if got := flagCalls .Load (); got != 0 {
326+ t .Fatalf ("needs_repair flag calls = %d, want 0" , got )
327+ }
328+ }
329+
123330func TestGoogleSupervisorManualReconnectUsesOwnedCommands (t * testing.T ) {
124331 sessionPath := filepath .Join (t .TempDir (), "session.json" )
125332 if err := os .WriteFile (sessionPath , []byte ("session-v1" ), 0o600 ); err != nil {
@@ -338,3 +545,48 @@ func awaitGoogleSupervisorGenerationState(
338545 }
339546 }
340547}
548+
549+ type googleCredentialRepairTestClock struct {
550+ offset atomic.Int64
551+ }
552+
553+ type googleRepairObservedContext struct {
554+ context.Context
555+ doneCalled chan struct {}
556+ once sync.Once
557+ }
558+
559+ func (c * googleRepairObservedContext ) Done () <- chan struct {} {
560+ c .once .Do (func () { close (c .doneCalled ) })
561+ return c .Context .Done ()
562+ }
563+
564+ func (c * googleCredentialRepairTestClock ) Now () time.Time {
565+ return time .Now ().Add (time .Duration (c .offset .Load ()))
566+ }
567+
568+ func (c * googleCredentialRepairTestClock ) Advance (delta time.Duration ) {
569+ c .offset .Add (int64 (delta ))
570+ }
571+
572+ func awaitGooglePacedRepairCount (
573+ t * testing.T ,
574+ repairer * googleCredentialRepairer ,
575+ want uint64 ,
576+ ) {
577+ t .Helper ()
578+ deadline := time .NewTimer (time .Second )
579+ defer deadline .Stop ()
580+ ticker := time .NewTicker (time .Millisecond )
581+ defer ticker .Stop ()
582+ for {
583+ if got := repairer .PacedRepairCount (); got == want {
584+ return
585+ }
586+ select {
587+ case <- deadline .C :
588+ t .Fatalf ("paced repair count = %d, want %d" , repairer .PacedRepairCount (), want )
589+ case <- ticker .C :
590+ }
591+ }
592+ }
0 commit comments