Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 11 additions & 36 deletions arcadyan.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"

"resty.dev/v3"
)

// infoURL is the endpoint for gateway information.
Expand Down Expand Up @@ -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)
}

Expand All @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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:
Expand All @@ -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(),
Expand Down
81 changes: 66 additions & 15 deletions arcadyan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
Expand Down
64 changes: 62 additions & 2 deletions gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
"context"
"fmt"
"net"
"net/http"
"strings"

"github.com/go-resty/resty/v2"
"resty.dev/v3"
)

// Gateway defines the interface for T-Mobile gateway implementations.
//
// 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)
Expand All @@ -32,6 +34,10 @@

// 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
Expand All @@ -54,6 +60,7 @@

if cfg.Retries > 0 {
client.SetRetryCount(cfg.Retries)
client.SetRetryAllowNonIdempotent(true)
}

if cfg.Debug {
Expand All @@ -66,6 +73,15 @@
}
}

// 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("/")
Expand All @@ -79,7 +95,51 @@
}

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 {

Check warning on line 105 in gateway.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this interface to follow Go naming conventions for single-method interfaces.

See more on https://sonarcloud.io/project/issues?id=hugoh_tmhi-gateway&issues=AZ8b1TgrqROOPfzX5QLX&open=AZ8b1TgrqROOPfzX5QLX&pullRequest=28
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
}
Loading
Loading