From 63e299134d8aeb7dbb28c7ded91185956b4c2fb7 Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 24 Mar 2026 14:42:08 +0400 Subject: [PATCH] fix(bot): preserve miniapp payloads after auth --- apps/bot/src/miniapp-auth.ts | 4 +- apps/bot/src/miniapp-billing.ts | 4 +- apps/bot/src/miniapp-notifications.test.ts | 148 +++++++++++++++++++++ apps/bot/src/miniapp-notifications.ts | 2 +- 4 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 apps/bot/src/miniapp-notifications.test.ts diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index 091a1b9..4a740db 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -48,7 +48,9 @@ export function allowedMiniAppOrigin( return allowedOrigins.includes(origin) ? origin : undefined } -export async function readMiniAppRequestPayload(request: Request): Promise { +export async function readMiniAppRequestPayload(request: { + text(): Promise +}): Promise { const text = await request.text() if (text.trim().length === 0) { diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts index 5bb36bf..ef81c6e 100644 --- a/apps/bot/src/miniapp-billing.ts +++ b/apps/bot/src/miniapp-billing.ts @@ -44,7 +44,7 @@ async function authenticateAdminSession( member: NonNullable } > { - const payload = await readMiniAppRequestPayload(request) + const payload = await readMiniAppRequestPayload(request.clone()) if (!payload.initData) { return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) } @@ -81,7 +81,7 @@ async function authenticateMemberSession( member: NonNullable } > { - const payload = await readMiniAppRequestPayload(request) + const payload = await readMiniAppRequestPayload(request.clone()) if (!payload.initData) { return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) } diff --git a/apps/bot/src/miniapp-notifications.test.ts b/apps/bot/src/miniapp-notifications.test.ts new file mode 100644 index 0000000..b5ce1db --- /dev/null +++ b/apps/bot/src/miniapp-notifications.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from 'bun:test' + +import type { AdHocNotificationService, HouseholdOnboardingService } from '@household/application' + +import { + createMiniAppCancelNotificationHandler, + createMiniAppUpdateNotificationHandler +} from './miniapp-notifications' +import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' + +function onboardingService(): HouseholdOnboardingService { + return { + ensureHouseholdJoinToken: async () => ({ + householdId: 'household-1', + householdName: 'Kojori House', + token: 'join-token' + }), + getMiniAppAccess: async () => ({ + status: 'active', + member: { + id: 'member-1', + householdId: 'household-1', + householdName: 'Kojori House', + displayName: 'Stas', + status: 'active', + isAdmin: true, + preferredLocale: 'ru', + householdDefaultLocale: 'ru', + rentShareWeight: 1 + } + }), + joinHousehold: async () => ({ + status: 'active', + member: { + id: 'member-1', + householdId: 'household-1', + householdName: 'Kojori House', + displayName: 'Stas', + status: 'active', + isAdmin: true, + preferredLocale: 'ru', + householdDefaultLocale: 'ru', + rentShareWeight: 1 + } + }) + } +} + +function notificationService(): AdHocNotificationService { + return { + scheduleNotification: async () => ({ status: 'scheduled', notification: null as never }), + listUpcomingNotifications: async () => [], + cancelNotification: async () => ({ status: 'cancelled', notification: null as never }), + updateNotification: async () => ({ status: 'updated', notification: null as never }), + listDueNotifications: async () => [], + claimDueNotification: async () => true, + releaseDueNotification: async () => {}, + markNotificationSent: async () => null as never + } +} + +describe('miniapp notification handlers', () => { + const botToken = '123456:ABCDEF' + + test('update handler authenticates without consuming the notification payload', async () => { + const handler = createMiniAppUpdateNotificationHandler({ + allowedOrigins: ['https://miniapp.example'], + botToken, + onboardingService: onboardingService(), + adHocNotificationService: { + ...notificationService(), + async updateNotification(input) { + expect(input.notificationId).toBe('notification-1') + expect(input.deliveryMode).toBe('topic') + expect(input.scheduledFor?.toString()).toBe('2026-03-25T07:00:00Z') + return { status: 'updated', notification: null as never } + } + } + }).handler + + const response = await handler( + new Request('https://example.test/api/miniapp/notifications/update', { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'https://miniapp.example' + }, + body: JSON.stringify({ + initData: buildMiniAppInitData(botToken, Math.floor(Date.now() / 1000), { + id: 123456, + first_name: 'Stas', + language_code: 'ru' + }), + notificationId: 'notification-1', + scheduledLocal: '2026-03-25T11:00', + timezone: 'Asia/Tbilisi', + deliveryMode: 'topic' + }) + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + ok: true, + authorized: true + }) + }) + + test('cancel handler authenticates without consuming the notification payload', async () => { + const handler = createMiniAppCancelNotificationHandler({ + allowedOrigins: ['https://miniapp.example'], + botToken, + onboardingService: onboardingService(), + adHocNotificationService: { + ...notificationService(), + async cancelNotification(input) { + expect(input.notificationId).toBe('notification-1') + expect(input.viewerMemberId).toBe('member-1') + return { status: 'cancelled', notification: null as never } + } + } + }).handler + + const response = await handler( + new Request('https://example.test/api/miniapp/notifications/cancel', { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'https://miniapp.example' + }, + body: JSON.stringify({ + initData: buildMiniAppInitData(botToken, Math.floor(Date.now() / 1000), { + id: 123456, + first_name: 'Stas', + language_code: 'ru' + }), + notificationId: 'notification-1' + }) + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + ok: true, + authorized: true + }) + }) +}) diff --git a/apps/bot/src/miniapp-notifications.ts b/apps/bot/src/miniapp-notifications.ts index f444930..e67e8a1 100644 --- a/apps/bot/src/miniapp-notifications.ts +++ b/apps/bot/src/miniapp-notifications.ts @@ -22,7 +22,7 @@ async function authenticateMemberSession( member: NonNullable } > { - const payload = await readMiniAppRequestPayload(request) + const payload = await readMiniAppRequestPayload(request.clone()) if (!payload.initData) { return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) }