trails/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts
Ullrich Schäfer 0360757ae8 feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)
Garmin Connect as the third connected-services provider (spec:
garmin-import). The interesting parts:

- Push-first ingestion: Garmin has no list endpoint. The webhook
  normalizes ping (callbackURL) and push (inline) notification batches
  into events; the slow work (authorized FIT download, FIT→GPX via the
  shared converter, activity creation) runs in a garmin-import-activity
  pg-boss job so the webhook answers fast. Callback URLs are validated
  against Garmin's API host before any fetch (SSRF guard).
- History via backfill requests: /sync/import/garmin is a date-range
  requester with honest async progress (no pick list — the concept
  doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap;
  overlaps are free via sync_imports dedupe. Requests persist in
  import_batches via two new nullable columns (range_start/range_end).
- OAuth2 + PKCE on the existing oauth credential kind. Design
  correction from apply: the verifier rides a short-lived httpOnly
  cookie scoped to the callback path — the state param is visible in
  redirect URLs and must never carry it. Manifests opt in via pkce:true.
- Deregistration notifications flip the connection to 'revoked'
  (row kept for audit, imports retained, re-connect prompt shown).
- Framework evolutions, all additive: parseWebhook returns
  WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains
  configured()/importUrl/pkce, importActivity accepts summary stats
  for FIT-less imports, manager gains markRevoked.
- Env-gated: no GARMIN_CLIENT_ID → provider hidden on
  /settings/connections. Privacy manifest entry (DE+EN). i18n en+de.

Rollout (§6) stays gated on the Garmin Developer Program application
(submitted 2026-06-07). Fixtures are doc-shaped; the staging soak
swaps in recorded payloads if shapes differ.

Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known
flakes green isolated ✓ openspec validate ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:47:22 +02:00

194 lines
6.2 KiB
TypeScript

// Contract tests for the Garmin WebhookReceiver (spec: garmin-import).
//
// Seam: parseWebhook(body) -> WebhookEvent[] (Garmin batches notifications)
// handle(event) -> void (enqueues the import job; deregistration
// revokes; SSRF-suspicious callback URLs are dropped)
import { describe, it, expect, beforeEach, vi } from "vitest";
const mockGetServiceByProviderUser = vi.fn();
const mockMarkRevoked = vi.fn();
const mockEnqueueOptional = vi.fn();
vi.mock("../../manager.ts", () => ({
getServiceByProviderUser: mockGetServiceByProviderUser,
markRevoked: mockMarkRevoked,
// imported transitively via import.server.ts
getServiceById: vi.fn(),
withFreshCredentials: vi.fn(),
}));
vi.mock("../../../boss.server.ts", () => ({
enqueueOptional: mockEnqueueOptional,
}));
vi.mock("../../../sync/imports.server.ts", () => ({
isAlreadyImported: vi.fn(),
importActivity: vi.fn(),
}));
vi.mock("../../../db.ts", () => ({ getDb: () => ({}) }));
beforeEach(() => {
mockGetServiceByProviderUser.mockReset();
mockMarkRevoked.mockReset();
mockEnqueueOptional.mockReset();
});
const { garminWebhook } = await import("./webhook.ts");
const { isAllowedGarminCallback } = await import("./import.server.ts");
describe("garminWebhook.parseWebhook", () => {
it("parses a ping-style activityFiles batch into file events", () => {
const events = garminWebhook.parseWebhook({
activityFiles: [
{
userId: "g-user-1",
summaryId: "s-1",
activityId: 1001,
activityName: "Morning Run",
callbackURL: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001",
fileType: "FIT",
startTimeInSeconds: 1780000000,
durationInSeconds: 3600,
distanceInMeters: 10000,
},
],
});
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
eventType: "garmin:activity-file",
providerUserId: "g-user-1",
workoutId: "1001",
fileUrl: expect.stringContaining("apis.garmin.com"),
name: "Morning Run",
duration: 3600,
distance: 10000,
fileType: "FIT",
});
});
it("parses push-style activity summaries (FIT-less, stats-only path)", () => {
const events = garminWebhook.parseWebhook({
activities: [
{
userId: "g-user-1",
summaryId: "s-2",
activityId: 1002,
activityName: "Indoor Row",
durationInSeconds: 1800,
},
],
});
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
eventType: "garmin:activity-summary",
workoutId: "1002",
duration: 1800,
});
expect(events[0]!.fileUrl).toBeUndefined();
});
it("parses deregistrations", () => {
const events = garminWebhook.parseWebhook({
deregistrations: [{ userId: "g-user-1" }],
});
expect(events).toEqual([
{
eventType: "garmin:deregistration",
providerUserId: "g-user-1",
workoutId: "",
},
]);
});
it("handles mixed batches and skips malformed entries", () => {
const events = garminWebhook.parseWebhook({
activityFiles: [
{ userId: "u1", activityId: 1 },
{ activityId: 2 }, // no userId — skipped
{ userId: "u3" }, // no id — skipped
],
deregistrations: [{}, { userId: "u4" }],
somethingGarminAddedLater: [{ userId: "u5" }],
});
expect(events.map((e) => e.eventType)).toEqual([
"garmin:activity-file",
"garmin:deregistration",
]);
});
it("returns no events for non-object bodies", () => {
expect(garminWebhook.parseWebhook(null)).toEqual([]);
expect(garminWebhook.parseWebhook("x")).toEqual([]);
});
});
describe("garminWebhook.handle", () => {
const service = { id: "svc-g1", userId: "u1", provider: "garmin" };
it("enqueues the import job for a known user's file event", async () => {
mockGetServiceByProviderUser.mockResolvedValue(service);
await garminWebhook.handle({
eventType: "garmin:activity-file",
providerUserId: "g-user-1",
workoutId: "1001",
fileUrl: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001",
fileType: "FIT",
name: "Morning Run",
});
expect(mockEnqueueOptional).toHaveBeenCalledWith(
"garmin-import-activity",
expect.objectContaining({
serviceId: "svc-g1",
userId: "u1",
externalId: "1001",
callbackUrl: expect.stringContaining("apis.garmin.com"),
fileType: "FIT",
}),
expect.anything(),
);
});
it("silently skips unknown users (no leak)", async () => {
mockGetServiceByProviderUser.mockResolvedValue(null);
await garminWebhook.handle({
eventType: "garmin:activity-file",
providerUserId: "nobody",
workoutId: "1",
});
expect(mockEnqueueOptional).not.toHaveBeenCalled();
expect(mockMarkRevoked).not.toHaveBeenCalled();
});
it("drops events whose callback URL is not Garmin's API host (SSRF guard)", async () => {
mockGetServiceByProviderUser.mockResolvedValue(service);
await garminWebhook.handle({
eventType: "garmin:activity-file",
providerUserId: "g-user-1",
workoutId: "1001",
fileUrl: "https://attacker.example/steal?token=",
});
expect(mockEnqueueOptional).not.toHaveBeenCalled();
});
it("revokes the connection on deregistration", async () => {
mockGetServiceByProviderUser.mockResolvedValue(service);
await garminWebhook.handle({
eventType: "garmin:deregistration",
providerUserId: "g-user-1",
workoutId: "",
});
expect(mockMarkRevoked).toHaveBeenCalledWith("svc-g1");
expect(mockEnqueueOptional).not.toHaveBeenCalled();
});
});
describe("isAllowedGarminCallback", () => {
it("allows only https URLs on Garmin's API host", () => {
expect(isAllowedGarminCallback("https://apis.garmin.com/wellness-api/rest/x")).toBe(true);
expect(isAllowedGarminCallback("http://apis.garmin.com/x")).toBe(false);
expect(isAllowedGarminCallback("https://apis.garmin.com.evil.example/x")).toBe(false);
expect(isAllowedGarminCallback("https://evil.example/apis.garmin.com")).toBe(false);
expect(isAllowedGarminCallback("not a url")).toBe(false);
});
});