Files
nexus/backend-tests/OpenClawDeviceIdentityAndAuditTests.cs
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

224 lines
8.6 KiB
C#

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<OpenClawDeviceIdentityStore>.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<OpenClawDeviceIdentityStore>.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<OpenClawDeviceIdentityStore>.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<OpenClawDeviceIdentityStore>.Instance);
await Assert.ThrowsAsync<InvalidDataException>(() => 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);
}
}