From e32665fe7810a4c57a565e9813607ebe16036caa Mon Sep 17 00:00:00 2001 From: Escelit Date: Thu, 30 Jul 2026 04:20:21 +0100 Subject: [PATCH] test: Add integration tests for Telegram bot role authorization Add backend/tests/integration/telegramBotAuth.test.ts with 10 tests verifying that unauthorized Telegram user IDs are rejected when invoking admin commands. Tests cover: - operator and super_admin roles grant access - auditor and viewer roles deny access - inactive admin accounts deny access - non-existent telegram_chat_id denied access - /pause and /resume command handlers reject unauthorized chats --- .../tests/integration/telegramBotAuth.test.ts | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 backend/tests/integration/telegramBotAuth.test.ts diff --git a/backend/tests/integration/telegramBotAuth.test.ts b/backend/tests/integration/telegramBotAuth.test.ts new file mode 100644 index 00000000..966d20a0 --- /dev/null +++ b/backend/tests/integration/telegramBotAuth.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; +import knex, { type Knex } from "knex"; + +vi.mock("telegraf", () => { + const mockTelegram = { + sendMessage: vi.fn().mockResolvedValue({}), + setWebhook: vi.fn().mockResolvedValue(true), + getWebhookInfo: vi.fn().mockResolvedValue({ url: "", pending_update_count: 0 }), + deleteWebhook: vi.fn().mockResolvedValue(true), + getMe: vi.fn().mockResolvedValue({ username: "test_bot", id: 123456 }), + }; + + class BotMock { + telegram = mockTelegram; + use = vi.fn(); + command = vi.fn(); + action = vi.fn(); + stop = vi.fn().mockResolvedValue(true); + webhookCallback = vi.fn().mockReturnValue(() => {}); + } + + return { + Telegraf: BotMock, + Context: class {}, + Markup: { + inlineKeyboard: vi.fn().mockReturnValue({}), + button: { + callback: vi.fn().mockReturnValue({}), + }, + }, + }; +}); + +vi.mock("../../src/config/index.js", () => ({ + config: { + NODE_ENV: "test", + POSTGRES_HOST: process.env.POSTGRES_HOST || "localhost", + POSTGRES_PORT: Number(process.env.POSTGRES_PORT) || 5432, + POSTGRES_DB: process.env.POSTGRES_DB || "bridge_watch", + POSTGRES_USER: process.env.POSTGRES_USER || "bridge_watch", + POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD || "bridge_watch_dev", + REDIS_HOST: "localhost", + REDIS_PORT: 6379, + REDIS_PASSWORD: "", + TELEGRAM_BOT_TOKEN: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + TELEGRAM_WEBHOOK_URL: "", + TELEGRAM_WEBHOOK_SECRET: "", + TELEGRAM_BOT_ENABLED: true, + TELEGRAM_RATE_LIMIT_OUTBOUND_GLOBAL_PER_SEC: 30, + TELEGRAM_RATE_LIMIT_OUTBOUND_PER_CHAT_PER_SEC: 1, + TELEGRAM_RATE_LIMIT_INBOUND_COMMANDS_PER_WINDOW: 5, + TELEGRAM_RATE_LIMIT_INBOUND_WINDOW_SEC: 30, + TELEGRAM_ADMIN_CHAT_IDS: "", + }, +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +import { TelegramBotService } from "../../src/services/telegram.bot.service.js"; + +describe("Telegram bot role authorization (integration)", () => { + let svc: TelegramBotService; + let isAdminChat: (chatId: string, userId?: string) => Promise; + let db: Knex; + + const mockRedis = { + incr: vi.fn().mockResolvedValue(1), + expire: vi.fn().mockResolvedValue(1), + subscribe: vi.fn().mockReturnThis(), + on: vi.fn(), + quit: vi.fn().mockResolvedValue(null), + createClient: vi.fn().mockReturnThis(), + }; + + function commandHandler(name: string) { + const bot = (svc as any).bot; + const registration = bot.command.mock.calls.find( + ([registeredName]: [string]) => registeredName === name, + ); + return registration ? registration[1] : null; + } + + beforeAll(async () => { + const pgHost = process.env.POSTGRES_HOST || "localhost"; + const pgPort = Number(process.env.POSTGRES_PORT) || 5432; + const pgDb = process.env.POSTGRES_DB || "bridge_watch"; + const pgUser = process.env.POSTGRES_USER || "bridge_watch"; + const pgPass = process.env.POSTGRES_PASSWORD || "bridge_watch_dev"; + + db = knex({ + client: "pg", + connection: { + host: pgHost, + port: pgPort, + database: pgDb, + user: pgUser, + password: pgPass, + }, + }); + + // Create admin_accounts table (same schema as migration 016 + 042) + const hasTable = await db.schema.hasTable("admin_accounts"); + if (!hasTable) { + await db.schema.createTable("admin_accounts", (table) => { + table.uuid("id").primary().defaultTo(db.raw("gen_random_uuid()")); + table.string("address").notNullable().unique(); + table.string("name").notNullable(); + table.string("email").nullable(); + table.jsonb("roles").notNullable().defaultTo("[]"); + table.boolean("is_active").notNullable().defaultTo(true); + table.string("added_by").notNullable(); + table.timestamp("activated_at").nullable(); + table.timestamp("deactivated_at").nullable(); + table.string("deactivated_by").nullable(); + table.timestamp("created_at").notNullable().defaultTo(db.fn.now()); + table.timestamp("updated_at").notNullable().defaultTo(db.fn.now()); + table.string("telegram_chat_id").nullable().unique(); + }); + } + }); + + afterAll(async () => { + await db.schema.dropTableIfExists("admin_accounts"); + await db.destroy(); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + await db("admin_accounts").del(); + svc = new TelegramBotService(mockRedis as any); + isAdminChat = (svc as any).isAdminChat.bind(svc); + }); + + describe("isAdminChat with real database", () => { + it("rejects chat not found in admin_accounts or bootstrap list", async () => { + await expect(isAdminChat("999")).resolves.toBe(false); + }); + + it("grants access when admin_accounts row has operator role", async () => { + await db("admin_accounts").insert({ + id: "00000000-0000-0000-0000-000000000001", + address: "GOPERATOR", + name: "Operator", + roles: JSON.stringify(["operator"]), + is_active: true, + added_by: "GSUPER", + telegram_chat_id: "111", + }); + + await expect(isAdminChat("111")).resolves.toBe(true); + }); + + it("grants access when admin_accounts row has super_admin role", async () => { + await db("admin_accounts").insert({ + id: "00000000-0000-0000-0000-000000000002", + address: "GSUPER", + name: "Super Admin", + roles: JSON.stringify(["super_admin"]), + is_active: true, + added_by: "GSUPER", + telegram_chat_id: "222", + }); + + await expect(isAdminChat("222")).resolves.toBe(true); + }); + + it("rejects access when admin_accounts row has only auditor role", async () => { + await db("admin_accounts").insert({ + id: "00000000-0000-0000-0000-000000000003", + address: "GAUDITOR", + name: "Auditor", + roles: JSON.stringify(["auditor"]), + is_active: true, + added_by: "GSUPER", + telegram_chat_id: "333", + }); + + await expect(isAdminChat("333")).resolves.toBe(false); + }); + + it("rejects access when admin_accounts row has only viewer role", async () => { + await db("admin_accounts").insert({ + id: "00000000-0000-0000-0000-000000000004", + address: "GVIEWER", + name: "Viewer", + roles: JSON.stringify(["viewer"]), + is_active: true, + added_by: "GSUPER", + telegram_chat_id: "444", + }); + + await expect(isAdminChat("444")).resolves.toBe(false); + }); + + it("rejects access when admin account is inactive", async () => { + await db("admin_accounts").insert({ + id: "00000000-0000-0000-0000-000000000005", + address: "GINACTIVE", + name: "Inactive Operator", + roles: JSON.stringify(["operator"]), + is_active: false, + added_by: "GSUPER", + telegram_chat_id: "555", + }); + + await expect(isAdminChat("555")).resolves.toBe(false); + }); + + it("rejects access for non-existent telegram_chat_id", async () => { + await db("admin_accounts").insert({ + id: "00000000-0000-0000-0000-000000000006", + address: "GEXISTING", + name: "Existing", + roles: JSON.stringify(["operator"]), + is_active: true, + added_by: "GSUPER", + telegram_chat_id: "666", + }); + + await expect(isAdminChat("999")).resolves.toBe(false); + }); + + it("handles multiple admin accounts correctly", async () => { + await db("admin_accounts").insert([ + { + id: "00000000-0000-0000-0000-000000000010", + address: "GSUPER1", + name: "Super Admin", + roles: JSON.stringify(["super_admin"]), + is_active: true, + added_by: "GSUPER1", + telegram_chat_id: "101", + }, + { + id: "00000000-0000-0000-0000-000000000011", + address: "GAUDITOR1", + name: "Auditor", + roles: JSON.stringify(["auditor"]), + is_active: true, + added_by: "GSUPER1", + telegram_chat_id: "102", + }, + { + id: "00000000-0000-0000-0000-000000000012", + address: "GVIEWER1", + name: "Viewer", + roles: JSON.stringify(["viewer"]), + is_active: true, + added_by: "GSUPER1", + telegram_chat_id: "103", + }, + ]); + + await expect(isAdminChat("101")).resolves.toBe(true); + await expect(isAdminChat("102")).resolves.toBe(false); + await expect(isAdminChat("103")).resolves.toBe(false); + }); + }); + + describe("admin command handlers reject unauthorized users", () => { + it("/pause replies Unauthorized for non-admin chat", async () => { + const handler = commandHandler("pause"); + expect(handler).not.toBeNull(); + + const reply = vi.fn(); + await handler({ + chat: { id: 999 }, + from: { id: 1111 }, + message: { text: "/pause" }, + reply, + }); + + expect(reply).toHaveBeenCalledWith("❌ Unauthorized"); + }); + + it("/resume replies Unauthorized for non-admin chat", async () => { + const handler = commandHandler("resume"); + expect(handler).not.toBeNull(); + + const reply = vi.fn(); + await handler({ + chat: { id: 999 }, + from: { id: 1111 }, + message: { text: "/resume" }, + reply, + }); + + expect(reply).toHaveBeenCalledWith("❌ Unauthorized"); + }); + }); +});