238 lines
8.9 KiB
Go
238 lines
8.9 KiB
Go
package bluelink
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/evcc-io/evcc/util"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// testCCSPApplicationID/testCfb are copied from the real production Kia EU
|
|
// config (vehicle/bluelink.go) so Identity.stamp() succeeds during Login's
|
|
// getDeviceID call - not secrets, both are already public there.
|
|
const (
|
|
testCCSPApplicationID = "a2b8469b-30a3-4361-8e13-6fceea8fbe74"
|
|
testCfb = "wLTVxwidmH8CfJYBWSnHD6E0huk0ozdiuygB4hLkM5XCgzAL1Dk5sE36d/bx5PFMbZs="
|
|
)
|
|
|
|
// deviceIDHandler serves Identity.getDeviceID's device registration endpoint,
|
|
// which Login always calls after either auth path succeeds.
|
|
func deviceIDHandler(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"RetCode":"S","ResMsg":{"DeviceID":"test-device-id"}}`)
|
|
}
|
|
|
|
// legacyTokenHandler serves the legacy refresh_token grant endpoint used by
|
|
// Identity.refreshToken.
|
|
func legacyTokenHandler(accessToken, refreshToken string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"access_token":%q,"refresh_token":%q,"expires_in":3600}`, accessToken, refreshToken)
|
|
}
|
|
}
|
|
|
|
// newLoginTestIdentity builds an Identity suitable for exercising the public
|
|
// Login() method end to end, including the legacy getDeviceID call it always
|
|
// makes. cci == nil mirrors a brand without CCI support (e.g. Genesis EU).
|
|
func newLoginTestIdentity(t *testing.T, loginURL, deviceURL, cciURL string, cci *CCIConfig) *Identity {
|
|
t.Helper()
|
|
|
|
config := Config{
|
|
Brand: "kia-login-test:" + t.Name(),
|
|
URI: deviceURL,
|
|
CCSPServiceID: "test-ccsp-service-id",
|
|
CCSPServiceSecret: "test-ccsp-secret",
|
|
CCSPApplicationID: testCCSPApplicationID,
|
|
Cfb: testCfb,
|
|
LoginFormHost: loginURL,
|
|
PushType: "APNS",
|
|
CCI: cci,
|
|
}
|
|
if cci != nil {
|
|
config.CCI.APIURL = cciURL
|
|
}
|
|
|
|
return NewIdentity(util.NewLogger("test"), config)
|
|
}
|
|
|
|
func testCCIConfig() *CCIConfig {
|
|
return &CCIConfig{
|
|
OneAppClientID: "test-client-id",
|
|
OneAppRedirectURI: "https://oneapp.kia.com/redirect",
|
|
PackageID: "com.kia.oneapp.eu",
|
|
ClientName: "kia",
|
|
OSVersion: "27",
|
|
NotificationProvider: "IOS_APPSTORE",
|
|
}
|
|
}
|
|
|
|
// TestLoginUsesLegacyWhenCCIIsNil covers brands without CCI support (Genesis
|
|
// EU, Hyundai AU): Login must always use the unmodified legacy refreshToken path
|
|
func TestLoginUsesLegacyWhenCCIIsNil(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
password string
|
|
}{
|
|
{"legacy-shaped password", strings.Repeat("A", 48)},
|
|
{"real-looking password", "s3cret-Passw0rd!"},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/auth/api/v2/user/oauth2/token", legacyTokenHandler("legacy-access", "legacy-refresh"))
|
|
loginSrv := httptest.NewServer(mux)
|
|
defer loginSrv.Close()
|
|
|
|
deviceSrv := httptest.NewServer(http.HandlerFunc(deviceIDHandler))
|
|
defer deviceSrv.Close()
|
|
|
|
identity := newLoginTestIdentity(t, loginSrv.URL, deviceSrv.URL, "", nil)
|
|
|
|
require.NoError(t, identity.Login("user@example.com", tt.password, "en", "kia"))
|
|
|
|
token, err := identity.Token()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "legacy-access", token.AccessToken)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLoginPrefersLegacyToken covers a CCI-capable brand whose configured
|
|
// password is still a valid legacy refresh_token: the CCI path must not be used
|
|
// (its endpoint is left unreachable, so routing there would fail the test)
|
|
func TestLoginPrefersLegacyToken(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/auth/api/v2/user/oauth2/token", legacyTokenHandler("legacy-access-2", "legacy-refresh-2"))
|
|
loginSrv := httptest.NewServer(mux)
|
|
defer loginSrv.Close()
|
|
|
|
deviceSrv := httptest.NewServer(http.HandlerFunc(deviceIDHandler))
|
|
defer deviceSrv.Close()
|
|
|
|
identity := newLoginTestIdentity(t, loginSrv.URL, deviceSrv.URL, unreachable, testCCIConfig())
|
|
|
|
require.NoError(t, identity.Login("user@example.com", strings.Repeat("A", 48), "en", "kia"))
|
|
|
|
token, err := identity.Token()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "legacy-access-2", token.AccessToken)
|
|
}
|
|
|
|
// TestLoginUsesCCIAndWiresRefreshCCI covers a CCI-capable brand with an account
|
|
// password: Login must fall back to the CCI login and wire TokenSource to refreshCCI
|
|
func TestLoginUsesCCIAndWiresRefreshCCI(t *testing.T) {
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
|
|
loginMux := http.NewServeMux()
|
|
loginMux.HandleFunc("/auth/api/v2/user/oauth2/authorize", okHandler)
|
|
loginMux.HandleFunc("/auth/api/v1/accounts/certs", certsHandler(priv))
|
|
loginMux.HandleFunc("/auth/account/signin", func(w http.ResponseWriter, r *http.Request) {
|
|
require.NoError(t, r.ParseForm())
|
|
ciphertext, err := hex.DecodeString(r.FormValue("password"))
|
|
require.NoError(t, err)
|
|
_, err = rsa.DecryptPKCS1v15(rand.Reader, priv, ciphertext)
|
|
require.NoError(t, err)
|
|
|
|
w.Header().Set("Location", "https://oneapp.kia.com/redirect?code=testcode&state=ccsp")
|
|
w.WriteHeader(http.StatusFound)
|
|
})
|
|
loginSrv := httptest.NewServer(loginMux)
|
|
defer loginSrv.Close()
|
|
|
|
var refreshCalls atomic.Int32
|
|
var exchangeCalls atomic.Int32
|
|
cciMux := http.NewServeMux()
|
|
cciMux.HandleFunc("/domain/api/v1/auth/token", func(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"accessToken": "cci-access", "refreshToken": "cci-refresh",
|
|
"nonCcsToken": "non-ccs", "exchangeableAccessToken": "exch-access",
|
|
"exchangeableRefreshToken": "exch-refresh", "nonCcsRefreshToken": "non-ccs-refresh",
|
|
"idToken": "id-1", "expiresIn": 3599,
|
|
})
|
|
})
|
|
cciMux.HandleFunc("/domain/api/v1/auth/token-exchange", func(w http.ResponseWriter, _ *http.Request) {
|
|
// the first (login) exchange returns a token expiring inside oauth2's
|
|
// 10s buffer, so the next Token() call triggers exactly one refresh
|
|
resp := map[string]any{"accessToken": "ccs-token-2", "expiresTime": time.Now().Add(time.Hour).Unix()}
|
|
if exchangeCalls.Add(1) == 1 {
|
|
resp["accessToken"] = "ccs-token-1"
|
|
resp["expiresTime"] = time.Now().Add(5 * time.Second).Unix()
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
cciMux.HandleFunc("/domain/api/v2/auth/token-refresh", func(w http.ResponseWriter, _ *http.Request) {
|
|
refreshCalls.Add(1)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"accessToken": "cci-access-2", "refreshToken": "cci-refresh-2",
|
|
})
|
|
})
|
|
cciSrv := httptest.NewServer(cciMux)
|
|
defer cciSrv.Close()
|
|
|
|
deviceSrv := httptest.NewServer(http.HandlerFunc(deviceIDHandler))
|
|
defer deviceSrv.Close()
|
|
|
|
identity := newLoginTestIdentity(t, loginSrv.URL, deviceSrv.URL, cciSrv.URL, testCCIConfig())
|
|
|
|
require.NoError(t, identity.Login("user@example.com", "s3cret-Passw0rd!", "en", "kia"))
|
|
|
|
// the freshly issued token is inside the expiry buffer, so this Token()
|
|
// call must trigger exactly one refresh against the CCI endpoint
|
|
token, err := identity.Token()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "ccs-token-2", token.AccessToken)
|
|
assert.EqualValues(t, 1, refreshCalls.Load(), "expected TokenSource to be wired to refreshCCI")
|
|
|
|
// the now long-lived token must not trigger a further refresh
|
|
_, err = identity.Token()
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 1, refreshCalls.Load(), "unexpected additional refresh")
|
|
}
|
|
|
|
// TestLoginPropagatesLegacyError covers the legacy path: a failure there must
|
|
// surface through Login prefixed with evcc's existing "login failed: " convention.
|
|
func TestLoginPropagatesLegacyError(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/auth/api/v2/user/oauth2/token", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
|
|
})
|
|
loginSrv := httptest.NewServer(mux)
|
|
defer loginSrv.Close()
|
|
|
|
identity := newLoginTestIdentity(t, loginSrv.URL, unreachable, "", nil)
|
|
|
|
err := identity.Login("user@example.com", strings.Repeat("A", 48), "en", "kia")
|
|
require.Error(t, err)
|
|
assert.True(t, strings.HasPrefix(err.Error(), "login failed:"), "got: %s", err.Error())
|
|
}
|
|
|
|
// TestLoginPropagatesCCIError covers the CCI path: a failure there must surface
|
|
// prefixed with "login failed: ", the same convention as the legacy path
|
|
func TestLoginPropagatesCCIError(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/auth/api/v2/user/oauth2/authorize", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("Your request looks like abusing our system"))
|
|
})
|
|
loginSrv := httptest.NewServer(mux)
|
|
defer loginSrv.Close()
|
|
|
|
identity := newLoginTestIdentity(t, loginSrv.URL, unreachable, unreachable, testCCIConfig())
|
|
|
|
err := identity.Login("user@example.com", "s3cret-Passw0rd!", "en", "kia")
|
|
require.Error(t, err)
|
|
assert.True(t, strings.HasPrefix(err.Error(), "login failed:"), "got: %s", err.Error())
|
|
assert.Contains(t, err.Error(), "WAF")
|
|
}
|