Files
Playa/tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/GoogleTokenStoreTests.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

142 lines
5.4 KiB
C#

using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleTokenStoreTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("gcal-store-").FullName;
private string TokenPath => Path.Combine(_root, "telegram-1", GoogleTokenStore.FileName);
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
private static readonly StoredToken Sample = new("1//0e-refresh", "https://www.googleapis.com/auth/calendar",
"user@gmail.com", "Europe/Berlin", new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
[Fact]
public void Round_trips_a_token()
{
var store = new GoogleTokenStore(TokenPath);
store.Write(Sample);
Assert.Equal(Sample, store.Read());
var json = File.ReadAllText(TokenPath);
Assert.Contains("\"refreshToken\"", json);
Assert.Contains("\"timeZone\"", json);
Assert.NotEqual(0xEF, File.ReadAllBytes(TokenPath)[0]); // no BOM
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not json")]
[InlineData("[]")]
[InlineData("{}")]
[InlineData("{\"refreshToken\":\"\"}")]
[InlineData("{\"account\":\"user@gmail.com\"}")]
public void Read_returns_null_for_missing_empty_garbage_or_keyless_files(string? content)
{
if (content is not null)
{
Directory.CreateDirectory(Path.GetDirectoryName(TokenPath)!);
File.WriteAllText(TokenPath, content);
}
Assert.Null(new GoogleTokenStore(TokenPath).Read());
}
[Fact]
public void Write_replaces_the_file_atomically_and_leaves_no_temp_file()
{
var store = new GoogleTokenStore(TokenPath);
store.Write(Sample);
File.WriteAllText(TokenPath + ".tmp", "stale leftover");
store.Write(Sample with { RefreshToken = "1//0e-second" });
Assert.Equal("1//0e-second", store.Read()!.RefreshToken);
Assert.False(File.Exists(TokenPath + ".tmp"));
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(TokenPath)!));
}
[Fact]
public void Write_creates_an_owner_only_file_and_directory_on_unix()
{
if (OperatingSystem.IsWindows()) return;
new GoogleTokenStore(TokenPath).Write(Sample);
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(TokenPath));
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
File.GetUnixFileMode(Path.GetDirectoryName(TokenPath)!));
}
[Fact]
public void Delete_removes_the_file_and_tolerates_a_missing_one()
{
var store = new GoogleTokenStore(TokenPath);
store.Delete(); // nothing there yet, not even the directory
store.Write(Sample);
store.Delete();
Assert.False(File.Exists(TokenPath));
Assert.Null(store.Read());
}
private static TokenCipher Cipher(string secret = "client-secret", string chat = "telegram-1") => new(secret, chat);
[Fact]
public void A_sealed_file_round_trips_and_never_shows_the_token()
{
var store = new GoogleTokenStore(TokenPath, Cipher());
store.Write(Sample);
Assert.Equal(Sample, store.Read());
var json = File.ReadAllText(TokenPath);
Assert.DoesNotContain("1//0e-refresh", json);
Assert.DoesNotContain("user@gmail.com", json);
Assert.Contains("\"version\": 2", json);
Assert.Null(new GoogleTokenStore(TokenPath).Read()); // no key, no token
}
[Fact]
public void A_sealed_file_does_not_open_with_another_client_secret_or_for_another_chat()
{
new GoogleTokenStore(TokenPath, Cipher()).Write(Sample);
Assert.Null(new GoogleTokenStore(TokenPath, Cipher(secret: "rotated-secret")).Read());
Assert.Null(new GoogleTokenStore(TokenPath, Cipher(chat: "telegram-2")).Read());
}
[Fact]
public void A_tampered_sealed_file_reads_as_not_connected()
{
new GoogleTokenStore(TokenPath, Cipher()).Write(Sample);
var json = File.ReadAllText(TokenPath);
var data = System.Text.Json.Nodes.JsonNode.Parse(json)!["sealed"]!.GetValue<string>();
var bytes = Convert.FromBase64String(data);
bytes[^1] ^= 1;
File.WriteAllText(TokenPath, json.Replace(data, Convert.ToBase64String(bytes)));
Assert.Null(new GoogleTokenStore(TokenPath, Cipher()).Read());
}
[Theory]
[InlineData("""{"refreshToken":"1//0e-plain","account":"user@gmail.com"}""", "user@gmail.com")]
[InlineData("""{"refresh_token":"1//0e-plain"}""", null)] // VolcanicSecretary
public void A_plain_file_is_read_and_rewritten_sealed(string content, string? account)
{
Directory.CreateDirectory(Path.GetDirectoryName(TokenPath)!);
File.WriteAllText(TokenPath, content);
var token = new GoogleTokenStore(TokenPath, Cipher()).Read();
Assert.Equal("1//0e-plain", token!.RefreshToken);
Assert.Equal(account, token.Account);
Assert.DoesNotContain("1//0e-plain", File.ReadAllText(TokenPath));
Assert.Equal(token, new GoogleTokenStore(TokenPath, Cipher()).Read());
}
[Fact]
public void ToString_never_shows_the_refresh_token()
{
Assert.DoesNotContain("1//0e-refresh", Sample.ToString());
Assert.Contains("user@gmail.com", Sample.ToString());
}
}