ADMIN_USER_IDS (Telegram:AdminUserIds) takes a comma separated list; the older ADMIN_USER_ID / Telegram:AdminUserId still works, also takes a list and is merged. Empty entries and duplicates are ignored, non-numeric entries fail validation. Impersonation is now per admin: one admin per target, an impersonating admin can't be impersonated, and every admin's chat menu lists /impersonate.
285 lines
12 KiB
C#
285 lines
12 KiB
C#
using Telegram.Bot.Types.ReplyMarkups;
|
|
using Volcanic.AgenticCore.Telegram.Chats;
|
|
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
|
using Volcanic.AgenticCore.Testing;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
|
|
|
public sealed class ImpersonationTests
|
|
{
|
|
private const long Admin = 5005; // not in the allow-list: only /impersonate works for them
|
|
private const long SecondAdmin = 6006;
|
|
|
|
private static ChatHost CreateHost()
|
|
{
|
|
var host = new ChatHost();
|
|
host.Options.AdminUserId = Admin;
|
|
return host;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_admin_talks_as_the_impersonated_user_and_gets_the_replies()
|
|
{
|
|
await using var host = CreateHost();
|
|
host.Faux.Enqueue(FauxTurn.Say("Hi, user 1001."));
|
|
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
Assert.StartsWith("🎠You are now acting as user 1001", host.LastReply(Admin), StringComparison.Ordinal);
|
|
await host.SendAsync("who am I?", userId: Admin);
|
|
await host.SettleAsync(Updates.AllowedUser);
|
|
|
|
Assert.EndsWith("\nwho am I?", host.Faux.Requests.Single().Messages.Last().Text, StringComparison.Ordinal);
|
|
Assert.Equal(host.WorkspaceOf(Updates.AllowedUser), (await host.ChatAsync(Updates.AllowedUser)).Session.Cwd);
|
|
Assert.Null(host.Chats.Find(Admin)); // no chat of the admin's own
|
|
Assert.Equal("Hi, user 1001.", host.LastReply(Admin));
|
|
Assert.DoesNotContain(host.Bot.Sent, m => m.ChatId.Identifier == Updates.AllowedUser);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bot_commands_run_in_the_impersonated_chat_and_answer_the_admin()
|
|
{
|
|
await using var host = CreateHost();
|
|
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
await host.SendAsync("/status", userId: Admin);
|
|
|
|
Assert.Contains($"Workspace: {host.WorkspaceOf(Updates.AllowedUser)}", host.LastReply(Admin), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Zero_ends_the_impersonation()
|
|
{
|
|
await using var host = CreateHost();
|
|
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
await host.SendAsync("/impersonate 0", userId: Admin);
|
|
await host.SendAsync("hello", userId: Admin);
|
|
|
|
Assert.Equal("🎠Impersonation off: you are yourself again.", host.LastReply(Admin));
|
|
Assert.Null(host.Impersonation.TargetOf(Admin));
|
|
Assert.Null(host.Chats.Find(Updates.AllowedUser)); // the admin is not allow-listed, so "hello" was ignored
|
|
Assert.Empty(host.Faux.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Impersonating_a_user_outside_the_allow_list_is_ignored_like_that_user()
|
|
{
|
|
await using var host = CreateHost();
|
|
|
|
await host.SendAsync("/impersonate 666", userId: Admin);
|
|
Assert.Contains("not in the allow-list", host.LastReply(Admin), StringComparison.Ordinal);
|
|
await host.SendAsync("hello", userId: Admin);
|
|
|
|
Assert.Null(host.Chats.Find(Updates.Stranger));
|
|
Assert.Empty(host.Faux.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Other_users_cannot_impersonate()
|
|
{
|
|
await using var host = CreateHost();
|
|
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
|
|
|
await host.SendAsync("/impersonate 5005");
|
|
await host.SettleAsync();
|
|
|
|
Assert.Null(host.Impersonation.TargetOf(Admin));
|
|
Assert.Equal("/impersonate 5005", host.Faux.Requests.Single().Messages.Last().Text); // an ordinary unknown command
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Without_an_admin_the_command_is_off()
|
|
{
|
|
await using var host = new ChatHost();
|
|
host.Options.AllowedUserIds.Add(Admin);
|
|
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
|
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
await host.SettleAsync(Admin);
|
|
|
|
Assert.Null(host.Impersonation.TargetOf(Admin));
|
|
Assert.NotNull(host.Chats.Find(Admin));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_impersonated_chats_questions_go_to_the_admin_and_take_their_answers()
|
|
{
|
|
await using var host = CreateHost();
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
var chat = await host.ChatAsync(Updates.AllowedUser);
|
|
|
|
var answer = chat.Ui.SelectAsync("Pick one", ["red", "green"], null, CancellationToken.None);
|
|
await Wait.UntilAsync(() => host.Bot.Sent.Any(s => s.ReplyMarkup is InlineKeyboardMarkup), "the keyboard");
|
|
var question = host.Bot.Sent.Last(s => s.ReplyMarkup is InlineKeyboardMarkup);
|
|
Assert.Equal(Admin, question.ChatId.Identifier);
|
|
var green = ((InlineKeyboardMarkup)question.ReplyMarkup!).InlineKeyboard.SelectMany(row => row).Single(b => b.Text == "green");
|
|
await host.RouteAsync(Updates.Callback(green.CallbackData!, chatId: Admin, userId: Admin));
|
|
|
|
Assert.Equal("green", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Output_returns_to_the_user_when_the_impersonation_ends()
|
|
{
|
|
await using var host = CreateHost();
|
|
host.Faux.Enqueue(FauxTurn.Say("for you"));
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
await host.SendAsync("/impersonate 0", userId: Admin);
|
|
|
|
await host.SendAsync("hi");
|
|
await host.SettleAsync();
|
|
|
|
Assert.Equal("for you", host.LastReply(Updates.AllowedUser));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_impersonated_user_is_told_an_admin_is_working_and_their_message_is_dropped()
|
|
{
|
|
await using var host = CreateHost();
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
var toAdmin = host.Bot.Sent.Count(m => m.ChatId.Identifier == Admin);
|
|
|
|
await host.SendAsync("hi, it's me");
|
|
|
|
Assert.Equal(Impersonation.BlockedNotice, host.LastReply(Updates.AllowedUser));
|
|
Assert.Equal(toAdmin, host.Bot.Sent.Count(m => m.ChatId.Identifier == Admin)); // nothing reaches the admin
|
|
Assert.Null(host.Chats.Find(Updates.AllowedUser));
|
|
Assert.Empty(host.Faux.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_impersonated_users_button_presses_are_blocked()
|
|
{
|
|
await using var host = CreateHost();
|
|
await host.SendAsync("/status"); // creates the chat
|
|
var chat = await host.ChatAsync();
|
|
var answer = chat.Ui.SelectAsync("Pick", ["a"], new(TimeSpan.FromMilliseconds(500)), CancellationToken.None);
|
|
await Wait.UntilAsync(() => host.Bot.Sent.Any(s => s.ReplyMarkup is InlineKeyboardMarkup), "the keyboard");
|
|
var data = ((InlineKeyboardMarkup)host.Bot.Sent.Last().ReplyMarkup!).InlineKeyboard.First().First().CallbackData!;
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
|
|
await host.RouteAsync(Updates.Callback(data, Updates.AllowedUser));
|
|
|
|
Assert.Null(await answer.WaitAsync(TimeSpan.FromSeconds(5)));
|
|
var reply = Assert.Single(host.Bot.Requests.OfType<global::Telegram.Bot.Requests.AnswerCallbackQueryRequest>());
|
|
Assert.Equal(Impersonation.BlockedNotice, reply.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_admin_impersonates_independently()
|
|
{
|
|
await using var host = CreateHost();
|
|
host.Options.AdminUserIds.Add(SecondAdmin);
|
|
host.Options.AllowedUserIds.Add(1002);
|
|
host.Faux.Enqueue(FauxTurn.Say("for the first admin"));
|
|
host.Faux.Enqueue(FauxTurn.Say("for the second admin"));
|
|
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
await host.SendAsync("/impersonate 1002", userId: SecondAdmin);
|
|
Assert.StartsWith("🎠You are now acting as user 1002", host.LastReply(SecondAdmin), StringComparison.Ordinal);
|
|
await host.SendAsync("one", userId: Admin);
|
|
await host.SettleAsync(Updates.AllowedUser);
|
|
await host.SendAsync("two", userId: SecondAdmin);
|
|
await host.SettleAsync(1002);
|
|
|
|
Assert.Equal("for the first admin", host.LastReply(Admin));
|
|
Assert.Equal("for the second admin", host.LastReply(SecondAdmin));
|
|
await host.SendAsync("/impersonate 0", userId: SecondAdmin);
|
|
Assert.Null(host.Impersonation.TargetOf(SecondAdmin));
|
|
Assert.Equal(Updates.AllowedUser, host.Impersonation.TargetOf(Admin));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Only_one_admin_impersonates_a_user_at_a_time()
|
|
{
|
|
await using var host = CreateHost();
|
|
host.Options.AdminUserIds.Add(SecondAdmin);
|
|
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
await host.SendAsync("/impersonate 1001", userId: SecondAdmin);
|
|
|
|
Assert.Equal($"Admin {Admin} is already acting as user 1001. Only one admin can impersonate a user at a time.", host.LastReply(SecondAdmin));
|
|
Assert.Null(host.Impersonation.TargetOf(SecondAdmin));
|
|
Assert.Equal(Admin, host.Impersonation.DeliveryChatFor(Updates.AllowedUser));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_admin_being_impersonated_cannot_impersonate_and_an_impersonating_admin_cannot_be_impersonated()
|
|
{
|
|
await using var host = CreateHost();
|
|
host.Options.AdminUserIds.Add(SecondAdmin);
|
|
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
await host.SendAsync($"/impersonate {Admin}", userId: SecondAdmin);
|
|
Assert.StartsWith($"User {Admin} is an admin who is impersonating", host.LastReply(SecondAdmin), StringComparison.Ordinal);
|
|
|
|
await host.SendAsync("/impersonate 0", userId: Admin);
|
|
await host.SendAsync($"/impersonate {Admin}", userId: SecondAdmin);
|
|
await host.SendAsync("/impersonate 1001", userId: Admin);
|
|
|
|
Assert.StartsWith($"Admin {SecondAdmin} is acting as you right now", host.LastReply(Admin), StringComparison.Ordinal);
|
|
Assert.Null(host.Impersonation.TargetOf(Admin));
|
|
}
|
|
|
|
[Fact]
|
|
public void Admin_ids_come_from_ADMIN_USER_IDS_and_ADMIN_USER_ID_merged()
|
|
{
|
|
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
|
|
var env = new Dictionary<string, string> { ["ADMIN_USER_IDS"] = " 42, 43 ,, 42 ", ["ADMIN_USER_ID"] = "44,43" };
|
|
options.ApplyEnvironment(name => env.GetValueOrDefault(name));
|
|
|
|
Assert.Equal([42, 43, 44], options.Admins);
|
|
Assert.Empty(options.Validate());
|
|
}
|
|
|
|
[Fact]
|
|
public void Configured_admins_win_over_the_environment()
|
|
{
|
|
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], AdminUserIds = [7] };
|
|
options.ApplyEnvironment(name => name == "ADMIN_USER_IDS" ? "42" : null);
|
|
Assert.Equal([7], options.Admins);
|
|
|
|
var legacy = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], AdminUserId = 8, AdminUserIds = [9, 8] };
|
|
Assert.Equal([8, 9], legacy.Admins);
|
|
}
|
|
|
|
[Fact]
|
|
public void Admin_ids_that_are_not_numbers_stop_the_host()
|
|
{
|
|
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
|
|
options.ApplyEnvironment(name => name == "ADMIN_USER_IDS" ? "42, abc" : null);
|
|
|
|
Assert.Equal([42], options.Admins);
|
|
Assert.Contains(options.Validate(), e => e.Contains("ADMIN_USER_IDS: 'abc'", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null, new long[0], new string[0])]
|
|
[InlineData("", new long[0], new string[0])]
|
|
[InlineData(" , ;; ", new long[0], new string[0])]
|
|
[InlineData("1", new long[] { 1 }, new string[0])]
|
|
[InlineData(" 1 ,2;3 4\t5\n6 ", new long[] { 1, 2, 3, 4, 5, 6 }, new string[0])]
|
|
[InlineData("3,1,3,2,1", new long[] { 3, 1, 2 }, new string[0])]
|
|
[InlineData("1,x,2.5,-4", new long[] { 1, -4 }, new[] { "x", "2.5" })]
|
|
public void User_id_lists_parse(string? text, long[] expected, string[] invalid)
|
|
{
|
|
Assert.Equal(expected, TelegramOptions.ParseUserIds(text, out var bad));
|
|
Assert.Equal(invalid, bad);
|
|
}
|
|
|
|
[Fact]
|
|
public void Admin_user_id_comes_from_the_environment_and_must_be_positive()
|
|
{
|
|
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
|
|
options.ApplyEnvironment(name => name == "ADMIN_USER_ID" ? " 42 " : null);
|
|
Assert.Equal([42], options.Admins);
|
|
Assert.Empty(options.Validate());
|
|
|
|
options.AdminUserIds[0] = -1;
|
|
Assert.Contains(options.Validate(), e => e.Contains("ADMIN_USER_ID", StringComparison.Ordinal));
|
|
options.AdminUserIds.Clear();
|
|
options.AdminUserId = -1; // the older single-id setting
|
|
Assert.Contains(options.Validate(), e => e.Contains("ADMIN_USER_ID", StringComparison.Ordinal));
|
|
}
|
|
}
|