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.
142 lines
5.4 KiB
C#
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());
|
|
}
|
|
}
|