@@ -5,8 +5,10 @@ import (
55 "errors"
66 "testing"
77
8+ "github.com/LeanerCloud/CUDly/internal/config"
89 "github.com/aws/aws-lambda-go/events"
910 "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
1012)
1113
1214func TestHandler_isPublicEndpoint (t * testing.T ) {
@@ -240,3 +242,181 @@ func TestHandler_authenticate_BearerTokenWithAPIKey(t *testing.T) {
240242 // Should be false because API key is configured and bearer token is invalid
241243 assert .False (t , handler .authenticate (ctx , req ))
242244}
245+
246+ // ---------------------------------------------------------------------------
247+ // CSRF boundary tests for session-authed approve/cancel (issue #404)
248+ // ---------------------------------------------------------------------------
249+
250+ // TestApproveViaSession_RequiresCSRF asserts that a session-authenticated POST
251+ // to approvePurchaseViaSession is rejected when the request carries no CSRF
252+ // token. Pre-fix, the blanket "/api/purchases/approve/" middleware exemption
253+ // meant this call succeeded without a CSRF token.
254+ func TestApproveViaSession_RequiresCSRF (t * testing.T ) {
255+ ctx := context .Background ()
256+ execID := "12345678-1234-1234-1234-123456789abc"
257+ adminEmail := "admin@example.com"
258+
259+ mockConfig := new (MockConfigStore )
260+ exec := & config.PurchaseExecution {
261+ ExecutionID : execID ,
262+ ApprovalToken : "email-tok" ,
263+ Status : "pending" ,
264+ Recommendations : []config.RecommendationRecord {{ID : "r1" }},
265+ }
266+ mockConfig .On ("GetExecutionByID" , ctx , execID ).Return (exec , nil )
267+
268+ mockAuth := new (MockAuthService )
269+ adminSession := & Session {Email : adminEmail , Role : "admin" }
270+ mockAuth .On ("ValidateSession" , ctx , "sess-tok" ).Return (adminSession , nil )
271+ // CSRF token is empty → ValidateCSRFToken must return an error so the
272+ // request is rejected. This is the critical regression assertion for #404.
273+ mockAuth .On ("ValidateCSRFToken" , ctx , "sess-tok" , "" ).Return (errors .New ("csrf mismatch" ))
274+ t .Cleanup (func () { mockAuth .AssertExpectations (t ) })
275+
276+ handler := & Handler {config : mockConfig , auth : mockAuth }
277+
278+ // Request with a valid session but NO CSRF token header.
279+ req := & events.LambdaFunctionURLRequest {
280+ Headers : map [string ]string {"authorization" : "Bearer sess-tok" },
281+ }
282+ _ , err := handler .approvePurchase (ctx , req , execID , "" )
283+ require .Error (t , err )
284+ ce , ok := IsClientError (err )
285+ require .True (t , ok , "expected a ClientError, got: %v" , err )
286+ assert .Equal (t , 403 , ce .code )
287+ assert .Contains (t , ce .Error (), "CSRF validation failed" )
288+ }
289+
290+ // TestApproveViaSession_PassesCSRF asserts that a session-authenticated POST
291+ // to approvePurchaseViaSession succeeds when a valid CSRF token is supplied.
292+ // This confirms the CSRF guard does not block the legitimate dashboard flow.
293+ func TestApproveViaSession_PassesCSRF (t * testing.T ) {
294+ ctx := context .Background ()
295+ execID := "12345678-1234-1234-1234-123456789abc"
296+ adminEmail := "admin@example.com"
297+
298+ mockConfig := new (MockConfigStore )
299+ exec := & config.PurchaseExecution {
300+ ExecutionID : execID ,
301+ ApprovalToken : "email-tok" ,
302+ Status : "pending" ,
303+ Recommendations : []config.RecommendationRecord {{ID : "r1" }},
304+ }
305+ mockConfig .On ("GetExecutionByID" , ctx , execID ).Return (exec , nil )
306+
307+ mockAuth := new (MockAuthService )
308+ adminSession := & Session {Email : adminEmail , Role : "admin" }
309+ mockAuth .On ("ValidateSession" , ctx , "sess-tok" ).Return (adminSession , nil )
310+ // Valid CSRF token supplied → ValidateCSRFToken succeeds.
311+ mockAuth .On ("ValidateCSRFToken" , ctx , "sess-tok" , "csrf-abc" ).Return (nil )
312+ t .Cleanup (func () { mockAuth .AssertExpectations (t ) })
313+
314+ mockPurchase := new (MockPurchaseManager )
315+ mockPurchase .On ("ApproveAndExecute" , ctx , execID , adminEmail ).Return (nil )
316+
317+ handler := & Handler {config : mockConfig , auth : mockAuth , purchase : mockPurchase }
318+
319+ // Request with a valid session AND a valid CSRF token header.
320+ req := & events.LambdaFunctionURLRequest {
321+ Headers : map [string ]string {
322+ "authorization" : "Bearer sess-tok" ,
323+ "x-csrf-token" : "csrf-abc" ,
324+ },
325+ }
326+ result , err := handler .approvePurchase (ctx , req , execID , "" )
327+ require .NoError (t , err )
328+ assert .Equal (t , "completed" , result .(map [string ]string )["status" ])
329+ }
330+
331+ // TestCancelViaSession_RequiresCSRF asserts that a session-authenticated POST
332+ // to cancelPurchaseViaSession is rejected when the request carries no CSRF
333+ // token. Mirror of TestApproveViaSession_RequiresCSRF for the cancel path.
334+ func TestCancelViaSession_RequiresCSRF (t * testing.T ) {
335+ ctx := context .Background ()
336+ execID := "55555555-5555-5555-5555-555555555555"
337+
338+ mockConfig := new (MockConfigStore )
339+ exec := & config.PurchaseExecution {
340+ ExecutionID : execID ,
341+ Status : "pending" ,
342+ }
343+ mockConfig .On ("GetExecutionByID" , ctx , execID ).Return (exec , nil )
344+
345+ mockAuth := new (MockAuthService )
346+ adminSession := & Session {Email : "admin@example.com" , Role : "admin" }
347+ mockAuth .On ("ValidateSession" , ctx , "sess-tok" ).Return (adminSession , nil )
348+ // No CSRF token → ValidateCSRFToken must return error to block the request.
349+ mockAuth .On ("ValidateCSRFToken" , ctx , "sess-tok" , "" ).Return (errors .New ("csrf mismatch" ))
350+ t .Cleanup (func () { mockAuth .AssertExpectations (t ) })
351+
352+ handler := & Handler {config : mockConfig , auth : mockAuth }
353+
354+ req := & events.LambdaFunctionURLRequest {
355+ Headers : map [string ]string {"authorization" : "Bearer sess-tok" },
356+ }
357+ _ , err := handler .cancelPurchase (ctx , req , execID , "" )
358+ require .Error (t , err )
359+ ce , ok := IsClientError (err )
360+ require .True (t , ok , "expected a ClientError, got: %v" , err )
361+ assert .Equal (t , 403 , ce .code )
362+ assert .Contains (t , ce .Error (), "CSRF validation failed" )
363+ }
364+
365+ // TestTokenOnlyApprove_BypassesCSRF asserts that the email-link token path
366+ // does NOT invoke approvePurchaseViaSession, so CSRF is not checked on that
367+ // code path. The token path uses authorizeApprovalAction which validates the
368+ // signed token directly -- it has no session cookie to forge.
369+ //
370+ // The caller must supply a session whose email matches the contact_email (the
371+ // approval gate added in PR #101 ensures only the intended inbox can confirm
372+ // via the email link). Because the session has no approve-* RBAC, the handler
373+ // falls through from the session-authed branch to the token branch.
374+ func TestTokenOnlyApprove_BypassesCSRF (t * testing.T ) {
375+ ctx := context .Background ()
376+ execID := "12345678-1234-1234-1234-123456789abc"
377+ contactEmail := "contact@example.com"
378+
379+ mockConfig := new (MockConfigStore )
380+ accountID := "acct-1"
381+ exec := & config.PurchaseExecution {
382+ ExecutionID : execID ,
383+ ApprovalToken : "email-token" ,
384+ Status : "pending" ,
385+ Recommendations : []config.RecommendationRecord {
386+ {ID : "r1" , CloudAccountID : & accountID },
387+ },
388+ }
389+ mockConfig .On ("GetExecutionByID" , ctx , execID ).Return (exec , nil )
390+ mockConfig .GetCloudAccountFn = func (_ context.Context , id string ) (* config.CloudAccount , error ) {
391+ return & config.CloudAccount {ID : id , ContactEmail : contactEmail }, nil
392+ }
393+ mockConfig .On ("GetGlobalConfig" , ctx ).Return (& config.GlobalConfig {
394+ NotificationEmail : & contactEmail ,
395+ }, nil )
396+
397+ // Session is present but has no approve-* permissions → RBAC denies,
398+ // falls through to the token branch. CSRF is NOT called on the token
399+ // branch (approvePurchaseViaSession is never reached).
400+ mockAuth := new (MockAuthService )
401+ mockAuth .On ("ValidateSession" , ctx , "sess-tok" ).Return (& Session {Email : contactEmail }, nil )
402+ mockAuth .On ("HasPermissionAPI" , ctx , "" , "approve-any" , "purchases" ).Return (false , nil ).Maybe ()
403+ mockAuth .On ("HasPermissionAPI" , ctx , "" , "approve-own" , "purchases" ).Return (false , nil ).Maybe ()
404+ t .Cleanup (func () { mockAuth .AssertExpectations (t ) })
405+
406+ mockPurchase := new (MockPurchaseManager )
407+ mockPurchase .On ("ApproveExecution" , ctx , execID , "email-token" , contactEmail ).Return (nil )
408+
409+ handler := & Handler {config : mockConfig , auth : mockAuth , purchase : mockPurchase }
410+
411+ // Session header present (so tryGetSession succeeds) but no CSRF header.
412+ // The session has no approve-* permission → falls to token branch.
413+ // No ValidateCSRFToken mock is registered here, so if the code ever
414+ // called ValidateCSRFToken, the test would panic with "unexpected call".
415+ req := & events.LambdaFunctionURLRequest {
416+ Headers : map [string ]string {"authorization" : "Bearer sess-tok" },
417+ // Intentionally no x-csrf-token header.
418+ }
419+ result , err := handler .approvePurchase (ctx , req , execID , "email-token" )
420+ require .NoError (t , err )
421+ assert .Equal (t , "completed" , result .(map [string ]string )["status" ])
422+ }
0 commit comments