fix: refresh action cache and classify auth failures

This commit is contained in:
2026-07-21 15:26:32 +08:00
parent 2cbac73f2a
commit 3f05417b83
4 changed files with 138 additions and 11 deletions
+9 -1
View File
@@ -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)
}
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)))
}
result, err := decodeAPIResponse(authData.Ssecurity, nonce, body)
@@ -234,7 +238,11 @@ func decodeAPIResponse(ssecurity, nonce string, body []byte) (json.RawMessage, e
if 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
}
+48
View File
@@ -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) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write(bytes.Repeat([]byte{'x'}, maxHTTPResponseBytes+1))
+11 -3
View File
@@ -19,8 +19,9 @@ import (
)
const (
deviceSpecUA = "mijiaAPI/4.1.2"
deviceSpecMaxSize = 8 << 20
deviceSpecUA = "mijiaAPI/4.1.2"
deviceSpecMaxSize = 8 << 20
deviceCacheVersion = 2
)
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 {
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 {
cache.Properties = append(cache.Properties, propertyCache{
PropertySpec: property,
@@ -505,6 +506,7 @@ type actionCache struct {
}
type deviceInfoCache struct {
Version int `json:"version"`
Name string `json:"name"`
Model string `json:"model"`
Properties []propertyCache `json:"properties"`
@@ -524,6 +526,12 @@ func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) {
}
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}
for _, cachedProperty := range cache.Properties {
+70 -7
View File
@@ -141,6 +141,7 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
t.Fatal(err)
}
var pythonCache struct {
Version int `json:"version"`
Properties []struct {
Method cacheMethod `json:"method"`
} `json:"properties"`
@@ -148,7 +149,7 @@ func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
Method cacheMethod `json:"method"`
} `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)
}
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}}],
"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}]}`,
`{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
`[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`,
@@ -213,9 +214,9 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
t.Fatal(err)
}
for index, want := range []map[string]any{
{"siid": float64(7), "piid": float64(8)},
{"siid": float64(7), "piid": float64(8)},
{"siid": float64(9), "aiid": float64(10)},
{"siid": float64(2), "piid": float64(1)},
{"siid": float64(2), "piid": float64(1)},
{"siid": float64(2), "aiid": float64(1)},
} {
params := testServer.requests[index+2]["params"]
var request map[string]any
@@ -230,8 +231,68 @@ func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
}
}
}
if testServer.specCalls != 0 {
t.Fatalf("spec calls = %d, want 0", testServer.specCalls)
if testServer.specCalls != 1 {
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) {
const cache = `{
"version":2,
"model":"test.light.v1",
"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}]}]
@@ -282,6 +344,7 @@ func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) {
func TestDecodeDeviceInfoCanonicalizesActionInputMetadata(t *testing.T) {
const cache = `{
"version":2,
"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}],
"actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"forged","description":"Forged","type":"string","rw":"w","range":[1,9,1],"siid":2,"piid":1}]}]