diff --git a/arcadyan.go b/arcadyan.go index f52e17f..e54c593 100644 --- a/arcadyan.go +++ b/arcadyan.go @@ -5,9 +5,10 @@ import ( "context" "encoding/json" "fmt" - "net/http" "strings" "time" + + "resty.dev/v3" ) // infoURL is the endpoint for gateway information. @@ -67,7 +68,7 @@ func (a *ArcadyanGateway) Login(ctx context.Context) error { return fmt.Errorf("login request failed: %w", err) } - if resp.IsError() { + if resp.IsStatusFailure() { return NewAuthError(resp.StatusCode(), resp.String(), nil) } @@ -86,35 +87,9 @@ func (a *ArcadyanGateway) Login(ctx context.Context) error { // Reboot restarts the Arcadyan gateway. func (a *ArcadyanGateway) Reboot(ctx context.Context) error { - err := a.Login(ctx) - if err != nil { - return fmt.Errorf("cannot reboot without successful login flow: %w", err) - } - - if a.config.DryRun { - return nil - } - - rebootRequestPath := "/TMI/v1/gateway/reset?set=reboot" - - resp, err := a.client.R().SetContext(ctx).Post(rebootRequestPath) - if err != nil { - return fmt.Errorf("reboot request failed: %w", err) - } - - if resp.IsError() { - status := resp.StatusCode() - if status == http.StatusUnauthorized || status == http.StatusForbidden { - a.logout() - } - - return NewGatewayError("reboot", status, resp.String(), ErrRebootFailed) - } - - // A successful reboot invalidates the session on the gateway side. - a.logout() - - return nil + return a.performReboot(ctx, a, a.Login, func() (*resty.Response, error) { + return a.client.R().SetContext(ctx).Post("/TMI/v1/gateway/reset?set=reboot") + }) } // Info retrieves gateway information. @@ -129,12 +104,12 @@ func (a *ArcadyanGateway) Request(ctx context.Context, method, path string) (*In return nil, fmt.Errorf("request failed: %w", err) } - if resp.IsError() { + if resp.IsStatusFailure() { return nil, fmt.Errorf("%w: HTTP %d", ErrRequestFailed, resp.StatusCode()) } contentType := resp.Header().Get("Content-Type") - body := resp.Body() + body := resp.Bytes() var data map[string]any if strings.HasPrefix(contentType, jsonContentType) { @@ -172,13 +147,13 @@ func (a *ArcadyanGateway) Status(ctx context.Context) (*StatusResult, error) { if webResult.Error == nil { webResult.Error = NewGatewayError("status", 0, "failed to get registration status", err) } - case resp.IsError(): + case resp.IsStatusFailure(): if webResult.Error == nil { webResult.Error = NewGatewayError( "status", resp.StatusCode(), "failed to get registration status", - ErrSignalFailed, + ErrStatusFailed, ) } default: @@ -199,7 +174,7 @@ func (a *ArcadyanGateway) Signal(ctx context.Context) (*SignalResult, error) { return nil, NewGatewayError("signal", 0, "failed to get signal info", err) } - if resp.IsError() { + if resp.IsStatusFailure() { return nil, NewGatewayError( "signal", resp.StatusCode(), diff --git a/arcadyan_test.go b/arcadyan_test.go index c1f3b19..6633c01 100644 --- a/arcadyan_test.go +++ b/arcadyan_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/go-resty/resty/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "resty.dev/v3" ) func newArcadyan(gc *GatewayCommon, token string, exp time.Time) *ArcadyanGateway { @@ -39,19 +39,8 @@ func newArcadyanWithToken(t *testing.T, ts *httptest.Server) *ArcadyanGateway { func newClosedServerGateway(t *testing.T, token string, exp time.Time) *ArcadyanGateway { t.Helper() - ts := newTestServer(t, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) - cfg := testConfigNoCreds(ts) - baseURL := ts.URL - ts.Close() - - return newArcadyan( - &GatewayCommon{ - client: resty.NewWithClient(&http.Client{}).SetBaseURL(baseURL), - config: cfg, - }, - token, - exp, - ) + + return newArcadyan(newClosedServerCommon(t), token, exp) } func withHeadCheck(next http.HandlerFunc) http.HandlerFunc { @@ -113,6 +102,21 @@ func TestArcadyanGateway_Reboot_DryRun(t *testing.T) { require.NoError(t, err) } +func TestArcadyanGateway_Reboot_DryRun_SkipsLogin(t *testing.T) { + // Not logged in: if DryRun didn't short-circuit before Login, this would + // hit the network and the handler below would fail the test. + ts := newTestServer(t, func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("unexpected HTTP call in dry run") + }) + + gw := newArcadyan(testCommon(ts), "", time.Time{}) + gw.config = testConfig(ts) + gw.config.DryRun = true + + err := gw.Reboot(t.Context()) + require.NoError(t, err) +} + func TestArcadyanGateway_Reboot_Success(t *testing.T) { ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) @@ -300,7 +304,54 @@ func TestArcadyanGateway_Status_Error(t *testing.T) { result, err := gw.Status(t.Context()) require.NoError(t, err) assert.True(t, result.WebInterfaceUp) - assert.Error(t, result.Error) + require.Error(t, result.Error) + require.ErrorIs(t, result.Error, ErrStatusFailed) + assert.NotErrorIs(t, result.Error, ErrSignalFailed) +} + +func TestArcadyanGateway_Status_NetworkError(t *testing.T) { + // HEAD succeeds so CheckWebInterface leaves webResult.Error nil; the GET + // for registration status hijacks and closes the connection to force a + // transport-level error distinct from an HTTP status failure. + ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + + return + } + + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatal("ResponseWriter does not support hijacking") + } + + conn, _, err := hj.Hijack() + if err != nil { + t.Fatal(err) + } + + _ = conn.Close() + }) + + gw := newArcadyanWithToken(t, ts) + + result, err := gw.Status(t.Context()) + require.NoError(t, err) + assert.True(t, result.WebInterfaceUp) + require.Error(t, result.Error) + assert.Contains(t, result.Error.Error(), "failed to get registration status") +} + +func TestArcadyanGateway_Request_InvalidJSON(t *testing.T) { + ts := newTestServer(t, jsonResponder(http.StatusOK, "not valid json")) + + gw := newArcadyan(testCommon(ts), "valid-token", time.Now().Add(1*time.Hour)) + gw.config = testConfigNoCreds(ts) + + result, err := gw.Request(t.Context(), "GET", "/test") + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "json unmarshal failed") } func TestArcadyanGateway_Request_ErrorStatus(t *testing.T) { diff --git a/errors.go b/errors.go index 7ca528e..3983d09 100644 --- a/errors.go +++ b/errors.go @@ -15,6 +15,8 @@ var ( ErrRebootFailed = errors.New("reboot failed") // ErrSignalFailed indicates a signal operation failed. ErrSignalFailed = errors.New("signal failed") + // ErrStatusFailed indicates a status operation failed. + ErrStatusFailed = errors.New("status failed") // ErrRequestFailed indicates an HTTP request returned an error status. ErrRequestFailed = errors.New("request failed") ) diff --git a/gateway.go b/gateway.go index 25180f1..3f88357 100644 --- a/gateway.go +++ b/gateway.go @@ -4,9 +4,10 @@ import ( "context" "fmt" "net" + "net/http" "strings" - "github.com/go-resty/resty/v2" + "resty.dev/v3" ) // Gateway defines the interface for T-Mobile gateway implementations. @@ -14,6 +15,7 @@ import ( // Implementations are not safe for concurrent use: Login mutates shared // client state such as auth headers and cookies. type Gateway interface { + Close() error Login(ctx context.Context) error Reboot(ctx context.Context) error Request(ctx context.Context, method, path string) (*InfoResult, error) @@ -32,6 +34,10 @@ type GatewayCommon struct { // NewGatewayCommon creates a new GatewayCommon with the given configuration. func NewGatewayCommon(cfg *GatewayConfig) *GatewayCommon { + if cfg == nil { + panic("tmhi: GatewayConfig must not be nil") + } + host := cfg.Host // Bare IPv6 literals must be bracketed in URLs. // Use ContainsRune(':') rather than To4()==nil so IPv4-mapped IPv6 @@ -54,6 +60,7 @@ func NewGatewayCommon(cfg *GatewayConfig) *GatewayCommon { if cfg.Retries > 0 { client.SetRetryCount(cfg.Retries) + client.SetRetryAllowNonIdempotent(true) } if cfg.Debug { @@ -66,6 +73,15 @@ func NewGatewayCommon(cfg *GatewayConfig) *GatewayCommon { } } +// Close releases resources held by the underlying HTTP client. +func (gc *GatewayCommon) Close() error { + if err := gc.client.Close(); err != nil { + return fmt.Errorf("close client: %w", err) + } + + return nil +} + // CheckWebInterface checks if the gateway web interface is accessible. func (gc *GatewayCommon) CheckWebInterface(ctx context.Context) *StatusResult { resp, err := gc.client.R().SetContext(ctx).Head("/") @@ -79,7 +95,51 @@ func (gc *GatewayCommon) CheckWebInterface(ctx context.Context) *StatusResult { } result.StatusCode = resp.StatusCode() - result.WebInterfaceUp = resp.IsSuccess() + result.WebInterfaceUp = resp.IsStatusSuccess() return result } + +// authSession is implemented by gateway credential holders that must be +// invalidated when the gateway rejects a request as unauthenticated. +type authSession interface { + logout() +} + +// performReboot runs the shared reboot flow used by every gateway +// implementation: skip entirely in dry-run mode, otherwise authenticate, +// issue the vendor-supplied reboot request, and invalidate the session on +// success or on an auth rejection. +func (gc *GatewayCommon) performReboot( + ctx context.Context, + sess authSession, + login func(context.Context) error, + doRequest func() (*resty.Response, error), +) error { + if gc.config.DryRun { + return nil + } + + if err := login(ctx); err != nil { + return fmt.Errorf("cannot reboot without successful login flow: %w", err) + } + + resp, err := doRequest() + if err != nil { + return fmt.Errorf("reboot request failed: %w", err) + } + + if resp.IsStatusFailure() { + status := resp.StatusCode() + if status == http.StatusUnauthorized || status == http.StatusForbidden { + sess.logout() + } + + return NewGatewayError("reboot", status, resp.String(), ErrRebootFailed) + } + + // A successful reboot invalidates the session on the gateway side. + sess.logout() + + return nil +} diff --git a/gateway_test.go b/gateway_test.go index 2ea541a..43ebc3e 100644 --- a/gateway_test.go +++ b/gateway_test.go @@ -7,12 +7,19 @@ import ( "testing" "time" - "github.com/go-resty/resty/v2" "github.com/stretchr/testify/assert" + "resty.dev/v3" ) const testServerErrMsg = "server error" +// Both vendor gateways must satisfy authSession so performReboot can +// invalidate their session on an auth rejection or a successful reboot. +var ( + _ authSession = (*ArcadyanGateway)(nil) + _ authSession = (*NokiaGateway)(nil) +) + func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { t.Helper() @@ -29,6 +36,30 @@ func testCommon(ts *httptest.Server) *GatewayCommon { } } +// newClosedServerCommon returns a GatewayCommon pointed at an already-closed +// server, used to simulate connection-refused errors. +func newClosedServerCommon(t *testing.T) *GatewayCommon { + t.Helper() + + ts := newTestServer(t, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + baseURL := ts.URL + ts.Close() + + return &GatewayCommon{ + client: resty.NewWithClient(&http.Client{}).SetBaseURL(baseURL), + config: &GatewayConfig{}, + } +} + +func TestGatewayCommon_Close(t *testing.T) { + ts := newTestServer(t, func(_ http.ResponseWriter, _ *http.Request) {}) + gc := testCommon(ts) + + assert.NoError(t, gc.Close()) + // Close is safe to call more than once. + assert.NoError(t, gc.Close()) +} + func TestNewGatewayCommon(t *testing.T) { cfg := &GatewayConfig{ Host: testIP, @@ -42,6 +73,12 @@ func TestNewGatewayCommon(t *testing.T) { assert.Equal(t, cfg, gc.config) } +func TestNewGatewayCommon_NilConfig(t *testing.T) { + assert.PanicsWithValue(t, "tmhi: GatewayConfig must not be nil", func() { + NewGatewayCommon(nil) + }) +} + func TestNewGatewayCommon_UserAgent(t *testing.T) { var gotUA string @@ -85,7 +122,7 @@ func TestNewGatewayCommon_HostForms(t *testing.T) { for _, tc := range cases { t.Run(tc.host, func(t *testing.T) { gc := NewGatewayCommon(&GatewayConfig{Host: tc.host}) - assert.Equal(t, tc.want, gc.client.BaseURL) + assert.Equal(t, tc.want, gc.client.BaseURL()) }) } } diff --git a/go.mod b/go.mod index 0f770f1..4595fdb 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/hugoh/tmhi-gateway/v2 go 1.26.0 require ( - github.com/go-resty/resty/v2 v2.17.2 github.com/stretchr/testify v1.11.1 + resty.dev/v3 v3.0.0-rc.2 ) require ( diff --git a/go.sum b/go.sum index d5e48c1..8ffb06c 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= -github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +resty.dev/v3 v3.0.0-rc.2 h1:NE6RqPkUd27eQ5dFSb5tfyT3s6XX9ZySp9qZ7Nw1bn0= +resty.dev/v3 v3.0.0-rc.2/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM= diff --git a/nokia.go b/nokia.go index aa4f2da..0e31008 100644 --- a/nokia.go +++ b/nokia.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "strings" + + "resty.dev/v3" ) const ( @@ -65,44 +67,24 @@ func (n *NokiaGateway) Login(ctx context.Context) error { n.credentials.SID = loginResp.Sid n.credentials.csrfToken = loginResp.CsrfToken - //nolint:gosec // Secure/HttpOnly/SameSite only apply to response cookies, not outgoing requests. - n.client.SetCookie(&http.Cookie{Name: sidCookieName, Value: n.credentials.SID}) return nil } // Reboot restarts the Nokia gateway. func (n *NokiaGateway) Reboot(ctx context.Context) error { - if err := n.Login(ctx); err != nil { - return fmt.Errorf("cannot reboot without successful login flow: %w", err) - } - - formData := map[string]string{ - "csrf_token": n.credentials.csrfToken, - } - - if n.config.DryRun { - return nil - } - - resp, err := n.client.R().SetContext(ctx).SetFormData(formData).Post("/reboot_web_app.cgi") - if err != nil { - return fmt.Errorf("error sending reboot request: %w", err) - } - - if resp.IsError() { - status := resp.StatusCode() - if status == http.StatusUnauthorized || status == http.StatusForbidden { - n.logout() + return n.performReboot(ctx, n, n.Login, func() (*resty.Response, error) { + formData := map[string]string{ + "csrf_token": n.credentials.csrfToken, } - return NewGatewayError("reboot", status, resp.String(), ErrRebootFailed) - } - - // A successful reboot invalidates the session on the gateway side. - n.logout() - - return nil + //nolint:gosec // Secure/HttpOnly/SameSite only apply to response cookies, not outgoing requests. + return n.client.R(). + SetContext(ctx). + SetCookie(&http.Cookie{Name: sidCookieName, Value: n.credentials.SID}). + SetFormData(formData). + Post("/reboot_web_app.cgi") + }) } // Request is not implemented for Nokia gateway. @@ -131,9 +113,6 @@ func (n *NokiaGateway) isLoggedIn() bool { func (n *NokiaGateway) logout() { n.credentials = nokiaLoginData{} - // resty.Client.SetCookie/SetCookies both append; assign directly to - // replace the slice so re-login doesn't accumulate stale sid cookies. - n.client.Cookies = nil } func (n *NokiaGateway) getCredentials( @@ -166,7 +145,7 @@ func (n *NokiaGateway) getCredentials( return nil, NewAuthError(0, "login request failed", err) } - if resp.IsError() { + if resp.IsStatusFailure() { return nil, NewAuthError(resp.StatusCode(), resp.String(), nil) } @@ -191,7 +170,7 @@ func (n *NokiaGateway) getNonce(ctx context.Context) (*nokiaNonce, error) { return nil, fmt.Errorf("error getting nonce: %w", err) } - if resp.IsError() { + if resp.IsStatusFailure() { return nil, NewGatewayError("nonce", resp.StatusCode(), resp.String(), ErrAuthentication) } diff --git a/nokia_test.go b/nokia_test.go index e3fd18b..9ad7b77 100644 --- a/nokia_test.go +++ b/nokia_test.go @@ -3,10 +3,8 @@ package tmhi import ( "net/http" "net/http/httptest" - "strings" "testing" - "github.com/go-resty/resty/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -30,49 +28,42 @@ func newNokia(gc *GatewayCommon, sid, token string) *NokiaGateway { return gw } -func nokiaConfig(ts *httptest.Server) *GatewayConfig { - return &GatewayConfig{ - Host: strings.TrimPrefix(ts.URL, "http://"), - Username: testUsername, - Password: testPassword, - } -} - func nokiaTestGw(ts *httptest.Server, cfg *GatewayConfig, sid, token string) *NokiaGateway { - return newNokia(&GatewayCommon{ - client: resty.NewWithClient(&http.Client{}).SetBaseURL(ts.URL), - config: cfg, - }, sid, token) + gc := testCommon(ts) + gc.config = cfg + + return newNokia(gc, sid, token) } -func nokiaTestGwClosed(cfg *GatewayConfig, sid, token string) *NokiaGateway { - ts := httptest.NewServer(http.HandlerFunc( - func(_ http.ResponseWriter, _ *http.Request) {}, - )) - ts.Close() +func nokiaTestGwClosed(t *testing.T, sid, token string) *NokiaGateway { + t.Helper() - return newNokia(&GatewayCommon{ - client: resty.NewWithClient(&http.Client{}).SetBaseURL(ts.URL), - config: cfg, - }, sid, token) + return newNokia(newClosedServerCommon(t), sid, token) } -func Test_LoginSuccess(t *testing.T) { - valid := &nokiaLoginResp{ - Success: 0, - Reason: 0, - Sid: "foo", - CsrfToken: "bar", +func TestNokiaLoginResp_HasCredentials(t *testing.T) { + cases := []struct { + name string + resp *nokiaLoginResp + want bool + }{ + { + name: "success", + resp: &nokiaLoginResp{Success: 0, Reason: 0, Sid: "foo", CsrfToken: "bar"}, + want: true, + }, + { + name: "failure reason", + resp: &nokiaLoginResp{Success: 0, Reason: 600}, + want: false, + }, } - assert.True(t, valid.hasCredentials()) -} -func Test_LoginFailure(t *testing.T) { - invalid := &nokiaLoginResp{ - Success: 0, - Reason: 600, + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.resp.hasCredentials()) + }) } - assert.False(t, invalid.hasCredentials()) } func TestNokiaGateway_getCredentials_ErrorResponse(t *testing.T) { @@ -92,7 +83,7 @@ func TestNokiaGateway_getCredentials_ErrorResponse(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - gw := nokiaTestGw(tc.ts, nokiaConfig(tc.ts), "", "") + gw := nokiaTestGw(tc.ts, testConfig(tc.ts), "", "") _, err := gw.getCredentials(t.Context(), nokiaNonce{Nonce: "test"}) require.Error(t, err) @@ -108,7 +99,7 @@ func TestNokiaGateway_Reboot_Success(t *testing.T) { w.WriteHeader(http.StatusOK) }) - gw := nokiaTestGw(ts, nokiaConfig(ts), testValidSID, testValidToken) + gw := nokiaTestGw(ts, testConfig(ts), testValidSID, testValidToken) err := gw.Reboot(t.Context()) require.NoError(t, err) @@ -117,7 +108,7 @@ func TestNokiaGateway_Reboot_Success(t *testing.T) { func TestNokiaGateway_Reboot_StaleSession(t *testing.T) { ts := newTestServer(t, textResponder(http.StatusUnauthorized, "session expired")) - gw := nokiaTestGw(ts, nokiaConfig(ts), testValidSID, testValidToken) + gw := nokiaTestGw(ts, testConfig(ts), testValidSID, testValidToken) err := gw.Reboot(t.Context()) require.ErrorIs(t, err, ErrRebootFailed) @@ -139,7 +130,7 @@ func TestNokiaGateway_Status(t *testing.T) { } func TestNokiaGateway_getNonce_ErrorResponse(t *testing.T) { - gw := nokiaTestGwClosed(&GatewayConfig{}, "", "") + gw := nokiaTestGwClosed(t, "", "") _, err := gw.getNonce(t.Context()) require.Error(t, err) @@ -182,7 +173,7 @@ func TestNokiaGateway_getCredentials_NonceFormField(t *testing.T) { _, _ = w.Write([]byte(testLoginRespBody)) }) - gw := nokiaTestGw(ts, nokiaConfig(ts), "", "") + gw := nokiaTestGw(ts, testConfig(ts), "", "") _, err := gw.getCredentials(t.Context(), nokiaNonce{Nonce: rawNonce, RandomKey: "key"}) require.NoError(t, err) assert.Equal( @@ -195,7 +186,7 @@ func TestNokiaGateway_getCredentials_NonceFormField(t *testing.T) { func TestNokiaGateway_getCredentials_Success(t *testing.T) { ts := newTestServer(t, jsonResponder(http.StatusOK, testLoginRespBody)) - gw := nokiaTestGw(ts, nokiaConfig(ts), "", "") + gw := nokiaTestGw(ts, testConfig(ts), "", "") loginResp, err := gw.getCredentials( t.Context(), @@ -214,7 +205,7 @@ func TestNokiaGateway_Login_Alreadyauthenticated(t *testing.T) { } func TestNokiaGateway_Login_NonceError(t *testing.T) { - gw := nokiaTestGwClosed(&GatewayConfig{}, "", "") + gw := nokiaTestGwClosed(t, "", "") err := gw.Login(t.Context()) require.Error(t, err) @@ -223,7 +214,7 @@ func TestNokiaGateway_Login_NonceError(t *testing.T) { func TestNokiaGateway_Login_CredentialsError(t *testing.T) { ts := newTestServer(t, jsonResponder(http.StatusOK, testNonceBody)) - gw := nokiaTestGw(ts, nokiaConfig(ts), "", "") + gw := nokiaTestGw(ts, testConfig(ts), "", "") err := gw.Login(t.Context()) assert.Error(t, err) @@ -240,6 +231,19 @@ func TestNokiaGateway_Reboot_DryRun(t *testing.T) { assert.NoError(t, err) } +func TestNokiaGateway_Reboot_DryRun_SkipsLogin(t *testing.T) { + // Not logged in: if DryRun didn't short-circuit before Login, this would + // hit the network and the handler below would fail the test. + ts := newTestServer(t, func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("unexpected HTTP call in dry run") + }) + + gw := nokiaTestGw(ts, &GatewayConfig{DryRun: true}, "", "") + + err := gw.Reboot(t.Context()) + assert.NoError(t, err) +} + func TestNokiaGateway_Reboot_ErrorResponse(t *testing.T) { ts := newTestServer(t, textResponder(http.StatusInternalServerError, "reboot failed")) gw := nokiaTestGw(ts, &GatewayConfig{}, testValidSID, testValidToken) @@ -276,7 +280,7 @@ func TestNokiaGateway_Login_NonceSuccessCredentialsError(t *testing.T) { } }) - gw := nokiaTestGw(ts, nokiaConfig(ts), "", "") + gw := nokiaTestGw(ts, testConfig(ts), "", "") err := gw.Login(t.Context()) require.Error(t, err) @@ -296,7 +300,7 @@ func TestNokiaGateway_Login_Success(t *testing.T) { } }) - gw := nokiaTestGw(ts, nokiaConfig(ts), "", "") + gw := nokiaTestGw(ts, testConfig(ts), "", "") err := gw.Login(t.Context()) require.NoError(t, err) @@ -305,11 +309,11 @@ func TestNokiaGateway_Login_Success(t *testing.T) { } func TestNokiaGateway_Reboot_RequestError(t *testing.T) { - gw := nokiaTestGwClosed(&GatewayConfig{}, testValidSID, testValidToken) + gw := nokiaTestGwClosed(t, testValidSID, testValidToken) err := gw.Reboot(t.Context()) require.Error(t, err) - assert.Contains(t, err.Error(), "error sending reboot request") + assert.Contains(t, err.Error(), "reboot request failed") } func TestNokiaGateway_NotImplemented(t *testing.T) { @@ -331,25 +335,37 @@ func TestNokiaGateway_NotImplemented(t *testing.T) { }) } -func TestNokiaGateway_logout_ClearsCookie(t *testing.T) { - ts := newTestServer(t, func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - gw := nokiaTestGw(ts, nokiaConfig(ts), testValidSID, testValidToken) - //nolint:gosec // Secure/HttpOnly/SameSite only apply to response cookies, not outgoing requests. - gw.client.SetCookie(&http.Cookie{Name: sidCookieName, Value: testValidSID}) +func TestNokiaGateway_logout_ClearsCredentials(t *testing.T) { + gw := newNokia(&GatewayCommon{config: &GatewayConfig{}}, testValidSID, testValidToken) gw.logout() assert.False(t, gw.isLoggedIn(), "logout should clear credentials") - assert.Empty(t, gw.client.Cookies, "logout should clear all resty cookies") } -func TestNokiaGateway_Reboot_ReloginNoDuplicateCookie(t *testing.T) { - // After reboot (which calls logout), a subsequent Login should not - // accumulate a second sid cookie from the previous session. - callCount := 0 +func TestNokiaGateway_Reboot_SendsSIDCookie(t *testing.T) { + // Reboot must include the current session SID in the request cookie. + var gotCookie string + + ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + if r.Method == http.MethodPost && r.URL.Path == "/reboot_web_app.cgi" { + if c, err := r.Cookie(sidCookieName); err == nil { + gotCookie = c.Value + } + } + }) + + gw := nokiaTestGw(ts, testConfig(ts), testValidSID, testValidToken) + + require.NoError(t, gw.Reboot(t.Context())) + assert.Equal(t, testValidSID, gotCookie, "reboot request must carry the session SID cookie") +} + +func TestNokiaGateway_Reboot_ReloginHasFreshSID(t *testing.T) { + // After reboot (which calls logout), a subsequent Login must use fresh credentials, + // not carry over the old SID. ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -358,34 +374,22 @@ func TestNokiaGateway_Reboot_ReloginNoDuplicateCookie(t *testing.T) { case r.Method == http.MethodGet: _, _ = w.Write([]byte(testNonceBody)) case r.Method == http.MethodPost && r.URL.Path == loginWebAppCGI: - callCount++ _, _ = w.Write([]byte(testLoginRespBody)) - case r.Method == http.MethodPost && r.URL.Path == "/reboot_web_app.cgi": - // pass } }) - gw := nokiaTestGw(ts, nokiaConfig(ts), testValidSID, testValidToken) - //nolint:gosec // Secure/HttpOnly/SameSite only apply to response cookies, not outgoing requests. - gw.client.SetCookie(&http.Cookie{Name: sidCookieName, Value: testValidSID}) + gw := nokiaTestGw(ts, testConfig(ts), testValidSID, testValidToken) require.NoError(t, gw.Reboot(t.Context())) require.NoError(t, gw.Login(t.Context())) - sidCookies := 0 - - for _, c := range gw.client.Cookies { - if c.Name == sidCookieName { - sidCookies++ - } - } - assert.Equal( t, - 1, - sidCookies, - "re-login after reboot must not accumulate duplicate sid cookies", + "testSid", + gw.credentials.SID, + "re-login after reboot must have fresh SID, not the old one", ) + assert.NotEqual(t, testValidSID, gw.credentials.SID) } func TestNewNokiaGateway(t *testing.T) {