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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -494,4 +494,5 @@ $RECYCLE.BIN/
.claude/settings.local.json

# Integration test run settings (contain secrets)
test/IntegrationTests/.runsettings/
core/test/IntegrationTests/.runsettings/
8 changes: 4 additions & 4 deletions test/IntegrationTests/CompatTeamsInfoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ public async Task GetMembersAsync_ReturnsTeamsChannelAccounts()
List<Microsoft.Bot.Schema.Teams.TeamsChannelAccount> members = [.. result];
Assert.NotEmpty(members);

foreach (Microsoft.Bot.Schema.Teams.TeamsChannelAccount m in members)
foreach (Microsoft.Bot.Schema.Teams.TeamsChannelAccount m in members.Take(5))
{
_output.WriteLine($"GetMembers: {m.Id} — {m.Name}");
}
Expand All @@ -152,7 +152,7 @@ public async Task GetPagedMembersAsync_ReturnsPaged()
Assert.NotNull(result.Members);
Assert.NotEmpty(result.Members);

foreach (Microsoft.Bot.Schema.Teams.TeamsChannelAccount m in result.Members)
foreach (Microsoft.Bot.Schema.Teams.TeamsChannelAccount m in result.Members.Take(5))
{
_output.WriteLine($"PagedMember: {m.Id} — {m.Name}");
}
Expand Down Expand Up @@ -203,7 +203,7 @@ public async Task GetTeamMembersAsync_ReturnsMembers()
List<Microsoft.Bot.Schema.Teams.TeamsChannelAccount> members = [.. result];
Assert.NotEmpty(members);

foreach (Microsoft.Bot.Schema.Teams.TeamsChannelAccount m in members)
foreach (Microsoft.Bot.Schema.Teams.TeamsChannelAccount m in members.Take(5))
{
_output.WriteLine($"TeamMember: {m.Id} — {m.Name}");
}
Expand All @@ -223,7 +223,7 @@ public async Task GetPagedTeamMembersAsync_ReturnsPaged()
Assert.NotNull(result.Members);
Assert.NotEmpty(result.Members);

foreach (Microsoft.Bot.Schema.Teams.TeamsChannelAccount m in result.Members)
foreach (Microsoft.Bot.Schema.Teams.TeamsChannelAccount m in result.Members.Take(5))
{
_output.WriteLine($"PagedTeamMember: {m.Id} — {m.Name}");
}
Expand Down
2 changes: 1 addition & 1 deletion test/IntegrationTests/ConversationClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public async Task GetConversationMembers()
Assert.NotNull(members);
Assert.NotEmpty(members);

foreach (ChannelAccount m in members)
foreach (ChannelAccount m in members.Take(5))
{
_output.WriteLine($"Member: {m.Id} — {m.Name}");
}
Expand Down
127 changes: 127 additions & 0 deletions test/IntegrationTests/FilesIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Teams.Apps.Files;
using Microsoft.Teams.Apps.Schema;
using Xunit.Abstractions;

namespace IntegrationTests;

