175 lines
7.1 KiB
Go
175 lines
7.1 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"data-terminal-cloud/internal/devicereset"
|
||
|
|
"encoding/json"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
func resetInput() devicereset.Input {
|
||
|
|
return devicereset.Input{ResetID: uuid.NewString(), ResetCounter: 1, OccurredAt: time.Now().UTC(), Trigger: "device_menu", FirmwareVersion: "1.7.0"}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFactoryResetDEVREQ1ClearsSettingsAndReplays(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_twin SET desired_config='{"identity":{"display_name":"旧用户"},"volume":{"speaker":12},"power":{"mode":"standby"}}',reported_config='{"volume":{"speaker":12}}',reported_revision=desired_revision WHERE device_id=$1`, f.d.ID)
|
||
|
|
must(t, err)
|
||
|
|
status, body := f.deviceCall("factory-reset/state", map[string]any{}, "", true)
|
||
|
|
if status != 200 || body["data"].(map[string]any)["reset_counter"] != float64(0) {
|
||
|
|
t.Fatalf("state %d %v", status, body)
|
||
|
|
}
|
||
|
|
in := resetInput()
|
||
|
|
status, body = f.deviceCall("factory-reset", in, "", true)
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatalf("reset %d %v", status, body)
|
||
|
|
}
|
||
|
|
first, _ := json.Marshal(body["data"])
|
||
|
|
result := body["data"].(map[string]any)
|
||
|
|
if result["claim"] != nil || result["activation_state"] != "CLAIMABLE" || result["reset_counter"] != float64(1) {
|
||
|
|
t.Fatalf("result %v", result)
|
||
|
|
}
|
||
|
|
var desired, reported []byte
|
||
|
|
var revision int64
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT desired_config,reported_config,desired_revision FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&desired, &reported, &revision))
|
||
|
|
if string(desired) != "{}" || string(reported) != "{}" {
|
||
|
|
t.Fatal("old settings survived reset")
|
||
|
|
}
|
||
|
|
// Same durable body, fresh signed HTTP nonce/request ID after lost response.
|
||
|
|
status, body = f.deviceCall("factory-reset", in, "", true)
|
||
|
|
second, _ := json.Marshal(body["data"])
|
||
|
|
if status != 200 || !bytes.Equal(first, second) {
|
||
|
|
t.Fatalf("replay %d %v", status, body)
|
||
|
|
}
|
||
|
|
var after int64
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT desired_revision FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&after))
|
||
|
|
if after != revision {
|
||
|
|
t.Fatal("replay reset twice")
|
||
|
|
}
|
||
|
|
in.FirmwareVersion = "different"
|
||
|
|
status, body = f.deviceCall("factory-reset", in, "", true)
|
||
|
|
if status != 409 || body["code"] != "IDEMPOTENCY_KEY_CONFLICT" {
|
||
|
|
t.Fatalf("mutated replay %d %v", status, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFactoryResetRejectsBothOwnershipKindsAndAnonymous(t *testing.T) {
|
||
|
|
for _, kind := range []string{"family", "account"} {
|
||
|
|
t.Run(kind, func(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
if kind == "family" {
|
||
|
|
f.bound()
|
||
|
|
} else {
|
||
|
|
p := accountOnlyUser(t, f)
|
||
|
|
token := accountToken(t, f, p)
|
||
|
|
_, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
|
||
|
|
must(t, err)
|
||
|
|
}
|
||
|
|
for _, path := range []string{"factory-reset/state", "factory-reset"} {
|
||
|
|
var in any = map[string]any{}
|
||
|
|
if path == "factory-reset" {
|
||
|
|
in = resetInput()
|
||
|
|
}
|
||
|
|
status, body := f.deviceCall(path, in, "", true)
|
||
|
|
if status != 409 || body["code"] != "DEVICE_RESET_AUTHORIZATION_REQUIRED" {
|
||
|
|
t.Fatalf("bound %s %d %v", path, status, body)
|
||
|
|
}
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
f.s.RegisterRoutes(mux)
|
||
|
|
r := httptest.NewRequest("POST", "https://platform.example/api/v1/device/"+path, bytes.NewReader(requestJSON(in)))
|
||
|
|
r.Header.Set("X-Request-ID", uuid.NewString())
|
||
|
|
r.Header.Set("Content-Type", "application/json")
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(w, r)
|
||
|
|
if w.Code != 401 {
|
||
|
|
t.Fatalf("anonymous request returned %d", w.Code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var counter int64
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT factory_reset_counter FROM device WHERE id=$1`, f.d.ID).Scan(&counter))
|
||
|
|
if counter != 0 {
|
||
|
|
t.Fatal("refused reset changed device")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFactoryResetRejectsFleetOTAAndDoesNotClearSettings(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
_, err := f.s.pool.Exec(f.ctx, `UPDATE device_twin SET desired_config='{"identity":{"display_name":"保留"}}' WHERE device_id=$1`, f.d.ID)
|
||
|
|
must(t, err)
|
||
|
|
var release string
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `INSERT INTO fleet_firmware(tenant_id,model,version,size,sha256,object_ref,status) VALUES($1,$2,'reset-guard',32,$3,$4,'READY') RETURNING id::text`, f.tenant, f.d.Model, digest([]byte("test")), "test://"+uuid.NewString()).Scan(&release))
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO fleet_ota(device_id,release_id,request_id) VALUES($1,$2,$3)`, f.d.ID, release, uuid.NewString())
|
||
|
|
must(t, err)
|
||
|
|
for _, phase := range []string{"pending", "accepted"} {
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `UPDATE fleet_ota SET status=$2 WHERE device_id=$1`, f.d.ID, phase)
|
||
|
|
must(t, err)
|
||
|
|
status, body := f.deviceCall("factory-reset", resetInput(), "", true)
|
||
|
|
if status != 409 || body["code"] != "DEVICE_RESET_FIRMWARE_IN_PROGRESS" {
|
||
|
|
t.Fatalf("OTA %s: %d %v", phase, status, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var counter int64
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT factory_reset_counter FROM device WHERE id=$1`, f.d.ID).Scan(&counter))
|
||
|
|
if counter != 0 {
|
||
|
|
t.Fatal("OTA denial incremented reset counter")
|
||
|
|
}
|
||
|
|
var name string
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT desired_config->'identity'->>'display_name' FROM device_twin WHERE device_id=$1`, f.d.ID).Scan(&name))
|
||
|
|
if name != "保留" {
|
||
|
|
t.Fatal("refused reset changed settings")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFactoryResetRejectsActiveDirectCalls(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
_, err := f.s.pool.Exec(f.ctx, `INSERT INTO call_session(device_id,tenant_id,boot_id,hook_event_id,call_generation,binding_generation) VALUES($1,$2,$3,'reset-guard',1,0)`, f.d.ID, f.tenant, uuid.NewString())
|
||
|
|
must(t, err)
|
||
|
|
for _, phase := range []string{"PREPARING", "STARTING", "TALKING", "STOPPING"} {
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `UPDATE call_session SET call_state=$2 WHERE device_id=$1`, f.d.ID, phase)
|
||
|
|
must(t, err)
|
||
|
|
status, body := f.deviceCall("factory-reset", resetInput(), "", true)
|
||
|
|
if status != 409 || body["code"] != "DEVICE_RESET_CALL_IN_PROGRESS" {
|
||
|
|
t.Fatalf("call %s: %d %v", phase, status, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `UPDATE call_session SET call_state='ENDED' WHERE device_id=$1`, f.d.ID)
|
||
|
|
must(t, err)
|
||
|
|
status, body := f.deviceCall("factory-reset", resetInput(), "", true)
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatalf("idle reset %d %v", status, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFactoryResetOldReceiptCannotClearNewOwner(t *testing.T) {
|
||
|
|
f := newFixture(t)
|
||
|
|
in := resetInput()
|
||
|
|
status, body := f.deviceCall("factory-reset", in, "", true)
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatalf("reset %d %v", status, body)
|
||
|
|
}
|
||
|
|
// Simulate the next boot's fresh authenticated device snapshot, then use
|
||
|
|
// the real account-first binding flow to establish a new owner.
|
||
|
|
d, err := f.s.findFactoryDevice(f.ctx, f.d.FactoryID, 1)
|
||
|
|
must(t, err)
|
||
|
|
f.d = d
|
||
|
|
p := accountOnlyUser(t, f)
|
||
|
|
token := accountToken(t, f, p)
|
||
|
|
_, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token})
|
||
|
|
must(t, err)
|
||
|
|
status, body = f.deviceCall("factory-reset", in, "", true)
|
||
|
|
if status != 409 || body["code"] != "DEVICE_RESET_AUTHORIZATION_REQUIRED" {
|
||
|
|
t.Fatalf("old receipt %d %v", status, body)
|
||
|
|
}
|
||
|
|
var count int
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM device_account_binding WHERE device_id=$1 AND released_at IS NULL`, f.d.ID).Scan(&count))
|
||
|
|
if count != 1 {
|
||
|
|
t.Fatal("cached reset removed new owner")
|
||
|
|
}
|
||
|
|
}
|