@@ -0,0 +1,385 @@
|
||||
package devicereset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("device factory reset input is invalid")
|
||||
ErrStateConflict = errors.New("device state does not allow factory reset")
|
||||
ErrResetCounterConflict = errors.New("device factory reset counter is stale")
|
||||
ErrFirmwareInProgress = errors.New("device firmware update is in progress")
|
||||
ErrPendingTranscripts = errors.New("device has pending transcripts")
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
ResetID string `json:"reset_id"`
|
||||
ResetCounter int64 `json:"reset_counter"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Trigger string `json:"trigger"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
PendingTurnCount int `json:"pending_turn_count"`
|
||||
ActiveConversationID string `json:"active_conversation_id,omitempty"`
|
||||
}
|
||||
|
||||
type Claim struct {
|
||||
Code string `json:"code"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ResetID string `json:"reset_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ActivationState string `json:"activation_state"`
|
||||
ResetCounter int64 `json:"reset_counter"`
|
||||
BindingRemoved bool `json:"binding_removed"`
|
||||
PreviousBindingID string `json:"previous_binding_id,omitempty"`
|
||||
ProviderCredentialStatus string `json:"provider_credential_status"`
|
||||
LicenseAction string `json:"license_action"`
|
||||
EndedConversationCount int `json:"ended_conversation_count"`
|
||||
CancelledCommandCount int64 `json:"cancelled_command_count"`
|
||||
ConfigRevision int64 `json:"config_revision"`
|
||||
Claim Claim `json:"claim"`
|
||||
LocalActions []string `json:"local_actions"`
|
||||
ResetAt time.Time `json:"reset_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
executor *idempotency.Executor
|
||||
audit *audit.Repository
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, executor *idempotency.Executor, auditRepository *audit.Repository) (*Service, error) {
|
||||
if pool == nil || executor == nil || auditRepository == nil {
|
||||
return nil, errors.New("device factory reset dependencies are required")
|
||||
}
|
||||
return &Service{pool: pool, executor: executor, audit: auditRepository}, nil
|
||||
}
|
||||
|
||||
func Validate(input Input) error {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(input.ResetID)); err != nil || input.ResetCounter < 1 || input.OccurredAt.IsZero() ||
|
||||
input.OccurredAt.After(time.Now().UTC().Add(5*time.Minute)) || len(strings.TrimSpace(input.FirmwareVersion)) < 1 ||
|
||||
len(strings.TrimSpace(input.FirmwareVersion)) > 80 || input.PendingTurnCount < 0 {
|
||||
return ErrInvalid
|
||||
}
|
||||
if input.Trigger != "physical_long_press" && input.Trigger != "device_menu" && input.Trigger != "service_tool" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if input.ActiveConversationID != "" {
|
||||
if _, err := uuid.Parse(input.ActiveConversationID); err != nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Reset(ctx context.Context, identity deviceauth.Identity, idempotencyKey, requestID string, rawRequest []byte, input Input) (Response, bool, error) {
|
||||
if err := Validate(input); err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
if input.PendingTurnCount > 0 {
|
||||
return Response{}, false, ErrPendingTranscripts
|
||||
}
|
||||
tenantID := identity.TenantID
|
||||
result, err := s.executor.Execute(ctx, idempotency.Request{
|
||||
TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
|
||||
Scope: "POST /device/v3/factory-reset", Key: idempotencyKey,
|
||||
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 7 * 24 * time.Hour,
|
||||
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
response, resetErr := s.reset(operationContext, tx, identity, requestID, input)
|
||||
if resetErr != nil {
|
||||
return idempotency.Result{}, resetErr
|
||||
}
|
||||
body, marshalErr := json.Marshal(response)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, marshalErr
|
||||
}
|
||||
resourceID := identity.PublicID
|
||||
return idempotency.Result{StatusCode: http.StatusOK, Body: body, ResourceID: &resourceID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
var response Response
|
||||
if err := json.Unmarshal(result.Body, &response); err != nil {
|
||||
return Response{}, result.Replayed, fmt.Errorf("decode device factory reset response: %w", err)
|
||||
}
|
||||
return response, result.Replayed, nil
|
||||
}
|
||||
|
||||
func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string, input Input) (Response, error) {
|
||||
var activationState string
|
||||
var currentCounter int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT activation_state,factory_reset_counter
|
||||
FROM device
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid AND status='registered'
|
||||
FOR UPDATE`, identity.ID, identity.TenantID).Scan(&activationState, ¤tCounter); err != nil {
|
||||
return Response{}, fmt.Errorf("lock device for factory reset: %w", err)
|
||||
}
|
||||
if activationState != string(deviceactivation.Claimable) && activationState != string(deviceactivation.Bound) &&
|
||||
activationState != string(deviceactivation.Provisioning) && activationState != string(deviceactivation.Active) {
|
||||
return Response{}, ErrStateConflict
|
||||
}
|
||||
if input.ResetCounter <= currentCounter {
|
||||
return Response{}, ErrResetCounterConflict
|
||||
}
|
||||
|
||||
var activeFirmware int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM firmware_assignment
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('ASSIGNED','DOWNLOADING','INSTALLING')`,
|
||||
identity.ID, identity.TenantID).Scan(&activeFirmware); err != nil {
|
||||
return Response{}, fmt.Errorf("check active firmware update: %w", err)
|
||||
}
|
||||
if activeFirmware > 0 {
|
||||
return Response{}, ErrFirmwareInProgress
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
endedConversations, err := endActiveConversations(ctx, tx, identity, requestID, now)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
var previousBindingID, previousHouseholdID, previousSubjectID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE device_binding
|
||||
SET unbound_at=$3,unbound_by=NULL,reason='device_factory_reset'
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL
|
||||
RETURNING public_id::text,household_id::text,subject_id::text`, identity.ID, identity.TenantID, now).
|
||||
Scan(&previousBindingID, &previousHouseholdID, &previousSubjectID)
|
||||
bindingRemoved := err == nil
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Response{}, fmt.Errorf("close device binding for factory reset: %w", err)
|
||||
}
|
||||
if bindingRemoved {
|
||||
if err := notifyPreviousOwner(ctx, tx, identity, previousHouseholdID, previousSubjectID, now); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device_claim SET revoked_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND used_at IS NULL AND revoked_at IS NULL`,
|
||||
identity.ID, identity.TenantID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("revoke old device claims: %w", err)
|
||||
}
|
||||
|
||||
providerStatus := "NOT_PROVISIONED"
|
||||
if tag, err := tx.Exec(ctx, `
|
||||
UPDATE hardware_agent_provider_credential
|
||||
SET status='SUSPENDED',updated_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status='ACTIVE'`, identity.ID, identity.TenantID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("suspend provider credential: %w", err)
|
||||
} else if tag.RowsAffected() > 0 {
|
||||
providerStatus = "SUSPENDED"
|
||||
} else if err := tx.QueryRow(ctx, `SELECT status FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, identity.ID).Scan(&providerStatus); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Response{}, fmt.Errorf("read provider credential status: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=$2 WHERE device_id=$1::uuid AND status='ACTIVE'`, identity.ID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("disable hardware agent binding: %w", err)
|
||||
}
|
||||
|
||||
var cancelledCommands int64
|
||||
if tag, err := tx.Exec(ctx, `
|
||||
UPDATE device_command
|
||||
SET status='CANCELLED',result_summary='{"error_code":"DEVICE_FACTORY_RESET"}'::jsonb,
|
||||
completed_at=$3,version=version+1,updated_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED')`,
|
||||
identity.ID, identity.TenantID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("cancel device commands: %w", err)
|
||||
} else {
|
||||
cancelledCommands = tag.RowsAffected()
|
||||
}
|
||||
|
||||
if err := transitionToClaimable(ctx, tx, identity, requestID); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
var configRevision int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE device_twin
|
||||
SET desired_revision=desired_revision+1,reported_revision=NULL,reported_config='{}'::jsonb,
|
||||
capabilities='{}'::jsonb,updated_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid
|
||||
RETURNING desired_revision`, identity.ID, identity.TenantID, now).Scan(&configRevision); err != nil {
|
||||
return Response{}, fmt.Errorf("reset device twin: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device SET factory_reset_counter=$3,version=version+1,updated_at=$4
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid`, identity.ID, identity.TenantID, input.ResetCounter, now); err != nil {
|
||||
return Response{}, fmt.Errorf("record factory reset counter: %w", err)
|
||||
}
|
||||
|
||||
claim, err := issueClaim(ctx, tx, identity, now)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
response := Response{
|
||||
ResetID: input.ResetID, DeviceID: identity.PublicID, ActivationState: string(deviceactivation.Claimable),
|
||||
ResetCounter: input.ResetCounter, BindingRemoved: bindingRemoved, PreviousBindingID: previousBindingID,
|
||||
ProviderCredentialStatus: providerStatus, LicenseAction: "RETAINED",
|
||||
EndedConversationCount: endedConversations, CancelledCommandCount: cancelledCommands,
|
||||
ConfigRevision: configRevision, Claim: claim, ResetAt: now,
|
||||
LocalActions: []string{"clear_volc_credential", "clear_user_context", "clear_session_state", "clear_network_credentials", "reboot_to_claimable"},
|
||||
}
|
||||
actorID := identity.PublicID
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{
|
||||
TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &actorID,
|
||||
Action: "device.factory_reset", ResourceType: "device", ResourceID: &actorID,
|
||||
RequestID: requestID, Result: "success", Detail: map[string]any{
|
||||
"reset_id": input.ResetID, "reset_counter": input.ResetCounter, "trigger": input.Trigger,
|
||||
"firmware_version": input.FirmwareVersion, "previous_binding_id": previousBindingID,
|
||||
"device_reported_conversation_id": input.ActiveConversationID,
|
||||
"ended_conversation_count": endedConversations, "cancelled_command_count": cancelledCommands,
|
||||
"provider_credential_status": providerStatus, "license_action": "RETAINED",
|
||||
},
|
||||
}); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func notifyPreviousOwner(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, householdID, subjectID string, now time.Time) error {
|
||||
var wasOnline bool
|
||||
err := tx.QueryRow(ctx, `
|
||||
UPDATE device_presence_state SET online=false,changed_at=$4
|
||||
WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND household_id=$3::uuid AND online
|
||||
RETURNING true`, identity.TenantID, identity.ID, householdID, now).Scan(&wasOnline)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("mark factory-reset device offline: %w", err)
|
||||
}
|
||||
if wasOnline {
|
||||
if _, err := tx.Exec(ctx, `SELECT append_web_event($1::uuid,$2::uuid,$3::uuid,'device.presence.changed','device',$4::uuid,'offline')`,
|
||||
identity.TenantID, householdID, subjectID, identity.PublicID); err != nil {
|
||||
return fmt.Errorf("publish factory-reset presence event: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT append_web_event($1::uuid,$2::uuid,$3::uuid,'device.binding.reset','device',$4::uuid,'CLAIMABLE')`,
|
||||
identity.TenantID, householdID, subjectID, identity.PublicID); err != nil {
|
||||
return fmt.Errorf("publish factory-reset binding event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func endActiveConversations(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string, now time.Time) (int, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id::text,status FROM conversation
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')
|
||||
FOR UPDATE`, identity.ID, identity.TenantID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("lock active conversations for factory reset: %w", err)
|
||||
}
|
||||
type activeConversation struct{ id, status string }
|
||||
items := make([]activeConversation, 0)
|
||||
for rows.Next() {
|
||||
var item activeConversation
|
||||
if err := rows.Scan(&item.id, &item.status); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE conversation SET status='FAILED',ended_at=$2,stop_reason='device_factory_reset',
|
||||
failure_code='DEVICE_FACTORY_RESET',version=version+1,updated_at=$2
|
||||
WHERE id=$1::uuid`, item.id, now); err != nil {
|
||||
return 0, fmt.Errorf("fail conversation during factory reset: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,request_id,occurred_at)
|
||||
VALUES($1::uuid,$2,'FAILED','device factory reset','device',NULLIF($3,''),$4)`, item.id, item.status, requestID, now); err != nil {
|
||||
return 0, fmt.Errorf("record factory reset conversation transition: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE hardware_agent_session SET status='FAILED',provider_ended_at=COALESCE(provider_ended_at,$2),updated_at=$2
|
||||
WHERE conversation_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')`, item.id, now); err != nil {
|
||||
return 0, fmt.Errorf("fail hardware agent session during factory reset: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE rtc_task SET status='FAILED',updated_at=$2
|
||||
WHERE conversation_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED')`, item.id, now); err != nil {
|
||||
return 0, fmt.Errorf("fail rtc task during factory reset: %w", err)
|
||||
}
|
||||
}
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
func transitionToClaimable(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string) error {
|
||||
var stateText string
|
||||
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&stateText); err != nil {
|
||||
return fmt.Errorf("read device activation state after reset cleanup: %w", err)
|
||||
}
|
||||
state, err := deviceactivation.Parse(stateText)
|
||||
if err != nil {
|
||||
return ErrStateConflict
|
||||
}
|
||||
actorID := identity.PublicID
|
||||
apply := func(target deviceactivation.State, reason string) error {
|
||||
_, applyErr := deviceactivation.Apply(ctx, tx, deviceactivation.Input{
|
||||
DeviceID: identity.ID, TenantID: identity.TenantID, Target: target,
|
||||
Reason: reason, Source: "device-factory-reset", ActorType: "device",
|
||||
ActorPublicID: &actorID, RequestID: requestID,
|
||||
})
|
||||
return applyErr
|
||||
}
|
||||
if state == deviceactivation.Provisioning {
|
||||
if err := apply(deviceactivation.Bound, "设备恢复出厂,结束预配"); err != nil {
|
||||
return fmt.Errorf("return provisioning device to bound state: %w", err)
|
||||
}
|
||||
state = deviceactivation.Bound
|
||||
}
|
||||
if state != deviceactivation.Claimable {
|
||||
if state != deviceactivation.Bound && state != deviceactivation.Active && state != deviceactivation.Lost {
|
||||
return ErrStateConflict
|
||||
}
|
||||
if err := apply(deviceactivation.Claimable, "设备恢复出厂并解除用户绑定"); err != nil {
|
||||
return fmt.Errorf("make factory-reset device claimable: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func issueClaim(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, issuedAt time.Time) (Claim, error) {
|
||||
randomBytes := make([]byte, 10)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return Claim{}, fmt.Errorf("generate factory reset claim: %w", err)
|
||||
}
|
||||
rawCode := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
|
||||
code := strings.Join([]string{rawCode[0:4], rawCode[4:8], rawCode[8:12], rawCode[12:16]}, "-")
|
||||
digest := sha256.Sum256([]byte(code))
|
||||
expiresAt := issuedAt.Add(24 * time.Hour)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO device_claim(tenant_id,device_id,token_digest,expires_at,created_at)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,$5)`, identity.TenantID, identity.ID, hex.EncodeToString(digest[:]), expiresAt, issuedAt); err != nil {
|
||||
return Claim{}, fmt.Errorf("insert factory reset claim: %w", err)
|
||||
}
|
||||
return Claim{Code: code, IssuedAt: issuedAt, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package devicereset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestFactoryResetUnbindsAndSuspendsProviderCredential(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
deviceID := uuid.NewString()
|
||||
devicePublicID := uuid.NewString()
|
||||
serial := sha256.Sum256([]byte(devicePublicID))
|
||||
const tenantID = "10000000-0000-0000-0000-000000000001"
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref,activation_state,activation_reason)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,$4,'factory-reset-test','registered',$5,'ACTIVE','integration-test')`,
|
||||
deviceID, devicePublicID, tenantID, hex.EncodeToString(serial[:]), "test://"+devicePublicID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO device_twin(tenant_id,device_id,desired_revision,desired_config) VALUES($1::uuid,$2::uuid,2,'{"voice_runtime":"volc_hardware_agent"}'::jsonb)`, tenantID, deviceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,reason)
|
||||
SELECT $1::uuid,$2::uuid,h.id,sp.id,'factory-reset-integration'
|
||||
FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
|
||||
WHERE h.public_id='10000000-0000-0000-0000-000000000211'::uuid
|
||||
AND sp.public_id='10000000-0000-0000-0000-000000000411'::uuid`, tenantID, deviceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO device_presence_state(tenant_id,device_id,household_id,subject_id,online,last_seen_at,changed_at)
|
||||
SELECT $1::uuid,$2::uuid,h.id,sp.id,true,now(),now()
|
||||
FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
|
||||
WHERE h.public_id='10000000-0000-0000-0000-000000000211'::uuid
|
||||
AND sp.public_id='10000000-0000-0000-0000-000000000411'::uuid`, tenantID, deviceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO hardware_agent_device_binding(tenant_id,device_id,product_key,provider_device_ref,credential_ref,bot_id,transport,status)
|
||||
VALUES($1::uuid,$2::uuid,'product-test','device-test',$3,'bot-test','low_load','ACTIVE')`, tenantID, deviceID, "volc-device-secret://"+devicePublicID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO hardware_agent_provider_credential(tenant_id,device_id,credential_ref,instance_id,product_key,provider_device_name,hardware_id,encrypted_secret,nonce,status)
|
||||
VALUES($1::uuid,$2::uuid,$3,'instance-test','product-test','device-test','hardware-test',$4,$5,'ACTIVE')`,
|
||||
tenantID, deviceID, "volc-device-secret://"+devicePublicID, []byte("encrypted"), []byte("nonce")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE actor_key=$1`, "device:"+devicePublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, devicePublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM web_event WHERE resource_public_id=$1::uuid`, devicePublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_presence_state WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_claim WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM hardware_agent_device_binding WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_activation_transition WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE id=$1::uuid`, deviceID)
|
||||
})
|
||||
|
||||
service, err := NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := Input{
|
||||
ResetID: uuid.NewString(), ResetCounter: 1, OccurredAt: time.Now().UTC(),
|
||||
Trigger: "physical_long_press", FirmwareVersion: "1.1.0", PendingTurnCount: 0,
|
||||
}
|
||||
raw, _ := json.Marshal(input)
|
||||
identity := deviceauth.Identity{ID: deviceID, PublicID: devicePublicID, TenantID: tenantID, Status: "registered", ActivationState: "ACTIVE"}
|
||||
first, replayed, err := service.Reset(ctx, identity, "factory-reset-integration-key", "factory-reset-integration-request", raw, input)
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("first reset response=%+v replayed=%v err=%v", first, replayed, err)
|
||||
}
|
||||
second, replayed, err := service.Reset(ctx, identity, "factory-reset-integration-key", "factory-reset-integration-request-replay", raw, input)
|
||||
if err != nil || !replayed || second.Claim.Code != first.Claim.Code {
|
||||
t.Fatalf("replay response=%+v replayed=%v err=%v", second, replayed, err)
|
||||
}
|
||||
var state, providerStatus, bindingStatus string
|
||||
var resetCounter int64
|
||||
var activeBindings, bindingResetEvents int
|
||||
var online bool
|
||||
if err := pool.QueryRow(ctx, `SELECT activation_state,factory_reset_counter FROM device WHERE id=$1::uuid`, deviceID).Scan(&state, &resetCounter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL`, deviceID).Scan(&activeBindings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, deviceID).Scan(&providerStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM hardware_agent_device_binding WHERE device_id=$1::uuid`, deviceID).Scan(&bindingStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT online FROM device_presence_state WHERE device_id=$1::uuid`, deviceID).Scan(&online); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM web_event WHERE resource_public_id=$1::uuid AND event_type='device.binding.reset'`, devicePublicID).Scan(&bindingResetEvents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "CLAIMABLE" || resetCounter != 1 || activeBindings != 0 || providerStatus != "SUSPENDED" || bindingStatus != "DISABLED" ||
|
||||
online || bindingResetEvents != 1 || !first.BindingRemoved || first.LicenseAction != "RETAINED" || first.Claim.Code == "" {
|
||||
t.Fatalf("reset state=%s counter=%d bindings=%d provider=%s binding=%s online=%v reset_events=%d response=%+v", state, resetCounter, activeBindings, providerStatus, bindingStatus, online, bindingResetEvents, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryResetRejectsPendingTranscriptsBeforeDatabaseWork(t *testing.T) {
|
||||
service := &Service{}
|
||||
input := Input{ResetID: uuid.NewString(), ResetCounter: 1, OccurredAt: time.Now().UTC(), Trigger: "device_menu", FirmwareVersion: "1.0.0", PendingTurnCount: 1}
|
||||
_, _, err := service.Reset(context.Background(), deviceauth.Identity{}, "factory-reset-pending-turns", "request", []byte(`{}`), input)
|
||||
if !errors.Is(err, ErrPendingTranscripts) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user