Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/login_backoff_test.go
T
tajniak81andClaude Opus 5 c1aee0fac1 One refused sign-in, not five: the chargers poll no longer locks the account
A chargers poll asks four cloud views. Each called apiRequest, each found no
token, and each ran its own login — so a login Anker refuses was offered four
times in one poll, and the next poll spent the fifth. Five is what disables the
account for ten minutes, which is how "code 26161: Failed to request." turned
into "your account has been disabled" on the very next attempt.

The plugin now remembers a refused login instead of repeating it: the failure is
cached and replayed to every caller until a backoff window passes — a minute at
first, doubling to fifteen, or the full ten minutes when Anker says it has
already locked the account (code 10019). New credentials clear it, so a fixed
password is tried at once.

chargerInventory signs in once up front. A login the cloud refuses is not four
views failing, so it is reported as itself rather than as three warnings with the
lockout notice buried in the last one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:54:24 +02:00

106 lines
3.6 KiB
Go

package ankersolix
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"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)
}
p.apiBase = srv.URL
_, 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) {
p := &Plugin{}
p.noteLogin(errors.New("boom"))
if wait := time.Until(p.loginRetryAt); wait > loginRetryBase || wait < loginRetryBase-time.Second {
t.Errorf("first failure waits %s, want ~%s", wait, loginRetryBase)
}
p.noteLogin(errors.New("boom"))
if wait := time.Until(p.loginRetryAt); wait <= loginRetryBase {
t.Errorf("second failure waits %s, want more than %s", wait, loginRetryBase)
}
p.noteLogin(&loginRejected{code: codeSignInLocked, msg: "account disabled for 10 minutes"})
if wait := time.Until(p.loginRetryAt); wait < loginLockoutWait-time.Second {
t.Errorf("locked account waits %s, want at least %s", wait, loginLockoutWait)
}
p.noteLogin(nil)
if p.loginErr != nil || p.loginFails != 0 || !p.loginRetryAt.IsZero() {
t.Error("a successful login must clear the backoff")
}
}
// TestLoginBackoffCappedAndCleared: the wait never exceeds loginRetryMax however
// long the outage lasts, and new credentials get an attempt straight away.
func TestLoginBackoffCappedAndCleared(t *testing.T) {
p := &Plugin{}
for i := 0; i < 20; i++ {
p.noteLogin(errors.New("boom"))
}
if wait := time.Until(p.loginRetryAt); wait > loginRetryMax {
t.Errorf("wait %s exceeds the %s cap", wait, loginRetryMax)
}
if err := p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "new"}); err != nil {
t.Fatal(err)
}
if p.loginErr != nil || p.loginFails != 0 {
t.Error("re-configuring must clear the backoff so new credentials are tried")
}
}