feat: bootstrap suitelink tag client codecs

This commit is contained in:
Joseph Doherty
2026-03-16 14:43:31 -04:00
commit 731bfe2237
30 changed files with 3429 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
using SuiteLink.Client.Protocol;
namespace SuiteLink.Client.Tests.Protocol;
public sealed class SuiteLinkConnectCodecTests
{
[Fact]
public void EncodeConnect_WritesConnectMessageTypeAndEndMarker()
{
var options = new SuiteLinkConnectionOptions(
host: "127.0.0.1",
application: "App",
topic: "Topic",
clientName: "Client",
clientNode: "Node",
userName: "User",
serverNode: "Server",
timezone: "UTC");
var bytes = SuiteLinkConnectCodec.Encode(options);
Assert.Equal(0x80, bytes[2]);
Assert.Equal(0x01, bytes[3]);
Assert.Equal(0xA5, bytes[^1]);
}
[Fact]
public void EncodeConnect_WritesExpectedFieldOrderReservedSegmentsAndTimezoneStrings()
{
var options = new SuiteLinkConnectionOptions(
host: "127.0.0.1",
application: "App",
topic: "Topic",
clientName: "Client",
clientNode: "Node",
userName: "User",
serverNode: "Server",
timezone: "UTC");
var bytes = SuiteLinkConnectCodec.Encode(options);
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
var payload = frame.Payload.Span;
var index = 0;
var app = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("App");
Assert.True(payload[index..].StartsWith(app));
index += app.Length;
var topic = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Topic");
Assert.True(payload[index..].StartsWith(topic));
index += topic.Length;
Assert.True(payload[index..(index + 3)].ToArray().All(static b => b == 0x00));
index += 3;
var client = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Client");
Assert.True(payload[index..].StartsWith(client));
index += client.Length;
var clientNode = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Node");
Assert.True(payload[index..].StartsWith(clientNode));
index += clientNode.Length;
var user = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("User");
Assert.True(payload[index..].StartsWith(user));
index += user.Length;
var serverNode = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Server");
Assert.True(payload[index..].StartsWith(serverNode));
index += serverNode.Length;
Assert.True(payload[index..(index + 20)].ToArray().All(static b => b == 0x00));
index += 20;
var timezone1 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out var timezone1ConsumedBytes);
index += timezone1ConsumedBytes;
Assert.Equal("UTC", timezone1);
Assert.True(payload[index..(index + 38)].ToArray().All(static b => b == 0x00));
index += 38;
var timezone2 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out var timezone2ConsumedBytes);
index += timezone2ConsumedBytes;
Assert.Equal("UTC", timezone2);
Assert.Equal(timezone1, timezone2);
Assert.Equal(payload.Length, index);
}
[Fact]
public void EncodeConnect_WhenTimezoneNotProvided_UsesUtcDefault()
{
var options = new SuiteLinkConnectionOptions(
host: "127.0.0.1",
application: "App",
topic: "Topic",
clientName: "Client",
clientNode: "Node",
userName: "User",
serverNode: "Server");
var frame = SuiteLinkFrameReader.ParseFrame(SuiteLinkConnectCodec.Encode(options));
var payload = frame.Payload.Span;
var index = 0;
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("App").Length;
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Topic").Length;
index += 3;
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Client").Length;
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Node").Length;
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("User").Length;
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Server").Length;
index += 20;
var timezone1 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out var timezone1ConsumedBytes);
index += timezone1ConsumedBytes + 38;
var timezone2 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out _);
Assert.Equal("UTC", timezone1);
Assert.Equal("UTC", timezone2);
}
}

View File

