package api_test import ( "bytes" "encoding/json" "net/http" "net/http/cookiejar" "regexp" "testing" "atlas9.dev/c/core/assert" "atlas9.dev/c/demo/api" ) var sixDigits = regexp.MustCompile(`\d{6}`) // codeFromSMS pulls the 6-digit code out of the message the fake SMS captured. func codeFromSMS(t *testing.T, s *testServer) string { t.Helper() m := sixDigits.FindString(s.sms.lastText()) if m == "" { t.Fatalf("no code in sms text %q", s.sms.lastText()) } return m } // enrollDefault enrolls and verifies a phone for the already-logged-in default // principal, so it has a working second factor. func enrollDefault(t *testing.T, s *testServer) { t.Helper() var start api.Mfa_StartEnrollmentRes res := s.call(t, "/Mfa_StartEnrollment", api.Mfa_StartEnrollmentReq{Phone: "+15555550100"}, &start) assert.Eq(t, res.StatusCode, http.StatusOK) res = s.call(t, "/Mfa_ConfirmEnrollment", api.Mfa_ConfirmEnrollmentReq{ChallengeID: start.ChallengeID, Code: codeFromSMS(t, s)}, nil) assert.Eq(t, res.StatusCode, http.StatusOK) } // A user whose tenant requires MFA and who has enrolled a phone gets no session // from the password step: login returns a challenge, and only VerifyMFA with the // texted code mints the session. func TestMfa_LoginChallenge_Success(t *testing.T) { s := startServer(t) // Enroll first (while not yet required, so nothing gates enrollment), then // turn on the tenant requirement. enrollDefault(t, s) res := s.call(t, "/Mfa_SetPolicy", api.Mfa_SetPolicyReq{Tenant: defaultTenant, Required: true}, nil) assert.Eq(t, res.StatusCode, http.StatusOK) // The password step now withholds the session and issues a challenge. var login api.Identity_LoginRes res = s.call(t, "/Identity_Login", api.Identity_LoginReq{Email: "default@test", Password: "pass"}, &login) assert.Eq(t, res.StatusCode, http.StatusOK) assert.Eq(t, login.MFARequired, true) assert.Eq(t, login.EnrollmentRequired, false) if login.ChallengeID == "" { t.Fatal("expected a challenge id") } // The texted code redeems the challenge for a session. res = s.call(t, "/Identity_VerifyMFA", api.Identity_VerifyMFAReq{ChallengeID: login.ChallengeID, Code: codeFromSMS(t, s)}, nil) assert.Eq(t, res.StatusCode, http.StatusOK) } // A wrong code is rejected without minting a session. func TestMfa_LoginChallenge_WrongCode(t *testing.T) { s := startServer(t) enrollDefault(t, s) s.call(t, "/Mfa_SetPolicy", api.Mfa_SetPolicyReq{Tenant: defaultTenant, Required: true}, nil) var login api.Identity_LoginRes s.call(t, "/Identity_Login", api.Identity_LoginReq{Email: "default@test", Password: "pass"}, &login) assert.Eq(t, login.MFARequired, true) var body api.ErrorResponse res := s.call(t, "/Identity_VerifyMFA", api.Identity_VerifyMFAReq{ChallengeID: login.ChallengeID, Code: "000000"}, &body) assert.Eq(t, res.StatusCode, http.StatusUnauthorized) } // A user required to use MFA but with no enrolled phone still gets a session, but // the enrollment gate confines it: non-MFA routes are 403 while the MFA routes // stay reachable so they can finish enrolling. func TestMfa_EnrollmentGate(t *testing.T) { s := startServer(t) // Require MFA on the tenant (as the default owner-like principal), then seed a // second member who has not enrolled. s.call(t, "/Mfa_SetPolicy", api.Mfa_SetPolicyReq{Tenant: defaultTenant, Required: true}, nil) s.seedUser(t, "gated@test", "pass") // Log the gated user in on their own client (fresh cookie jar). jar, err := cookiejar.New(nil) assert.Ok(t, err) client := &http.Client{Jar: jar} var login api.Identity_LoginRes res := post(t, client, s.URL+"/Identity_Login", api.Identity_LoginReq{Email: "gated@test", Password: "pass"}, &login) assert.Eq(t, res.StatusCode, http.StatusOK) assert.Eq(t, login.EnrollmentRequired, true) assert.Eq(t, login.MFARequired, false) // A non-MFA route is blocked for this session. res = post(t, client, s.URL+"/Mfa_SetPolicy", api.Mfa_SetPolicyReq{Tenant: defaultTenant, Required: false}, nil) assert.Eq(t, res.StatusCode, http.StatusForbidden) // The enrollment routes stay reachable so the user can finish enabling MFA. res = post(t, client, s.URL+"/Mfa_GetStatus", api.Mfa_GetStatusReq{}, nil) assert.Eq(t, res.StatusCode, http.StatusOK) } // post sends req as JSON on the given client, so a test can drive a session other // than the default principal's. func post(t *testing.T, client *http.Client, url string, req, res any) *http.Response { t.Helper() body, err := json.Marshal(req) assert.Ok(t, err) httpReq, err := http.NewRequestWithContext(t.Context(), "POST", url, bytes.NewReader(body)) assert.Ok(t, err) httpReq.Header.Set("Content-Type", "application/json") httpRes, err := client.Do(httpReq) assert.Ok(t, err) t.Cleanup(func() { httpRes.Body.Close() }) if res != nil { assert.Ok(t, json.NewDecoder(httpRes.Body).Decode(res)) } return httpRes }