/// <summary>
/// Integration tests for inbound file handling as shipped today (PR A), covering the two things unit tests
/// structurally cannot reach: that <see cref="FileDownloader"/> is actually wired into the DI container the app
/// builds, and that its byte-fetch contract holds against a real Microsoft endpoint over a real network.
/// <para><b>What these do and do not prove.</b> High fidelity for the <em>contract</em> the downloader depends on:
/// an https URL fetched with a plain unauthenticated GET, streamed with <c>ResponseHeadersRead</c>, returning bytes
/// or a clean 401. Low fidelity for <em>provenance</em>: these URLs are not Teams <c>tempauth</c> URLs, because no
/// automated route mints one. **These must never be described as end-to-end file receive.** Manual e2e remains the
/// only evidence for that.</para>
/// <para>Graph is used purely as a conveniently stable first-party HTTP endpoint. No token is sent and no Graph
/// application permission is required, so these run wherever the rest of the suite runs.</para>
/// </summary>
public class FilesIntegrationTests : IClassFixture<IntegrationTestFixture>
{
private readonly IntegrationTestFixture _f;
private readonly ITestOutputHelper _output;

public FilesIntegrationTests(IntegrationTestFixture fixture, ITestOutputHelper output)
{
_f = fixture;
_f.OutputHelper = output;
_output = output;
}

// Public, unauthenticated, returns real bytes with a real content-type. Stands in for the "URL that serves the
// file" half of the contract.
private static readonly Uri PublicBytesUrl = new("https://graph.microsoft.com/v1.0/$metadata");

// Requires auth, so an unauthenticated GET is a genuine 401 from a live Microsoft service. Stands in for an
// expired `tempauth` URL.
private static readonly Uri UnauthorizedUrl = new("https://graph.microsoft.com/v1.0/me");

private FileDownloader Downloader => _f.ServiceProvider.GetRequiredService<FileDownloader>();

// No Timeout: this one performs no I/O, and xUnit only supports Timeout on async tests.
[Fact]
[Trait("Category", "Files")]
public void FileDownloader_ResolvesFromTheContainerTheAppBuilds()
{
// Catches removal of `AddHttpClient<Files.FileDownloader>()`. TeamsBotApplication falls back to
// FileDownloader.CreateDefault() silently and with no log, so a broken registration degrades to a
// process-wide static HttpClient with no signal and every unit test still passing.
FileDownloader downloader = _f.ServiceProvider.GetRequiredService<FileDownloader>();

Assert.NotNull(downloader);

// Typed clients are transient, so two resolutions are distinct instances. Asserting that rather than
// singleton-ness documents the registration's actual lifetime instead of guessing at it.
Assert.NotSame(downloader, _f.ServiceProvider.GetRequiredService<FileDownloader>());
_output.WriteLine("FileDownloader resolved from DI container");
}

[Fact(Timeout = 15000)]
[Trait("Category", "Files")]
public async Task OpenFileStream_FetchesRealBytesOverTheRealNetwork()
{
await using OpenedFileStream stream = await Downloader.OpenFileStreamAsync(
ConversationType.Personal, PublicBytesUrl, contentType: null, priorFetchSucceeded: false, CancellationToken.None);

// Reading only a prefix is deliberate: it proves HttpCompletionOption.ResponseHeadersRead really streams
// rather than buffering the whole body, which a fake handler cannot demonstrate.
byte[] head = new byte[64];
int read = await stream.ReadAsync(head, CancellationToken.None);

Assert.True(read > 0);
Assert.Equal(PublicBytesUrl, stream.SourceUrl);

// Resolved from the live response, not from the fallback. Real header casing and parameter handling.
Assert.StartsWith("application/xml", stream.ContentType, StringComparison.OrdinalIgnoreCase);
_output.WriteLine($"Fetched {read} bytes, contentType={stream.ContentType}");
}

[Fact(Timeout = 15000)]
[Trait("Category", "Files")]
public async Task OpenFileStream_MapsARealUnauthorizedResponseOntoFileUrlExpired()
{
// The failure mode this exists for: if the platform ever answered an expired URL with a redirect to a login
// page, or 200 plus an HTML login body instead of 401, every mocked test would still pass and the downloader
// would hand callers HTML as if it were the file. Only a live call can tell the difference.
FileUrlExpiredException ex = await Assert.ThrowsAsync<FileUrlExpiredException>(
() => Downloader.OpenFileStreamAsync(
ConversationType.Personal, UnauthorizedUrl, contentType: null, priorFetchSucceeded: false, CancellationToken.None));

Assert.Equal(FileUrlExpiredReason.FirstFetch, ex.Reason);
_output.WriteLine($"Live 401 mapped to FileUrlExpiredException: {ex.Reason}");
}

[Fact(Timeout = 15000)]
[Trait("Category", "Files")]
public async Task OpenFileStream_ReportsRereadWhenAPriorFetchSucceeded()
{
// Same live 401, opposite reason. Pins that the caller-supplied re-fetch state, not the response, chooses
// between FirstFetch and Reread.
FileUrlExpiredException ex = await Assert.ThrowsAsync<FileUrlExpiredException>(
() => Downloader.OpenFileStreamAsync(
ConversationType.Personal, UnauthorizedUrl, contentType: null, priorFetchSucceeded: true, CancellationToken.None));

Assert.Equal(FileUrlExpiredReason.Reread, ex.Reason);
}

[Fact(Timeout = 15000)]
[Trait("Category", "Files")]
public async Task OpenFileStream_RejectsUnsupportedScopesAndNonHttpsUrls_BeforeAnyNetworkCall()
{
// Non-personal scopes have no receive path yet; this pins the shipped behaviour so PR J has to change it
// deliberately rather than by accident.
FileScopeNotSupportedException scopeEx = await Assert.ThrowsAsync<FileScopeNotSupportedException>(
() => Downloader.OpenFileStreamAsync(
ConversationType.GroupChat, PublicBytesUrl, contentType: null, priorFetchSucceeded: false, CancellationToken.None));
Assert.Equal(ConversationType.GroupChat, scopeEx.Scope);

InvalidOperationException httpEx = await Assert.ThrowsAsync<InvalidOperationException>(
() => Downloader.OpenFileStreamAsync(
ConversationType.Personal, new Uri("http://graph.microsoft.com/v1.0/$metadata"), contentType: null, priorFetchSucceeded: false, CancellationToken.None));
Assert.Contains("must use https", httpEx.Message, StringComparison.Ordinal);
}
}
9 changes: 9 additions & 0 deletions test/IntegrationTests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ Tests are configured via `.runsettings` files that set environment variables. Fo