@@ -0,0 +1,111 @@
using SuiteLink.Client.Protocol;
namespace SuiteLink.Client.Tests.Protocol;
public sealed class SuiteLinkEncodingTests
{
[Fact]
public void EncodeLengthPrefixedUtf16_WithAsciiText_WritesCharacterCountAndUtf16Bytes()
{
var bytes = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("AB");
Assert.Equal(new byte[] { 0x02, 0x41, 0x00, 0x42, 0x00 }, bytes);
}
[Fact]
public void DecodeNullTerminatedUtf16_ReadsStringAndConsumedBytes()
{
var buffer = new byte[] { 0x48, 0x00, 0x69, 0x00, 0x00, 0x00, 0x20, 0x00 };
var text = SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out var consumed);
Assert.Equal("Hi", text);
Assert.Equal(6, consumed);
}
[Fact]
public void WriteAndReadUInt32LittleEndian_RoundTripsValue()
{
Span<byte> bytes = stackalloc byte[4];
SuiteLinkEncoding.WriteUInt32LittleEndian(bytes, 0x11223344);
var value = SuiteLinkEncoding.ReadUInt32LittleEndian(bytes);
Assert.Equal((uint)0x11223344, value);
Assert.Equal(new byte[] { 0x44, 0x33, 0x22, 0x11 }, bytes.ToArray());
}
[Fact]
public void FileTimeToUtcDateTime_ConvertsKnownEpochValue()
{
const long unixEpochFileTime = 116444736000000000L;
var value = SuiteLinkEncoding.FileTimeToUtcDateTime(unixEpochFileTime);
Assert.Equal(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), value);
}
[Fact]
public void DecodeNullTerminatedUtf16_WithOddLength_ThrowsFormatException()
{
var buffer = new byte[] { 0x41, 0x00, 0x00 };
Assert.Throws<FormatException>(() => SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out _));
}
[Fact]
public void DecodeNullTerminatedUtf16_WithoutNullTerminator_ThrowsFormatException()
{
var buffer = new byte[] { 0x41, 0x00, 0x42, 0x00 };
Assert.Throws<FormatException>(() => SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out _));
}
[Fact]
public void EncodeLengthPrefixedUtf16_WhenInputIsTooLong_ThrowsArgumentOutOfRangeException()
{
var value = new string('A', 256);
Assert.Throws<ArgumentOutOfRangeException>(() => SuiteLinkEncoding.EncodeLengthPrefixedUtf16(value));
}
[Fact]
public void EncodeLengthPrefixedUtf16_WithSurrogatePair_PrefixUsesUtf16CodeUnits()
{
var bytes = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("A\U0001F600");
Assert.Equal(3, bytes[0]);
}
[Fact]
public void ReadUInt32LittleEndian_WhenInputIsTooShort_ThrowsFormatException()
{
var buffer = new byte[] { 0x01, 0x02, 0x03 };
Assert.Throws<FormatException>(() => SuiteLinkEncoding.ReadUInt32LittleEndian(buffer));
}
[Fact]
public void WriteAndReadSingleLittleEndian_RoundTripsNonTrivialValue()
{
Span<byte> bytes = stackalloc byte[4];
const float expected = 123.4567f;
SuiteLinkEncoding.WriteSingleLittleEndian(bytes, expected);
var value = SuiteLinkEncoding.ReadSingleLittleEndian(bytes);
Assert.Equal(expected, value);
}
[Fact]
public void WriteAndReadSingleLittleEndian_RoundTripsNaN()
{
Span<byte> bytes = stackalloc byte[4];
var expected = float.NaN;
SuiteLinkEncoding.WriteSingleLittleEndian(bytes, expected);
var value = SuiteLinkEncoding.ReadSingleLittleEndian(bytes);
Assert.True(float.IsNaN(value));
}
}

View File

