fix: validate QR and action metadata edges
This commit is contained in:
@@ -262,6 +262,9 @@ func (writer *recordingWriter) Write(payload []byte) (int, error) {
|
||||
return 0, writer.err
|
||||
}
|
||||
written, err := writer.w.Write(payload)
|
||||
if err == nil && written < len(payload) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
writer.err = err
|
||||
}
|
||||
@@ -354,17 +357,21 @@ func (client *Client) getLocation(ctx context.Context) (url.Values, bool, error)
|
||||
if readErr != nil {
|
||||
return nil, false, fmt.Errorf("read token refresh response: %w", readErr)
|
||||
}
|
||||
if response.StatusCode == http.StatusOK && string(body) == "ok" {
|
||||
candidate := client.AuthData()
|
||||
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
|
||||
candidate.Ssecurity = data.Ssecurity
|
||||
if !serviceTokenReceived || !candidate.complete() {
|
||||
return nil, false, fmt.Errorf("%w: %w", ErrReauthenticationRequired, &LoginError{Code: -1, Message: "刷新Token响应认证信息不完整"})
|
||||
}
|
||||
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||
client.setAuthData(candidate)
|
||||
return nil, true, nil
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, false, &LoginError{Code: response.StatusCode, Message: string(body)}
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
return nil, false, &LoginError{Code: -1, Message: string(body)}
|
||||
}
|
||||
candidate := client.AuthData()
|
||||
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
|
||||
candidate.Ssecurity = data.Ssecurity
|
||||
if !serviceTokenReceived || !candidate.complete() {
|
||||
return nil, false, fmt.Errorf("%w: %w", ErrReauthenticationRequired, &LoginError{Code: -1, Message: "刷新Token响应认证信息不完整"})
|
||||
}
|
||||
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||
client.setAuthData(candidate)
|
||||
return nil, true, nil
|
||||
}
|
||||
locationURL, err := url.Parse(data.Location)
|
||||
if err != nil {
|
||||
|
||||
+109
@@ -27,6 +27,15 @@ type failThenBlockWriter struct {
|
||||
blockWrites chan struct{}
|
||||
}
|
||||
|
||||
type shortWriter struct {
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (writer *shortWriter) Write(payload []byte) (int, error) {
|
||||
writer.calls.Add(1)
|
||||
return len(payload) - 1, nil
|
||||
}
|
||||
|
||||
func (writer *failThenBlockWriter) Write(payload []byte) (int, error) {
|
||||
call := writer.calls.Add(1)
|
||||
if call == writer.failOnCall {
|
||||
@@ -62,6 +71,24 @@ func TestRecordingWriterStopsAfterFirstError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingWriterRecordsShortWriteAndStops(t *testing.T) {
|
||||
underlying := &shortWriter{}
|
||||
writer := &recordingWriter{w: underlying}
|
||||
payload := []byte("payload")
|
||||
|
||||
written, err := writer.Write(payload)
|
||||
if written != len(payload)-1 || !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("first Write() = %d, %v, want %d, %v", written, err, len(payload)-1, io.ErrShortWrite)
|
||||
}
|
||||
written, err = writer.Write(payload)
|
||||
if written != 0 || !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("second Write() = %d, %v, want 0, %v", written, err, io.ErrShortWrite)
|
||||
}
|
||||
if calls := underlying.calls.Load(); calls != 1 {
|
||||
t.Fatalf("underlying Write calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServiceResponse(t *testing.T) {
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
@@ -225,6 +252,54 @@ func TestRefreshWithoutNewTokenRequiresReauthentication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshCallbackFailuresDoNotRequireReauthentication(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
wantCode int
|
||||
}{
|
||||
{name: "server error", statusCode: http.StatusServiceUnavailable, body: "temporarily unavailable", wantCode: http.StatusServiceUnavailable},
|
||||
{name: "unexpected body", statusCode: http.StatusOK, body: "pending", wantCode: -1},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/v2/message/v2/check_new_msg":
|
||||
_, _ = io.WriteString(writer, `{"code":-10030,"message":"expired"}`)
|
||||
case "/serviceLogin":
|
||||
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"location":"`+server.URL+`/refresh","ssecurity":"`+testSsecurity+`"}`)
|
||||
case "/refresh":
|
||||
writer.WriteHeader(test.statusCode)
|
||||
_, _ = io.WriteString(writer, test.body)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := testClient(t, server.Client())
|
||||
client.baseURL = server.URL
|
||||
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||
client.availabilityValid = false
|
||||
|
||||
err := client.refreshToken(context.Background())
|
||||
if errors.Is(err, ErrReauthenticationRequired) {
|
||||
t.Fatalf("refreshToken() error = %v, do not want ErrReauthenticationRequired", err)
|
||||
}
|
||||
var loginErr *LoginError
|
||||
if !errors.As(err, &loginErr) {
|
||||
t.Fatalf("refreshToken() error = %v, want LoginError", err)
|
||||
}
|
||||
if loginErr.Code != test.wantCode || !strings.Contains(loginErr.Message, test.body) {
|
||||
t.Fatalf("LoginError = %#v, want code %d containing %q", loginErr, test.wantCode, test.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQRLoginTimeoutDoesNotRequireReauthentication(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
@@ -389,6 +464,40 @@ func TestLoginReturnsQROutputErrorBeforeLongPoll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginReturnsShortQROutputErrorBeforeLongPoll(t *testing.T) {
|
||||
var longPollRequests atomic.Int32
|
||||
qrWriter := &shortWriter{}
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/serviceLogin":
|
||||
_, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+server.URL+`/prepare"}`)
|
||||
case "/loginUrl":
|
||||
_, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"https://qr.example/login","lp":"`+server.URL+`/lp"}`)
|
||||
case "/lp":
|
||||
longPollRequests.Add(1)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(t.TempDir(), WithHTTPClient(server.Client()), WithQRWriter(qrWriter))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.serviceLoginURL = server.URL + "/serviceLogin"
|
||||
client.loginURL = server.URL + "/loginUrl"
|
||||
|
||||
_, err = client.Login(context.Background())
|
||||
if !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("Login() error = %v, want %v", err, io.ErrShortWrite)
|
||||
}
|
||||
if requests := longPollRequests.Load(); requests != 0 {
|
||||
t.Fatalf("long-poll requests = %d, want 0", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQRLoginRejectsIncompleteCallbackWithoutSaving(t *testing.T) {
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
|
||||
@@ -161,7 +161,7 @@ func fetchDeviceInfo(ctx context.Context, httpClient *http.Client, model string)
|
||||
if err != nil {
|
||||
return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err)
|
||||
}
|
||||
if err := validateDeviceInfo(info, model); err != nil {
|
||||
if err := validateDeviceInfo(&info, model); err != nil {
|
||||
return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err)
|
||||
}
|
||||
return info, nil
|
||||
@@ -546,21 +546,27 @@ func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) {
|
||||
}
|
||||
info.Actions = append(info.Actions, action)
|
||||
}
|
||||
if err := validateDeviceInfo(info, model); err != nil {
|
||||
if err := validateDeviceInfo(&info, model); err != nil {
|
||||
return DeviceInfo{}, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func validateDeviceInfo(info DeviceInfo, model string) error {
|
||||
func validateDeviceInfo(info *DeviceInfo, model string) error {
|
||||
if info.Model == "" || info.Model != model {
|
||||
return fmt.Errorf("model %q does not match requested model %q", info.Model, model)
|
||||
}
|
||||
type propertyID struct {
|
||||
siid int
|
||||
piid int
|
||||
}
|
||||
properties := make(map[propertyID]PropertySpec, len(info.Properties))
|
||||
for index, property := range info.Properties {
|
||||
if strings.TrimSpace(property.Name) == "" || !validPropertyType(property.Type) ||
|
||||
(property.RW != "r" && property.RW != "w" && property.RW != "rw") || property.SIID <= 0 || property.PIID <= 0 {
|
||||
return fmt.Errorf("property %d is invalid", index)
|
||||
}
|
||||
properties[propertyID{siid: property.SIID, piid: property.PIID}] = property
|
||||
}
|
||||
for index, action := range info.Actions {
|
||||
if strings.TrimSpace(action.Name) == "" || action.SIID <= 0 || action.AIID <= 0 {
|
||||
@@ -570,6 +576,11 @@ func validateDeviceInfo(info DeviceInfo, model string) error {
|
||||
if !validPropertyType(input.Type) || input.SIID <= 0 || input.PIID <= 0 || input.SIID != action.SIID {
|
||||
return fmt.Errorf("action %d input %d is invalid", index, inputIndex)
|
||||
}
|
||||
property, exists := properties[propertyID{siid: input.SIID, piid: input.PIID}]
|
||||
if !exists {
|
||||
return fmt.Errorf("action %d input %d does not reference a property", index, inputIndex)
|
||||
}
|
||||
info.Actions[index].Inputs[inputIndex] = property
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -267,6 +268,36 @@ func TestInvalidDeviceInfoCacheRefreshesAndOverwrites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) {
|
||||
const cache = `{
|
||||
"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}]}]
|
||||
}`
|
||||
_, err := decodeDeviceInfo([]byte(cache), "test.light.v1")
|
||||
if err == nil || !strings.Contains(err.Error(), "does not reference a property") {
|
||||
t.Fatalf("decodeDeviceInfo() error = %v, want missing property error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDeviceInfoCanonicalizesActionInputMetadata(t *testing.T) {
|
||||
const cache = `{
|
||||
"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}]}]
|
||||
}`
|
||||
info, err := decodeDeviceInfo([]byte(cache), "test.light.v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(info.Actions) != 1 || len(info.Actions[0].Inputs) != 1 {
|
||||
t.Fatalf("actions = %#v", info.Actions)
|
||||
}
|
||||
if got, want := info.Actions[0].Inputs[0], info.Properties[0]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("canonical input = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) {
|
||||
testServer := newDeviceTestServer(t, []byte("unavailable"), nil)
|
||||
testServer.status = http.StatusServiceUnavailable
|
||||
|
||||
Reference in New Issue
Block a user