using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public sealed class OpenClawDeviceIdentityAndAuditTests { private const string TraceParent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; [Fact] public async Task DeviceIdentity_IsStableSignsCanonicalPayloadAndPersistsDeviceToken() { var testDirectory = CreateTestDirectory(); try { var statePath = Path.Combine(testDirectory, "device.json"); var options = Options.Create(new GatewayConnectorOptions { DeviceStatePath = statePath }); var firstStore = new OpenClawDeviceIdentityStore( options, NullLogger.Instance); var firstIdentity = await firstStore.LoadOrCreateAsync(); var payload = OpenClawGatewayProtocol.BuildDeviceAuthPayloadV3( firstIdentity.DeviceId, "gateway-client", "backend", "operator", ["operator.read"], 1_737_264_000_000, "bootstrap-token", "nonce-1", "linux", "server"); var signature = firstIdentity.Sign(payload); Assert.Equal(64, firstIdentity.DeviceId.Length); Assert.True(firstIdentity.Verify(payload, signature)); await firstStore.StoreTokenAsync( firstIdentity.DeviceId, "operator", "gateway-binding-1", "paired-device-token", ["operator.read"]); var secondStore = new OpenClawDeviceIdentityStore( options, NullLogger.Instance); var secondIdentity = await secondStore.LoadOrCreateAsync(); var token = await secondStore.LoadTokenAsync( secondIdentity.DeviceId, "operator", "gateway-binding-1"); var wrongGatewayToken = await secondStore.LoadTokenAsync( secondIdentity.DeviceId, "operator", "gateway-binding-2"); Assert.Equal(firstIdentity.DeviceId, secondIdentity.DeviceId); Assert.Equal(firstIdentity.PublicKey, secondIdentity.PublicKey); Assert.NotNull(token); Assert.Equal("paired-device-token", token!.Token); Assert.Equal(["operator.read"], token.Scopes); Assert.Equal("gateway-binding-1", token.GatewayBinding); Assert.Null(wrongGatewayToken); Assert.True(await secondStore.RemoveTokenAsync( secondIdentity.DeviceId, "operator", "gateway-binding-1")); var detachedStore = new OpenClawDeviceIdentityStore( options, NullLogger.Instance); Assert.Null(await detachedStore.LoadTokenAsync( secondIdentity.DeviceId, "operator", "gateway-binding-1")); } finally { DeleteTestDirectory(testDirectory); } } [Fact] public async Task DeviceIdentity_CorruptStateFailsClosedInsteadOfRotatingIdentity() { var testDirectory = CreateTestDirectory(); try { var statePath = Path.Combine(testDirectory, "device.json"); await File.WriteAllTextAsync(statePath, """{ "schemaVersion": 1, "deviceId": "wrong" }"""); var store = new OpenClawDeviceIdentityStore( Options.Create(new GatewayConnectorOptions { DeviceStatePath = statePath }), NullLogger.Instance); await Assert.ThrowsAsync(() => store.LoadOrCreateAsync()); var unchanged = await File.ReadAllTextAsync(statePath); Assert.Contains("\"wrong\"", unchanged, StringComparison.Ordinal); } finally { DeleteTestDirectory(testDirectory); } } [Fact] public async Task OperationAudit_ReplaysCompletedKeyAcrossStoreRestartWithoutRawKey() { var testDirectory = CreateTestDirectory(); try { var auditPath = Path.Combine(testDirectory, "operations.jsonl"); var options = Options.Create(new GatewayConnectorOptions { OperationAuditPath = auditPath, DeviceStatePath = Path.Combine(testDirectory, "device.json") }); var context = OpenClawInvocationContext.Create( actor: "owner-1", idempotencyKey: "client-secret-shaped-idempotency-value", correlationId: "corr-1", traceParent: TraceParent); var operation = new OpenClawOperationDescriptor( "tasks.cancel", "task", "task-1", OpenClawInvocationContextFactory.Hash("tasks.cancel|task-1|reason")); var firstStore = new OpenClawOperationAuditStore(options); var firstClaim = await firstStore.ClaimAsync(context, operation); Assert.Equal(OpenClawOperationClaimDisposition.Started, firstClaim.Disposition); await firstStore.CompleteAsync( context, operation, ok: true, state: "completed", message: "Task cancelled."); var restartedStore = new OpenClawOperationAuditStore(options); var replay = await restartedStore.ClaimAsync(context, operation); Assert.Equal(OpenClawOperationClaimDisposition.Replayed, replay.Disposition); Assert.True(replay.PreviousOk); var persisted = await File.ReadAllTextAsync(auditPath); Assert.DoesNotContain(context.IdempotencyKey, persisted, StringComparison.Ordinal); Assert.DoesNotContain("token", persisted, StringComparison.OrdinalIgnoreCase); } finally { DeleteTestDirectory(testDirectory); } } [Fact] public async Task OperationAudit_RejectsKeyReuseForDifferentIntentAndBlocksInDoubtRetry() { var testDirectory = CreateTestDirectory(); try { var options = Options.Create(new GatewayConnectorOptions { OperationAuditPath = Path.Combine(testDirectory, "operations.jsonl"), DeviceStatePath = Path.Combine(testDirectory, "device.json") }); var context = OpenClawInvocationContext.Create( idempotencyKey: "idem-1", correlationId: "corr-1", traceParent: TraceParent); var first = new OpenClawOperationDescriptor( "cron.run", "cron-job", "job-1", OpenClawInvocationContextFactory.Hash("force")); var conflict = first with { IntentFingerprint = OpenClawInvocationContextFactory.Hash("different") }; var store = new OpenClawOperationAuditStore(options); Assert.Equal( OpenClawOperationClaimDisposition.Started, (await store.ClaimAsync(context, first)).Disposition); Assert.Equal( OpenClawOperationClaimDisposition.Conflict, (await store.ClaimAsync(context, conflict)).Disposition); var restartedStore = new OpenClawOperationAuditStore(options); Assert.Equal( OpenClawOperationClaimDisposition.InDoubt, (await restartedStore.ClaimAsync(context, first)).Disposition); } finally { DeleteTestDirectory(testDirectory); } } private static string CreateTestDirectory() { var root = Path.Combine(Path.GetTempPath(), "nexus-openclaw-tests"); Directory.CreateDirectory(root); var path = Path.Combine(root, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); return path; } private static void DeleteTestDirectory(string path) { var root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "nexus-openclaw-tests")); var resolved = Path.GetFullPath(path); if (!resolved.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal)) throw new InvalidOperationException("Refusing to delete a test directory outside the expected root."); if (Directory.Exists(resolved)) Directory.Delete(resolved, recursive: true); } }