@@ -0,0 +1,122 @@
using SuiteLink.Client.Protocol;
namespace SuiteLink.Client.Tests.Protocol;
public sealed class SuiteLinkFrameReaderTests
{
[Fact]
public void ParseFrame_WithValidFrame_ParsesMessageTypeAndPayload()
{
var bytes = new byte[]
{
0x05, 0x00,
0x00, 0x09,
0x01, 0x02,
0xA5
};
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
Assert.Equal((ushort)0x0900, frame.MessageType);
Assert.Equal(new byte[] { 0x01, 0x02 }, frame.Payload.ToArray());
Assert.Equal((ushort)5, frame.RemainingLength);
}
[Fact]
public void ParseFrame_WithInvalidMarker_ThrowsFormatException()
{
var bytes = new byte[]
{
0x03, 0x00,
0x40, 0x24,
0x00
};
var exception = Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
Assert.Contains("marker", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseFrame_WithTooShortInput_ThrowsFormatException()
{
var bytes = new byte[] { 0x03, 0x00, 0x40, 0x24 };
Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
}
[Fact]
public void ParseFrame_WithRemainingLengthBelowMinimum_ThrowsFormatException()
{
var bytes = new byte[]
{
0x02, 0x00,
0x40, 0x24
};
Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
}
[Fact]
public void ParseFrame_WithTruncatedInput_ThrowsFormatException()
{
var bytes = new byte[]
{
0x05, 0x00,
0x40, 0x24,
0x01,
0xA5
};
Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
}
[Fact]
public void TryParseFrame_WithExtraBytes_ReturnsFrameAndConsumedLength()
{
var bytes = new byte[]
{
0x03, 0x00,
0x40, 0x24,
0xA5,
0xFF, 0xEE
};
var parsed = SuiteLinkFrameReader.TryParseFrame(bytes, out var frame, out var consumed);
Assert.True(parsed);
Assert.Equal(5, consumed);
Assert.Equal((ushort)0x2440, frame.MessageType);
Assert.True(frame.Payload.IsEmpty);
}
[Fact]
public void ParseFrame_WithExtraBytes_ThrowsFormatException()
{
var bytes = new byte[]
{
0x03, 0x00,
0x40, 0x24,
0xA5,
0xFF
};
Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
}
[Fact]
public void TryParseFrame_WithIncompleteBuffer_ReturnsFalse()
{
var bytes = new byte[]
{
0x05, 0x00,
0x00, 0x09,
0x01
};
var parsed = SuiteLinkFrameReader.TryParseFrame(bytes, out _, out var consumed);
Assert.False(parsed);
Assert.Equal(0, consumed);
}
}

View File

@@ -0,0 +1,19 @@
using SuiteLink.Client.Protocol;
namespace SuiteLink.Client.Tests.Protocol;
public sealed class SuiteLinkFrameWriterTests
{
[Fact]
public void WriteFrame_WithEmptyPayload_WritesHeaderAndMarker()
{
var bytes = SuiteLinkFrameWriter.WriteFrame(0x2440, []);
Assert.Equal(5, bytes.Length);
Assert.Equal(0x03, bytes[0]);
Assert.Equal(0x00, bytes[1]);
Assert.Equal(0x40, bytes[2]);
Assert.Equal(0x24, bytes[3]);
Assert.Equal(0xA5, bytes[4]);
}
}

View File

@@ -0,0 +1,58 @@
using System.Buffers.Binary;
using SuiteLink.Client.Protocol;
namespace SuiteLink.Client.Tests.Protocol;
public sealed class SuiteLinkHandshakeCodecTests
{
[Fact]
public void EncodeNormalQueryHandshake_WritesLengthMagicsAndIdentityStrings()
{
var bytes = SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
targetApplication: "Intouch",
sourceNode: "NodeA",
sourceUser: "UserA");
Assert.Equal(bytes.Length - 1, bytes[0]);
Assert.Equal(SuiteLinkHandshakeCodec.QueryMagic.ToArray(), bytes.AsSpan(1, 16).ToArray());
Assert.Equal(SuiteLinkHandshakeCodec.UnknownQueryMagic.ToArray(), bytes.AsSpan(17, 16).ToArray());
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(33, 4)));
var expectedApp = SuiteLinkEncoding.EncodeNullTerminatedUtf16("Intouch");
var expectedNode = SuiteLinkEncoding.EncodeNullTerminatedUtf16("NodeA");
var expectedUser = SuiteLinkEncoding.EncodeNullTerminatedUtf16("UserA");
var payload = bytes.AsSpan(37);
Assert.True(payload.StartsWith(expectedApp));
payload = payload[expectedApp.Length..];
Assert.True(payload.StartsWith(expectedNode));
payload = payload[expectedNode.Length..];
Assert.True(payload.StartsWith(expectedUser));
}
[Fact]
public void ParseNormalHandshakeAck_WithNormalAckFrame_ReturnsAckData()
{
// Fixed vector for normal ACK assumption:
// remaining=0x0006, type=0x0001, payload=0xA1B2C3, marker=0xA5.
byte[] frame = [0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5];
var ack = SuiteLinkHandshakeCodec.ParseNormalHandshakeAck(frame);
Assert.Equal(0x0001, ack.MessageType);
Assert.Equal(new byte[] { 0xA1, 0xB2, 0xC3 }, ack.Data.ToArray());
}
[Fact]
public void EncodeNormalQueryHandshake_WhenPayloadExceedsOneByteLength_ThrowsWithPayloadContext()
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
targetApplication: "App",
sourceNode: new string('N', 80),
sourceUser: new string('U', 80)));
Assert.Equal("payloadLength", ex.ParamName);
Assert.Contains("Total handshake payload", ex.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,73 @@
using SuiteLink.Client.Protocol;
namespace SuiteLink.Client.Tests.Protocol;
public sealed class SuiteLinkSubscriptionCodecTests
{
[Fact]
public void EncodeAdvise_WritesExpectedGoldenVectorWithCallerTagId()
{
var bytes = SuiteLinkSubscriptionCodec.EncodeAdvise(0x11223344, "A");
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
byte[] expected = [0x0A, 0x00, 0x10, 0x80, 0x44, 0x33, 0x22, 0x11, 0x01, 0x41, 0x00, 0xA5];
Assert.Equal(expected, bytes);
Assert.Equal(0x10, bytes[2]);
Assert.Equal(0x80, bytes[3]);
Assert.Equal(SuiteLinkSubscriptionCodec.AdviseMessageType, frame.MessageType);
Assert.Equal(0x11223344u, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span[..4]));
}
[Fact]
public void EncodeAdviseWithoutTagId_UsesExplicitDefaultTagIdOfZero()
{
var bytes = SuiteLinkSubscriptionCodec.EncodeAdvise("Pump001.Run");
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
Assert.Equal(0u, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span[..4]));
}
[Fact]
public void EncodeUnadvise_WritesExpectedGoldenVector()
{
var bytes = SuiteLinkSubscriptionCodec.EncodeUnadvise(0x78563412);
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
byte[] expected = [0x07, 0x00, 0x04, 0x80, 0x12, 0x34, 0x56, 0x78, 0xA5];
Assert.Equal(expected, bytes);
Assert.Equal(0x04, bytes[2]);
Assert.Equal(0x80, bytes[3]);
Assert.Equal(SuiteLinkSubscriptionCodec.UnadviseMessageType, frame.MessageType);
Assert.Equal(4, frame.Payload.Length);
Assert.Equal(0x78563412u, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span));
}
[Fact]
public void DecodeAdviseAck_ParsesTagIdFromFixedVector()
{
// remaining=0x0008, type=0x0003, payload={tag_id=0x78563412, unknown=0x00}, marker=0xA5
byte[] frame = [0x08, 0x00, 0x03, 0x00, 0x12, 0x34, 0x56, 0x78, 0x00, 0xA5];
var ack = SuiteLinkSubscriptionCodec.DecodeAdviseAck(frame);
Assert.Equal(0x78563412u, ack.TagId);
}
[Fact]
public void DecodeAdviseAckMany_ParsesTwoAckItems()
{
// remaining=0x000D, type=0x0003, payload={item1,item2}, marker=0xA5
byte[] frame =
[
0x0D, 0x00, 0x03, 0x00,
0x12, 0x34, 0x56, 0x78, 0x00,
0xAA, 0xBB, 0xCC, 0xDD, 0x01,
0xA5
];
var acks = SuiteLinkSubscriptionCodec.DecodeAdviseAckMany(frame);
Assert.Equal(2, acks.Count);
Assert.Equal(0x78563412u, acks[0].TagId);
Assert.Equal(0xDDCCBBAAu, acks[1].TagId);
}
}

