From c3da1f4b0f8f18a799eb600b5561c86e6eaac734 Mon Sep 17 00:00:00 2001 From: Christopher House Date: Fri, 29 May 2026 10:09:59 -0500 Subject: [PATCH] Add integration test workflow against deployed dev Two workflow_dispatch tests in a new Integration.Tests xunit project: - HappyPathTests.SeededItem_PublishesEndToEnd: publishes a message with the SharePointMock's seeded welcome.txt tuple, polls the Log Analytics workspace for `AppEvents` where Name=DocumentPublished and EventId matches the message id, asserts the SharePoint dimensions are present. - DeadLetterPathTests.UnknownItem_404s_AndEventuallyDeadLetters: publishes with a unique non-seeded itemId, polls for DocumentPublishingDeadLettered with that EventId, asserts the DeadLetterReason is recorded. PipelineFixture wraps SB sender + LogsQueryClient (both DefaultAzureCredential) plus a global ActivityListener so trace context propagates onto the message. New `.github/workflows/integration.yml` runs on workflow_dispatch only. Resolves the SB namespace and LAWS workspace ID via `az`, then hands them to the test runner via env vars. Configurable poll timeouts via workflow inputs (default 360s / 600s). Infra: bootstrap only granted the GHA SP Owner on the RG, which doesn't include data-plane actions. Adds `gha_runtime_roles.tf` granting "Azure Service Bus Data Sender" on the topic and "Log Analytics Reader" on the workspace, using an azuread data source on the SP's client_id (default to the known bootstrap-created app). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/integration.yml | 77 +++++++++++ ...-Functions-Document-Publishing-Example.sln | 15 +++ BACKLOG.md | 19 +-- infra/environments/dev/gha_runtime_roles.tf | 20 +++ infra/environments/dev/providers.tf | 6 + infra/environments/dev/variables.tf | 6 + .../Integration.Tests/DeadLetterPathTests.cs | 39 ++++++ tests/Integration.Tests/HappyPathTests.cs | 38 ++++++ .../Integration.Tests.csproj | 19 +++ tests/Integration.Tests/PipelineFixture.cs | 122 ++++++++++++++++++ tests/Integration.Tests/TestEnvironment.cs | 51 ++++++++ 11 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/integration.yml create mode 100644 infra/environments/dev/gha_runtime_roles.tf create mode 100644 tests/Integration.Tests/DeadLetterPathTests.cs create mode 100644 tests/Integration.Tests/HappyPathTests.cs create mode 100644 tests/Integration.Tests/Integration.Tests.csproj create mode 100644 tests/Integration.Tests/PipelineFixture.cs create mode 100644 tests/Integration.Tests/TestEnvironment.cs diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..8266d58 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,77 @@ +name: integration + +on: + workflow_dispatch: + inputs: + poll_timeout_seconds: + description: "Happy-path poll timeout (seconds)" + required: false + default: "360" + deadletter_timeout_seconds: + description: "Dead-letter poll timeout (seconds)" + required: false + default: "600" + +permissions: + id-token: write + contents: read + +env: + DOTNET_VERSION: "10.0.x" + RESOURCE_GROUP: rg-azfunc-pub-dev + +jobs: + integration: + runs-on: ubuntu-latest + environment: dev + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: resolve Service Bus + LAWS + id: resolve + run: | + sb_namespace=$(az servicebus namespace list \ + -g ${{ env.RESOURCE_GROUP }} \ + --query "[0].name" -o tsv) + if [ -z "$sb_namespace" ]; then + echo "Service Bus namespace not found in ${{ env.RESOURCE_GROUP }}." >&2 + exit 1 + fi + + laws_workspace_id=$(az monitor log-analytics workspace list \ + -g ${{ env.RESOURCE_GROUP }} \ + --query "[0].customerId" -o tsv) + if [ -z "$laws_workspace_id" ]; then + echo "Log Analytics workspace not found in ${{ env.RESOURCE_GROUP }}." >&2 + exit 1 + fi + + echo "sb_fqdn=${sb_namespace}.servicebus.windows.net" >> "$GITHUB_OUTPUT" + echo "laws_workspace_id=${laws_workspace_id}" >> "$GITHUB_OUTPUT" + + - name: restore + run: dotnet restore tests/Integration.Tests/Integration.Tests.csproj + + - name: dotnet test (integration) + env: + INTEGRATION_SB_NAMESPACE: ${{ steps.resolve.outputs.sb_fqdn }} + INTEGRATION_LAWS_WORKSPACE_ID: ${{ steps.resolve.outputs.laws_workspace_id }} + INTEGRATION_POLL_TIMEOUT_SECONDS: ${{ inputs.poll_timeout_seconds }} + INTEGRATION_DEADLETTER_TIMEOUT_SECONDS: ${{ inputs.deadletter_timeout_seconds }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + run: | + dotnet test tests/Integration.Tests/Integration.Tests.csproj \ + -c Release --no-restore --nologo \ + --filter "Category=Integration" \ + --logger "console;verbosity=normal" diff --git a/Azure-Functions-Document-Publishing-Example.sln b/Azure-Functions-Document-Publishing-Example.sln index f9bc7d5..c5e2ec8 100644 --- a/Azure-Functions-Document-Publishing-Example.sln +++ b/Azure-Functions-Document-Publishing-Example.sln @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Function.Tests", "tests\Fun EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Integration.Tests", "tests\Integration.Tests\Integration.Tests.csproj", "{0D489430-7740-4F34-B741-3E1B10CA5EAC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -99,11 +101,24 @@ Global {92021E13-C7C2-42F2-B9D2-7B5BEDC1CEF4}.Release|x64.Build.0 = Release|Any CPU {92021E13-C7C2-42F2-B9D2-7B5BEDC1CEF4}.Release|x86.ActiveCfg = Release|Any CPU {92021E13-C7C2-42F2-B9D2-7B5BEDC1CEF4}.Release|x86.Build.0 = Release|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Debug|x64.ActiveCfg = Debug|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Debug|x64.Build.0 = Debug|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Debug|x86.ActiveCfg = Debug|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Debug|x86.Build.0 = Debug|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Release|Any CPU.Build.0 = Release|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Release|x64.ActiveCfg = Release|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Release|x64.Build.0 = Release|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Release|x86.ActiveCfg = Release|Any CPU + {0D489430-7740-4F34-B741-3E1B10CA5EAC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {92021E13-C7C2-42F2-B9D2-7B5BEDC1CEF4} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {0D489430-7740-4F34-B741-3E1B10CA5EAC} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/BACKLOG.md b/BACKLOG.md index 83542d5..4093aeb 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -3,15 +3,14 @@ Living doc so a fresh Claude session can resume cold. Keep entries terse; delete what's done. -## Current state (snapshot 2026-05-28) +## Current state (snapshot 2026-05-29) -- **Merged:** PR #1 scaffold, PR #2 storage AAD fix, PR #3 function unit tests + CI, PR #4 backlog doc, PR #5 dead-letter handler, PR #6 custom telemetry, PR #7 lint workflow, PR #8 bump GH actions, PR #9 dependabot config, PR #10 SharePoint mock store + 404s, PR #11 README polish + Mermaid diagram. -- **Deployed to `rg-azfunc-pub-dev` (Sub `8bd05b2f-...`, East US 2):** all infra applied — Service Bus, ACR, CAE, both container apps, function app + plan + storage, App Insights + LAWS, UAMIs, RBAC, diagnostic settings. -- **NOT yet deployed:** - - **Function code** — function app is empty. The `function-app` workflow only triggers on changes under `src/Function/**`, `src/Shared/**`, or `tests/Function.Tests/**`. Any change to those (or `workflow_dispatch`) ships it. - - **Mock images** — `mocks` workflow only triggers on changes under `src/SharePointMock/**`, `src/SitecoreMock/**`, or `src/Shared/**`. Until it runs, both container apps are on the public `mcr.microsoft.com/k8se/quickstart:latest` placeholder. +- **Merged:** PR #1 scaffold, PR #2 storage AAD fix, PR #3 function unit tests + CI, PR #4 backlog doc, PR #5 dead-letter handler, PR #6 custom telemetry, PR #7 lint workflow, PR #8 bump GH actions, PR #9 dependabot config, PR #10 SharePoint mock store + 404s, PR #11 README polish + Mermaid diagram, PR #12 integration test workflow. +- **All infra applied** to `rg-azfunc-pub-dev` (Sub `8bd05b2f-...`, East US 2): Service Bus, ACR, CAE, both container apps, function app + plan + storage, App Insights + LAWS, UAMIs, RBAC, diagnostic settings. +- **Function code deployed** to `func-azfunc-pub-dev*` via `function-app` workflow (latest run 2026-05-29). +- **Mock images deployed** to both container apps via `mocks` workflow (latest run 2026-05-29 after the DocumentStore PR). +- **End-to-end on Azure**: covered by the new `integration` workflow (`workflow_dispatch`), runs happy-path + dead-letter assertions against the live pipeline. - **Local SB emulator** ready under `local/service-bus-emulator/`. -- **End-to-end on Azure has never run** because of the deployment gap above. Pushing any change that touches the relevant paths (or manually dispatching both workflows) closes it. ## Open PRs @@ -25,12 +24,6 @@ CLI, gh. Plus a `Makefile` (or `tasks.json`) wrapping common flows: `make local-up` (emulator + both mocks + `func start`), `make publish-event`, `make destroy-dev`. -### 2. Integration tests against deployed dev *(nice-to-have)* -A workflow-dispatch-only test that hits the deployed function via the -publisher CLI, asserts the SharePoint mock saw the GETs and the Sitecore -mock saw the POST. Runs against real Azure. Needs a way to inspect ACA logs -or scrape `webhook.site` programmatically. - ## Things I've considered but don't think are worth doing - Bicep alternative of the tofu modules — duplication, no upside. diff --git a/infra/environments/dev/gha_runtime_roles.tf b/infra/environments/dev/gha_runtime_roles.tf new file mode 100644 index 0000000..53e94bf --- /dev/null +++ b/infra/environments/dev/gha_runtime_roles.tf @@ -0,0 +1,20 @@ +# Data-plane access for the GitHub Actions federated SP so the `integration` +# workflow can publish to the Service Bus topic and query App Insights via the +# underlying Log Analytics workspace. Bootstrap only grants Owner on the RG; +# Owner doesn't include data-plane actions for SB or LAWS. + +data "azuread_service_principal" "gha" { + client_id = var.gha_client_id +} + +resource "azurerm_role_assignment" "gha_sb_sender" { + scope = module.service_bus.topic_id + role_definition_name = "Azure Service Bus Data Sender" + principal_id = data.azuread_service_principal.gha.object_id +} + +resource "azurerm_role_assignment" "gha_laws_reader" { + scope = module.observability.log_analytics_id + role_definition_name = "Log Analytics Reader" + principal_id = data.azuread_service_principal.gha.object_id +} diff --git a/infra/environments/dev/providers.tf b/infra/environments/dev/providers.tf index 2658aa4..42ec0ea 100644 --- a/infra/environments/dev/providers.tf +++ b/infra/environments/dev/providers.tf @@ -6,6 +6,10 @@ terraform { source = "hashicorp/azurerm" version = "~> 4.0" } + azuread = { + source = "hashicorp/azuread" + version = "~> 3.0" + } random = { source = "hashicorp/random" version = "~> 3.6" @@ -18,3 +22,5 @@ provider "azurerm" { storage_use_azuread = true features {} } + +provider "azuread" {} diff --git a/infra/environments/dev/variables.tf b/infra/environments/dev/variables.tf index c955f39..a470ed7 100644 --- a/infra/environments/dev/variables.tf +++ b/infra/environments/dev/variables.tf @@ -38,3 +38,9 @@ variable "placeholder_image" { type = string default = "mcr.microsoft.com/k8se/quickstart:latest" } + +variable "gha_client_id" { + description = "Application (client) ID of the GitHub Actions federated service principal. Used to grant data-plane access for the integration test workflow." + type = string + default = "4d66e196-0ed9-4f7d-9d23-c853699b82e8" +} diff --git a/tests/Integration.Tests/DeadLetterPathTests.cs b/tests/Integration.Tests/DeadLetterPathTests.cs new file mode 100644 index 0000000..6329c1a --- /dev/null +++ b/tests/Integration.Tests/DeadLetterPathTests.cs @@ -0,0 +1,39 @@ +using Xunit; + +namespace DocumentPublishing.Integration.Tests; + +[Trait("Category", "Integration")] +public class DeadLetterPathTests : IClassFixture +{ + private readonly PipelineFixture _fixture; + + public DeadLetterPathTests(PipelineFixture fixture) => _fixture = fixture; + + [Fact] + public async Task UnknownItem_404s_AndEventuallyDeadLetters() + { + // Unique itemId so we can correlate this run's dead-letter event. + // SharePointMock will 404 since the store isn't seeded with it. + var itemId = $"does-not-exist-{Guid.NewGuid():N}"; + + var eventId = await _fixture.PublishEventAsync( + siteId: "site-default", + driveId: "drive-default", + itemId: itemId, + fileName: "missing.txt"); + + var result = await _fixture.PollForCustomEventAsync( + eventName: "DocumentPublishingDeadLettered", + eventIdPropertyName: "EventId", + eventIdValue: eventId, + timeout: TestEnvironment.DeadLetterPollTimeout, + pollInterval: TestEnvironment.PollInterval); + + Assert.NotNull(result); + var row = result!.Rows[0]; + var props = row["Properties"]?.ToString() ?? string.Empty; + + Assert.Contains($"\"ItemId\":\"{itemId}\"", props); + Assert.Contains("\"DeadLetterReason\"", props); + } +} diff --git a/tests/Integration.Tests/HappyPathTests.cs b/tests/Integration.Tests/HappyPathTests.cs new file mode 100644 index 0000000..71699be --- /dev/null +++ b/tests/Integration.Tests/HappyPathTests.cs @@ -0,0 +1,38 @@ +using Xunit; + +namespace DocumentPublishing.Integration.Tests; + +[Trait("Category", "Integration")] +public class HappyPathTests : IClassFixture +{ + private readonly PipelineFixture _fixture; + + public HappyPathTests(PipelineFixture fixture) => _fixture = fixture; + + [Fact] + public async Task SeededItem_PublishesEnd_To_End_And_EmitsDocumentPublished() + { + // SharePointMock's DocumentStore seeds welcome.txt at this tuple. + var eventId = await _fixture.PublishEventAsync( + siteId: "site-default", + driveId: "drive-default", + itemId: "item-default", + fileName: "welcome.txt"); + + var result = await _fixture.PollForCustomEventAsync( + eventName: "DocumentPublished", + eventIdPropertyName: "EventId", + eventIdValue: eventId, + timeout: TestEnvironment.PollTimeout, + pollInterval: TestEnvironment.PollInterval); + + Assert.NotNull(result); + var row = result!.Rows[0]; + var props = row["Properties"]?.ToString() ?? string.Empty; + + Assert.Contains("\"SiteId\":\"site-default\"", props); + Assert.Contains("\"DriveId\":\"drive-default\"", props); + Assert.Contains("\"ItemId\":\"item-default\"", props); + Assert.Contains("\"FileName\":\"welcome.txt\"", props); + } +} diff --git a/tests/Integration.Tests/Integration.Tests.csproj b/tests/Integration.Tests/Integration.Tests.csproj new file mode 100644 index 0000000..0bdc51a --- /dev/null +++ b/tests/Integration.Tests/Integration.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + DocumentPublishing.Integration.Tests + false + true + + + + + + + + + + + + + diff --git a/tests/Integration.Tests/PipelineFixture.cs b/tests/Integration.Tests/PipelineFixture.cs new file mode 100644 index 0000000..1fd39bc --- /dev/null +++ b/tests/Integration.Tests/PipelineFixture.cs @@ -0,0 +1,122 @@ +using System.Diagnostics; +using System.Text.Json; +using Azure.Identity; +using Azure.Messaging.ServiceBus; +using Azure.Monitor.Query; +using Azure.Monitor.Query.Models; +using DocumentPublishing.Shared.Events; +using Xunit; + +namespace DocumentPublishing.Integration.Tests; + +/// Shared resources for the integration suite: SB client (DefaultAzureCredential), +/// LAWS query client, and a single ActivityListener so trace context propagates. +public sealed class PipelineFixture : IAsyncLifetime +{ + public ServiceBusClient ServiceBus { get; private set; } = default!; + public ServiceBusSender Sender { get; private set; } = default!; + public LogsQueryClient Logs { get; private set; } = default!; + public ActivitySource Activity { get; } = new("DocumentPublishing.Integration.Tests"); + + private ActivityListener? _listener; + + public Task InitializeAsync() + { + var credential = new DefaultAzureCredential(); + ServiceBus = new ServiceBusClient(TestEnvironment.ServiceBusNamespace, credential); + Sender = ServiceBus.CreateSender(TestEnvironment.ServiceBusTopic); + Logs = new LogsQueryClient(credential); + + _listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + }; + ActivitySource.AddActivityListener(_listener); + + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + _listener?.Dispose(); + await Sender.DisposeAsync(); + await ServiceBus.DisposeAsync(); + } + + public async Task PublishEventAsync( + string siteId, + string driveId, + string itemId, + string fileName) + { + using var activity = Activity.StartActivity("PublishIntegrationEvent", ActivityKind.Producer); + + var evt = new SharePointListUpdatedEvent + { + Subject = $"listItem/{itemId}", + Data = new SharePointListUpdatedData + { + SiteId = siteId, + DriveId = driveId, + ItemId = itemId, + FileName = fileName, + Resource = $"sites/{siteId}/drives/{driveId}/items/{itemId}", + SiteUrl = $"/sites/{siteId}", + }, + }; + + var message = new ServiceBusMessage(JsonSerializer.Serialize(evt)) + { + ContentType = "application/cloudevents+json", + MessageId = evt.Id, + Subject = evt.Subject, + }; + + if (System.Diagnostics.Activity.Current is { } current) + { + message.ApplicationProperties["Diagnostic-Id"] = current.Id!; + message.ApplicationProperties["traceparent"] = current.Id!; + } + + await Sender.SendMessageAsync(message); + return evt.Id; + } + + public async Task PollForCustomEventAsync( + string eventName, + string eventIdPropertyName, + string eventIdValue, + TimeSpan timeout, + TimeSpan pollInterval) + { + var deadline = DateTimeOffset.UtcNow + timeout; + var query = $@" +AppEvents +| where Name == '{eventName}' +| where Properties['{eventIdPropertyName}'] == '{eventIdValue}' +| project TimeGenerated, Name, Properties, Measurements +| order by TimeGenerated desc +| take 1"; + + var queryWindow = TimeSpan.FromMinutes(30); + while (DateTimeOffset.UtcNow < deadline) + { + var response = await Logs.QueryWorkspaceAsync( + TestEnvironment.LogAnalyticsWorkspaceId, + query, + new QueryTimeRange(queryWindow)); + + var table = response.Value.Table; + if (table.Rows.Count > 0) + { + return table; + } + + await Task.Delay(pollInterval); + } + + return null; + } +} diff --git a/tests/Integration.Tests/TestEnvironment.cs b/tests/Integration.Tests/TestEnvironment.cs new file mode 100644 index 0000000..02b62ac --- /dev/null +++ b/tests/Integration.Tests/TestEnvironment.cs @@ -0,0 +1,51 @@ +using Xunit; + +namespace DocumentPublishing.Integration.Tests; + +/// Reads required env vars at test-startup time. Throws SkipException-style +/// failure with a clear message if any are missing — the workflow is the only +/// runner that sets these, so a local `dotnet test` of this project surfaces +/// "use the integration workflow" rather than a cryptic null-ref. +internal static class TestEnvironment +{ + public static string ServiceBusNamespace => + Require("INTEGRATION_SB_NAMESPACE"); + + public static string ServiceBusTopic => + Optional("INTEGRATION_SB_TOPIC") ?? "sbt-sharepoint-events"; + + public static string LogAnalyticsWorkspaceId => + Require("INTEGRATION_LAWS_WORKSPACE_ID"); + + public static TimeSpan PollTimeout => + TimeSpan.FromSeconds(int.Parse(Optional("INTEGRATION_POLL_TIMEOUT_SECONDS") ?? "360")); + + public static TimeSpan DeadLetterPollTimeout => + TimeSpan.FromSeconds(int.Parse(Optional("INTEGRATION_DEADLETTER_TIMEOUT_SECONDS") ?? "600")); + + public static TimeSpan PollInterval => TimeSpan.FromSeconds(15); + + private static string Require(string name) + { + var value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw new SkipException( + $"Required env var {name} not set. These tests run via the `integration` workflow (workflow_dispatch)."); + } + return value; + } + + private static string? Optional(string name) + { + var value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) ? null : value; + } +} + +/// xunit doesn't ship a first-class skip; throwing this is the conventional dodge +/// (it surfaces as a failure but with a clear "not run" message in the log). +internal sealed class SkipException : Exception +{ + public SkipException(string message) : base(message) { } +}