Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -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"
15 changes: 15 additions & 0 deletions Azure-Functions-Document-Publishing-Example.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
19 changes: 6 additions & 13 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions infra/environments/dev/gha_runtime_roles.tf
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 6 additions & 0 deletions infra/environments/dev/providers.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -18,3 +22,5 @@ provider "azurerm" {
storage_use_azuread = true
features {}
}

provider "azuread" {}
6 changes: 6 additions & 0 deletions infra/environments/dev/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
39 changes: 39 additions & 0 deletions tests/Integration.Tests/DeadLetterPathTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Xunit;

namespace DocumentPublishing.Integration.Tests;

[Trait("Category", "Integration")]
public class DeadLetterPathTests : IClassFixture<PipelineFixture>
{
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);
}
}
38 changes: 38 additions & 0 deletions tests/Integration.Tests/HappyPathTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Xunit;

namespace DocumentPublishing.Integration.Tests;

[Trait("Category", "Integration")]
public class HappyPathTests : IClassFixture<PipelineFixture>
{
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);
}
}
19 changes: 19 additions & 0 deletions tests/Integration.Tests/Integration.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>DocumentPublishing.Integration.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Azure.Identity" Version="1.13.1" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.2" />
<PackageReference Include="Azure.Monitor.Query" Version="1.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Shared\Shared.csproj" />
</ItemGroup>
</Project>
Loading