159 lines
7.9 KiB
Go
159 lines
7.9 KiB
Go
package opsdevice
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"data-terminal-cloud/internal/audit"
|
|
"data-terminal-cloud/internal/deviceauth"
|
|
"data-terminal-cloud/internal/identity"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func TestOperationsDeviceLifecycle(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()
|
|
resolver, err := deviceauth.NewDatabaseSecretResolver(pool, "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service, err := NewService(pool, resolver, audit.NewRepository())
|
|
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: "integration-terminal", HardwareRevision: "rev-test",
|
|
HouseholdID: "10000000-0000-0000-0000-000000000211", SubjectID: "10000000-0000-0000-0000-000000000411",
|
|
VoiceRuntime: "volc_hardware_agent",
|
|
})
|
|
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)
|
|
_, _ = 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 hardware_agent_device_binding WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM device_credential WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
|
_, _ = 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)
|
|
}()
|
|
if len(created.Credential.Secret) != 64 || created.Credential.KeyVersion != 1 || created.Device.SubjectName == nil || *created.Device.SubjectName != "测试长者" ||
|
|
created.Device.ActivationState != "BOUND" ||
|
|
created.Device.VoiceRuntime != "volc_hardware_agent" || created.Device.HardwareAgentBotID != "" || created.Device.HardwareAgentTransport != "" || created.Device.HardwareAgentMinimumSDKVersion != "" || created.Device.HardwareAgentStatus != "" {
|
|
t.Fatalf("unexpected created result: %+v", created)
|
|
}
|
|
if created.Claim != nil {
|
|
t.Fatalf("assigned device unexpectedly received claim: %+v", created.Claim)
|
|
}
|
|
if _, err := service.IssueDeviceClaim(ctx, principal, tenantID, deviceID, "ops-device-integration-bound-claim"); !errors.Is(err, ErrClaimUnavailable) {
|
|
t.Fatalf("bound device claim error=%v", err)
|
|
}
|
|
resolved, err := resolver.Resolve(ctx, "device-secret://"+deviceID)
|
|
if err != nil || !bytes.Equal(resolved, []byte(created.Credential.Secret)) {
|
|
t.Fatalf("created credential did not resolve: err=%v", err)
|
|
}
|
|
model := "integration-terminal-v2"
|
|
status := "quarantined"
|
|
updated, err := service.Update(ctx, principal, tenantID, deviceID, "ops-device-integration-update", created.Device.Version, UpdateInput{Model: &model, Status: &status})
|
|
if err != nil || updated.Model != model || updated.Status != status || updated.Version != created.Device.Version+1 {
|
|
t.Fatalf("unexpected update: %+v err=%v", updated, err)
|
|
}
|
|
rotated, err := service.RotateCredential(ctx, principal, tenantID, deviceID, "ops-device-integration-rotate")
|
|
if err != nil || rotated.KeyVersion != 2 || rotated.Secret == created.Credential.Secret {
|
|
t.Fatalf("unexpected credential rotation: %+v err=%v", rotated, err)
|
|
}
|
|
resolved, err = resolver.Resolve(ctx, "device-secret://"+deviceID)
|
|
if err != nil || !bytes.Equal(resolved, []byte(rotated.Secret)) {
|
|
t.Fatalf("rotated credential did not resolve: err=%v", err)
|
|
}
|
|
retired, err := service.Delete(ctx, principal, tenantID, deviceID, "ops-device-integration-delete", updated.Version)
|
|
if err != nil || retired.Status != "revoked" || retired.ActivationState != "REVOKED" || retired.CredentialStatus != "revoked" || retired.HouseholdID != nil || retired.HardwareAgentStatus != "" {
|
|
t.Fatalf("unexpected delete result: %+v err=%v", retired, err)
|
|
}
|
|
if _, err := resolver.Resolve(ctx, "device-secret://"+deviceID); err == nil {
|
|
t.Fatal("retired device credential remained active")
|
|
}
|
|
}
|
|
|
|
func TestOperationsDeviceClaimIssueAndRotation(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()
|
|
resolver, err := deviceauth.NewDatabaseSecretResolver(pool, "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service, err := NewService(pool, resolver, audit.NewRepository())
|
|
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-claim-integration-create", CreateInput{Model: "unbound-integration-terminal"})
|
|
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)
|
|
_, _ = 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_claim WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM device_credential WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
|
_, _ = 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)
|
|
}()
|
|
if created.Device.ActivationState != "CLAIMABLE" || created.Claim == nil || len(created.Claim.ClaimCode) != 19 || !created.Claim.ExpiresAt.After(created.Claim.IssuedAt) {
|
|
t.Fatalf("unexpected initial claim: %+v", created.Claim)
|
|
}
|
|
firstDigest := sha256.Sum256([]byte(created.Claim.ClaimCode))
|
|
var storedDigest string
|
|
if err := pool.QueryRow(ctx, `SELECT token_digest FROM device_claim WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid) AND revoked_at IS NULL`, deviceID).Scan(&storedDigest); err != nil || storedDigest != hex.EncodeToString(firstDigest[:]) {
|
|
t.Fatalf("stored initial claim digest=%s err=%v", storedDigest, err)
|
|
}
|
|
rotated, err := service.IssueDeviceClaim(ctx, principal, tenantID, deviceID, "ops-device-claim-integration-rotate")
|
|
if err != nil || rotated.ClaimCode == created.Claim.ClaimCode {
|
|
t.Fatalf("rotated claim=%+v err=%v", rotated, err)
|
|
}
|
|
var activeClaims, revokedClaims int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FILTER (WHERE revoked_at IS NULL),count(*) FILTER (WHERE revoked_at IS NOT NULL) FROM device_claim WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID).Scan(&activeClaims, &revokedClaims); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if activeClaims != 1 || revokedClaims != 1 {
|
|
t.Fatalf("claim counts active=%d revoked=%d", activeClaims, revokedClaims)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|