feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user