diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..35161b2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,86 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.cs] +# --- Language style --- +csharp_preferred_modifier_order = public, private, protected, internal, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, file, required:suggestion +csharp_space_after_cast = true +csharp_new_line_before_members_in_object_initializers = false +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_qualification_for_field = true:suggestion +dotnet_style_qualification_for_property = true:suggestion +dotnet_style_qualification_for_method = true:suggestion +dotnet_style_qualification_for_event = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# --- Expression-bodied members: use an expression body when the member is a single statement --- +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = when_on_single_line:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion +csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion +csharp_style_expression_bodied_indexers = when_on_single_line:suggestion +csharp_style_expression_bodied_accessors = when_on_single_line:suggestion + +# --- Naming (warning-level) --- +# private instance / static fields: camelCase, NO underscore prefix (CONVENTIONS ยง4) +dotnet_naming_rule.private_fields_camel_case.severity = warning +dotnet_naming_rule.private_fields_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_camel_case.style = camel_case +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +# private const: PascalCase (declared before the general rule so it wins) +dotnet_naming_rule.private_const_pascal_case.severity = warning +dotnet_naming_rule.private_const_pascal_case.symbols = private_const_fields +dotnet_naming_rule.private_const_pascal_case.style = pascal_case +dotnet_naming_symbols.private_const_fields.applicable_kinds = field +dotnet_naming_symbols.private_const_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_const_fields.required_modifiers = const + +# private static readonly: PascalCase +dotnet_naming_rule.private_static_readonly_pascal_case.severity = warning +dotnet_naming_rule.private_static_readonly_pascal_case.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_pascal_case.style = pascal_case +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static, readonly + +dotnet_naming_style.camel_case.capitalization = camel_case +dotnet_naming_style.pascal_case.capitalization = pascal_case + +# --- ReSharper / Rider hints (IDE-only; not enforced by dotnet build) --- +# Collapse blank lines in method bodies to 0 โ€” backs the "no blank lines between arrange/act/assert" +# rule. Rider-enforced; CI cannot enforce this via editorconfig alone. +resharper_csharp_keep_blank_lines_in_code = 0 +resharper_csharp_keep_blank_lines_in_declarations = 1 +resharper_braces_for_ifelse = required +resharper_braces_for_for = required +resharper_braces_for_foreach = required +resharper_braces_for_while = required +resharper_trailing_comma_in_multiline_lists = true + +[*.{csproj,props,targets,nuspec}] +indent_size = 2 + +[*.{json,jsonc}] +indent_size = 2 + +[*.{yaml,yml}] +indent_size = 2 + +[*.{bash,sh,zsh}] +indent_size = 2 diff --git a/.gitignore b/.gitignore index e57ded3..9f728e6 100644 --- a/.gitignore +++ b/.gitignore @@ -407,5 +407,6 @@ ASALocalRun/ # Supabase .supabase .temp +.branches launchSettings.json \ No newline at end of file diff --git a/FunctionsTests/ClientContractTests.cs b/FunctionsTests/ClientContractTests.cs new file mode 100644 index 0000000..011b633 --- /dev/null +++ b/FunctionsTests/ClientContractTests.cs @@ -0,0 +1,209 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Functions; +using Supabase.Functions.Exceptions; +using WireMock; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using static Supabase.Functions.Client; + +namespace FunctionsTests +{ + /// + /// Contract tests for the HTTP request the client builds (path, method, auth, region and dynamic + /// headers, JSON body) and for how it interprets the response (success payload shapes, and the + /// failures โ€” error status codes and relay errors โ€” that surface as a + /// carrying its status, content, and detected reason). + /// + [TestClass] + [TestCategory("Contract")] + public class ClientContractTests + { + private const string FunctionName = "hello"; + + private WireMockServer server = null!; + private Client client = null!; + + [TestInitialize] + public void TestInitialize() + { + this.server = WireMockServer.Start(); + this.client = new Client($"{this.server.Url}/functions/v1"); + } + + [TestCleanup] + public void TestCleanup() => this.server.Stop(); + + [TestMethod] + public async Task Invoke_ShouldReturnResponseBody() + { + this.RespondWith(200, "{\"message\":\"Hello supabase!\"}"); + var result = await this.client.Invoke(FunctionName); + result.Should().Be("{\"message\":\"Hello supabase!\"}"); + } + + [TestMethod] + public async Task Invoke_ShouldDeserializeResponse_GivenTypedInvoke() + { + this.RespondWith(200, "{\"message\":\"Hello supabase!\"}"); + var result = await this.client.Invoke>(FunctionName); + result.Should().Contain("message", "Hello supabase!"); + } + + [TestMethod] + public async Task RawInvoke_ShouldReturnReadableContent() + { + this.RespondWith(200, "raw-payload"); + var content = await this.client.RawInvoke(FunctionName); + (await content.ReadAsStringAsync()).Should().Be("raw-payload"); + } + + [TestMethod] + public async Task Invoke_ShouldPostToTheFunctionPath() + { + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName); + var request = this.SingleRequest(); + using (new AssertionScope()) + { + request.Method.Should().Be("POST"); + request.Path.Should().Be($"/functions/v1/{FunctionName}"); + } + } + + [TestMethod] + public async Task Invoke_ShouldUseTheGivenHttpMethod() + { + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions { HttpMethod = HttpMethod.Get }); + this.SingleRequest().Method.Should().Be("GET"); + } + + [TestMethod] + public async Task Invoke_ShouldSerializeBodyAsJson() + { + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions + { + Body = new Dictionary { { "name", "supabase" } } + }); + this.SingleRequest().Body.Should().Be("{\"name\":\"supabase\"}"); + } + + [TestMethod] + public async Task Invoke_ShouldSendBearerToken_GivenToken() + { + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName, "the-token"); + this.HeaderOf("Authorization").Should().Be("Bearer the-token"); + } + + [TestMethod] + public async Task Invoke_ShouldSendClientInfoHeader() + { + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName); + this.HeaderOf("X-Client-Info").Should().StartWith("supabase.functions-csharp/"); + } + + [TestMethod] + public async Task Invoke_ShouldSendRegionHeader_GivenOptionRegion() + { + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions { FunctionRegion = FunctionRegion.UsEast1 }); + this.HeaderOf("x-region").Should().Be("us-east-1"); + } + + [TestMethod] + public async Task Invoke_ShouldNotSendRegionHeader_GivenAnyRegion() + { + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions { FunctionRegion = FunctionRegion.Any }); + this.SingleRequest().Headers.Should().NotContainKey("x-region"); + } + + [TestMethod] + public async Task Invoke_ShouldSendConstructorRegion_GivenNoOptionRegion() + { + this.client = new Client($"{this.server.Url}/functions/v1", FunctionRegion.EuWest2); + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName); + this.HeaderOf("x-region").Should().Be("eu-west-2"); + } + + [TestMethod] + public async Task Invoke_ShouldPreferOptionRegionOverConstructorRegion() + { + this.client = new Client($"{this.server.Url}/functions/v1", FunctionRegion.EuWest2); + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions { FunctionRegion = FunctionRegion.UsEast1 }); + this.HeaderOf("x-region").Should().Be("us-east-1"); + } + + [TestMethod] + public async Task Invoke_ShouldMergeDynamicHeaders_GivenGetHeaders() + { + this.client.GetHeaders = () => new Dictionary { { "x-dynamic", "from-callback" } }; + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName); + this.HeaderOf("x-dynamic").Should().Be("from-callback"); + } + + [TestMethod] + public async Task Invoke_ShouldPreferOptionHeadersOverDynamicHeaders() + { + this.client.GetHeaders = () => new Dictionary { { "x-shared", "from-callback" } }; + this.RespondWith(200, "ok"); + await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions + { + Headers = new Dictionary { { "x-shared", "from-options" } } + }); + this.HeaderOf("x-shared").Should().Be("from-options"); + } + + [TestMethod] + public async Task Invoke_ShouldThrowFunctionsException_GivenServerError() + { + this.RespondWith(500, "internal boom"); + var act = () => this.client.Invoke(FunctionName); + var exception = (await act.Should().ThrowAsync()).Which; + using (new AssertionScope()) + { + exception.StatusCode.Should().Be(500); + exception.Content.Should().Be("internal boom"); + exception.Response.Should().NotBeNull(); + exception.Reason.Should().Be(FailureHint.Reason.Internal); + } + } + + [TestMethod] + public async Task Invoke_ShouldThrowNotAuthorized_GivenUnauthorized() + { + this.RespondWith(401, "no"); + var act = () => this.client.Invoke(FunctionName); + (await act.Should().ThrowAsync()).Which.Reason.Should().Be(FailureHint.Reason.NotAuthorized); + } + + [TestMethod] + public async Task Invoke_ShouldThrowFunctionsException_GivenRelayErrorOnSuccessStatus() + { + this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingAnyMethod()) + .RespondWith(Response.Create().WithStatusCode(200).WithHeader("x-relay-error", "true").WithBody("relayed")); + var act = () => this.client.Invoke(FunctionName); + (await act.Should().ThrowAsync()).Which.Content.Should().Be("relayed"); + } + + private void RespondWith(int statusCode, string body) => + this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingAnyMethod()) + .RespondWith(Response.Create().WithStatusCode(statusCode).WithHeader("Content-Type", "application/json").WithBody(body)); + + private IRequestMessage SingleRequest() => this.server.LogEntries.Should().ContainSingle().Which.RequestMessage!; + + private string HeaderOf(string name) => this.SingleRequest().Headers![name][0]; + } +} diff --git a/FunctionsTests/ClientTests.cs b/FunctionsTests/ClientTests.cs index 78bbbcd..aacecaf 100644 --- a/FunctionsTests/ClientTests.cs +++ b/FunctionsTests/ClientTests.cs @@ -1,102 +1,90 @@ -using System; using System.Collections.Generic; -using System.IdentityModel.Tokens.Jwt; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using FluentAssertions; using Microsoft.IdentityModel.Tokens; using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IdentityModel.Tokens.Jwt; using Supabase.Functions; using static Supabase.Functions.Client; namespace FunctionsTests { + /// + /// End-to-end tests that invoke the hello edge function against a running local Supabase + /// stack (started with supabase start), exercising the full request/response round trip for + /// the string, typed, and raw invocation shapes. + /// [TestClass] + [TestCategory("E2E")] public class ClientTests { - private Client _client = null!; - private string _token = null!; + private const string Function = "hello"; + + private Client client = null!; + private string token = null!; [TestInitialize] - public void Initialize() + public void TestInitialize() { - _token = GenerateToken("super-secret-jwt-token-with-at-least-32-characters-long"); - _client = new Client("http://localhost:54321/functions/v1"); + this.token = GenerateToken("super-secret-jwt-token-with-at-least-32-characters-long"); + this.client = new Client("http://localhost:54321/functions/v1"); } - [TestMethod("Invokes a function.")] - public async Task Invokes() + [TestMethod] + public async Task Invoke_ShouldReturnGreetingContainingTheName() { - const string function = "hello"; - - var result = await _client.Invoke( - function, - _token, - new InvokeFunctionOptions - { - Body = new Dictionary { { "name", "supabase" } }, - HttpMethod = HttpMethod.Post, - } - ); - - Assert.IsTrue(result.Contains("supabase")); - - var result2 = await _client.Invoke>( - function, - _token, - new InvokeFunctionOptions - { - Body = new Dictionary { { "name", "functions" } }, - HttpMethod = HttpMethod.Post, - } - ); - - Assert.IsInstanceOfType(result2, typeof(Dictionary)); - Assert.IsTrue(result2.ContainsKey("message")); - Assert.IsTrue(result2["message"].Contains("functions")); - - var result3 = await _client.RawInvoke( - function, - _token, - new InvokeFunctionOptions - { - Body = new Dictionary { { "name", "functions" } }, - HttpMethod = HttpMethod.Post, - } - ); + var result = await this.client.Invoke(Function, this.token, new InvokeFunctionOptions + { + Body = new Dictionary { { "name", "supabase" } }, + HttpMethod = HttpMethod.Post + }); + result.Should().Contain("supabase"); + } - var bytes = await result3.ReadAsByteArrayAsync(); + [TestMethod] + public async Task Invoke_ShouldReturnDeserializedGreeting_GivenTypedInvoke() + { + var result = await this.client.Invoke>(Function, this.token, new InvokeFunctionOptions + { + Body = new Dictionary { { "name", "functions" } }, + HttpMethod = HttpMethod.Post + }); + result.Should().ContainKey("message").WhoseValue.Should().Contain("functions"); + } - Assert.IsInstanceOfType(bytes, typeof(byte[])); - - var result4 = await _client.Invoke( - function, - _token, - new InvokeFunctionOptions - { - Body = [], - HttpMethod = HttpMethod.Get, - } - ); + [TestMethod] + public async Task RawInvoke_ShouldReturnReadableBytes() + { + var content = await this.client.RawInvoke(Function, this.token, new InvokeFunctionOptions + { + Body = new Dictionary { { "name", "functions" } }, + HttpMethod = HttpMethod.Post + }); + (await content.ReadAsByteArrayAsync()).Should().NotBeEmpty(); + } - Assert.IsTrue(result4.Contains(function)); + [TestMethod] + public async Task Invoke_ShouldGreetWithFunctionName_GivenGetWithoutBody() + { + var result = await this.client.Invoke(Function, this.token, new InvokeFunctionOptions + { + Body = [], + HttpMethod = HttpMethod.Get + }); + result.Should().Contain(Function); } private static string GenerateToken(string secret) { var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); - var tokenDescriptor = new SecurityTokenDescriptor { - SigningCredentials = new SigningCredentials( - signingKey, - SecurityAlgorithms.HmacSha256Signature - ), + SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature) }; - var tokenHandler = new JwtSecurityTokenHandler(); - var securityToken = tokenHandler.CreateToken(tokenDescriptor); - return tokenHandler.WriteToken(securityToken); + return tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)); } } -} \ No newline at end of file +} diff --git a/FunctionsTests/FailureHintTests.cs b/FunctionsTests/FailureHintTests.cs new file mode 100644 index 0000000..cfa693f --- /dev/null +++ b/FunctionsTests/FailureHintTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Functions.Exceptions; +using Reason = Supabase.Functions.Exceptions.FailureHint.Reason; + +namespace FunctionsTests +{ + /// + /// Covers : the mapping from a failed response's status code + /// and content onto a . A missing body is always + /// , and 403 only counts as an authorization failure when + /// the body mentions an API key. + /// + [TestClass] + [TestCategory("Unit")] + public class FailureHintTests + { + [TestMethod] + public void DetectReason_ShouldReturnUnknown_GivenNoContent() => + FailureHint.DetectReason(Failure(statusCode: 401, content: null)).Should().Be(Reason.Unknown); + + [TestMethod] + public void DetectReason_ShouldReturnNotAuthorized_Given401() => + FailureHint.DetectReason(Failure(statusCode: 401, content: "nope")).Should().Be(Reason.NotAuthorized); + + [TestMethod] + public void DetectReason_ShouldReturnNotAuthorized_Given403MentioningApiKey() => + FailureHint.DetectReason(Failure(statusCode: 403, content: "invalid apikey")).Should().Be(Reason.NotAuthorized); + + [TestMethod] + public void DetectReason_ShouldReturnUnknown_Given403WithoutApiKey() => + FailureHint.DetectReason(Failure(statusCode: 403, content: "forbidden")).Should().Be(Reason.Unknown); + + [TestMethod] + public void DetectReason_ShouldReturnInternal_Given500() => + FailureHint.DetectReason(Failure(statusCode: 500, content: "boom")).Should().Be(Reason.Internal); + + [TestMethod] + public void DetectReason_ShouldReturnUnknown_GivenUnmappedStatus() => + FailureHint.DetectReason(Failure(statusCode: 400, content: "bad request")).Should().Be(Reason.Unknown); + + private static FunctionsException Failure(int statusCode, string? content) => + new("failed") { StatusCode = statusCode, Content = content }; + } +} diff --git a/FunctionsTests/FunctionRegionTests.cs b/FunctionsTests/FunctionRegionTests.cs new file mode 100644 index 0000000..d181955 --- /dev/null +++ b/FunctionsTests/FunctionRegionTests.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static Supabase.Functions.Client; + +namespace FunctionsTests +{ + /// + /// Covers : the wire string carried by every named region, its value + /// semantics (equality, hash code, operators) and the explicit conversions to and from + /// . The wire string is what ends up in the x-region header, so each + /// constant is pinned to its exact value. + /// + [TestClass] + [TestCategory("Unit")] + public class FunctionRegionTests + { + [TestMethod] + public void Region_ShouldExposeItsWireString() + { + using (new AssertionScope()) + { + FunctionRegion.Any.ToString().Should().Be("any"); + FunctionRegion.ApNortheast1.ToString().Should().Be("ap-northeast-1"); + FunctionRegion.ApNortheast2.ToString().Should().Be("ap-northeast-2"); + FunctionRegion.ApSouth1.ToString().Should().Be("ap-south-1"); + FunctionRegion.ApSoutheast1.ToString().Should().Be("ap-southeast-1"); + FunctionRegion.ApSoutheast2.ToString().Should().Be("ap-southeast-2"); + FunctionRegion.CaCentral1.ToString().Should().Be("ca-central-1"); + FunctionRegion.EuCentral1.ToString().Should().Be("eu-central-1"); + FunctionRegion.EuWest1.ToString().Should().Be("eu-west-1"); + FunctionRegion.EuWest2.ToString().Should().Be("eu-west-2"); + FunctionRegion.EuWest3.ToString().Should().Be("eu-west-3"); + FunctionRegion.SaEast1.ToString().Should().Be("sa-east-1"); + FunctionRegion.UsEast1.ToString().Should().Be("us-east-1"); + FunctionRegion.UsWest1.ToString().Should().Be("us-west-1"); + FunctionRegion.UsWest2.ToString().Should().Be("us-west-2"); + } + } + + [TestMethod] + public void Region_ShouldConvertToItsWireString_GivenExplicitStringCast() => + ((string) FunctionRegion.UsEast1).Should().Be("us-east-1"); + + [TestMethod] + public void Region_ShouldCarryTheGivenString_GivenExplicitCastFromString() => + ((FunctionRegion) "custom-region").ToString().Should().Be("custom-region"); + + [TestMethod] + public void Region_ShouldEqualAnotherRegion_GivenSameWireString() + { + var left = (FunctionRegion) "us-east-1"; + var right = (FunctionRegion) "us-east-1"; + using (new AssertionScope()) + { + left.Equals(right).Should().BeTrue(); + left.Equals((object) right).Should().BeTrue(); + (left == right).Should().BeTrue(); + (left != right).Should().BeFalse(); + } + } + + [TestMethod] + public void Region_ShouldNotEqualAnotherRegion_GivenDifferentWireString() + { + var left = FunctionRegion.UsEast1; + var right = FunctionRegion.EuWest1; + using (new AssertionScope()) + { + left.Equals(right).Should().BeFalse(); + (left == right).Should().BeFalse(); + (left != right).Should().BeTrue(); + } + } + + [TestMethod] + public void Region_ShouldShareHashCodeWithItsWireString() => + FunctionRegion.UsEast1.GetHashCode().Should().Be("us-east-1".GetHashCode()); + } +} diff --git a/FunctionsTests/FunctionsTests.csproj b/FunctionsTests/FunctionsTests.csproj index f354510..6ca7b6e 100644 --- a/FunctionsTests/FunctionsTests.csproj +++ b/FunctionsTests/FunctionsTests.csproj @@ -4,9 +4,14 @@ net8.0 false enable + true + true + latest + + diff --git a/FunctionsTests/ObservabilityContractTests.cs b/FunctionsTests/ObservabilityContractTests.cs index decda8a..205887d 100644 --- a/FunctionsTests/ObservabilityContractTests.cs +++ b/FunctionsTests/ObservabilityContractTests.cs @@ -1,25 +1,27 @@ -using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.Metrics; using System.Linq; -using System.Net.Http; using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Execution; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Functions; +using Supabase.Functions.Exceptions; using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; -using static Supabase.Functions.Client; namespace FunctionsTests { /// - /// Contract tests for the diagnostics the SDK emits through System.Diagnostics - /// (ActivitySource/Meter "Supabase.Functions") and for the sanitization rule: telemetry must - /// never contain a query string, the request body, a token, or other secret. + /// Contract tests for the diagnostics the client emits through + /// (the Supabase.Functions and ): the span's + /// OpenTelemetry tags and error status, the invocation duration histogram and its dimensions, and + /// the sanitization rule that telemetry must never carry the query string, request body, or a token. /// [TestClass] + [TestCategory("Contract")] public class ObservabilityContractTests { private const string FunctionName = "hello"; @@ -29,135 +31,180 @@ public class ObservabilityContractTests private readonly List>> measurements = new(); private ActivityListener activityListener = null!; private MeterListener meterListener = null!; + private Instrument? durationInstrument; private WireMockServer server = null!; private Client client = null!; [TestInitialize] - public void TestInitializer() + public void TestInitialize() { - server = WireMockServer.Start(); - client = new Client($"{server.Url}/functions/v1"); - - activityListener = new ActivityListener + this.server = WireMockServer.Start(); + this.client = new Client($"{this.server.Url}/functions/v1"); + this.activityListener = new ActivityListener { ShouldListenTo = source => source.Name == FunctionsDiagnostics.SourceName, Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => activities.Add(activity) + ActivityStopped = activity => this.activities.Add(activity) }; - ActivitySource.AddActivityListener(activityListener); - - meterListener = new MeterListener + ActivitySource.AddActivityListener(this.activityListener); + this.meterListener = new MeterListener { InstrumentPublished = (instrument, listener) => { - if (instrument.Meter.Name == FunctionsDiagnostics.SourceName) - listener.EnableMeasurementEvents(instrument); + if (instrument.Meter.Name != FunctionsDiagnostics.SourceName) + return; + this.durationInstrument = instrument; + listener.EnableMeasurementEvents(instrument); } }; - meterListener.SetMeasurementEventCallback((_, value, tags, _) => + this.meterListener.SetMeasurementEventCallback((_, value, tags, _) => { var tagValues = new Dictionary(); foreach (var tag in tags) tagValues[tag.Key] = tag.Value; - measurements.Add(new KeyValuePair>(value, tagValues)); + this.measurements.Add(new KeyValuePair>(value, tagValues)); }); - meterListener.Start(); + this.meterListener.Start(); } [TestCleanup] public void TestCleanup() { - activityListener.Dispose(); - meterListener.Dispose(); - server.Stop(); + this.activityListener.Dispose(); + this.meterListener.Dispose(); + this.server.Stop(); } - [TestMethod(DisplayName = "The invoke span records the request URL without its query string")] - public async Task InvokeSpanRecordsSanitizedUrl() + [TestMethod] + public async Task InvokeSpan_ShouldRecordUrlWithoutQueryString() { - MockInvokeOk(); - await Invoke(); - var span = SingleInvokeSpan(); - Assert.AreEqual($"{server.Url}/functions/v1/{FunctionName}", span.GetTagItem("url.full"), + this.MockInvokeOk(); + await this.Invoke(); + this.InvokeSpan().GetTagItem("url.full").Should().Be($"{this.server.Url}/functions/v1/{FunctionName}", "the query string must never be recorded"); } - [TestMethod(DisplayName = "The invoke span follows OpenTelemetry conventions and tags the function name")] - public async Task InvokeSpanRecordsMethodStatusAndFunctionName() + [TestMethod] + public async Task InvokeSpan_ShouldFollowOpenTelemetryConventions() { - MockInvokeOk(); - await Invoke(); - var span = SingleInvokeSpan(); - Assert.AreEqual(ActivityKind.Client, span.Kind); - Assert.AreEqual("POST", span.GetTagItem("http.request.method")); - Assert.AreEqual(200, span.GetTagItem("http.response.status_code")); - Assert.AreEqual(FunctionName, span.GetTagItem("faas.invoked_name")); + this.MockInvokeOk(); + await this.Invoke(); + var span = this.InvokeSpan(); + using (new AssertionScope()) + { + span.Kind.Should().Be(ActivityKind.Client); + span.GetTagItem("http.request.method").Should().Be("POST"); + span.GetTagItem("http.response.status_code").Should().Be(200); + span.GetTagItem("faas.invoked_name").Should().Be(FunctionName); + } } - [TestMethod(DisplayName = "A failed invocation marks the span as an error")] - public async Task FailedInvocationMarksTheSpanAsError() + [TestMethod] + public async Task InvokeSpan_ShouldMarkError_GivenFailedStatus() { - server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost()) + this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost()) .RespondWith(Response.Create().WithStatusCode(500).WithBody("boom")); - await Assert.ThrowsAsync(Invoke); - var span = SingleInvokeSpan(); - Assert.AreEqual(ActivityStatusCode.Error, span.Status); - Assert.AreEqual(500, span.GetTagItem("http.response.status_code")); + var act = this.Invoke; + await act.Should().ThrowAsync(); + var span = this.InvokeSpan(); + using (new AssertionScope()) + { + span.Status.Should().Be(ActivityStatusCode.Error); + span.GetTagItem("http.response.status_code").Should().Be(500); + } } - [TestMethod(DisplayName = "A relay error marks the span as an error even on a 2xx status")] - public async Task RelayErrorOnSuccessStatusMarksTheSpanAsError() + [TestMethod] + public async Task InvokeSpan_ShouldMarkError_GivenRelayErrorOnSuccessStatus() { - server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost()) + this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithHeader("x-relay-error", "true").WithBody("relayed")); - await Assert.ThrowsAsync(Invoke); - var span = SingleInvokeSpan(); - Assert.AreEqual(ActivityStatusCode.Error, span.Status); - Assert.AreEqual("x-relay-error", span.GetTagItem("error.type")); + var act = this.Invoke; + await act.Should().ThrowAsync(); + var span = this.InvokeSpan(); + using (new AssertionScope()) + { + span.Status.Should().Be(ActivityStatusCode.Error); + span.GetTagItem("error.type").Should().Be("x-relay-error"); + } } - [TestMethod(DisplayName = "The invoke duration histogram is recorded per request")] - public async Task InvokeDurationMetricIsRecorded() + [TestMethod] + public async Task InvokeDuration_ShouldRecordPositiveDurationPerRequest() { - MockInvokeOk(); - await Invoke(); - meterListener.RecordObservableInstruments(); - Assert.AreEqual(1, measurements.Count); - var measurement = measurements.Single(); - Assert.IsTrue(measurement.Key > 0); - Assert.AreEqual(200, measurement.Value["http.response.status_code"]); - Assert.AreEqual($"/functions/v1/{FunctionName}", measurement.Value["url.path"]); - Assert.AreEqual(FunctionName, measurement.Value["faas.invoked_name"]); + this.MockInvokeOk(); + await this.Invoke(); + this.meterListener.RecordObservableInstruments(); + var measurement = this.measurements.Should().ContainSingle().Which; + measurement.Key.Should().BeInRange(0, 60, "the value is an elapsed time in seconds, not raw ticks"); + measurement.Key.Should().BeGreaterThan(0); + } + + [TestMethod] + public async Task InvokeDuration_ShouldBeNamedAndMeasuredInSeconds() + { + this.MockInvokeOk(); + await this.Invoke(); + using (new AssertionScope()) + { + this.durationInstrument!.Name.Should().Be("supabase.functions.invoke.duration"); + this.durationInstrument!.Unit.Should().Be("s"); + } + } + + [TestMethod] + public async Task InvokeDuration_ShouldTagRequestDimensions() + { + this.MockInvokeOk(); + await this.Invoke(); + var dimensions = this.measurements.Should().ContainSingle().Which.Value; + using (new AssertionScope()) + { + dimensions["http.request.method"].Should().Be("POST"); + dimensions["http.response.status_code"].Should().Be(200); + dimensions["server.address"].Should().Be(new System.Uri(this.server.Url!).Host); + dimensions["url.path"].Should().Be($"/functions/v1/{FunctionName}"); + dimensions["faas.invoked_name"].Should().Be(FunctionName); + } + } + + [TestMethod] + public async Task InvokeDuration_ShouldTagErrorType_GivenFailedStatus() + { + this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(500).WithBody("boom")); + var act = this.Invoke; + await act.Should().ThrowAsync(); + this.measurements.Should().ContainSingle().Which.Value["error.type"].Should().Be("500"); } - [TestMethod(DisplayName = "Telemetry never contains the request body")] - public async Task TelemetryDoesNotLeakTheBody() + [TestMethod] + public async Task Telemetry_ShouldNotLeakTheRequestBody() { - MockInvokeOk(); - await Invoke(); - var recorded = activities + this.MockInvokeOk(); + await this.Invoke(); + var recorded = this.activities .SelectMany(a => a.TagObjects) .Select(tag => tag.Value?.ToString() ?? "") - .Concat(measurements.SelectMany(m => m.Value.Values).Select(v => v?.ToString() ?? "")) - .Concat(activities.Select(a => a.DisplayName)); - Assert.IsFalse(recorded.Any(value => value.Contains(SecretBodyValue)), + .Concat(this.measurements.SelectMany(m => m.Value.Values).Select(v => v?.ToString() ?? "")) + .Concat(this.activities.Select(a => a.DisplayName)); + recorded.Should().NotContain(value => value.Contains(SecretBodyValue), "no span name, tag, or metric dimension may contain the request body"); } private Task Invoke() => - client.Invoke(FunctionName, options: new InvokeFunctionOptions + this.client.Invoke(FunctionName, options: new Client.InvokeFunctionOptions { Body = new Dictionary { { "name", SecretBodyValue } } }); private void MockInvokeOk() => - server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost()) + this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost()) .RespondWith(Response.Create() .WithStatusCode(200) .WithHeader("Content-Type", "application/json") .WithBody("{\"message\":\"ok\"}")); - private Activity SingleInvokeSpan() => - activities.Single(a => a.OperationName == $"POST /functions/v1/{FunctionName}"); + private Activity InvokeSpan() => this.activities.Single(a => a.OperationName == $"POST /functions/v1/{FunctionName}"); } } diff --git a/FunctionsTests/TestConventions.cs b/FunctionsTests/TestConventions.cs new file mode 100644 index 0000000..073969c --- /dev/null +++ b/FunctionsTests/TestConventions.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FunctionsTests +{ + /// + /// Mechanized guardrails for the suite itself: these fail the build when a test class or method drifts + /// from the documented conventions, so the rules are enforced deterministically instead of relying on + /// review. + /// + [TestClass] + [TestCategory("Unit")] + public class TestConventions + { + private static readonly Regex NamePattern = + new("^[A-Za-z][A-Za-z0-9]*_Should[A-Za-z0-9]+(_Given[A-Za-z0-9]+)?$", RegexOptions.Compiled); + + private static readonly Type[] TestClasses = typeof(TestConventions).Assembly.GetTypes() + .Where(type => type.GetCustomAttribute() != null) + .ToArray(); + + [TestMethod] + public void TestClass_ShouldDeclareTestCategory() + { + using (new AssertionScope()) + { + foreach (var type in TestClasses) + { + type.GetCustomAttributes().Should().ContainSingle( + $"{type.Name} must carry exactly one [TestCategory] tier (Unit/Contract/E2E)"); + } + } + } + + [TestMethod] + public void TestMethod_ShouldFollowNaming() + { + using (new AssertionScope()) + { + var testMethods = TestClasses + .SelectMany(type => type.GetMethods()) + .Where(method => method.GetCustomAttribute() != null); + foreach (var method in testMethods) + { + method.Name.Should().MatchRegex(NamePattern, + $"{method.DeclaringType!.Name}.{method.Name} must read Sut_ShouldConsequence[_GivenScenario]"); + } + } + } + } +}