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.
This commit is contained in:
Maurice
2026-11-07 14:50:17 +01:00
parent 924836be31
commit 99a8701bd4
8 changed files with 185 additions and 9 deletions
@@ -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<GoogleWalletAvailabilityResponse> {
return { available: await this.passes.googleAvailable() };
}
@Get(':id/wallet-pass/google')
@ResponseContract(googleWalletSaveResponseSchema)
async google(
@@ -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<boolean> {
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<WalletPass> {
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<string> {
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 {
+46
View File
@@ -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);
});
});
@@ -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 });
});
});
@@ -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();
});
});
+1 -1
View File
@@ -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.',
};
+1 -1
View File
@@ -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.',
};
@@ -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<typeof googleWalletSaveResponseSchema>;
/**
* 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<typeof googleWalletAvailabilityResponseSchema>;