package fwh_pro import ( "crypto/md5" "encoding/hex" "encoding/json" "fwh-toolbox-server/utils/httpClient" "strings" "testing" "time" ) func loginTestResponse(data any) *httpClient.Response { body, _ := json.Marshal(map[string]any{"code": 200, "success": true, "data": data}) return &httpClient.Response{StatusCode: 200, Body: body} } func withLoginTestConfig(t *testing.T, secret string) { t.Helper() oldUser, oldPassword, oldSecret, oldInfo := fwhUser, fwhPassword, fwhTotpSecret, loginInfo passwordHash := md5.Sum([]byte("plain-password")) fwhUser, fwhPassword, fwhTotpSecret = "user", hex.EncodeToString(passwordHash[:]), secret t.Cleanup(func() { fwhUser, fwhPassword, fwhTotpSecret, loginInfo = oldUser, oldPassword, oldSecret, oldInfo }) } func TestLoginDirectWithoutTOTP(t *testing.T) { withLoginTestConfig(t, "") calls := 0 post := func(path string, body any, clientID string) (*httpClient.Response, error) { calls++ if path != "/api-user/user/login" || clientID != "device-1" { t.Fatalf("unexpected request: %s, %s", path, clientID) } payload, ok := body.(LoginPayload) if !ok || payload.Password != fwhPassword || payload.Timezone != "Asia/Shanghai" || payload.Lang != "ZH_CN" { t.Fatalf("unexpected login payload: %+v", payload) } return loginTestResponse(map[string]any{"userId": 7, "token": "direct-token"}), nil } if err := loginWithPost(post, time.Now, "device-1"); err != nil { t.Fatal(err) } if calls != 1 || loginInfo.Token != "direct-token" || loginInfo.UserId != 7 { t.Fatalf("direct login result: calls=%d, token=%q, userId=%d", calls, loginInfo.Token, loginInfo.UserId) } } func TestLoginTOTPFromMFAResponse(t *testing.T) { withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") calls := 0 post := func(path string, body any, clientID string) (*httpClient.Response, error) { calls++ if clientID != "device-1" { t.Fatalf("client ID changed between login steps") } switch calls { case 1: if path != "/api-user/user/login" { t.Fatalf("unexpected first path: %s", path) } return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil case 2: verify, ok := body.(MfaVerifyPayload) if path != "/api-user/mfa/login/verify" || !ok || verify.MfaToken != "temporary-token" || verify.Code != "287082" || verify.Method != "TOTP" || verify.DeviceFingerprint != clientID || verify.RememberDevice != "" { t.Fatalf("unexpected verify request: path=%s, body=%+v", path, body) } return loginTestResponse(map[string]any{"userId": 9, "token": "verified-token"}), nil default: t.Fatalf("too many login requests") return nil, nil } } if err := loginWithPost(post, func() time.Time { return time.Unix(59, 0) }, "device-1"); err != nil { t.Fatal(err) } if calls != 2 || loginInfo.Token != "verified-token" || loginInfo.UserId != 9 { t.Fatalf("TOTP login result: calls=%d, token=%q, userId=%d", calls, loginInfo.Token, loginInfo.UserId) } } func TestLoginRequiresConfiguredTOTPWhenMFARequested(t *testing.T) { withLoginTestConfig(t, "") loginInfo.Token = "old-token" calls := 0 post := func(path string, body any, clientID string) (*httpClient.Response, error) { calls++ return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil } err := loginWithPost(post, time.Now, "device-1") if err == nil || !strings.Contains(err.Error(), "FWH_TOTP_SECRET") || calls != 1 || loginInfo.Token != "" { t.Fatalf("missing TOTP handling: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token) } } func TestLoginWithConfiguredSecretAcceptsDirectToken(t *testing.T) { withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") calls := 0 post := func(path string, body any, clientID string) (*httpClient.Response, error) { calls++ return loginTestResponse(map[string]any{"userId": 4, "token": "direct-token"}), nil } if err := loginWithPost(post, time.Now, "device-1"); err != nil || calls != 1 || loginInfo.Token != "direct-token" { t.Fatalf("direct response with secret: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token) } } func TestLoginRejectedTOTPDoesNotReuseOldToken(t *testing.T) { withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") loginInfo.Token = "old-token" calls := 0 post := func(path string, body any, clientID string) (*httpClient.Response, error) { calls++ if calls == 1 { return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil } return &httpClient.Response{StatusCode: 200, Body: []byte(`{"code":401,"success":false}`)}, nil } err := loginWithPost(post, func() time.Time { return time.Unix(59, 0) }, "device-1") if err == nil || calls != 2 || loginInfo.Token != "" { t.Fatalf("rejected TOTP handling: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token) } }