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>
106 lines
3.6 KiB
Go
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")
|
|
}
|
|
}
|