mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user