Files
Playa/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleCalendarConnectionTests.cs
EugeneTes d2fd7ffa02 feat(google-calendar): encrypt chat tokens and give watchers a short-lived access token
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.
2026-10-02 08:31:24 +00:00

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);
}
}