Files
story/internal/onboarding/device_reset_test.go
T

175 lines
7.1 KiB
Go
Raw Normal View History

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")
}
}