From c2d62fd8cc40f03b4025e6661fbcb074dc1c2817 Mon Sep 17 00:00:00 2001 From: Neil Trodden Date: Thu, 17 Sep 2026 17:58:13 +0100 Subject: [PATCH 1/2] fix: accept backup probes with case-insensitive headers The deployed June Lambda rejects the app's empty test request; upstream added probe support in August. Handle every HTTP header casing so probes consistently succeed without creating an empty backup. Exercise storage with a complete gzipped SQLite fixture and its independently recorded database digest, and document upgrading older deployments. --- .../aws-lambda-s3-deployable/README.md | 18 ++++++++- .../src/src/index.test.ts | 37 ++++++++++++++++++- .../aws-lambda-s3-deployable/src/src/index.ts | 6 ++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/README.md b/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/README.md index 855d010cc..06a414478 100644 --- a/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/README.md +++ b/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/README.md @@ -123,7 +123,23 @@ There is also an optional `delete_after_days` variable. Uncomment this to set an ## Configuring LiftLog to use the remote backup -Save the file `output.txt` somewhere safe. Open it and note the url and api key. Add these to the remote backup configuration in LiftLog and when you click 'Test', it should work. +Save the file `output.txt` somewhere safe. Under **Settings → Backends**, add a +**Backup endpoint only** backend using the complete API URL (including +`/prod/backup`). Add an `X-API-Key` header with the API key, then select this +backend for automatic remote backup. + +The **Test** button sends an empty POST with `X-LiftLog-Probe: true`. API Gateway +checks the API key as usual, and the Lambda returns `200` without writing a file. +This checks connectivity and authentication; it does not exercise S3 uploads. +An empty request without the probe header is rejected. + +If Test reports **No file uploaded or body is empty**, an older Lambda may still +be deployed. Updating your checkout alone does not update AWS: rebuild the package +and follow the Terraform upgrade steps above. See [the backup protocol](../../../../docs/RemoteBackup.md). + +To verify storage after upgrading, perform a real backup, download the new S3 +object, and check that it decompresses successfully. For current SQLite backups, +`PRAGMA integrity_check` on the decompressed database should return `ok`. To access your backup, log into the AWS Console, navigate to S3, find your bucket and your files will be organised into folders by date. Object versioning is enabled so accidental overwrites and deletes can be recovered from S3. diff --git a/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/src/src/index.test.ts b/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/src/src/index.test.ts index dab6c25f3..a3dadd440 100644 --- a/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/src/src/index.test.ts +++ b/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/src/src/index.test.ts @@ -1,9 +1,18 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { describe, it } from "node:test"; +import { gunzipSync } from "node:zlib"; import { APIGatewayProxyEvent } from "aws-lambda"; import { createHandler } from "./index"; -const gzipBody = Buffer.from([0x1f, 0x8b, 0x08, 0x00]); +// A complete gzipped SQLite fixture with one synthetic workout. The digest below +// was recorded from the original database, before compression or upload. +const gzipBody = Buffer.from( + "H4sIAAAAAAAC/wsO9MksSVVIyy/KTSxRMGZgYmBkZHBQUGBgADIhGAYYgZgFjU8IMDHo9f7gBSlmPMgARKOA2sCWkU1cVpbRvyQxKSe1PL8oO7+0pBhGMzkHuTqGuCqEODr5uCrARBU08hJzUxVCXCNCNCFx84EBiEbBCAB8jEyqIanFJbDEAACq0lLxAAQAAA==", + "base64", +); +const sqliteSha256 = + "f127f2053426517c57f0d84a92965dd82060512226b26b96c6c5ca67d2ce557f"; function event(overrides: Partial = {}): APIGatewayProxyEvent { return { @@ -39,6 +48,11 @@ describe("backup handler", () => { ContentType: "application/octet-stream", }, ]); + const storedBody = (uploads[0] as { Body: Buffer }).Body; + assert.equal( + createHash("sha256").update(gunzipSync(storedBody)).digest("hex"), + sqliteSha256, + ); }); it("rejects requests that are not POST /backup without uploading", async () => { @@ -85,6 +99,22 @@ describe("backup handler", () => { assert.equal(uploads, 0); }); + it("answers a probe when HTTP header names use mixed casing", async () => { + const uploads: unknown[] = []; + const handler = createHandler( + { send: async (command) => uploads.push(command.input) }, + () => new Date(), + { BUCKET_NAME: "backup-bucket" }, + ); + + const result = await handler( + event({ body: "", headers: { "X-LIFTLOG-PROBE": "true" } }), + ); + + assert.equal(result.statusCode, 200); + assert.deepEqual(uploads, []); + }); + it("preserves bytes from legacy non-base64 binary requests", async () => { const uploads: unknown[] = []; const handler = createHandler( @@ -102,6 +132,11 @@ describe("backup handler", () => { assert.equal(result.statusCode, 200); assert.deepEqual((uploads[0] as { Body: Buffer }).Body, gzipBody); + const storedBody = (uploads[0] as { Body: Buffer }).Body; + assert.equal( + createHash("sha256").update(gunzipSync(storedBody)).digest("hex"), + sqliteSha256, + ); }); it("rejects unsupported non-base64 content without uploading", async () => { diff --git a/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/src/src/index.ts b/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/src/src/index.ts index 92ce4359d..a4326ddfb 100644 --- a/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/src/src/index.ts +++ b/examples/remote-backup/reference-server-implementation/aws-lambda-s3-deployable/src/src/index.ts @@ -46,7 +46,11 @@ export function createHandler( // The app checks its backup target by posting an empty body with this header. Answer it, but do // not store it - see docs/RemoteBackup.md. - if (event.headers["X-LiftLog-Probe"] || event.headers["x-liftlog-probe"]) { + if ( + Object.entries(event.headers).some( + ([name, value]) => name.toLowerCase() === "x-liftlog-probe" && value, + ) + ) { return getReturnResult(200, "Probe acknowledged. Nothing was stored."); } From ade0662dbd7c1e9919ae7afc0e4990908b3774a1 Mon Sep 17 00:00:00 2001 From: Neil Trodden Date: Thu, 17 Sep 2026 18:28:44 +0100 Subject: [PATCH 2/2] chore: fix API test formatting check --- .../Integration/InboxControllerTests.cs | 15 ++++++++++++--- .../Integration/UsersControllerTests.cs | 4 +++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/LiftLog.Tests.Api/Integration/InboxControllerTests.cs b/tests/LiftLog.Tests.Api/Integration/InboxControllerTests.cs index cdb27c7df..695460c61 100644 --- a/tests/LiftLog.Tests.Api/Integration/InboxControllerTests.cs +++ b/tests/LiftLog.Tests.Api/Integration/InboxControllerTests.cs @@ -10,7 +10,12 @@ namespace LiftLog.Tests.Api.Integration; public class InboxControllerTests(ApiFactory factory) { // RSA can only encrypt up to the key size, so a message arrives as ordered chunks. - private static readonly byte[][] chunks = [[0x01, 0x02], [0x03, 0x04], [0x05, 0x06]]; + private static readonly byte[][] chunks = + [ + [0x01, 0x02], + [0x03, 0x04], + [0x05, 0x06], + ]; [Test] public async Task PutThenGet_RoundTripsEveryChunkInOrder() @@ -43,8 +48,12 @@ public async Task Get_DrainsTheInbox() ).EnsureSuccessStatusCode(); var request = new GetInboxMessagesRequest(alice.Id, alice.Password); - var first = await (await client.PostAsJsonAsync("/inbox", request)).Content.ReadFromJsonAsync(); - var second = await (await client.PostAsJsonAsync("/inbox", request)).Content.ReadFromJsonAsync(); + var first = await ( + await client.PostAsJsonAsync("/inbox", request) + ).Content.ReadFromJsonAsync(); + var second = await ( + await client.PostAsJsonAsync("/inbox", request) + ).Content.ReadFromJsonAsync(); await Assert.That(first!.InboxMessages).Count().IsEqualTo(1); await Assert.That(second!.InboxMessages).IsEmpty(); diff --git a/tests/LiftLog.Tests.Api/Integration/UsersControllerTests.cs b/tests/LiftLog.Tests.Api/Integration/UsersControllerTests.cs index f13004346..7a1b5e238 100644 --- a/tests/LiftLog.Tests.Api/Integration/UsersControllerTests.cs +++ b/tests/LiftLog.Tests.Api/Integration/UsersControllerTests.cs @@ -23,7 +23,9 @@ public async Task Post_ReturnsEveryRequestedUserKeyedById() response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); - await Assert.That(body!.Users.Keys.Order()).IsEquivalentTo(new[] { alice.Id, bob.Id }.Order()); + await Assert + .That(body!.Users.Keys.Order()) + .IsEquivalentTo(new[] { alice.Id, bob.Id }.Order()); await Assert.That(body.Users[alice.Id].Lookup).IsEqualTo(alice.Lookup); }