View File

@@ -0,0 +1,159 @@
using System.Text;
using SuiteLink.Client.Protocol;
namespace SuiteLink.Client.Tests.Protocol;
public sealed class SuiteLinkUpdateCodecTests
{
[Fact]
public void DecodeUpdate_DecodesBinaryValue()
{
byte[] frame =
[
0x0D, 0x00, 0x09, 0x00,
0x34, 0x12, 0x00, 0x00,
0x0A, 0x00,
0xC0, 0x00,
0x01,
0x01,
0xA5
];
Assert.Equal(0x09, frame[2]);
Assert.Equal(0x00, frame[3]);
var update = SuiteLinkUpdateCodec.Decode(frame);
Assert.Equal(0x1234u, update.TagId);
Assert.Equal(0x00C0, update.Quality);
Assert.Equal(10, update.ElapsedMilliseconds);
Assert.True(update.Value.TryGetBoolean(out var value));
Assert.True(value);
}
[Fact]
public void DecodeUpdate_DecodesIntegerValue()
{
byte[] frame =
[
0x10, 0x00, 0x09, 0x00,
0x78, 0x56, 0x34, 0x12,
0x01, 0x00,
0xC0, 0x00,
0x02,
0x2A, 0x00, 0x00, 0x00,
0xA5
];
var update = SuiteLinkUpdateCodec.Decode(frame);
Assert.Equal(0x12345678u, update.TagId);
Assert.True(update.Value.TryGetInt32(out var value));
Assert.Equal(42, value);
}
[Fact]
public void DecodeUpdate_DecodesRealValue()
{
byte[] frame =
[
0x10, 0x00, 0x09, 0x00,
0x34, 0x12, 0x00, 0x00,
0x01, 0x00,
0xC0, 0x00,
0x03,
0x00, 0x00, 0x48, 0x41,
0xA5
];
var update = SuiteLinkUpdateCodec.Decode(frame);
Assert.True(update.Value.TryGetFloat32(out var value));
Assert.Equal(12.5f, value);
}
[Fact]
public void DecodeUpdate_DecodesMessageValue()
{
byte[] frame =
[
0x10, 0x00, 0x09, 0x00,
0x22, 0x22, 0x00, 0x00,
0x02, 0x00,
0xC0, 0x00,
0x04,
0x02, 0x00,
0x4F, 0x4B,
0xA5
];
var update = SuiteLinkUpdateCodec.Decode(frame);
Assert.True(update.Value.TryGetString(out var value));
Assert.Equal("OK", value);
}
[Fact]
public void DecodeUpdateMany_ParsesTwoItemsFromSingleFrame()
{
byte[] frame =
[
0x1A, 0x00, 0x09, 0x00,
0x11, 0x11, 0x00, 0x00, 0x01, 0x00, 0xC0, 0x00, 0x01, 0x01,
0x22, 0x22, 0x00, 0x00, 0x02, 0x00, 0xC0, 0x00, 0x02, 0x2A, 0x00, 0x00, 0x00,
0xA5
];
var updates = SuiteLinkUpdateCodec.DecodeMany(frame);
Assert.Equal(2, updates.Count);
Assert.Equal(0x1111u, updates[0].TagId);
Assert.True(updates[0].Value.TryGetBoolean(out var boolValue));
Assert.True(boolValue);
Assert.Equal(0x2222u, updates[1].TagId);
Assert.True(updates[1].Value.TryGetInt32(out var intValue));
Assert.Equal(42, intValue);
}
[Fact]
public void DecodeUpdate_WithDefaultMessageEncoding_UsesLatin1LosslessMapping()
{
byte[] frame =
[
0x0F, 0x00, 0x09, 0x00,
0x33, 0x33, 0x00, 0x00,
0x01, 0x00,
0xC0, 0x00,
0x04,
0x01, 0x00,
0xE9,
0xA5
];
var update = SuiteLinkUpdateCodec.Decode(frame);
Assert.True(update.Value.TryGetString(out var value));
Assert.Equal("\u00E9", value);
}
[Fact]
public void DecodeUpdate_WithExplicitUtf8MessageEncoding_UsesProvidedEncoding()
{
byte[] frame =
[
0x10, 0x00, 0x09, 0x00,
0x44, 0x44, 0x00, 0x00,
0x01, 0x00,
0xC0, 0x00,
0x04,
0x02, 0x00,
0xC3, 0xA9,
0xA5
];
var update = SuiteLinkUpdateCodec.Decode(frame, Encoding.UTF8);
Assert.True(update.Value.TryGetString(out var value));
Assert.Equal("\u00E9", value);
}
}