|
|
@@ -0,0 +1,124 @@
|
|
|
+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)
|
|
|
+ }
|
|
|
+}
|