Skip to content

Commit fbfc1ee

Browse files
committed
test(providers): cover CopilotRequestPolicy headers and plugin wiring
Adds two test files: CopilotRequestPolicyTests - ProcessAsync_AppliesAllFourRequiredHeaders: asserts Authorization (Bearer + Copilot token), copilot-integration-id, editor-version, and openai-intent all land on the outbound request, and that the next policy in the pipeline runs. - ProcessAsync_OverwritesPreviousAuthorizationHeader: confirms the placeholder ApiKeyCredential the SDK sets is replaced with the real short-lived Copilot bearer on every call (the production failure mode this guards against is a stale placeholder reaching the API). GitHubCopilotProviderPluginTests - CreateChatClient_DefaultEndpoint_ReturnsNonNullClient - CreateChatClient_CustomEndpoint_DoesNotThrow - Plugin_AdvertisesGitHubCopilotTypeKey Brings the Copilot test count from 14 to 19. Closes the gap noted in the PR review around 'no integration test for the chat-completion path'.
1 parent 51cae87 commit fbfc1ee

2 files changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="CopilotRequestPolicyTests.cs" company="Petabridge, LLC">
3+
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
using System.ClientModel.Primitives;
7+
using System.Net;
8+
using System.Text;
9+
using System.Text.Json;
10+
using Netclaw.Configuration;
11+
using Netclaw.Providers.GitHubCopilot;
12+
using Netclaw.Tests.Utilities;
13+
using Xunit;
14+
15+
namespace Netclaw.Daemon.Tests.Providers.GitHubCopilot;
16+
17+
public sealed class CopilotRequestPolicyTests
18+
{
19+
private static ProviderEntry OAuthEntry(string token = "oauth-1") =>
20+
new()
21+
{
22+
Type = "github-copilot",
23+
AuthMethod = AuthMethod.OAuthDevice,
24+
OAuthAccessToken = new SensitiveString(token),
25+
};
26+
27+
private static CopilotTokenExchanger ExchangerReturning(string copilotToken)
28+
{
29+
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
30+
{
31+
Content = new StringContent(
32+
JsonSerializer.Serialize(new
33+
{
34+
token = copilotToken,
35+
expires_at = DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds(),
36+
}),
37+
Encoding.UTF8,
38+
"application/json"),
39+
});
40+
return new CopilotTokenExchanger(new HttpClient(handler));
41+
}
42+
43+
[Fact]
44+
public async Task ProcessAsync_AppliesAllFourRequiredHeaders()
45+
{
46+
var policy = new CopilotRequestPolicy(
47+
ExchangerReturning("copilot-bearer"), OAuthEntry());
48+
49+
var clientPipeline = ClientPipeline.Create(new ClientPipelineOptions());
50+
using var message = clientPipeline.CreateMessage();
51+
message.Request.Method = "POST";
52+
message.Request.Uri = new Uri("https://api.githubcopilot.com/chat/completions");
53+
54+
var captured = false;
55+
var terminal = new TerminalCapturingPolicy(() => captured = true);
56+
IReadOnlyList<PipelinePolicy> pipeline = [policy, terminal];
57+
58+
await policy.ProcessAsync(message, pipeline, 0);
59+
60+
Assert.True(captured);
61+
62+
message.Request.Headers.TryGetValue("Authorization", out var auth);
63+
Assert.Equal("Bearer copilot-bearer", auth);
64+
65+
message.Request.Headers.TryGetValue("copilot-integration-id", out var integrationId);
66+
Assert.Equal("vscode-chat", integrationId);
67+
68+
message.Request.Headers.TryGetValue("editor-version", out var editorVersion);
69+
Assert.False(string.IsNullOrWhiteSpace(editorVersion),
70+
"editor-version header must be present");
71+
72+
message.Request.Headers.TryGetValue("openai-intent", out var intent);
73+
Assert.Equal("conversation-agent", intent);
74+
}
75+
76+
[Fact]
77+
public async Task ProcessAsync_OverwritesPreviousAuthorizationHeader()
78+
{
79+
// The OpenAI SDK populates Authorization from the placeholder ApiKeyCredential
80+
// we pass in. The policy must overwrite it on every call with the real
81+
// short-lived Copilot token; a stale placeholder bearer is a 401 in production.
82+
var policy = new CopilotRequestPolicy(
83+
ExchangerReturning("copilot-real"), OAuthEntry());
84+
85+
var clientPipeline = ClientPipeline.Create(new ClientPipelineOptions());
86+
using var message = clientPipeline.CreateMessage();
87+
message.Request.Method = "POST";
88+
message.Request.Uri = new Uri("https://api.githubcopilot.com/chat/completions");
89+
message.Request.Headers.Set("Authorization", "Bearer placeholder");
90+
91+
IReadOnlyList<PipelinePolicy> pipeline = [policy, new TerminalCapturingPolicy(() => { })];
92+
93+
await policy.ProcessAsync(message, pipeline, 0);
94+
95+
message.Request.Headers.TryGetValue("Authorization", out var auth);
96+
Assert.Equal("Bearer copilot-real", auth);
97+
}
98+
99+
/// <summary>
100+
/// No-op terminal policy that records whether the previous policy invoked
101+
/// the chain. Used so ProcessNextAsync has somewhere to land without
102+
/// pulling in a real HTTP transport.
103+
/// </summary>
104+
private sealed class TerminalCapturingPolicy(Action onInvoke) : PipelinePolicy
105+
{
106+
public override void Process(
107+
PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
108+
{
109+
onInvoke();
110+
}
111+
112+
public override ValueTask ProcessAsync(
113+
PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
114+
{
115+
onInvoke();
116+
return ValueTask.CompletedTask;
117+
}
118+
}
119+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="GitHubCopilotProviderPluginTests.cs" company="Petabridge, LLC">
3+
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
using System.Net;
7+
using System.Text;
8+
using System.Text.Json;
9+
using Netclaw.Configuration;
10+
using Netclaw.Providers.GitHubCopilot;
11+
using Netclaw.Tests.Utilities;
12+
using Xunit;
13+
14+
namespace Netclaw.Daemon.Tests.Providers.GitHubCopilot;
15+
16+
public sealed class GitHubCopilotProviderPluginTests
17+
{
18+
private static CopilotTokenExchanger NewExchanger()
19+
{
20+
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
21+
{
22+
Content = new StringContent(
23+
JsonSerializer.Serialize(new
24+
{
25+
token = "copilot-token",
26+
expires_at = DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds(),
27+
}),
28+
Encoding.UTF8,
29+
"application/json"),
30+
});
31+
return new CopilotTokenExchanger(new HttpClient(handler));
32+
}
33+
34+
private static GitHubCopilotProviderPlugin NewPlugin()
35+
{
36+
var exchanger = NewExchanger();
37+
var descriptor = new GitHubCopilotDescriptor(new HttpClient(), exchanger);
38+
return new GitHubCopilotProviderPlugin(descriptor, exchanger);
39+
}
40+
41+
[Fact]
42+
public void CreateChatClient_DefaultEndpoint_ReturnsNonNullClient()
43+
{
44+
var plugin = NewPlugin();
45+
var entry = new ProviderEntry
46+
{
47+
Type = "github-copilot",
48+
AuthMethod = AuthMethod.OAuthDevice,
49+
OAuthAccessToken = new SensitiveString("oauth-1"),
50+
};
51+
var model = new ModelReference { Provider = "my-copilot", ModelId = "gpt-4o" };
52+
53+
var client = plugin.CreateChatClient(entry, model);
54+
55+
Assert.NotNull(client);
56+
}
57+
58+
[Fact]
59+
public void CreateChatClient_CustomEndpoint_DoesNotThrow()
60+
{
61+
// Operators may point the entry at a corporate proxy in front of
62+
// api.githubcopilot.com. The plugin must respect the override and
63+
// not double-up the trailing slash.
64+
var plugin = NewPlugin();
65+
var entry = new ProviderEntry
66+
{
67+
Type = "github-copilot",
68+
AuthMethod = AuthMethod.OAuthDevice,
69+
OAuthAccessToken = new SensitiveString("oauth-1"),
70+
Endpoint = "https://copilot-proxy.example.com/",
71+
};
72+
var model = new ModelReference { Provider = "my-copilot", ModelId = "gpt-4o" };
73+
74+
var client = plugin.CreateChatClient(entry, model);
75+
76+
Assert.NotNull(client);
77+
}
78+
79+
[Fact]
80+
public void Plugin_AdvertisesGitHubCopilotTypeKey()
81+
{
82+
var plugin = NewPlugin();
83+
Assert.Equal("github-copilot", plugin.TypeKey);
84+
Assert.Equal("GitHub Copilot", plugin.DisplayName);
85+
}
86+
}

0 commit comments

Comments
 (0)