Place your `.runsettings` files in the `.runsettings/` directory (gitignored).

> Verify that before you write a secret into it. The ignore rule pointed at a
> pre-migration path until [#659](https://github.com/microsoft/teams.net/pull/659), so on
> older branches this directory is **not** ignored despite what this line says:
>
> ```bash
> git check-ignore -v test/IntegrationTests/.runsettings/botid-prod.runsettings
> # no output means NOT ignored — fix .gitignore first
> ```

### Required environment variables

```xml
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Teams.Apps.Files;
using Microsoft.Teams.Apps.Schema;

namespace Microsoft.Teams.Apps.UnitTests.Files;

/// <summary>
/// Transport invariants for the file download request, as shipped today. A download URL embeds its own
/// <c>tempauth</c> credential and points at a third-party storage host, so the request must not carry bot
/// credentials: attaching one can get the request rejected and sends the credential to a host with no business
/// seeing it.
/// <para><b>Where the invariant actually lives.</b> <see cref="FileDownloader"/> does not strip anything. It issues a
/// plain GET and inherits whatever its <see cref="HttpClient"/> carries, because <see cref="HttpClient"/> merges
/// <c>DefaultRequestHeaders</c> into every request. The guarantee therefore rests entirely on the registration:
/// <c>AddHttpClient&lt;FileDownloader&gt;()</c> is deliberately bare, and the <c>AddBotHttpClient</c> call above it,
/// which is what attaches bot authentication, is deliberately not applied to it.</para>
/// <para>So these tests assert the invariant at the level that holds it, the DI container, rather than at a level
/// that would require changing the shipped downloader. teams.ts asserts the equivalent in
/// <c>download.http-client.spec.ts</c>; C# had no equivalent at any level.</para>
/// <para>This matters more once PR J lands, since it adds a Graph path on the same downloader that <em>does</em>
/// authenticate. Once one path attaches a credential and the other must not, "nobody has configured one" stops being
/// a safe place to leave the guarantee.</para>
/// </summary>
public class FileDownloaderTransportTests
{
private const string DownloadUrl = "https://download.example/notes.txt?tempauth=abc";

/// <summary>Records the outbound request and answers with a canned body, so nothing reaches the network.</summary>
private sealed class RecordingHandler : HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = [];

public HttpRequestMessage Last => Assert.Single(Requests);

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("bytes")),
});
}
}

private static IncomingFile PersonalFile(FileDownloader downloader)
=> new("notes.txt", ConversationType.Personal, FileSource.BotActivity, downloader)
{
DownloadUrl = new Uri(DownloadUrl),
};

/// <summary>
/// Builds the container the hosting extensions build, then overrides only the primary handler of the typed
/// client they already registered. Deliberately does <b>not</b> call <c>AddHttpClient&lt;FileDownloader&gt;()</c>
/// itself: doing so would register the downloader independently, so these tests would keep passing even if the
/// hosting extensions stopped registering it at all. Reaching into the existing
/// <see cref="HttpClientFactoryOptions"/> instead means resolution fails outright if the production registration
/// goes away, which is the regression these tests exist to catch.
/// <para>Every other piece of the shipped registration, including any handler or default header it configures,
/// is left intact, so what the recorder sees is what a real download would send.</para>
/// </summary>
private static ServiceProvider BuildAppContainer(RecordingHandler handler, Action<HttpClientFactoryOptions>? extraConfig = null)
{
ServiceCollection services = new();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AzureAd:ClientId"] = "files-transport-client-id",
["AzureAd:TenantId"] = "files-transport-tenant-id",
})
.Build());
services.AddLogging();
services.AddTeamsBotApplication();

// The name AddHttpClient<FileDownloader>() registers its options under.
services.Configure<HttpClientFactoryOptions>(nameof(FileDownloader), options =>
{
options.HttpMessageHandlerBuilderActions.Add(b => b.PrimaryHandler = handler);
extraConfig?.Invoke(options);
});

return services.BuildServiceProvider();
}

[Fact]
public async Task DiRegisteredDownloader_SendsNoAuthorizationHeader()
{
RecordingHandler handler = new();
using ServiceProvider provider = BuildAppContainer(handler);

await PersonalFile(provider.GetRequiredService<FileDownloader>()).DownloadAsync();

// Goes red if anyone attaches bot auth to this registration, adds a credential default header, or applies a
// global auth handler across AddHttpClient registrations. That is the regression worth catching: the code
// comment at the registration site asks for this, and nothing else enforces it.
Assert.False(handler.Last.Headers.Contains("Authorization"));
Assert.Null(handler.Last.Headers.Authorization);
}

[Fact]
public async Task DiRegisteredDownloader_SendsAPlainGetToTheDownloadUrl()
{
RecordingHandler handler = new();
using ServiceProvider provider = BuildAppContainer(handler);

await PersonalFile(provider.GetRequiredService<FileDownloader>()).DownloadAsync();

Assert.Equal(HttpMethod.Get, handler.Last.Method);
Assert.Equal(new Uri(DownloadUrl), handler.Last.RequestUri);
Assert.Null(handler.Last.Content);
}

/// <summary>
/// Characterizes the shipped behaviour that makes the test above the one that matters: the downloader has no
/// defence of its own. This is documentation of a constraint, not an endorsement of it. If C# ever grows an
/// explicit strip, as teams.ts has, this test is the one that should change.
/// </summary>
[Fact]
public async Task Download_ForwardsClientDefaults_SoTheTypedClientMustStayCredentialFree()
{
RecordingHandler handler = new();
HttpClient credentialed = new(handler);
credentialed.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "token");
credentialed.DefaultRequestHeaders.UserAgent.ParseAdd("teams.net-test/1.0");

await PersonalFile(new FileDownloader(credentialed)).DownloadAsync();

// HttpClient merges DefaultRequestHeaders into every request and offers no per-request opt-out, so a
// credential on the client reaches the storage host. Hence the guarantee has to be held at registration.
Assert.True(handler.Last.Headers.Contains("Authorization"));
Assert.Equal(["teams.net-test/1.0"], handler.Last.Headers.GetValues("User-Agent"));
}
}
Loading
Loading