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."); } 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); }