fix: refresh action cache and classify auth failures
This commit is contained in:
@@ -201,6 +201,10 @@ func (client *Client) request(ctx context.Context, uri string, data any, refresh
|
|||||||
return nil, fmt.Errorf("read API response: %w", err)
|
return nil, fmt.Errorf("read API response: %w", err)
|
||||||
}
|
}
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||||||
|
loginErr := &LoginError{Code: response.StatusCode, Message: strings.TrimSpace(string(body))}
|
||||||
|
return nil, fmt.Errorf("API HTTP status from %s: %w: %w", uri, ErrReauthenticationRequired, loginErr)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("API HTTP status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
|
return nil, fmt.Errorf("API HTTP status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
result, err := decodeAPIResponse(authData.Ssecurity, nonce, body)
|
result, err := decodeAPIResponse(authData.Ssecurity, nonce, body)
|
||||||
@@ -234,7 +238,11 @@ func decodeAPIResponse(ssecurity, nonce string, body []byte) (json.RawMessage, e
|
|||||||
if message == "" {
|
if message == "" {
|
||||||
message = "未知错误"
|
message = "未知错误"
|
||||||
}
|
}
|
||||||
return nil, &APIError{Code: envelope.Code, Message: message}
|
apiErr := &APIError{Code: envelope.Code, Message: message}
|
||||||
|
if envelope.Code == -10020 || envelope.Code == -10030 {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrReauthenticationRequired, apiErr)
|
||||||
|
}
|
||||||
|
return nil, apiErr
|
||||||
}
|
}
|
||||||
return envelope.Result, nil
|
return envelope.Result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,54 @@ func TestRequestRejectsHTTPStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBusinessRequestClassifiesReauthenticationFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
wantSentinel bool
|
||||||
|
wantCode int
|
||||||
|
wantLogin bool
|
||||||
|
}{
|
||||||
|
{name: "HTTP 401", status: http.StatusUnauthorized, body: "expired", wantSentinel: true, wantCode: http.StatusUnauthorized, wantLogin: true},
|
||||||
|
{name: "HTTP 403", status: http.StatusForbidden, body: "forbidden", wantSentinel: true, wantCode: http.StatusForbidden, wantLogin: true},
|
||||||
|
{name: "API -10020", body: `{"code":-10020,"message":"oauth expired"}`, wantSentinel: true, wantCode: -10020},
|
||||||
|
{name: "API -10030", body: `{"code":-10030,"message":"token expired"}`, wantSentinel: true, wantCode: -10030},
|
||||||
|
{name: "HTTP 500", status: http.StatusInternalServerError, body: "failed", wantCode: http.StatusInternalServerError},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
if test.status != 0 {
|
||||||
|
writer.WriteHeader(test.status)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(writer, test.body)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := testClient(t, server.Client())
|
||||||
|
client.baseURL = server.URL
|
||||||
|
_, err := client.request(context.Background(), "/business", nil, true)
|
||||||
|
if errors.Is(err, ErrReauthenticationRequired) != test.wantSentinel {
|
||||||
|
t.Fatalf("error = %v, ErrReauthenticationRequired = %v", err, errors.Is(err, ErrReauthenticationRequired))
|
||||||
|
}
|
||||||
|
if test.wantLogin {
|
||||||
|
var loginErr *LoginError
|
||||||
|
if !errors.As(err, &loginErr) || loginErr.Code != test.wantCode {
|
||||||
|
t.Fatalf("error = %v, want LoginError code %d", err, test.wantCode)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if test.status == 0 {
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) || apiErr.Code != test.wantCode {
|
||||||
|
t.Fatalf("error = %v, want APIError code %d", err, test.wantCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRequestRejectsOversizedRawResponse(t *testing.T) {
|
func TestRequestRejectsOversizedRawResponse(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = writer.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1))
|
_, _ = writer.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1))
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
deviceSpecUA = "mijiaAPI/4.1.2"
|
deviceSpecUA = "mijiaAPI/4.1.2"
|
||||||
deviceSpecMaxSize = 8 << 20
|
deviceSpecMaxSize = 8 << 20
|
||||||
|
deviceCacheVersion = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
var deviceSpecURL = "https://home.miot-spec.com/spec/"
|
var deviceSpecURL = "https://home.miot-spec.com/spec/"
|
||||||
@@ -444,7 +445,7 @@ func writeDeviceInfoCache(path string, info DeviceInfo) error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return fmt.Errorf("create device info cache directory: %w", err)
|
return fmt.Errorf("create device info cache directory: %w", err)
|
||||||
}
|
}
|
||||||
cache := deviceInfoCache{Name: info.Name, Model: info.Model}
|
cache := deviceInfoCache{Version: deviceCacheVersion, Name: info.Name, Model: info.Model}
|
||||||
for _, property := range info.Properties {
|
for _, property := range info.Properties {
|
||||||
cache.Properties = append(cache.Properties, propertyCache{
|
cache.Properties = append(cache.Properties, propertyCache{
|
||||||
PropertySpec: property,
|
PropertySpec: property,
|
||||||
@@ -505,6 +506,7 @@ type actionCache struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deviceInfoCache struct {
|
type deviceInfoCache struct {
|
||||||
|
Version int `json:"version"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Properties []propertyCache `json:"properties"`
|
Properties []propertyCache `json:"properties"`
|
||||||
@@ -524,6 +526,12 @@ func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) {
|
|||||||
}
|
}
|
||||||
return DeviceInfo{}, fmt.Errorf("trailing content: %w", err)
|
return DeviceInfo{}, fmt.Errorf("trailing content: %w", err)
|
||||||
}
|
}
|
||||||
|
if cache.Version < deviceCacheVersion {
|
||||||
|
return DeviceInfo{}, fmt.Errorf("device info cache stale: version %d, want %d", cache.Version, deviceCacheVersion)
|
||||||
|
}
|
||||||
|
if cache.Version != deviceCacheVersion {
|
||||||
|
return DeviceInfo{}, fmt.Errorf("unsupported device info cache version %d", cache.Version)
|
||||||
|
}
|
||||||
|
|
||||||
info := DeviceInfo{Name: cache.Name, Model: cache.Model}
|
info := DeviceInfo{Name: cache.Name, Model: cache.Model}
|
||||||
for _, cachedProperty := range cache.Properties {
|
for _, cachedProperty := range cache.Properties {
|
||||||
|
|||||||
+70
-7
@@ -141,6 +141,7 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var pythonCache struct {
|
var pythonCache struct {
|
||||||
|
Version int `json:"version"`
|
||||||
Properties []struct {
|
Properties []struct {
|
||||||
Method cacheMethod `json:"method"`
|
Method cacheMethod `json:"method"`
|
||||||
} `json:"properties"`
|
} `json:"properties"`
|
||||||
@@ -148,7 +149,7 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
|
|||||||
Method cacheMethod `json:"method"`
|
Method cacheMethod `json:"method"`
|
||||||
} `json:"actions"`
|
} `json:"actions"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(cacheData, &pythonCache); err != nil || pythonCache.Properties[0].Method.SIID != 2 || pythonCache.Properties[0].Method.PIID != 1 || pythonCache.Actions[0].Method.SIID != 2 || pythonCache.Actions[0].Method.AIID != 1 {
|
if err := json.Unmarshal(cacheData, &pythonCache); err != nil || pythonCache.Version != 2 || pythonCache.Properties[0].Method.SIID != 2 || pythonCache.Properties[0].Method.PIID != 1 || pythonCache.Actions[0].Method.SIID != 2 || pythonCache.Actions[0].Method.AIID != 1 {
|
||||||
t.Fatalf("Python-compatible cache = %#v, %v", pythonCache, err)
|
t.Fatalf("Python-compatible cache = %#v, %v", pythonCache, err)
|
||||||
}
|
}
|
||||||
testServer.fixture = nil
|
testServer.fixture = nil
|
||||||
@@ -186,7 +187,7 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
|||||||
"properties": [{"name":"power","description":"Power","type":"bool","rw":"rw","method":{"siid":7,"piid":8}}],
|
"properties": [{"name":"power","description":"Power","type":"bool","rw":"rw","method":{"siid":7,"piid":8}}],
|
||||||
"actions": [{"name":"toggle","description":"Toggle","method":{"siid":9,"aiid":10}}]
|
"actions": [{"name":"toggle","description":"Toggle","method":{"siid":9,"aiid":10}}]
|
||||||
}`
|
}`
|
||||||
testServer := newDeviceTestServer(t, nil, []string{
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{
|
||||||
`{"homelist":[{"id":"10","uid":1}]}`,
|
`{"homelist":[{"id":"10","uid":1}]}`,
|
||||||
`{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
|
`{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
|
||||||
`[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`,
|
`[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`,
|
||||||
@@ -213,9 +214,9 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for index, want := range []map[string]any{
|
for index, want := range []map[string]any{
|
||||||
{"siid": float64(7), "piid": float64(8)},
|
{"siid": float64(2), "piid": float64(1)},
|
||||||
{"siid": float64(7), "piid": float64(8)},
|
{"siid": float64(2), "piid": float64(1)},
|
||||||
{"siid": float64(9), "aiid": float64(10)},
|
{"siid": float64(2), "aiid": float64(1)},
|
||||||
} {
|
} {
|
||||||
params := testServer.requests[index+2]["params"]
|
params := testServer.requests[index+2]["params"]
|
||||||
var request map[string]any
|
var request map[string]any
|
||||||
@@ -230,8 +231,68 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if testServer.specCalls != 0 {
|
if testServer.specCalls != 1 {
|
||||||
t.Fatalf("spec calls = %d, want 0", testServer.specCalls)
|
t.Fatalf("spec calls = %d, want 1", testServer.specCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOldDeviceInfoCacheRefreshesActionInputs(t *testing.T) {
|
||||||
|
for _, version := range []int{0, 1} {
|
||||||
|
t.Run(fmt.Sprintf("version %d", version), func(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
cache := fmt.Sprintf(`{
|
||||||
|
"version":%d,
|
||||||
|
"name":"Old Lamp",
|
||||||
|
"model":"test.light.v1",
|
||||||
|
"properties":[{"name":"power","type":"bool","rw":"rw","siid":2,"piid":1}],
|
||||||
|
"actions":[{"name":"toggle","siid":2,"aiid":1}]
|
||||||
|
}`, version)
|
||||||
|
if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(cache), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
||||||
|
if err != nil || testServer.specCalls != 1 || len(info.Actions) == 0 || len(info.Actions[0].Inputs) != 2 {
|
||||||
|
t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersion2DeviceInfoCacheTrustsActionWithoutInputs(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, nil, nil)
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
const cache = `{
|
||||||
|
"version":2,
|
||||||
|
"name":"Cached Lamp",
|
||||||
|
"model":"test.light.v1",
|
||||||
|
"properties":[{"name":"power","type":"bool","rw":"rw","siid":2,"piid":1}],
|
||||||
|
"actions":[{"name":"toggle","siid":2,"aiid":1}]
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(cache), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
||||||
|
if err != nil || testServer.specCalls != 0 || len(info.Actions) != 1 || len(info.Actions[0].Inputs) != 0 {
|
||||||
|
t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaleDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) {
|
||||||
|
testServer := newDeviceTestServer(t, []byte("unavailable"), nil)
|
||||||
|
testServer.status = http.StatusServiceUnavailable
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
|
||||||
|
const cache = `{"version":1,"model":"test.light.v1","properties":[],"actions":[]}`
|
||||||
|
if err := os.WriteFile(cachePath, []byte(cache), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cache stale") || !strings.Contains(err.Error(), "refresh failed") || !strings.Contains(err.Error(), "503") {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +331,7 @@ func TestInvalidDeviceInfoCacheRefreshesAndOverwrites(t *testing.T) {
|
|||||||
|
|
||||||
func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) {
|
func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) {
|
||||||
const cache = `{
|
const cache = `{
|
||||||
|
"version":2,
|
||||||
"model":"test.light.v1",
|
"model":"test.light.v1",
|
||||||
"properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","siid":2,"piid":1}],
|
"properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","siid":2,"piid":1}],
|
||||||
"actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"missing","type":"bool","rw":"rw","siid":2,"piid":99}]}]
|
"actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"missing","type":"bool","rw":"rw","siid":2,"piid":99}]}]
|
||||||
@@ -282,6 +344,7 @@ func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) {
|
|||||||
|
|
||||||
func TestDecodeDeviceInfoCanonicalizesActionInputMetadata(t *testing.T) {
|
func TestDecodeDeviceInfoCanonicalizesActionInputMetadata(t *testing.T) {
|
||||||
const cache = `{
|
const cache = `{
|
||||||
|
"version":2,
|
||||||
"model":"test.light.v1",
|
"model":"test.light.v1",
|
||||||
"properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","value-list":[{"value":0,"description":"Off"},{"value":1,"description":"On"}],"siid":2,"piid":1}],
|
"properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","value-list":[{"value":0,"description":"Off"},{"value":1,"description":"On"}],"siid":2,"piid":1}],
|
||||||
"actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"forged","description":"Forged","type":"string","rw":"w","range":[1,9,1],"siid":2,"piid":1}]}]
|
"actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"forged","description":"Forged","type":"string","rw":"w","range":[1,9,1],"siid":2,"piid":1}]}]
|
||||||
|
|||||||
Reference in New Issue
Block a user