840 lines
32 KiB
C#
840 lines
32 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Nexus.Api.Services;
|
|
using Xunit;
|
|
|
|
namespace Nexus.Api.Tests;
|
|
|
|
public sealed class OpenClawControlServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task DisconnectedTaskList_ReportsRecoveryWithoutInvokingGateway()
|
|
{
|
|
var gateway = new StubOpenClawConnector
|
|
{
|
|
ConnectionState = GatewayConnectionState.Disconnected
|
|
};
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.GetTasksAsync();
|
|
|
|
Assert.Equal("disconnected", result.State);
|
|
Assert.Empty(result.Items);
|
|
Assert.NotNull(result.Recovery);
|
|
Assert.Empty(gateway.Invocations);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DisconnectedMutation_WithInvalidMetadata_ReturnsControlledInvalidResult()
|
|
{
|
|
var gateway = new StubOpenClawConnector
|
|
{
|
|
ConnectionState = GatewayConnectionState.Disconnected
|
|
};
|
|
var service = CreateService(gateway);
|
|
var invalidContext = new OpenClawInvocationContext(
|
|
"idem-task-1",
|
|
"corr-task-1",
|
|
"owner-1",
|
|
"not-a-traceparent");
|
|
|
|
var result = await service.CancelTaskAsync(
|
|
"task-1",
|
|
null,
|
|
invocationContext: invalidContext);
|
|
|
|
Assert.False(result.Ok);
|
|
Assert.Equal("invalid", result.State);
|
|
Assert.Empty(gateway.Invocations);
|
|
}
|
|
|
|
[Fact]
|
|
public void PairingRecovery_OnlyEmbedsShellSafeRequestId()
|
|
{
|
|
var gateway = new StubOpenClawConnector
|
|
{
|
|
ConnectionState = GatewayConnectionState.Disconnected,
|
|
PairingRequired = true,
|
|
PairingRequestId = "request'; remove-item secret"
|
|
};
|
|
var service = CreateService(gateway);
|
|
|
|
var connection = service.GetConnection();
|
|
|
|
Assert.True(connection.PairingRequired);
|
|
Assert.Equal("request'; remove-item secret", connection.PairingRequestId);
|
|
Assert.NotNull(connection.Recovery);
|
|
Assert.DoesNotContain("request'; remove-item secret", connection.Recovery);
|
|
Assert.DoesNotContain("openclaw devices approve", connection.Recovery);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TaskList_NormalizesGatewayLedgerShape()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["tasks.list", "tasks.cancel"],
|
|
scopes: ["operator.read", "operator.write"]);
|
|
gateway.Handler = (method, _) => method == "tasks.list"
|
|
? JsonNode.Parse(
|
|
"""
|
|
{
|
|
"tasks": [
|
|
{
|
|
"id": "task-1",
|
|
"title": "Index workspace",
|
|
"status": "running",
|
|
"agentId": "researcher",
|
|
"sessionKey": "agent:researcher:main",
|
|
"runId": "run-1",
|
|
"startedAtMs": 1785000000000,
|
|
"progress": 42
|
|
},
|
|
{
|
|
"id": "task-2",
|
|
"title": "Review output",
|
|
"status": "completed"
|
|
}
|
|
],
|
|
"nextCursor": "next-1"
|
|
}
|
|
""")
|
|
: null;
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.GetTasksAsync();
|
|
|
|
Assert.Equal("ready", result.State);
|
|
Assert.Equal("next-1", result.NextCursor);
|
|
Assert.Equal(2, result.Items.Count);
|
|
Assert.Equal("running", result.Items[0].Status);
|
|
Assert.True(result.Items[0].CanCancel);
|
|
Assert.Equal("succeeded", result.Items[1].Status);
|
|
Assert.False(result.Items[1].CanCancel);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronList_MapsScheduleAndRequiresAdminForRun()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.list", "cron.run"],
|
|
scopes: ["operator.read"]);
|
|
gateway.Handler = (_, _) => JsonNode.Parse(
|
|
"""
|
|
{
|
|
"jobs": [{
|
|
"id": "job-1",
|
|
"name": "Morning brief",
|
|
"enabled": true,
|
|
"schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "Europe/Berlin" },
|
|
"state": { "nextRunAtMs": 1785000000000, "lastRunStatus": "ok" }
|
|
}]
|
|
}
|
|
""");
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.GetCronJobsAsync();
|
|
|
|
var job = Assert.Single(result.Items);
|
|
Assert.Equal("0 7 * * *", job.Schedule);
|
|
Assert.Equal("Europe/Berlin", job.TimeZone);
|
|
Assert.Equal("ok", job.Status);
|
|
Assert.False(job.CanRun);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronList_ForwardsFiltersAndTranslatesOffsetCursor()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.list"],
|
|
scopes: ["operator.read"]);
|
|
gateway.Handler = (_, parameters) =>
|
|
{
|
|
var offset = parameters?["offset"]?.GetValue<int>() ?? 0;
|
|
return offset == 0
|
|
? JsonNode.Parse(
|
|
"""
|
|
{
|
|
"jobs": [{ "id": "job-1", "name": "First", "enabled": true,
|
|
"schedule": { "kind": "every", "everyMs": 60000 }, "state": {} }],
|
|
"nextOffset": 1
|
|
}
|
|
""")
|
|
: JsonNode.Parse(
|
|
"""
|
|
{
|
|
"jobs": [{ "id": "job-2", "name": "Second", "enabled": true,
|
|
"schedule": { "kind": "every", "everyMs": 120000 }, "state": {} }],
|
|
"nextOffset": null
|
|
}
|
|
""");
|
|
};
|
|
var service = CreateService(gateway);
|
|
|
|
var first = await service.GetCronJobsAsync(
|
|
includeDisabled: false,
|
|
limit: 1);
|
|
var second = await service.GetCronJobsAsync(
|
|
includeDisabled: false,
|
|
limit: 1,
|
|
cursor: first.NextCursor);
|
|
|
|
Assert.NotNull(first.NextCursor);
|
|
Assert.Equal("job-2", Assert.Single(second.Items).Id);
|
|
Assert.Collection(
|
|
gateway.Invocations,
|
|
invocation =>
|
|
{
|
|
Assert.Equal(false, invocation.Parameters?["includeDisabled"]?.GetValue<bool>());
|
|
Assert.Equal(0, invocation.Parameters?["offset"]?.GetValue<int>());
|
|
},
|
|
invocation => Assert.Equal(1, invocation.Parameters?["offset"]?.GetValue<int>()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronDetail_MapsEditableDefinitionAndStableResourceHash()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.get", "cron.update", "cron.remove", "cron.run"],
|
|
scopes: ["operator.read", "operator.admin"]);
|
|
gateway.Handler = (_, _) => CronJob(
|
|
name: "Morning brief",
|
|
enabled: true,
|
|
deliveryTarget: "+49 151 12345678");
|
|
var service = CreateService(gateway);
|
|
|
|
var first = await service.GetCronJobAsync("job-1");
|
|
var second = await service.GetCronJobAsync("job-1");
|
|
|
|
Assert.True(first.Ok);
|
|
Assert.NotNull(first.Data);
|
|
Assert.Equal("cron", first.Data.Schedule.Kind);
|
|
Assert.Equal("agentTurn", first.Data.Payload.Kind);
|
|
Assert.Equal("+49 151 12345678", first.Data.Delivery?.Target);
|
|
Assert.Equal(first.Data.ResourceHash, second.Data?.ResourceHash);
|
|
Assert.True(first.Data.CanUpdate);
|
|
Assert.True(first.Data.CanDelete);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronRuns_UsesRunFilterAndRedactsDeliveryTargets()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.runs"],
|
|
scopes: ["operator.read"]);
|
|
gateway.Handler = (_, _) => JsonNode.Parse(
|
|
"""
|
|
{
|
|
"entries": [{
|
|
"ts": 1785000000000,
|
|
"jobId": "job-1",
|
|
"runId": "run-1",
|
|
"action": "finished",
|
|
"status": "error",
|
|
"summary": "Delivery to +49 151 12345678 failed",
|
|
"deliveryError": "Webhook https://example.invalid/hooks/secret failed"
|
|
}],
|
|
"nextOffset": 1
|
|
}
|
|
""");
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.GetCronRunsAsync(
|
|
"job-1",
|
|
limit: 1,
|
|
runId: "run-1");
|
|
|
|
var run = Assert.Single(result.Items);
|
|
Assert.DoesNotContain("12345678", run.Summary);
|
|
Assert.DoesNotContain("example.invalid", run.DeliveryError);
|
|
Assert.NotNull(result.NextCursor);
|
|
var invocation = Assert.Single(gateway.Invocations);
|
|
Assert.Equal("job", invocation.Parameters?["scope"]?.GetValue<string>());
|
|
Assert.Equal("job-1", invocation.Parameters?["id"]?.GetValue<string>());
|
|
Assert.Equal("run-1", invocation.Parameters?["runId"]?.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronCreate_BlocksCommandPayloadBeforeGatewayInvocation()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.add"],
|
|
scopes: ["operator.admin"]);
|
|
var service = CreateService(gateway, allowCommandCron: false);
|
|
var request = new Nexus.Api.Models.CreateOpenClawCronJobRequest(
|
|
"Unsafe",
|
|
JsonNode.Parse("""{ "kind": "every", "everyMs": 60000 }""")!.AsObject(),
|
|
"isolated",
|
|
"now",
|
|
JsonNode.Parse("""{ "kind": "command", "argv": ["whoami"] }""")!.AsObject());
|
|
|
|
var result = await service.CreateCronJobAsync(request);
|
|
|
|
Assert.False(result.Ok);
|
|
Assert.Equal("restricted", result.State);
|
|
Assert.Equal("cron", result.Operation?.PrimaryRef?.Type);
|
|
Assert.StartsWith("create:", result.Operation?.PrimaryRef?.Id);
|
|
Assert.Empty(gateway.Invocations);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronCreate_UsesOfficialAddShapeAndReturnsTypedDetail()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.add"],
|
|
scopes: ["operator.admin"]);
|
|
gateway.Handler = (_, _) => new JsonObject
|
|
{
|
|
["created"] = true,
|
|
["job"] = CronJob(name: "Safe reminder", enabled: true)
|
|
};
|
|
var service = CreateService(gateway);
|
|
var request = new Nexus.Api.Models.CreateOpenClawCronJobRequest(
|
|
"Safe reminder",
|
|
JsonNode.Parse("""{ "kind": "every", "everyMs": 60000 }""")!.AsObject(),
|
|
"main",
|
|
"now",
|
|
JsonNode.Parse("""{ "kind": "systemEvent", "text": "Check status" }""")!.AsObject(),
|
|
DeclarationKey: "nexus.safe-reminder");
|
|
|
|
var result = await service.CreateCronJobAsync(request);
|
|
|
|
Assert.True(result.Ok);
|
|
Assert.Equal("job-1", result.Data?.Id);
|
|
Assert.Equal("cron", result.Operation?.PrimaryRef?.Type);
|
|
Assert.Equal("job-1", result.Operation?.PrimaryRef?.Id);
|
|
var invocation = Assert.Single(gateway.Invocations);
|
|
Assert.Equal("cron.add", invocation.Method);
|
|
Assert.Equal("Safe reminder", invocation.Parameters?["name"]?.GetValue<string>());
|
|
Assert.Equal("every", invocation.Parameters?["schedule"]?["kind"]?.GetValue<string>());
|
|
Assert.Equal("systemEvent", invocation.Parameters?["payload"]?["kind"]?.GetValue<string>());
|
|
Assert.Equal(
|
|
"nexus.safe-reminder",
|
|
invocation.Parameters?["declarationKey"]?.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronPatch_RejectsStaleHashWithoutMutatingGateway()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.get", "cron.update"],
|
|
scopes: ["operator.read", "operator.admin"]);
|
|
gateway.Handler = (method, _) => method == "cron.get"
|
|
? CronJob(name: "Morning brief", enabled: true)
|
|
: throw new InvalidOperationException("stale writes must not reach cron.update");
|
|
var service = CreateService(gateway);
|
|
var patch = JsonNode.Parse("""{ "enabled": false }""")!.AsObject();
|
|
|
|
var result = await service.PatchCronJobAsync(
|
|
"job-1",
|
|
patch,
|
|
expectedHash: "stale");
|
|
|
|
Assert.False(result.Ok);
|
|
Assert.Equal("conflict", result.State);
|
|
Assert.Single(gateway.Invocations);
|
|
Assert.Equal("cron.get", gateway.Invocations[0].Method);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronPatch_UsesOfficialPatchShapeAfterHashCheck()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.get", "cron.update"],
|
|
scopes: ["operator.read", "operator.admin"]);
|
|
gateway.Handler = (method, parameters) => method switch
|
|
{
|
|
"cron.get" => CronJob(name: "Morning brief", enabled: true),
|
|
"cron.update" => CronJob(
|
|
name: "Morning brief",
|
|
enabled: parameters?["patch"]?["enabled"]?.GetValue<bool>() ?? true),
|
|
_ => null
|
|
};
|
|
var service = CreateService(gateway);
|
|
var detail = await service.GetCronJobAsync("job-1");
|
|
|
|
var result = await service.PatchCronJobAsync(
|
|
"job-1",
|
|
JsonNode.Parse("""{ "enabled": false }""")!.AsObject(),
|
|
detail.Data!.ResourceHash);
|
|
|
|
Assert.True(result.Ok);
|
|
Assert.False(result.Data!.Enabled);
|
|
Assert.Equal("cron.update", gateway.Invocations.Last().Method);
|
|
Assert.Equal("job-1", gateway.Invocations.Last().Parameters?["id"]?.GetValue<string>());
|
|
Assert.Equal(
|
|
false,
|
|
gateway.Invocations.Last().Parameters?["patch"]?["enabled"]?.GetValue<bool>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronDelete_ChecksHashAndUsesOfficialRemoveShape()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.get", "cron.remove"],
|
|
scopes: ["operator.read", "operator.admin"]);
|
|
gateway.Handler = (method, _) => method switch
|
|
{
|
|
"cron.get" => CronJob(name: "Morning brief", enabled: true),
|
|
"cron.remove" => JsonNode.Parse("""{ "removed": true }"""),
|
|
_ => null
|
|
};
|
|
var service = CreateService(gateway);
|
|
var detail = await service.GetCronJobAsync("job-1");
|
|
|
|
var result = await service.DeleteCronJobAsync(
|
|
"job-1",
|
|
detail.Data!.ResourceHash);
|
|
|
|
Assert.True(result.Ok);
|
|
Assert.Equal("cron.remove", gateway.Invocations.Last().Method);
|
|
Assert.Equal("job-1", gateway.Invocations.Last().Parameters?["id"]?.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CronMutation_RequiresLocalManagementGate()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.run"],
|
|
scopes: ["operator.admin"]);
|
|
var service = CreateService(gateway, managementEnabled: false);
|
|
|
|
var result = await service.RunCronJobAsync("job-1");
|
|
|
|
Assert.False(result.Ok);
|
|
Assert.Equal("management_disabled", result.State);
|
|
Assert.Equal("cron", result.Operation?.PrimaryRef?.Type);
|
|
Assert.Equal("job-1", result.Operation?.PrimaryRef?.Id);
|
|
Assert.Empty(gateway.Invocations);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capabilities_DistinguishMissingMethodFromMissingScope()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["tasks.list", "tasks.cancel"],
|
|
scopes: ["operator.read"]);
|
|
var service = CreateService(gateway);
|
|
|
|
var capabilities = service.GetCapabilities();
|
|
|
|
Assert.Equal("ready", capabilities.Single(item => item.Id == "tasks-read").State);
|
|
Assert.Equal("forbidden", capabilities.Single(item => item.Id == "tasks-cancel").State);
|
|
Assert.Equal("unsupported", capabilities.Single(item => item.Id == "sessions-read").State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancelTask_UsesDocumentedTaskIdAndReason()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["tasks.cancel"],
|
|
scopes: ["operator.write"]);
|
|
gateway.Handler = (_, _) => JsonNode.Parse(
|
|
"""
|
|
{
|
|
"found": true,
|
|
"cancelled": true,
|
|
"task": { "id": "task-9", "title": "Long run", "status": "cancelled" }
|
|
}
|
|
""");
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.CancelTaskAsync("task-9", "Operator stop");
|
|
|
|
Assert.True(result.Ok);
|
|
Assert.Equal("openclaw-task", result.Operation?.PrimaryRef?.Type);
|
|
Assert.Equal("task-9", result.Operation?.PrimaryRef?.Id);
|
|
var invocation = Assert.Single(gateway.Invocations);
|
|
Assert.Equal("tasks.cancel", invocation.Method);
|
|
Assert.Equal("task-9", invocation.Parameters?["taskId"]?.GetValue<string>());
|
|
Assert.Equal("Operator stop", invocation.Parameters?["reason"]?.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveApproval_RejectsUnknownDecisionBeforeGatewayCall()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["approval.resolve"],
|
|
scopes: ["operator.approvals"]);
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.ResolveApprovalAsync("approval-1", "exec", "approve");
|
|
|
|
Assert.False(result.Ok);
|
|
Assert.Equal("invalid", result.State);
|
|
Assert.Equal("approval", result.Operation?.PrimaryRef?.Type);
|
|
Assert.Equal("approval-1", result.Operation?.PrimaryRef?.Id);
|
|
Assert.Empty(gateway.Invocations);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PatchSessionModel_UsesDocumentedSessionKeyAndModel()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["sessions.patch"],
|
|
scopes: ["operator.write"]);
|
|
gateway.Handler = (_, _) => JsonNode.Parse(
|
|
"""{ "model": "openai/gpt-5.4", "provider": "openai" }""");
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.PatchSessionModelAsync(
|
|
"agent:iris:main",
|
|
"openai/gpt-5.4");
|
|
|
|
Assert.True(result.Ok);
|
|
var invocation = Assert.Single(gateway.Invocations);
|
|
Assert.Equal("sessions.patch", invocation.Method);
|
|
Assert.Equal("agent:iris:main", invocation.Parameters?["key"]?.GetValue<string>());
|
|
Assert.Equal("openai/gpt-5.4", invocation.Parameters?["model"]?.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ModelAuthStatus_AggregatesProfilesAndDropsSensitiveGatewayFields()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["models.authStatus"],
|
|
scopes: ["operator.read"]);
|
|
gateway.Handler = (_, _) => JsonNode.Parse(
|
|
"""
|
|
{
|
|
"ts": 1785400000000,
|
|
"providers": [{
|
|
"provider": "openai-codex",
|
|
"displayName": "OpenAI Codex",
|
|
"status": "expiring",
|
|
"expiry": {
|
|
"at": 1785486400000,
|
|
"remainingMs": 86400000,
|
|
"label": "untrusted gateway label"
|
|
},
|
|
"profiles": [
|
|
{
|
|
"profileId": "bao@example.test",
|
|
"type": "oauth",
|
|
"status": "ok",
|
|
"accessToken": "secret-oauth-token"
|
|
},
|
|
{
|
|
"profileId": "second-private-profile",
|
|
"type": "oauth",
|
|
"status": "ok"
|
|
},
|
|
{
|
|
"profileId": "legacy-token-profile",
|
|
"type": "token",
|
|
"status": "expired"
|
|
}
|
|
],
|
|
"apiKey": {
|
|
"source": "env",
|
|
"envVar": "OPENAI_API_KEY",
|
|
"value": "secret-api-key"
|
|
},
|
|
"usage": {
|
|
"providerId": "openai",
|
|
"summary": "82% remaining",
|
|
"plan": "team",
|
|
"accountEmail": "billing@example.test",
|
|
"billing": [{ "amount": 99, "currency": "EUR" }],
|
|
"windows": [{ "label": "5h", "usedPercent": 18 }]
|
|
}
|
|
}]
|
|
}
|
|
""");
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.GetModelAuthStatusAsync(refresh: true);
|
|
|
|
Assert.Equal("ready", result.State);
|
|
var provider = Assert.Single(result.Items);
|
|
Assert.Equal("openai-codex", provider.Provider);
|
|
Assert.Equal("OpenAI Codex", provider.DisplayName);
|
|
Assert.Equal("expiring", provider.Status);
|
|
Assert.Equal("1d", provider.Expiry?.Label);
|
|
Assert.Equal("env", provider.ApiKey?.Source);
|
|
Assert.Equal("OPENAI_API_KEY", provider.ApiKey?.EnvVar);
|
|
Assert.Equal("82% remaining", provider.Usage?.Summary);
|
|
Assert.Equal("team", provider.Usage?.Plan);
|
|
|
|
var oauth = Assert.Single(
|
|
provider.Profiles,
|
|
profile => profile.Type == "oauth" && profile.Status == "ok");
|
|
Assert.Equal(2, oauth.Count);
|
|
var token = Assert.Single(
|
|
provider.Profiles,
|
|
profile => profile.Type == "token" && profile.Status == "expired");
|
|
Assert.Equal(1, token.Count);
|
|
|
|
var serialized = JsonSerializer.Serialize(provider);
|
|
Assert.DoesNotContain("profileId", serialized, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("bao@example.test", serialized, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("billing", serialized, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("secret", serialized, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("windows", serialized, StringComparison.OrdinalIgnoreCase);
|
|
|
|
var invocation = Assert.Single(gateway.Invocations);
|
|
Assert.Equal("models.authStatus", invocation.Method);
|
|
Assert.True(invocation.Parameters?["refresh"]?.GetValue<bool>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ModelAuthStatus_RejectsUnsafeMetadataAndReportsUnsupported()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["models.authStatus"],
|
|
scopes: ["operator.read"]);
|
|
gateway.Handler = (_, _) => JsonNode.Parse(
|
|
"""
|
|
{
|
|
"providers": [{
|
|
"provider": "custom",
|
|
"displayName": "Custom",
|
|
"status": "future-state",
|
|
"profiles": [{ "type": "future-profile", "status": "future-state" }],
|
|
"apiKey": { "source": "env", "envVar": "bad env name" },
|
|
"usage": {
|
|
"summary": "details at billing@example.test",
|
|
"plan": "https://provider.example/private"
|
|
}
|
|
}]
|
|
}
|
|
""");
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.GetModelAuthStatusAsync();
|
|
|
|
var provider = Assert.Single(result.Items);
|
|
Assert.Equal("unknown", provider.Status);
|
|
var profile = Assert.Single(provider.Profiles);
|
|
Assert.Equal("unknown", profile.Type);
|
|
Assert.Equal("unknown", profile.Status);
|
|
Assert.Null(provider.ApiKey?.EnvVar);
|
|
Assert.Null(provider.Usage);
|
|
|
|
gateway.AdvertisedMethods = new HashSet<string>(StringComparer.Ordinal);
|
|
var unsupported = await service.GetModelAuthStatusAsync();
|
|
Assert.Equal("unsupported", unsupported.State);
|
|
Assert.Empty(unsupported.Items);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunCronJob_RequiresAdminAndUsesForceMode()
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["cron.run"],
|
|
scopes: ["operator.admin"]);
|
|
gateway.Handler = (_, _) => JsonNode.Parse(
|
|
"""{ "enqueued": true, "runId": "run-17" }""");
|
|
var service = CreateService(gateway);
|
|
|
|
var result = await service.RunCronJobAsync("job-17");
|
|
|
|
Assert.True(result.Ok);
|
|
var invocation = Assert.Single(gateway.Invocations);
|
|
Assert.Equal("cron.run", invocation.Method);
|
|
Assert.Equal("job-17", invocation.Parameters?["id"]?.GetValue<string>());
|
|
Assert.Equal("force", invocation.Parameters?["mode"]?.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MutationContext_DeduplicatesRetryAndDoesNotAddUndocumentedGatewayField()
|
|
{
|
|
var testRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"nexus-openclaw-control-tests",
|
|
Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(testRoot);
|
|
try
|
|
{
|
|
var gateway = Connected(
|
|
methods: ["tasks.cancel"],
|
|
scopes: ["operator.write"]);
|
|
gateway.Handler = (_, _) => JsonNode.Parse(
|
|
"""
|
|
{
|
|
"found": true,
|
|
"cancelled": true,
|
|
"task": { "id": "task-9", "title": "Long run", "status": "cancelled" }
|
|
}
|
|
""");
|
|
var audit = new OpenClawOperationAuditStore(
|
|
Options.Create(new GatewayConnectorOptions
|
|
{
|
|
DeviceStatePath = Path.Combine(testRoot, "device.json"),
|
|
OperationAuditPath = Path.Combine(testRoot, "operations.jsonl")
|
|
}));
|
|
var service = CreateService(gateway, audit);
|
|
var context = OpenClawInvocationContext.Create(
|
|
actor: "owner-1",
|
|
idempotencyKey: "idem-task-9",
|
|
correlationId: "corr-task-9",
|
|
traceParent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
|
includeIdempotencyParameter: true);
|
|
|
|
var first = await service.CancelTaskAsync(
|
|
"task-9",
|
|
"Operator stop",
|
|
invocationContext: context);
|
|
var retry = await service.CancelTaskAsync(
|
|
"task-9",
|
|
"Operator stop",
|
|
invocationContext: context);
|
|
var conflict = await service.CancelTaskAsync(
|
|
"task-9",
|
|
"Different reason",
|
|
invocationContext: context);
|
|
|
|
Assert.True(first.Ok);
|
|
Assert.Equal("replayed", retry.State);
|
|
Assert.Equal("idempotency_conflict", conflict.State);
|
|
var invocation = Assert.Single(gateway.Invocations);
|
|
Assert.Equal(context.TraceParent, invocation.Context?.TraceParent);
|
|
Assert.Equal(context.CorrelationId, first.CorrelationId);
|
|
Assert.Equal(context.IdempotencyKey, first.IdempotencyKey);
|
|
Assert.Equal(context.CorrelationId, first.Operation?.OperationId);
|
|
Assert.Equal("openclaw-task", retry.Operation?.PrimaryRef?.Type);
|
|
Assert.Equal("task-9", conflict.Operation?.PrimaryRef?.Id);
|
|
Assert.Null(invocation.Parameters?["idempotencyKey"]);
|
|
}
|
|
finally
|
|
{
|
|
var safeRoot = Path.GetFullPath(Path.Combine(
|
|
Path.GetTempPath(),
|
|
"nexus-openclaw-control-tests"));
|
|
var resolved = Path.GetFullPath(testRoot);
|
|
if (resolved.StartsWith(safeRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
|
|
Directory.Exists(resolved))
|
|
{
|
|
Directory.Delete(resolved, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static OpenClawControlService CreateService(
|
|
StubOpenClawConnector gateway,
|
|
IOpenClawOperationAuditStore? auditStore = null,
|
|
bool managementEnabled = true,
|
|
bool allowCommandCron = true)
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Integrations:OpenClaw:BaseUrl"] = "http://127.0.0.1:18789",
|
|
["Integrations:OpenClaw:Token"] = "test-only-token",
|
|
["Integrations:OpenClaw:ManagementEnabled"] =
|
|
managementEnabled.ToString(CultureInfo.InvariantCulture),
|
|
["Integrations:OpenClaw:AllowCommandCron"] =
|
|
allowCommandCron.ToString(CultureInfo.InvariantCulture)
|
|
})
|
|
.Build();
|
|
return new OpenClawControlService(
|
|
gateway,
|
|
configuration,
|
|
new StubOpenClawWriteGate(
|
|
managementEnabled
|
|
? OpenClawWriteGateDecision.Permit()
|
|
: OpenClawWriteGateDecision.Block(
|
|
"management_disabled",
|
|
"OpenClaw management is disabled.")),
|
|
NullLogger<OpenClawControlService>.Instance,
|
|
operationAuditStore: auditStore);
|
|
}
|
|
|
|
private static JsonNode CronJob(
|
|
string name,
|
|
bool enabled,
|
|
string? deliveryTarget = null)
|
|
{
|
|
var job = JsonNode.Parse(
|
|
$$"""
|
|
{
|
|
"id": "job-1",
|
|
"name": {{JsonSerializer.Serialize(name)}},
|
|
"enabled": {{enabled.ToString().ToLowerInvariant()}},
|
|
"createdAtMs": 1784000000000,
|
|
"updatedAtMs": 1785000000000,
|
|
"schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "Europe/Berlin" },
|
|
"sessionTarget": "isolated",
|
|
"wakeMode": "now",
|
|
"payload": { "kind": "agentTurn", "message": "Prepare brief" },
|
|
"state": { "nextRunAtMs": 1786000000000 }
|
|
}
|
|
""")!.AsObject();
|
|
if (deliveryTarget is not null)
|
|
{
|
|
job["delivery"] = new JsonObject
|
|
{
|
|
["mode"] = "announce",
|
|
["channel"] = "telegram",
|
|
["to"] = deliveryTarget
|
|
};
|
|
}
|
|
|
|
return job;
|
|
}
|
|
|
|
private static StubOpenClawConnector Connected(
|
|
IEnumerable<string> methods,
|
|
IEnumerable<string> scopes)
|
|
{
|
|
return new StubOpenClawConnector
|
|
{
|
|
ConnectionState = GatewayConnectionState.Connected,
|
|
GatewayVersion = "2026.7.1",
|
|
ProtocolVersion = 4,
|
|
AdvertisedMethods = methods.ToHashSet(StringComparer.Ordinal),
|
|
GrantedScopes = scopes.ToHashSet(StringComparer.Ordinal)
|
|
};
|
|
}
|
|
}
|
|
|
|
public sealed class StubOpenClawConnector : IGatewayConnector
|
|
{
|
|
public GatewayConnectionState ConnectionState { get; set; } = GatewayConnectionState.Initializing;
|
|
public string? GatewayVersion { get; set; }
|
|
public string? RequiredVersion { get; set; }
|
|
public DateTimeOffset? LastConnectedAt { get; set; }
|
|
public int ReconnectAttempts { get; set; }
|
|
public string? StatusMessage { get; set; }
|
|
public string? DeviceId { get; set; }
|
|
public string? ActiveEndpoint { get; set; }
|
|
public string? ActiveTlsFingerprint { get; set; }
|
|
public bool DeviceTokenConfigured { get; set; }
|
|
public bool PairingRequired { get; set; }
|
|
public string? PairingRequestId { get; set; }
|
|
public int? ProtocolVersion { get; set; }
|
|
public IReadOnlySet<string> AdvertisedMethods { get; set; } =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
public IReadOnlySet<string> AdvertisedEvents { get; set; } =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
public IReadOnlySet<string> GrantedScopes { get; set; } =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
public DateTimeOffset? LastEventAt { get; set; }
|
|
public Func<string, JsonNode?, JsonNode?>? Handler { get; set; }
|
|
public List<(string Method, JsonNode? Parameters, OpenClawInvocationContext? Context)> Invocations { get; } = [];
|
|
|
|
public bool Supports(string method) => AdvertisedMethods.Contains(method);
|
|
|
|
public Task<JsonNode?> InvokeAsync(
|
|
string method,
|
|
object? parameters = null,
|
|
TimeSpan? timeout = null,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
var node = parameters switch
|
|
{
|
|
null => null,
|
|
JsonNode jsonNode => jsonNode.DeepClone(),
|
|
_ => JsonSerializer.SerializeToNode(parameters)
|
|
};
|
|
Invocations.Add((method, node, invocationContext));
|
|
return Task.FromResult(Handler?.Invoke(method, node));
|
|
}
|
|
|
|
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100) => [];
|
|
}
|