taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit c3b21df23776c687e9e12d1b46dd94f00383553e
parent b97d40494ebfb80a6bb459277fc00403ee6866a3
Author: Florian Dold <dold@taler.net>
Date:   Thu,  6 Aug 2026 18:26:32 +0200

util: let the exchange /keys client cherry-pick

The exchange only leaves denominations out when last_issue_date names one it
still offers, and answers with the full list otherwise, so a stale value costs
bandwidth but never correctness.

Issue: https://bugs.taler.net/n/11715

Diffstat:
Mpackages/taler-util/src/http-client/exchange-client.ts | 13+++++++++++--
Apackages/taler-util/src/http-client/exchange-keys.test.ts | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 103 insertions(+), 2 deletions(-)

diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -303,13 +303,22 @@ export class TalerExchangeHttpClient { * PARTIALLY IMPLEMENTED!! */ async getKeys( - opts: { noCache?: boolean } = {}, + opts: { noCache?: boolean; lastIssueDate?: number } = {}, ): Promise<OperationOk<ExchangeKeysResponse> | OperationFail<HttpStatusCode.NotFound>> { const headers: Record<string, string> = {}; if (opts.noCache) { headers["cache-control"] = "no-cache"; } - const resp = await this.fetch("keys", { headers }); + const url = new URL("keys", this.baseUrl); + if (opts.lastIssueDate != null) { + // Cherry-picking: the exchange leaves out every denomination that + // started before this timestamp. It only does so when the value is + // exactly the stamp_start of a denomination it still offers, and + // answers with the full list otherwise, so a stale value costs + // bandwidth but never correctness. + url.searchParams.set("last_issue_date", String(opts.lastIssueDate)); + } + const resp = await this.fetch(url, { headers }); switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForExchangeKeysResponse()); diff --git a/packages/taler-util/src/http-client/exchange-keys.test.ts b/packages/taler-util/src/http-client/exchange-keys.test.ts @@ -0,0 +1,92 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert"; +import { test } from "node:test"; +import { + HeadersImpl, + HttpRequestLibrary, + HttpRequestOptions, + HttpResponse, +} from "../http-common.js"; +import { TalerExchangeHttpClient } from "./exchange-client.js"; + +/** + * Records the URL and headers of the last request and answers with an empty + * 204, which is enough to get back out of the client. + */ +class RecordingHttpLib implements HttpRequestLibrary { + lastUrl: string | undefined; + lastHeaders: { [name: string]: string | undefined } | undefined; + async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { + this.lastUrl = url; + this.lastHeaders = opt?.headers; + return { + requestUrl: url, + requestMethod: opt?.method ?? "GET", + status: 204, + headers: new HeadersImpl(), + async json() { + return {}; + }, + async text() { + return ""; + }, + async bytes() { + return new Uint8Array(); + }, + }; + } +} + +test("getKeys asks for the whole response by default", async (t) => { + const lib = new RecordingHttpLib(); + const client = new TalerExchangeHttpClient("https://exchange.example.com/", { + httpClient: lib, + }); + // The 204 is not a valid /keys response; only the request matters here. + await client.getKeys().catch(() => undefined); + assert.strictEqual(lib.lastUrl, "https://exchange.example.com/keys"); +}); + +test("getKeys passes the cherry-picking date as last_issue_date", async (t) => { + const lib = new RecordingHttpLib(); + const client = new TalerExchangeHttpClient("https://exchange.example.com/", { + httpClient: lib, + }); + await client.getKeys({ lastIssueDate: 1750000000 }).catch(() => undefined); + // Seconds since the epoch, as an unsigned integer -- not an ISO string and + // not milliseconds, either of which the exchange answers with a 400. + assert.strictEqual( + lib.lastUrl, + "https://exchange.example.com/keys?last_issue_date=1750000000", + ); +}); + +test("getKeys combines cherry-picking with a cache break", async (t) => { + const lib = new RecordingHttpLib(); + const client = new TalerExchangeHttpClient("https://exchange.example.com/", { + httpClient: lib, + }); + await client + .getKeys({ lastIssueDate: 1750000000, noCache: true }) + .catch(() => undefined); + assert.strictEqual( + lib.lastUrl, + "https://exchange.example.com/keys?last_issue_date=1750000000", + ); + assert.strictEqual(lib.lastHeaders?.["cache-control"], "no-cache"); +});