From 99a8701bd4dcddc02b5332ae31fbe20ae2219f95 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 14:50:17 +0100 Subject: [PATCH] fix(wallet): offer Google Wallet only where it works, and never post it to the Apple route - GET /api/trips/:tripId/reservations/google-wallet answers whether this server can make Google Wallet passes: passes on, and the TREK services' signer with its Google route mounted (it is once liketrek has a Wallet issuer). The probe is kept for ten minutes. - The Google route is derived from TREK_WALLET_SIGNER_URL by its path, the query kept; a signer URL that does not end in /pass has none, and the booking is refused with a 404 instead of going to the Apple signer. - End-to-end tests for the save link: owner, stranger, another trip, no issuer yet, a link that is not Google Wallet, and the availability. - Italian and Hungarian wording of the button. --- .../wallet-pass/wallet-pass.controller.ts | 16 ++++- .../nest/wallet-pass/wallet-pass.service.ts | 64 +++++++++++++++++-- server/tests/e2e/wallet-pass.e2e.test.ts | 46 +++++++++++++ .../wallet-pass.controller.test.ts | 6 ++ .../wallet-pass/wallet-pass.service.test.ts | 51 ++++++++++++++- shared/src/i18n/hu/reservations.ts | 2 +- shared/src/i18n/it/reservations.ts | 2 +- shared/src/reservation/reservation.schema.ts | 7 ++ 8 files changed, 185 insertions(+), 9 deletions(-) diff --git a/server/src/nest/wallet-pass/wallet-pass.controller.ts b/server/src/nest/wallet-pass/wallet-pass.controller.ts index 1a8242adf..e0dce547b 100644 --- a/server/src/nest/wallet-pass/wallet-pass.controller.ts +++ b/server/src/nest/wallet-pass/wallet-pass.controller.ts @@ -6,7 +6,12 @@ import { TripAccessGuard } from '../permissions/trip-access.guard'; import { WalletPassService } from './wallet-pass.service'; import { walletLanguage } from './wallet-pass.words'; import { Controller, Get, Param, ParseIntPipe, Query, Res, UseGuards } from '@nestjs/common'; -import { googleWalletSaveResponseSchema, type GoogleWalletSaveResponse } from '@trek/shared'; +import { + googleWalletAvailabilityResponseSchema, + googleWalletSaveResponseSchema, + type GoogleWalletAvailabilityResponse, + type GoogleWalletSaveResponse, +} from '@trek/shared'; import type { Response } from 'express'; @@ -14,7 +19,8 @@ import type { Response } from 'express'; * GET /api/trips/:tripId/reservations/:id/wallet-pass: the booking's Apple * Wallet pass, which the iOS app hands to Wallet's own sheet (native/wallet.ts). * GET .../wallet-pass/google: the same booking as a Google Wallet save link, - * which the Android app opens. + * which the Android app opens. GET .../google-wallet: whether that works here + * at all, so the app offers the button only where it does. */ @Controller('api/trips/:tripId/reservations') // TripAccessGuard resolves :tripId and 404s a trip the user cannot reach. @@ -37,6 +43,12 @@ export class WalletPassController { res.status(200).type('application/vnd.apple.pkpass').send(pass.bytes); } + @Get('google-wallet') + @ResponseContract(googleWalletAvailabilityResponseSchema) + async googleAvailability(): Promise { + return { available: await this.passes.googleAvailable() }; + } + @Get(':id/wallet-pass/google') @ResponseContract(googleWalletSaveResponseSchema) async google( diff --git a/server/src/nest/wallet-pass/wallet-pass.service.ts b/server/src/nest/wallet-pass/wallet-pass.service.ts index d1959f0d1..378de8764 100644 --- a/server/src/nest/wallet-pass/wallet-pass.service.ts +++ b/server/src/nest/wallet-pass/wallet-pass.service.ts @@ -17,6 +17,10 @@ const TIMEOUT_MS = 10_000; /** The signer's answer for Google Wallet: a JSON object with one signed link. */ const MAX_GOOGLE_ANSWER_BYTES = 16 * 1024; const GOOGLE_SAVE_PREFIX = 'https://pay.google.com/gp/v/save/'; +/** How long a probe of the signer's Google route counts: an issuer is set up once. */ +const GOOGLE_PROBE_TTL_MS = 10 * 60_000; +/** A failed probe (the signer unreachable) is tried again sooner. */ +const GOOGLE_PROBE_RETRY_MS = 60_000; /** A pass is its texts plus TREK's icon and wordmark: some 25 KB. */ const MAX_PASS_BYTES = 512 * 1024; const PKPASS = 'application/vnd.apple.pkpass'; @@ -47,6 +51,40 @@ export class WalletPassService { return this.config.mode === 'auto'; } + private googleProbe: { available: boolean; until: number } | null = null; + + /** + * Whether a booking can go to Google Wallet: passes on here, and the TREK + * services' signer has its Google route mounted (it is only once liketrek has + * a Wallet issuer; before that it answers 404). The answer is kept for ten + * minutes, so the Android app's button costs the signer one request now and then. + */ + async googleAvailable(): Promise { + if (!this.enabled()) return false; + const url = googleSignerUrl(this.config.walletPassUrl); + if (!url) return false; + const now = Date.now(); + if (this.googleProbe && this.googleProbe.until > now) return this.googleProbe.available; + let available = false; + let ttl = GOOGLE_PROBE_TTL_MS; + try { + // A GET of the POST-only route: 405 when it is there, 404 when it is not. + const response = await safeFetchAdminConfigured( + url, + { method: 'GET', signal: AbortSignal.timeout(TIMEOUT_MS) }, + 0, + undefined, + 2048, + ); + available = response.status !== 404 && response.status < 500; + if (response.status >= 500) ttl = GOOGLE_PROBE_RETRY_MS; + } catch { + ttl = GOOGLE_PROBE_RETRY_MS; + } + this.googleProbe = { available, until: now + ttl }; + return available; + } + async passFor(userId: number, tripId: number, reservationId: number, language: string): Promise { const { request, title } = await this.requestFor(userId, tripId, reservationId, language); return { bytes: await this.sign(request), filename: `${filenameOf(title)}.pkpass` }; @@ -113,10 +151,15 @@ export class WalletPassService { } private async signGoogle(request: object): Promise { + const url = googleSignerUrl(this.config.walletPassUrl); + if (!url) { + logWarn('[Wallet pass] TREK_WALLET_SIGNER_URL does not end in /pass, so there is no Google route beside it'); + throw new DomainError(404, 'Google Wallet passes are not available on this server'); + } let response: Response; try { response = await safeFetchAdminConfigured( - googleSignerUrl(this.config.walletPassUrl), + url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, @@ -193,9 +236,22 @@ export class WalletPassService { } } -/** The signer's Google route beside its Apple one: �/v1/wallet/pass becomes �/v1/wallet/google. */ -function googleSignerUrl(passUrl: string): string { - return passUrl.replace(/\/pass\/?$/, '/google'); +/** + * The signer's Google route beside its Apple one: …/v1/wallet/pass becomes + * …/v1/wallet/google, the query kept. Null for a signer URL that does not end in + * /pass: there is no telling where its Google route is, and posting the booking + * to the Apple route instead would only sign a pass nobody asked for. + */ +export function googleSignerUrl(passUrl: string): string | null { + let url: URL; + try { + url = new URL(passUrl); + } catch { + return null; + } + if (!/\/pass\/?$/.test(url.pathname)) return null; + url.pathname = url.pathname.replace(/\/pass\/?$/, '/google'); + return url.toString(); } function stringOrNull(value: unknown): string | null { diff --git a/server/tests/e2e/wallet-pass.e2e.test.ts b/server/tests/e2e/wallet-pass.e2e.test.ts index e7326193a..7df09045d 100644 --- a/server/tests/e2e/wallet-pass.e2e.test.ts +++ b/server/tests/e2e/wallet-pass.e2e.test.ts @@ -166,3 +166,49 @@ describe('GET /api/trips/:tripId/reservations/:id/wallet-pass', () => { expect((await request(app.getHttpServer()).get(path(tripId, flightId)).set(as(OWNER))).status).toBe(502); }); }); + +describe('Google Wallet', () => { + const google = (trip: number, id: number) => `/api/trips/${trip}/reservations/${id}/wallet-pass/google?lang=de`; + const SAVE_URL = 'https://pay.google.com/gp/v/save/eyJhbGciOiJSUzI1NiJ9.payload.signature'; + const answer = (status: number, body: string, type = 'application/json') => + safeFetchAdminConfigured.mockResolvedValueOnce(new Response(body, { status, headers: { 'content-type': type } })); + + it('WALLET-E2E-006: the owner gets the save link, signed at the Google route beside the Apple one', async () => { + answer(200, JSON.stringify({ saveUrl: SAVE_URL })); + const res = await request(app.getHttpServer()).get(google(tripId, flightId)).set(as(OWNER)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ saveUrl: SAVE_URL }); + const [url, init] = safeFetchAdminConfigured.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://push.liketrek.com/v1/wallet/google'); + expect(JSON.parse(String(init.body))).toMatchObject({ kind: 'flight', title: 'LH 716', locale: 'de-DE' }); + }); + + it('WALLET-E2E-007: a stranger, another trip and a missing booking get a 404 and ask nobody', async () => { + expect((await request(app.getHttpServer()).get(google(tripId, flightId))).status).toBe(401); + expect((await request(app.getHttpServer()).get(google(tripId, flightId)).set(as(STRANGER))).status).toBe(404); + const other = await makeTrip(orm, OWNER, { title: 'Elsewhere too' }); + expect((await request(app.getHttpServer()).get(google(other.id, flightId)).set(as(OWNER))).status).toBe(404); + expect((await request(app.getHttpServer()).get(google(tripId, 999_999)).set(as(OWNER))).status).toBe(404); + expect(safeFetchAdminConfigured).not.toHaveBeenCalled(); + }); + + it('WALLET-E2E-008: no issuer yet is a 404, a link that is not Google Wallet a 502', async () => { + answer(404, 'not found', 'text/plain'); + expect((await request(app.getHttpServer()).get(google(tripId, flightId)).set(as(OWNER))).status).toBe(404); + answer(200, JSON.stringify({ saveUrl: 'https://evil.example/save' })); + expect((await request(app.getHttpServer()).get(google(tripId, flightId)).set(as(OWNER))).status).toBe(502); + }); + + it('WALLET-E2E-009: the trip members can ask whether Google Wallet works here', async () => { + safeFetchAdminConfigured.mockResolvedValueOnce(new Response('', { status: 405 })); + const res = await request(app.getHttpServer()) + .get(`/api/trips/${tripId}/reservations/google-wallet`) + .set(as(OWNER)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ available: true }); + expect( + (await request(app.getHttpServer()).get(`/api/trips/${tripId}/reservations/google-wallet`).set(as(STRANGER))) + .status, + ).toBe(404); + }); +}); diff --git a/server/tests/unit/nest/wallet-pass/wallet-pass.controller.test.ts b/server/tests/unit/nest/wallet-pass/wallet-pass.controller.test.ts index 403720242..0c42785d5 100644 --- a/server/tests/unit/nest/wallet-pass/wallet-pass.controller.test.ts +++ b/server/tests/unit/nest/wallet-pass/wallet-pass.controller.test.ts @@ -62,4 +62,10 @@ describe('WalletPassController', () => { }); expect(googleSaveUrlFor).toHaveBeenCalledWith(5, 42, 7, 'fr'); }); + + it('WALLET-C-004: says whether Google Wallet works on this server', async () => { + const googleAvailable = vi.fn(async () => false); + const controller = new WalletPassController({ googleAvailable } as never); + await expect(controller.googleAvailability()).resolves.toEqual({ available: false }); + }); }); diff --git a/server/tests/unit/nest/wallet-pass/wallet-pass.service.test.ts b/server/tests/unit/nest/wallet-pass/wallet-pass.service.test.ts index 7dbb12968..18a8cf8fa 100644 --- a/server/tests/unit/nest/wallet-pass/wallet-pass.service.test.ts +++ b/server/tests/unit/nest/wallet-pass/wallet-pass.service.test.ts @@ -3,7 +3,12 @@ import { DomainError } from '../../../../src/nest/common/domain-error'; import { RateLimitService } from '../../../../src/nest/common/rate-limit.service'; import { ReservationsReadService } from '../../../../src/nest/reservations/reservations-read.service'; import { WalletPassModule } from '../../../../src/nest/wallet-pass/wallet-pass.module'; -import { filenameOf, parseMetadata, WalletPassService } from '../../../../src/nest/wallet-pass/wallet-pass.service'; +import { + filenameOf, + googleSignerUrl, + parseMetadata, + WalletPassService, +} from '../../../../src/nest/wallet-pass/wallet-pass.service'; import { loadWalletWords, walletLanguage } from '../../../../src/nest/wallet-pass/wallet-pass.words'; import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { createTestModule, type TestModule } from '../../../helpers/test-module'; @@ -219,4 +224,48 @@ describe('WalletPassService', () => { reservations.getReservationWithJoins.mockResolvedValueOnce({ ...ROW, trip_id: 99 }); expect(await refusal(service.googleSaveUrlFor(1, 42, 7, 'en'))).toBe(404); }); + + it('WALLET-S-010: the Google route sits beside /pass, query kept; any other signer URL has none', async () => { + expect(googleSignerUrl('https://push.liketrek.com/v1/wallet/pass')).toBe( + 'https://push.liketrek.com/v1/wallet/google', + ); + expect(googleSignerUrl('https://signer.example/v1/wallet/pass/?key=x')).toBe( + 'https://signer.example/v1/wallet/google?key=x', + ); + expect(googleSignerUrl('https://signer.example/v1/sign')).toBeNull(); + expect(googleSignerUrl('not a url')).toBeNull(); + make(); + config.walletPassUrl = 'https://signer.example/v1/sign'; + try { + expect(await refusal(service.googleSaveUrlFor(1, 42, 7, 'en'))).toBe(404); + expect(safeFetchAdminConfigured).not.toHaveBeenCalled(); + } finally { + config.walletPassUrl = 'https://push.liketrek.com/v1/wallet/pass'; + } + }); + + it('WALLET-S-011: Google Wallet counts as available once the signer has the route, asked once in ten minutes', async () => { + const probe = (s: number) => safeFetchAdminConfigured.mockResolvedValueOnce(new Response('', { status: s })); + const fresh = () => ((service as unknown as { googleProbe: unknown }).googleProbe = null); + make('off'); + fresh(); + expect(await service.googleAvailable()).toBe(false); + expect(safeFetchAdminConfigured).not.toHaveBeenCalled(); + make(); + fresh(); + probe(404); + expect(await service.googleAvailable()).toBe(false); + expect(await service.googleAvailable()).toBe(false); + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1); + expect((safeFetchAdminConfigured.mock.calls[0] as [string, RequestInit])[0]).toBe( + 'https://push.liketrek.com/v1/wallet/google', + ); + fresh(); + probe(405); + expect(await service.googleAvailable()).toBe(true); + fresh(); + safeFetchAdminConfigured.mockRejectedValueOnce(new Error('ECONNREFUSED')); + expect(await service.googleAvailable()).toBe(false); + fresh(); + }); }); diff --git a/shared/src/i18n/hu/reservations.ts b/shared/src/i18n/hu/reservations.ts index 932ae82bb..9a9143d73 100644 --- a/shared/src/i18n/hu/reservations.ts +++ b/shared/src/i18n/hu/reservations.ts @@ -278,7 +278,7 @@ const reservations: TranslationStrings = { 'reservations.needsConnection': 'Ehhez kapcsolat kell. Próbáld újra, amint a TREK újra online lesz.', 'reservations.exportCsvFiltered': 'A megjelenített {shown} sor exportálása a(z) {total} közül (CSV)', 'reservations.wallet.add': 'Hozzáadás az Apple Wallethez', - 'reservations.wallet.addGoogle': 'Hozzáadás az Google Wallethez', + 'reservations.wallet.addGoogle': 'Hozzáadás a Google Wallethez', 'reservations.wallet.failed': 'Nem sikerült létrehozni a Wallet-kártyát. Próbáld újra.', 'reservations.wallet.undated': 'Előbb adj dátumot a foglalásnak: a Wallet az időpont körül jeleníti meg a kártyát.', }; diff --git a/shared/src/i18n/it/reservations.ts b/shared/src/i18n/it/reservations.ts index 98ce77b97..c7cf59df2 100644 --- a/shared/src/i18n/it/reservations.ts +++ b/shared/src/i18n/it/reservations.ts @@ -275,7 +275,7 @@ const reservations: TranslationStrings = { 'reservations.needsConnection': 'Serve una connessione. Riprova quando TREK tornerà online.', 'reservations.exportCsvFiltered': 'Esporta le {shown} righe mostrate su {total} (CSV)', 'reservations.wallet.add': 'Aggiungi ad Apple Wallet', - 'reservations.wallet.addGoogle': 'Aggiungi ad Google Wallet', + 'reservations.wallet.addGoogle': 'Aggiungi a Google Wallet', 'reservations.wallet.failed': 'Impossibile creare il pass di Wallet. Riprova.', 'reservations.wallet.undated': 'Prima dai una data alla prenotazione: Wallet mostra il pass intorno a quel momento.', }; diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index d3efb7ef5..fdfb4484b 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -412,3 +412,10 @@ export const googleWalletSaveResponseSchema = z.object({ saveUrl: z.string().url().startsWith('https://pay.google.com/gp/v/save/'), }); export type GoogleWalletSaveResponse = z.infer; + +/** + * GET /api/trips/:tripId/reservations/google-wallet: whether this server can make + * Google Wallet passes right now (passes on, and liketrek's Wallet issuer live). + */ +export const googleWalletAvailabilityResponseSchema = z.object({ available: z.boolean() }); +export type GoogleWalletAvailabilityResponse = z.infer;