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>
194 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|