Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/login_backoff_test.go
T
tajniak81andClaude Opus 5 190ae923a6 The Anker token outlives the request that fetched it
The manager builds a throwaway plugin instance for every per-user call —
HealthCheckWith, InvokeWith, InvokeBatchWith each construct, Init, probe and
Shutdown. The auth token lived on that instance, so it died with the HTTP request
that fetched it: opening the Anker panel signed in once for the health probe and
again for the charger list, and a page that also asked for OCPP info signed in a
third time. Every refresh, a fresh login.

Anker throttles passport/login per IP per minute and answers code 26161 ("Failed
to request.") once tripped, so this is the shape of the failure the panel has been
reporting; the cloud has also historically kept one token per account, so each of
those logins could evict the one the mobile app was holding.

Tokens and the login backoff now live in a package-level session keyed by the
account signing in, so every instance configured for that account shares one
login. Re-configuring the same credentials keeps the token; a different account,
or the same account on the other regional server, gets its own session. Sessions
unused for a fortnight are pruned, so an edited password does not leave its entry
behind for the life of the process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:11:52 +02:00

171 lines
5.8 KiB
Go

package ankersolix
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// TestChargerInventoryLoginRefusedOnce is the regression: one chargers poll asks
// four cloud views, and each of them holding no token used to try its own login.
// Anker disables the account after five refusals, so the poll that found a bad
// login also locked the account out for ten minutes. The poll must spend exactly
// one sign-in, and the next one must spend none until the backoff expires.
func TestChargerInventoryLoginRefusedOnce(t *testing.T) {
logins := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, epLogin) {
t.Errorf("unexpected request to %s with no token", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
return
}
logins++
_, _ = w.Write([]byte(`{"code":26161,"msg":"Failed to request."}`))
}))
defer srv.Close()
p := &Plugin{}
if err := p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "pw"}); err != nil {
t.Fatal(err)
}
// Point the instance at the test server, session key included, so this test
// shares nothing with the others.
p.apiBase = srv.URL
p.sess = sessionFor(srv.URL, "u@example.com", "pw")
_, err := p.chargerInventory(context.Background())
if err == nil {
t.Fatal("expected the refused login to fail the inventory")
}
if !isLoginFailure(err) {
t.Errorf("error should be recognisable as a login failure, got %v", err)
}
if !strings.Contains(err.Error(), "26161") {
t.Errorf("error should carry Anker's code, got %v", err)
}
if strings.Contains(err.Error(), "every cloud view failed") {
t.Errorf("a refused login is not four failed views, got %v", err)
}
if logins != 1 {
t.Fatalf("login attempts = %d, want 1", logins)
}
if _, err := p.chargerInventory(context.Background()); err == nil {
t.Fatal("expected the cached failure to be replayed")
}
if logins != 1 {
t.Fatalf("login attempts after a second poll = %d, want 1", logins)
}
}
// TestLoginBackoffWindows checks how long each kind of refusal buys: an ordinary
// one starts at loginRetryBase and doubles, an account Anker has already
// disabled gets the full penalty it asked for.
func TestLoginBackoffWindows(t *testing.T) {
s := &session{}
s.noteLogin(errors.New("boom"))
if wait := time.Until(s.loginRetryAt); wait > loginRetryBase || wait < loginRetryBase-time.Second {
t.Errorf("first failure waits %s, want ~%s", wait, loginRetryBase)
}
s.noteLogin(errors.New("boom"))
if wait := time.Until(s.loginRetryAt); wait <= loginRetryBase {
t.Errorf("second failure waits %s, want more than %s", wait, loginRetryBase)
}
s.noteLogin(&loginRejected{code: codeSignInLocked, msg: "account disabled for 10 minutes"})
if wait := time.Until(s.loginRetryAt); wait < loginLockoutWait-time.Second {
t.Errorf("locked account waits %s, want at least %s", wait, loginLockoutWait)
}
s.noteLogin(nil)
if s.loginErr != nil || s.loginFails != 0 || !s.loginRetryAt.IsZero() {
t.Error("a successful login must clear the backoff")
}
}
// TestLoginBackoffCapped: the wait never exceeds loginRetryMax however long the
// outage lasts.
func TestLoginBackoffCapped(t *testing.T) {
s := &session{}
for i := 0; i < 20; i++ {
s.noteLogin(errors.New("boom"))
}
if wait := time.Until(s.loginRetryAt); wait > loginRetryMax {
t.Errorf("wait %s exceeds the %s cap", wait, loginRetryMax)
}
}
// TestSessionSharedAcrossInstances is the second half of the regression. The
// manager builds a throwaway plugin instance per HTTP call, so a token kept in
// the instance meant a fresh login for the health probe, another for the charger
// list, another for OCPP info — enough on its own to trip Anker's login throttle
// (code 26161) and then its five-failure lockout. Instances sharing an account
// must share the token; a different account must not.
func TestSessionSharedAcrossInstances(t *testing.T) {
logins := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, epLogin) {
logins++
_, _ = w.Write([]byte(`{"code":0,"msg":"success!","data":{"auth_token":"t","user_id":"u","token_expires_at":` +
strconv.FormatInt(time.Now().Add(24*time.Hour).Unix(), 10) + `}}`))
return
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success!","data":{}}`))
}))
defer srv.Close()
dial := func(email, password string) *Plugin {
p := &Plugin{}
if err := p.Init(context.Background(), map[string]string{"email": email, "password": password}); err != nil {
t.Fatal(err)
}
p.apiBase = srv.URL
p.sess = sessionFor(srv.URL, email, password)
return p
}
// Three calls the panel makes as three separate instances, one account.
for i := 0; i < 3; i++ {
if _, err := dial("u@example.com", "pw").ensureToken(context.Background()); err != nil {
t.Fatal(err)
}
}
if logins != 1 {
t.Fatalf("login attempts for one account = %d, want 1", logins)
}
// A different account is a different session, and signs in for itself.
if _, err := dial("other@example.com", "pw").ensureToken(context.Background()); err != nil {
t.Fatal(err)
}
if logins != 2 {
t.Fatalf("login attempts after a second account = %d, want 2", logins)
}
}
// TestPruneSessionsDropsIdle keeps the package-level cache from growing an entry
// for every credential the server has ever seen.
func TestPruneSessionsDropsIdle(t *testing.T) {
sessionsMu.Lock()
sessions["stale"] = &session{lastUse: time.Now().Add(-2 * sessionIdle)}
sessions["fresh"] = &session{lastUse: time.Now()}
pruneSessionsLocked()
_, stale := sessions["stale"]
_, fresh := sessions["fresh"]
delete(sessions, "fresh")
sessionsMu.Unlock()
if stale {
t.Error("a session idle beyond sessionIdle should have been pruned")
}
if !fresh {
t.Error("a session in use must not be pruned")
}
}