package saas import ( "context" "crypto/sha256" "encoding/hex" "errors" "os" "testing" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestDeviceBindingImpactTransferAndUnbind(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) } defer pool.Close() devicePublicID := uuid.NewString() targetSubjectPublicID := uuid.NewString() serialSum := sha256.Sum256([]byte(devicePublicID)) serialDigest := hex.EncodeToString(serialSum[:]) var deviceID, targetSubjectID string if err := pool.QueryRow(ctx, ` INSERT INTO subject_profile (public_id,tenant_id,household_id,preferred_name,status,profile_version) SELECT $1::uuid,t.id,h.id,'绑定转移测试长者','active',1 FROM tenant t JOIN household h ON h.tenant_id=t.id WHERE t.public_id=$2::uuid AND h.public_id=$3::uuid RETURNING id::text`, targetSubjectPublicID, testTenantPublicID, "10000000-0000-0000-0000-000000000211").Scan(&targetSubjectID); err != nil { t.Fatalf("insert target subject: %v", err) } if err := pool.QueryRow(ctx, ` INSERT INTO device (public_id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version) SELECT $1::uuid,id,$2,'binding-test-terminal','test-rev','registered','db://binding-test','test-v1' FROM tenant WHERE public_id=$3::uuid RETURNING id::text`, devicePublicID, serialDigest, testTenantPublicID).Scan(&deviceID); err != nil { t.Fatalf("insert device: %v", err) } if _, err := pool.Exec(ctx, `INSERT INTO device_twin (device_id,tenant_id) SELECT id,tenant_id FROM device WHERE id=$1::uuid`, deviceID); err != nil { t.Fatalf("insert device twin: %v", err) } var sourceBindingID string if err := pool.QueryRow(ctx, ` INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason) SELECT d.tenant_id,d.id,h.id,sp.id,ua.id,'integration-test' FROM device d JOIN household h ON h.tenant_id=d.tenant_id AND h.public_id=$2::uuid JOIN subject_profile sp ON sp.household_id=h.id AND sp.public_id=$3::uuid JOIN user_account ua ON ua.tenant_id=d.tenant_id AND ua.oidc_issuer=$4 AND ua.oidc_subject=$5 WHERE d.id=$1::uuid RETURNING public_id::text`, deviceID, "10000000-0000-0000-0000-000000000211", "10000000-0000-0000-0000-000000000411", testIssuer, testOwnerSubject).Scan(&sourceBindingID); err != nil { t.Fatalf("insert source binding: %v", err) } defer func() { _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'device-binding-integration-%'`) _, _ = pool.Exec(ctx, `DELETE FROM device_binding 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) _, _ = pool.Exec(ctx, `DELETE FROM subject_profile WHERE id=$1::uuid`, targetSubjectID) }() service := NewService(pool, audit.NewRepository(), nil, nil) owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject} impact, err := service.GetDeviceBindingImpact(ctx, owner, devicePublicID, DeviceBindingActionTransfer, targetSubjectPublicID) if err != nil || !impact.Allowed || impact.BindingID != sourceBindingID || impact.TargetSubjectID == nil || *impact.TargetSubjectID != targetSubjectPublicID { t.Fatalf("unexpected transfer impact: %+v err=%v", impact, err) } if _, err := service.ChangeDeviceBinding(ctx, owner, devicePublicID, "device-binding-integration-stale", ChangeDeviceBindingInput{ Action: DeviceBindingActionTransfer, ExpectedBindingID: uuid.NewString(), TargetSubjectID: targetSubjectPublicID, Reason: "stale review", Acknowledged: true, }); !errors.Is(err, ErrDeviceBindingStale) { t.Fatalf("stale binding error=%v", err) } transferred, err := service.ChangeDeviceBinding(ctx, owner, devicePublicID, "device-binding-integration-transfer", ChangeDeviceBindingInput{ Action: DeviceBindingActionTransfer, ExpectedBindingID: impact.BindingID, TargetSubjectID: targetSubjectPublicID, Reason: "change assigned elder", Acknowledged: true, }) if err != nil || transferred.BindingID == nil || transferred.SubjectID == nil || *transferred.SubjectID != targetSubjectPublicID { t.Fatalf("transfer result=%+v err=%v", transferred, err) } device, err := service.GetDevice(ctx, owner, devicePublicID) if err != nil || device.BindingID != *transferred.BindingID || device.SubjectID != targetSubjectPublicID { t.Fatalf("transferred device=%+v err=%v", device, err) } unbindImpact, err := service.GetDeviceBindingImpact(ctx, owner, devicePublicID, DeviceBindingActionUnbind, "") if err != nil || !unbindImpact.Allowed { t.Fatalf("unbind impact=%+v err=%v", unbindImpact, err) } unbound, err := service.ChangeDeviceBinding(ctx, owner, devicePublicID, "device-binding-integration-unbind", ChangeDeviceBindingInput{ Action: DeviceBindingActionUnbind, ExpectedBindingID: unbindImpact.BindingID, Reason: "device removed from service", Acknowledged: true, }) if err != nil || unbound.BindingID != nil { t.Fatalf("unbind result=%+v err=%v", unbound, err) } if _, err := service.GetDevice(ctx, owner, devicePublicID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("unbound device should no longer be visible, error=%v", err) } }