224 lines
8.6 KiB
C#
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);
|
|
}
|
|
}
|