fix(gateway): resolve 2026-06-18 array-write review findings
- Server-057: extend []-suffix normalization to AddItemBulk/AddBufferedItem so bulk-added array tags bind write-capable handles (authz check, worker bind, and registration kept consistent); update gateway.md + client READMEs. Tests: AddItemBulk/AddBufferedItem wiring. - Server-058: assert []-fallback-resolved bare array names are still denied when out of read/write scope and that MaxWriteClassification is enforced on suffixed array registrations. - Contracts-023/024/025: round-trip + field-19 descriptor pin for MxSparseArray; document MxSparseArray in docs/Contracts.md; enumerate it in the protocol-version-3 test summary. - Tests-040: add wiring tests for the six uncovered sparse-write arms (WriteSecured, Write2, WriteSecured2, Write2Bulk, WriteSecuredBulk, WriteSecured2Bulk). dotnet build + targeted tests green (184 passed).
This commit is contained in:
@@ -247,6 +247,440 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
Assert.Equal(new[] { 0, 7, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare array address added via <c>AddItemBulk</c> is normalized to its writable array form
|
||||
/// on the wire (so the worker registers a write-capable handle), while a scalar address in the
|
||||
/// same batch is forwarded unchanged. Tracking the worker's echoed reply lands the normalized
|
||||
/// address in the <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddItemBulk_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItemBulk,
|
||||
AddItemBulk = new AddItemBulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
TagAddresses = { "Obj.Arr", "Obj.Scalar" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
// The array address gains the writable "[]" suffix; the scalar passes through unchanged.
|
||||
Assert.NotNull(worker.LastCommand);
|
||||
Assert.Equal(
|
||||
new[] { "Obj.Arr[]", "Obj.Scalar" },
|
||||
worker.LastCommand!.Command.AddItemBulk.TagAddresses);
|
||||
|
||||
// The real worker echoes back the (suffixed) address it bound; tracking the reply must land the
|
||||
// normalized address in the registration so a later write resolves the write-capable handle.
|
||||
MxCommand trackingCopy = new()
|
||||
{
|
||||
Kind = MxCommandKind.AddItemBulk,
|
||||
AddItemBulk = new AddItemBulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
TagAddresses = { "Obj.Arr", "Obj.Scalar" },
|
||||
},
|
||||
};
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
Kind = MxCommandKind.AddItemBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
AddItemBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 50,
|
||||
TagAddress = "Obj.Arr[]",
|
||||
WasSuccessful = true,
|
||||
},
|
||||
new SubscribeResult
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 51,
|
||||
TagAddress = "Obj.Scalar",
|
||||
WasSuccessful = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
session.TrackCommandReply(trackingCopy, reply);
|
||||
|
||||
Assert.True(session.TryGetItemRegistration(1, 50, out SessionItemRegistration arrayRegistration));
|
||||
Assert.Equal("Obj.Arr[]", arrayRegistration.TagAddress);
|
||||
Assert.True(session.TryGetItemRegistration(1, 51, out SessionItemRegistration scalarRegistration));
|
||||
Assert.Equal("Obj.Scalar", scalarRegistration.TagAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array <see cref="WriteSecuredCommand"/> value is expanded to a full,
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the secured variant's
|
||||
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteSecured_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured,
|
||||
WriteSecured = new WriteSecuredCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 2,
|
||||
Value = new MxValue { Int32Value = 9 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.WriteSecured.Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 0, 9, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array <see cref="Write2Command"/> (timestamped) value is expanded to a full,
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the Write2 variant's
|
||||
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write2_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2,
|
||||
Write2 = new Write2Command
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 3,
|
||||
Value = new MxValue { Int32Value = 5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.Write2.Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 0, 0, 5 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array <see cref="WriteSecured2Command"/> (timestamped secured) value is expanded
|
||||
/// to a full, default-filled <see cref="MxArray"/> before reaching the worker; the
|
||||
/// WriteSecured2 variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
|
||||
/// the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteSecured2_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2,
|
||||
WriteSecured2 = new WriteSecured2Command
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 0,
|
||||
Value = new MxValue { Int32Value = 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.WriteSecured2.Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 3, 0, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array entry in a <see cref="Write2BulkCommand"/> (timestamped) is expanded to a
|
||||
/// full, default-filled <see cref="MxArray"/> before reaching the worker; the Write2Bulk
|
||||
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2Bulk,
|
||||
Write2Bulk = new Write2BulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
Entries =
|
||||
{
|
||||
new Write2BulkEntry
|
||||
{
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 1,
|
||||
Value = new MxValue { Int32Value = 11 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.Write2Bulk.Entries[0].Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 11, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array entry in a <see cref="WriteSecuredBulkCommand"/> is expanded to a full,
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the WriteSecuredBulk
|
||||
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecuredBulk,
|
||||
WriteSecuredBulk = new WriteSecuredBulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
Entries =
|
||||
{
|
||||
new WriteSecuredBulkEntry
|
||||
{
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 2,
|
||||
Value = new MxValue { Int32Value = 13 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.WriteSecuredBulk.Entries[0].Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 0, 13, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array entry in a <see cref="WriteSecured2BulkCommand"/> (timestamped secured) is
|
||||
/// expanded to a full, default-filled <see cref="MxArray"/> before reaching the worker; the
|
||||
/// WriteSecured2Bulk variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
|
||||
/// the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteSecured2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||
WriteSecured2Bulk = new WriteSecured2BulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
Entries =
|
||||
{
|
||||
new WriteSecured2BulkEntry
|
||||
{
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 3,
|
||||
Value = new MxValue { Int32Value = 17 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.WriteSecured2Bulk.Entries[0].Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 0, 0, 17 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare array address added via <c>AddBufferedItem</c> is normalized to its writable array
|
||||
/// form on the wire and in the tracked <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddBufferedItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddBufferedItem,
|
||||
AddBufferedItem = new AddBufferedItemCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemDefinition = "Obj.Arr",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
worker.NextReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
Kind = MxCommandKind.AddBufferedItem,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
AddBufferedItem = new AddBufferedItemReply { ItemHandle = 60 },
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(worker.LastCommand);
|
||||
Assert.Equal("Obj.Arr[]", worker.LastCommand!.Command.AddBufferedItem.ItemDefinition);
|
||||
|
||||
// AddBufferedItem tracking keys off the command's ItemDefinition (the reply carries no address),
|
||||
// so the tracking-path normalization must run for the registration to match the bound handle.
|
||||
MxCommand trackingCopy = new()
|
||||
{
|
||||
Kind = MxCommandKind.AddBufferedItem,
|
||||
AddBufferedItem = new AddBufferedItemCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemDefinition = "Obj.Arr",
|
||||
},
|
||||
};
|
||||
session.TrackCommandReply(trackingCopy, worker.NextReply.Reply);
|
||||
|
||||
Assert.True(session.TryGetItemRegistration(1, 60, out SessionItemRegistration registration));
|
||||
Assert.Equal("Obj.Arr[]", registration.TagAddress);
|
||||
}
|
||||
|
||||
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
|
||||
{
|
||||
GatewaySession session = new(
|
||||
|
||||
Reference in New Issue
Block a user