Files

123 lines
5.9 KiB
Go
Raw Permalink Normal View History

2026-08-29 21:56:51 +08:00
package opsdevice
import (
"context"
"encoding/hex"
"errors"
"os"
"regexp"
"strings"
2026-08-29 21:56:51 +08:00
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/devicebinding"
2026-08-29 21:56:51 +08:00
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestOperationsDeviceCreatesPhysicalConfirmIdentity(t *testing.T) {
2026-08-29 21:56:51 +08:00
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()
resolver, err := deviceauth.NewDatabaseSecretResolver(pool, "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
if err != nil {
t.Fatal(err)
}
service, err := NewService(pool, resolver, audit.NewRepository(), Options{
OnboardingMasterKey: strings.Repeat("15", 32),
DeviceOrigin: "https://platform.example/onboarding/entry",
2026-08-29 21:56:51 +08:00
})
if err != nil {
t.Fatal(err)
}
principal := integrationOperatorPrincipal(t, ctx, pool)
const tenantID = "10000000-0000-0000-0000-000000000010"
created, err := service.Create(ctx, principal, tenantID, "ops-device-integration-create", CreateInput{Model: "ECHOEAR-S3"})
2026-08-29 21:56:51 +08:00
if err != nil {
t.Fatal(err)
}
deviceID := created.Device.ID
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, deviceID)
2026-09-06 17:04:11 +08:00
_, _ = pool.Exec(ctx, `DELETE FROM device_activation_transition WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
_, _ = pool.Exec(ctx, `DELETE FROM device_onboarding_key WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
2026-08-29 21:56:51 +08:00
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE public_id=$1::uuid`, deviceID)
}()
credential := created.Credential
if !regexp.MustCompile(`^KP01-[0-9A-F]{12}$`).MatchString(credential.FactoryID) || credential.FactoryID != created.Device.FactoryID || credential.Model != "ECHOEAR-S3" || credential.Origin != "https://platform.example" || credential.KeyVersion != 1 {
t.Fatalf("unexpected burn-in identity: %+v", credential)
2026-08-29 21:56:51 +08:00
}
key, err := hex.DecodeString(credential.DeviceKeyHex)
if err != nil || len(key) != 32 {
t.Fatalf("invalid device key: len=%d err=%v", len(key), err)
2026-08-29 21:56:51 +08:00
}
if created.Device.EnrollmentMode != "physical_confirm" || created.Device.ActivationState != "CLAIMABLE" || created.Device.Bound || created.Device.CredentialStatus != "active" || created.Device.KeyVersion != 1 {
t.Fatalf("unexpected created device: %+v", created.Device)
2026-08-29 21:56:51 +08:00
}
var released bool
var desiredConfig string
var activeKeys, legacyCredentials, claims, bindings int
if err := pool.QueryRow(ctx, `SELECT onboarding_released_at IS NOT NULL FROM device WHERE public_id=$1::uuid`, deviceID).Scan(&released); err != nil {
2026-08-29 21:56:51 +08:00
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT desired_config::text FROM device_twin WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID).Scan(&desiredConfig); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT
(SELECT count(*) FROM device_onboarding_key WHERE device_id=d.id AND status='ACTIVE'),
(SELECT count(*) FROM device_credential WHERE device_id=d.id),
(SELECT count(*) FROM device_claim WHERE device_id=d.id),
(SELECT count(*) FROM device_binding WHERE device_id=d.id AND unbound_at IS NULL)
FROM device d WHERE d.public_id=$1::uuid`, deviceID).Scan(&activeKeys, &legacyCredentials, &claims, &bindings); err != nil {
t.Fatal(err)
}
if !released || desiredConfig != "{}" || activeKeys != 1 || legacyCredentials != 0 || claims != 0 || bindings != 0 {
t.Fatalf("device was not created as direct BLE inventory: released=%v desired=%s active_keys=%d legacy_credentials=%d claims=%d bindings=%d", released, desiredConfig, activeKeys, legacyCredentials, claims, bindings)
}
if _, err := service.IssueDeviceClaim(ctx, principal, tenantID, deviceID, "ops-device-integration-legacy-claim"); !errors.Is(err, devicebinding.ErrConfirmationRequired) {
t.Fatalf("physical-confirm device accepted a legacy claim: %v", err)
}
rotated, err := service.RotateCredential(ctx, principal, tenantID, deviceID, "ops-device-integration-rotate")
if err != nil || rotated.KeyVersion != 2 || rotated.DeviceKeyHex == credential.DeviceKeyHex || rotated.FactoryID != credential.FactoryID {
t.Fatalf("unexpected credential rotation: %+v err=%v", rotated, err)
}
var activeVersion, revokedKeys int
if err := pool.QueryRow(ctx, `SELECT max(key_version) FILTER (WHERE status='ACTIVE'),count(*) FILTER (WHERE status='REVOKED') FROM device_onboarding_key WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID).Scan(&activeVersion, &revokedKeys); err != nil {
t.Fatal(err)
}
if activeVersion != 2 || revokedKeys != 1 {
t.Fatalf("unexpected rotated key state: active=%d revoked=%d", activeVersion, revokedKeys)
}
var version int64
if err := pool.QueryRow(ctx, `SELECT version FROM device WHERE public_id=$1::uuid`, deviceID).Scan(&version); err != nil {
t.Fatal(err)
}
retired, err := service.Delete(ctx, principal, tenantID, deviceID, "ops-device-integration-delete", version)
if err != nil || retired.Status != "revoked" || retired.ActivationState != "REVOKED" || retired.CredentialStatus != "missing" {
t.Fatalf("unexpected delete result: %+v err=%v", retired, err)
2026-08-29 21:56:51 +08:00
}
}
func integrationOperatorPrincipal(t *testing.T, ctx context.Context, pool *pgxpool.Pool) identity.Principal {
t.Helper()
var principal identity.Principal
if err := pool.QueryRow(ctx, `SELECT oidc_issuer,oidc_subject FROM user_account WHERE public_id='10000000-0000-0000-0000-000000000114'::uuid AND status='active' AND account_kind='operator'`).Scan(&principal.Issuer, &principal.Subject); err != nil {
t.Fatalf("resolve integration operator: %v", err)
}
return principal
}