@@ -485,22 +485,62 @@ func TestSASL_FailResponse_SanitizesReason(t *testing.T) {
485485}
486486
487487// TestSASL_IdleConnection_ClosesCleanly verifies that the server cleanly
488- // closes idle connections via the per-iteration ReadDeadline rather than
489- // holding onto them under the global ConnectionTimeout (which previously
490- // caused `reader.ReadString` to time out mid-AUTH after 60s, closing the
491- // connection without sending a FAIL — Postfix then mapped that EOF to
492- // 454 "Connection lost to authentication server" instead of 535).
488+ // closes truly idle connections via saslIdleTimeout rather than holding
489+ // onto them forever. Uses a short override so the test stays fast.
493490func TestSASL_IdleConnection_ClosesCleanly (t * testing.T ) {
494491 logger = zap .NewNop ()
495492 mockService := & MockUserliService {}
493+
494+ prev := saslIdleTimeout
495+ saslIdleTimeout = 500 * time .Millisecond
496+ t .Cleanup (func () { saslIdleTimeout = prev })
497+
496498 addr , _ := startSASLTestServer (t , mockService )
497499
498500 h := newSASLTestHelper (t , addr )
499501 defer h .close ()
500502 h .doHandshake ()
501503
502- // Allow more than ReadTimeout (10s) of idle so the server closes us.
503- h .conn .SetReadDeadline (time .Now ().Add (ReadTimeout + 5 * time .Second ))
504+ // Allow more than saslIdleTimeout so the server closes us.
505+ h .conn .SetReadDeadline (time .Now ().Add (saslIdleTimeout + 2 * time .Second ))
504506 _ , err := h .reader .ReadString ('\n' )
505507 assert .ErrorIs (t , err , io .EOF , "server must close idle connections cleanly" )
506508}
509+
510+ // TestSASL_PersistentConnection_SurvivesIdleGap verifies that the AUTH
511+ // loop holds the connection open across an idle gap longer than the
512+ // per-operation ReadTimeout. Postfix' xsasl_dovecot caches the auth
513+ // socket per smtpd worker; closing it earlier caused
514+ // `454 Connection lost to authentication server` on the next AUTH.
515+ func TestSASL_PersistentConnection_SurvivesIdleGap (t * testing.T ) {
516+ logger = zap .NewNop ()
517+ mockService := & MockUserliService {}
518+ mockService .On ("Authenticate" , mock .Anything , "user@example.org" , "secret" ).
519+ Return (true , "success" , nil )
520+ mockService .On ("Authenticate" , mock .Anything , "user@example.org" , "wrong" ).
521+ Return (false , "authentication failed" , nil )
522+
523+ prev := saslIdleTimeout
524+ saslIdleTimeout = 5 * time .Second
525+ t .Cleanup (func () { saslIdleTimeout = prev })
526+
527+ addr , _ := startSASLTestServer (t , mockService )
528+
529+ h := newSASLTestHelper (t , addr )
530+ defer h .close ()
531+ h .doHandshake ()
532+
533+ resp := h .sendPlainAuth ("1" , "user@example.org" , "secret" )
534+ assert .Equal (t , "OK\t 1\t user=user@example.org" , resp )
535+
536+ // Idle longer than the per-operation ReadTimeout (10s would be too
537+ // long for a unit test; we just need to outlast the previous broken
538+ // timeout). The server must keep the connection alive.
539+ time .Sleep (2 * time .Second )
540+
541+ resp = h .sendPlainAuth ("2" , "user@example.org" , "wrong" )
542+ assert .True (t , strings .HasPrefix (resp , "FAIL\t 2\t " ),
543+ "server must accept AUTH after idle gap, got %q" , resp )
544+ assert .Contains (t , resp , "reason=authentication failed" )
545+ mockService .AssertExpectations (t )
546+ }
0 commit comments