forked from admin/VolcanicAgenticCore
token.json is sealed with AES-256-GCM (key derived from the OAuth client secret, bound to the chat), so the agent's processes can read the file but not the refresh token. Plain files, including VolcanicSecretary's refresh_token format, are rewritten sealed on first read. The scheduler gains IScriptEnvironment: extra per-run variables set after the process environment filter and redacted from the run's output. The Telegram host uses it to pass GOOGLE_ACCESS_TOKEN, refreshed to cover the job's timeout; the refresh token and client secret never reach the script.
221 lines
9.7 KiB
C#
221 lines
9.7 KiB
C#
using System.Net;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
|
|
|
public class GoogleCalendarConnectionTests : IDisposable
|
|
{
|
|
private static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
|
|
private readonly string _root = Directory.CreateTempSubdirectory("gcal-conn-").FullName;
|
|
private readonly FakeTimeProvider _time = new(Now);
|
|
private readonly GoogleFakeHttp _http;
|
|
private readonly GoogleCalendarSettings _settings;
|
|
private readonly GoogleCalendarConnections _connections = new();
|
|
|
|
public GoogleCalendarConnectionTests()
|
|
{
|
|
_http = new GoogleFakeHttp(_time);
|
|
_settings = new GoogleCalendarSettings("cid", "csecret", _root, _http.CreateClient(), _time);
|
|
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3600}""");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
|
}
|
|
|
|
private GoogleCalendarConnection Connected(string scope = "telegram-1", string refreshToken = "rt-1", string? zone = null)
|
|
{
|
|
var connection = _connections.For(_settings, scope, _root);
|
|
connection.Store.Write(new StoredToken(refreshToken, TimeZone: zone));
|
|
return connection;
|
|
}
|
|
|
|
private int Refreshes => _http.To("POST /token").Count;
|
|
|
|
[Fact]
|
|
public void Without_a_token_root_each_chat_keeps_its_token_in_its_workspace()
|
|
{
|
|
var settings = _settings with { TokenRoot = null };
|
|
var wsA = Path.Combine(_root, "workspaces", "1");
|
|
var wsB = Path.Combine(_root, "workspaces", "2");
|
|
var a = _connections.For(settings, "telegram-1", wsA);
|
|
var b = _connections.For(settings, "telegram-2", wsB);
|
|
Assert.Equal(Path.Combine(wsA, ".volcanic", "google-calendar", "token.json"), a.Store.Path);
|
|
Assert.Equal(Path.Combine(wsB, ".volcanic", "google-calendar", "token.json"), b.Store.Path);
|
|
Assert.Same(a, _connections.For(settings, "telegram-1", wsA));
|
|
Assert.NotSame(a, b);
|
|
}
|
|
|
|
[Fact]
|
|
public void Two_scopes_get_two_token_files_under_their_own_directories()
|
|
{
|
|
var a = _connections.For(_settings, "telegram-1", _root);
|
|
var b = _connections.For(_settings, "telegram-2", _root);
|
|
Assert.Equal(Path.Combine(_root, "telegram-1", "token.json"), a.Store.Path);
|
|
Assert.Equal(Path.Combine(_root, "telegram-2", "token.json"), b.Store.Path);
|
|
Assert.Same(a, _connections.For(_settings, "telegram-1", _root));
|
|
Assert.NotSame(a, b);
|
|
Assert.StartsWith(Path.Combine(_root, "scope-"), _connections.For(_settings, "../escape", _root).Store.Path);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Not_connected_throws_the_connect_hint()
|
|
{
|
|
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _connections.For(_settings, "telegram-1", _root).GetAccessTokenAsync(CancellationToken.None));
|
|
Assert.True(ex.NotConnected);
|
|
Assert.Equal("Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.", ex.Message);
|
|
Assert.Empty(_http.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reuses_the_cached_access_token_while_valid()
|
|
{
|
|
var connection = Connected();
|
|
Assert.Equal("at-1", await connection.GetAccessTokenAsync(CancellationToken.None));
|
|
_time.Advance(TimeSpan.FromSeconds(3539));
|
|
Assert.Equal("at-1", await connection.GetAccessTokenAsync(CancellationToken.None));
|
|
Assert.Equal(1, Refreshes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refreshes_within_60_seconds_of_expiry()
|
|
{
|
|
var connection = Connected();
|
|
await connection.GetAccessTokenAsync(CancellationToken.None);
|
|
_http.Json("POST /token", """{"access_token":"at-2","expires_in":3600}""");
|
|
_time.Advance(TimeSpan.FromSeconds(3541));
|
|
Assert.Equal("at-2", await connection.GetAccessTokenAsync(CancellationToken.None));
|
|
Assert.Equal(2, Refreshes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_changed_refresh_token_drops_the_cache()
|
|
{
|
|
var connection = Connected(refreshToken: "rt-1");
|
|
await connection.GetAccessTokenAsync(CancellationToken.None);
|
|
connection.Store.Write(new StoredToken("rt-2")); // a reconnect wrote a new token
|
|
await connection.GetAccessTokenAsync(CancellationToken.None);
|
|
Assert.Equal(2, Refreshes);
|
|
Assert.Equal("rt-2", _http.To("POST /token")[1].Form["refresh_token"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_callers_share_one_refresh()
|
|
{
|
|
var connection = Connected();
|
|
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
_http.On("POST /token", async _ =>
|
|
{
|
|
await gate.Task;
|
|
return GoogleFakeHttp.Response(HttpStatusCode.OK, """{"access_token":"at-9","expires_in":3600}""");
|
|
});
|
|
var calls = Enumerable.Range(0, 5).Select(_ => connection.GetAccessTokenAsync(CancellationToken.None)).ToList();
|
|
await Eventually.UntilAsync(() => Refreshes == 1, "the first refresh");
|
|
gate.SetResult();
|
|
Assert.All(await Task.WhenAll(calls), t => Assert.Equal("at-9", t));
|
|
Assert.Equal(1, Refreshes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invalidate_forces_a_refresh()
|
|
{
|
|
var connection = Connected();
|
|
await connection.GetAccessTokenAsync(CancellationToken.None);
|
|
connection.Invalidate();
|
|
await connection.GetAccessTokenAsync(CancellationToken.None);
|
|
Assert.Equal(2, Refreshes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_longer_validity_refreshes_a_token_that_would_expire_during_the_run()
|
|
{
|
|
var connection = Connected();
|
|
await connection.GetAccessTokenAsync(CancellationToken.None);
|
|
_http.Json("POST /token", """{"access_token":"at-2","expires_in":3600}""");
|
|
_time.Advance(TimeSpan.FromMinutes(50)); // 10 minutes left
|
|
|
|
Assert.Equal("at-1", await connection.GetAccessTokenAsync(CancellationToken.None)); // enough for the tool
|
|
Assert.Equal("at-2", await connection.GetAccessTokenAsync(CancellationToken.None, TimeSpan.FromMinutes(15))); // not for a 15-minute run
|
|
Assert.Equal(2, Refreshes);
|
|
}
|
|
|
|
private GoogleCalendarOptions Options(string? secret = "csecret") =>
|
|
new() { ClientId = "cid", ClientSecret = secret, TokenDirectory = _root, Http = _http.CreateClient(), Time = _time, GetEnv = _ => null };
|
|
|
|
[Fact]
|
|
public async Task TryGetAccessTokenAsync_returns_the_chats_token_or_null_when_not_connected()
|
|
{
|
|
Assert.Null(await _connections.TryGetAccessTokenAsync(Options(), "telegram-1", _root, TimeSpan.FromMinutes(6), CancellationToken.None));
|
|
Connected();
|
|
|
|
Assert.Equal("at-1", await _connections.TryGetAccessTokenAsync(Options(), "telegram-1", _root, TimeSpan.FromMinutes(6), CancellationToken.None));
|
|
Assert.Null(await _connections.TryGetAccessTokenAsync(Options(secret: null), "telegram-1", _root, TimeSpan.FromMinutes(6), CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryGetAccessTokenAsync_returns_null_when_Google_rejects_the_token()
|
|
{
|
|
Connected();
|
|
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant"}"""));
|
|
|
|
Assert.Null(await _connections.TryGetAccessTokenAsync(Options(), "telegram-1", _root, TimeSpan.FromMinutes(6), CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_token_file_is_sealed_for_its_chat()
|
|
{
|
|
var connection = Connected(refreshToken: "1//0e-secret-refresh");
|
|
|
|
var json = File.ReadAllText(connection.Store.Path);
|
|
Assert.DoesNotContain("1//0e-secret-refresh", json);
|
|
Assert.Contains("\"sealed\"", json);
|
|
Assert.Equal("1//0e-secret-refresh", connection.Store.Read()!.RefreshToken);
|
|
var other = _connections.For(_settings, "telegram-2", _root);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(other.Store.Path)!);
|
|
File.Copy(connection.Store.Path, other.Store.Path); // another chat's agent copies the file into its folder
|
|
Assert.Null(other.Store.Read());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invalid_grant_throws_not_connected_and_keeps_the_file()
|
|
{
|
|
var connection = Connected();
|
|
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant"}"""));
|
|
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => connection.GetAccessTokenAsync(CancellationToken.None));
|
|
Assert.True(ex.NotConnected);
|
|
Assert.True(File.Exists(connection.Store.Path));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolved_zone_is_the_calendars_stored_zone()
|
|
{
|
|
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
|
var zone = Connected(zone: "America/New_York").ResolveTimeZone(berlin);
|
|
Assert.Equal("America/New_York", ZoneIds.Name(zone));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolved_zone_falls_back_to_the_chat_zone()
|
|
{
|
|
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
|
Assert.Same(berlin, _connections.For(_settings, "telegram-9", _root).ResolveTimeZone(berlin)); // not connected
|
|
Assert.Same(berlin, Connected(zone: null).ResolveTimeZone(berlin));
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unknown_stored_zone_is_logged_once_and_falls_back()
|
|
{
|
|
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
|
var logger = new ListLogger();
|
|
var connection = Connected(zone: "Mars/Olympus_Mons");
|
|
Assert.Same(berlin, connection.ResolveTimeZone(berlin, logger));
|
|
Assert.Same(berlin, connection.ResolveTimeZone(berlin, logger));
|
|
var entry = Assert.Single(logger.Entries);
|
|
Assert.Equal(LogLevel.Warning, entry.Level);
|
|
Assert.Contains("Mars/Olympus_Mons", entry.Message);
|
|
}
|
|
}
|