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") } }