@@ -3,10 +3,18 @@ package proxy
33import (
44 "encoding/base64"
55 "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
68 "testing"
9+ "time"
710
811 "github.com/stretchr/testify/assert"
912 "github.com/stretchr/testify/require"
13+
14+ "github.com/DocSpring/rack-gateway/internal/gateway/auth"
15+ "github.com/DocSpring/rack-gateway/internal/gateway/config"
16+ "github.com/DocSpring/rack-gateway/internal/gateway/db"
17+ "github.com/DocSpring/rack-gateway/internal/gateway/rbac"
1018)
1119
1220func TestParseWebAuthnAssertion_CLIFormat (t * testing.T ) {
@@ -193,3 +201,95 @@ func TestParseWebAuthnAssertion_PreservesAssertionFormat(t *testing.T) {
193201 assert .Equal (t , "dGVzdC1hdXRoLWRhdGE" , response ["authenticatorData" ])
194202 assert .Equal (t , "dGVzdC1jbGllbnQtZGF0YS1qc29u" , response ["clientDataJSON" ])
195203}
204+
205+ // TestVerifyMFAIfRequired_StepUpWindowCheckedFirst verifies that the step-up window
206+ // is checked BEFORE attempting inline MFA verification. This is critical for multi-step
207+ // CLI operations (like "env set --promote") where the same MFA code is sent for all requests.
208+ // The first request verifies the MFA code and sets the step-up window. Subsequent requests
209+ // should skip MFA verification and reuse the step-up window, avoiding TOTP replay errors.
210+ func TestVerifyMFAIfRequired_StepUpWindowCheckedFirst (t * testing.T ) {
211+ t .Parallel ()
212+
213+ // Create a minimal handler with no MFA service to test the logic flow
214+ h := & Handler {}
215+
216+ // Create a test session with a recent step-up timestamp
217+ recentStepUp := time .Now ().Add (- 5 * time .Minute ) // 5 minutes ago, within 10-minute window
218+ session := & db.UserSession {
219+ ID : 1 ,
220+ RecentStepUpAt : & recentStepUp ,
221+ }
222+
223+ // Create auth user WITH inline MFA credentials (simulating CLI with embedded MFA code)
224+ authUser := & auth.User {
225+ Email : "test@example.com" ,
226+ Session : session ,
227+ MFAType : "totp" ,
228+ MFAValue : "123456" , // Inline MFA code that would normally be verified
229+ }
230+
231+ // Test that isStepUpValid returns true for recent step-up
232+ require .True (t , h .isStepUpValid (authUser ), "step-up should be valid when within window" )
233+
234+ // Test that isStepUpValid returns false for expired step-up
235+ expiredStepUp := time .Now ().Add (- 15 * time .Minute ) // 15 minutes ago, outside 10-minute window
236+ authUser .Session .RecentStepUpAt = & expiredStepUp
237+ require .False (t , h .isStepUpValid (authUser ), "step-up should be invalid when outside window" )
238+
239+ // Test that isStepUpValid returns false when RecentStepUpAt is nil
240+ authUser .Session .RecentStepUpAt = nil
241+ require .False (t , h .isStepUpValid (authUser ), "step-up should be invalid when RecentStepUpAt is nil" )
242+ }
243+
244+ // TestVerifyMFAIfRequired_SkipsInlineMFAWhenStepUpValid tests that verifyMFAIfRequired
245+ // returns early without calling verifyInlineMFA when step-up window is valid.
246+ // This test uses nil mfaService to ensure verifyInlineMFA would panic if called.
247+ func TestVerifyMFAIfRequired_SkipsInlineMFAWhenStepUpValid (t * testing.T ) {
248+ t .Parallel ()
249+
250+ // Handler with nil mfaService - verifyInlineMFA would fail if called
251+ h := & Handler {
252+ mfaService : nil , // This will cause early return
253+ sessionManager : nil ,
254+ }
255+
256+ recentStepUp := time .Now ().Add (- 5 * time .Minute )
257+ session := & db.UserSession {
258+ ID : 1 ,
259+ RecentStepUpAt : & recentStepUp ,
260+ }
261+
262+ authUser := & auth.User {
263+ Email : "test@example.com" ,
264+ Session : session ,
265+ MFAType : "totp" ,
266+ MFAValue : "123456" ,
267+ }
268+
269+ req := httptest .NewRequest (http .MethodPost , "/apps/test/releases/R1/promote" , nil )
270+ w := httptest .NewRecorder ()
271+ rackConfig := & config.RackConfig {Name : "default" }
272+
273+ // Should return nil because mfaService is nil (early return)
274+ err := h .verifyMFAIfRequired (req , w , authUser , rbac .ResourceRelease , rbac .ActionPromote , rackConfig , time .Now ())
275+ require .NoError (t , err , "should return nil when mfaService is nil" )
276+ }
277+
278+ // TestVerifyMFAIfRequired_NoMFANeeded tests that MFANone permissions skip all MFA checks.
279+ func TestVerifyMFAIfRequired_NoMFANeeded (t * testing.T ) {
280+ t .Parallel ()
281+
282+ h := & Handler {}
283+
284+ authUser := & auth.User {
285+ Email : "test@example.com" ,
286+ }
287+
288+ req := httptest .NewRequest (http .MethodGet , "/apps" , nil )
289+ w := httptest .NewRecorder ()
290+ rackConfig := & config.RackConfig {Name : "default" }
291+
292+ // ActionList on ResourceApp should be MFANone (read operation)
293+ err := h .verifyMFAIfRequired (req , w , authUser , rbac .ResourceApp , rbac .ActionList , rackConfig , time .Now ())
294+ require .NoError (t , err , "read operations should not require MFA" )
295+ }
0 commit comments