Add Komoot import with public (bio verification) and authenticated modes
Two-mode import: public mode verifies Komoot account ownership by checking that the user's trails.cool profile URL appears in their Komoot bio — no credentials stored. Authenticated mode uses email + password (AES-256-GCM encrypted) to import private tours as well. Includes unit tests for crypto/komoot client and E2E tests for the full connect + import flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b63fd1a303
commit
03304c354b
22 changed files with 1612 additions and 4 deletions
262
e2e/komoot-import.test.ts
Normal file
262
e2e/komoot-import.test.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test";
|
||||
|
||||
const KOMOOT_USER_ID = "99999999999";
|
||||
const KOMOOT_PROFILE_URL = `https://www.komoot.com/user/${KOMOOT_USER_ID}`;
|
||||
|
||||
const SAMPLE_GPX = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="komoot" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk>
|
||||
<name>Morning Hike</name>
|
||||
<trkseg>
|
||||
<trkpt lat="48.137" lon="11.575"><ele>520</ele></trkpt>
|
||||
<trkpt lat="48.138" lon="11.576"><ele>525</ele></trkpt>
|
||||
<trkpt lat="48.139" lon="11.577"><ele>530</ele></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`;
|
||||
|
||||
const MOCK_TOURS_PAGE = {
|
||||
_embedded: {
|
||||
tours: [
|
||||
{
|
||||
id: 111111111,
|
||||
name: "Morning Hike",
|
||||
type: "hike",
|
||||
date: "2024-06-01T08:00:00.000Z",
|
||||
distance: 12500,
|
||||
duration: 7200,
|
||||
elevation_up: 350,
|
||||
elevation_down: 350,
|
||||
},
|
||||
],
|
||||
},
|
||||
page: { totalPages: 1, number: 0 },
|
||||
};
|
||||
|
||||
async function setupVirtualAuthenticator(cdp: CDPSession) {
|
||||
await cdp.send("WebAuthn.enable");
|
||||
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
||||
options: {
|
||||
protocol: "ctap2",
|
||||
transport: "internal",
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
},
|
||||
});
|
||||
return authenticatorId;
|
||||
}
|
||||
|
||||
async function removeVirtualAuthenticator(cdp: CDPSession, authenticatorId: string) {
|
||||
await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
|
||||
await cdp.send("WebAuthn.disable");
|
||||
}
|
||||
|
||||
async function registerAndLogin(page: Page, cdp: CDPSession) {
|
||||
const ts = Date.now();
|
||||
const email = `komoot-${ts}@example.com`;
|
||||
const username = `komootuser${ts}`;
|
||||
|
||||
await page.goto("/auth/register");
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByLabel("Username").fill(username);
|
||||
await page.getByRole("button", { name: /Register with Passkey/ }).click();
|
||||
await expect(page).toHaveURL("/", { timeout: 10000 });
|
||||
|
||||
return { email, username };
|
||||
}
|
||||
|
||||
function mockKomootPublicProfile(page: Page, userId: string, trailsProfileUrl: string) {
|
||||
return page.route(`https://api.komoot.de/v007/users/${userId}/`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
username: userId,
|
||||
display_name: "Test Komoot User",
|
||||
content: {
|
||||
text: `Check out my trails profile: ${trailsProfileUrl}`,
|
||||
link: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockKomootPublicProfileNoMatch(page: Page, userId: string) {
|
||||
return page.route(`https://api.komoot.de/v007/users/${userId}/`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
username: userId,
|
||||
display_name: "Test Komoot User",
|
||||
content: { text: "I like hiking", link: null },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockKomootTours(page: Page, userId: string) {
|
||||
return page.route(
|
||||
(url) => url.href.includes(`api.komoot.de/v007/users/${userId}/tours`),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(MOCK_TOURS_PAGE),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockKomootGpx(page: Page, tourId: string) {
|
||||
return page.route(
|
||||
(url) => url.href.includes(`api.komoot.de/v007/tours/${tourId}.gpx`),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/gpx+xml",
|
||||
body: SAMPLE_GPX,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Komoot connection page", () => {
|
||||
test("unauthenticated user is redirected to login", async ({ page }) => {
|
||||
await page.goto("/settings/connections/komoot");
|
||||
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test("public verify — success path", async ({ page }) => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
||||
const { username } = await registerAndLogin(page, cdp);
|
||||
|
||||
const trailsProfileUrl = `http://localhost:3000/users/${username}`;
|
||||
await mockKomootPublicProfile(page, KOMOOT_USER_ID, trailsProfileUrl);
|
||||
|
||||
await page.goto("/settings/connections/komoot");
|
||||
await waitForHydration(page);
|
||||
|
||||
await page.getByLabel("Komoot profile URL or user ID").fill(KOMOOT_PROFILE_URL);
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
|
||||
await expect(page.getByRole("status")).toContainText("connected in public mode", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await removeVirtualAuthenticator(cdp, authenticatorId);
|
||||
});
|
||||
|
||||
test("public verify — bio does not contain profile URL shows error", async ({ page }) => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
||||
await registerAndLogin(page, cdp);
|
||||
|
||||
await mockKomootPublicProfileNoMatch(page, KOMOOT_USER_ID);
|
||||
|
||||
await page.goto("/settings/connections/komoot");
|
||||
await waitForHydration(page);
|
||||
|
||||
await page.getByLabel("Komoot profile URL or user ID").fill(KOMOOT_PROFILE_URL);
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Could not verify", { timeout: 10000 });
|
||||
|
||||
await removeVirtualAuthenticator(cdp, authenticatorId);
|
||||
});
|
||||
|
||||
test("public verify — invalid URL shows error", async ({ page }) => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
||||
await registerAndLogin(page, cdp);
|
||||
|
||||
await page.goto("/settings/connections/komoot");
|
||||
await waitForHydration(page);
|
||||
|
||||
await page.getByLabel("Komoot profile URL or user ID").fill("not-a-valid-url");
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("valid Komoot", { timeout: 10000 });
|
||||
|
||||
await removeVirtualAuthenticator(cdp, authenticatorId);
|
||||
});
|
||||
|
||||
test("authenticated connect — invalid credentials shows error", async ({ page }) => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
||||
await registerAndLogin(page, cdp);
|
||||
|
||||
// Mock Komoot auth endpoint to reject
|
||||
await page.route(/api\.komoot\.de\/v006\/account\/email/, (route) =>
|
||||
route.fulfill({ status: 401, body: "Unauthorized" }),
|
||||
);
|
||||
|
||||
await page.goto("/settings/connections/komoot");
|
||||
await waitForHydration(page);
|
||||
|
||||
await page.getByLabel("Komoot email").fill("wrong@example.com");
|
||||
await page.getByLabel("Komoot password").fill("wrongpassword");
|
||||
await page.getByRole("button", { name: "Connect" }).click();
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Invalid Komoot", { timeout: 10000 });
|
||||
|
||||
await removeVirtualAuthenticator(cdp, authenticatorId);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Komoot import page", () => {
|
||||
test("shows importable public tours after public verify", async ({ page }) => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
||||
const { username } = await registerAndLogin(page, cdp);
|
||||
|
||||
const trailsProfileUrl = `http://localhost:3000/users/${username}`;
|
||||
await mockKomootPublicProfile(page, KOMOOT_USER_ID, trailsProfileUrl);
|
||||
await mockKomootTours(page, KOMOOT_USER_ID);
|
||||
await mockKomootGpx(page, "111111111");
|
||||
|
||||
// Connect first
|
||||
await page.goto("/settings/connections/komoot");
|
||||
await waitForHydration(page);
|
||||
await page.getByLabel("Komoot profile URL or user ID").fill(KOMOOT_PROFILE_URL);
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("connected in public mode", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Navigate to import page
|
||||
await page.goto("/sync/import/komoot");
|
||||
await expect(page.getByText("Morning Hike")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await removeVirtualAuthenticator(cdp, authenticatorId);
|
||||
});
|
||||
|
||||
test("import tour creates a route", async ({ page }) => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
||||
const { username } = await registerAndLogin(page, cdp);
|
||||
|
||||
const trailsProfileUrl = `http://localhost:3000/users/${username}`;
|
||||
await mockKomootPublicProfile(page, KOMOOT_USER_ID, trailsProfileUrl);
|
||||
await mockKomootTours(page, KOMOOT_USER_ID);
|
||||
await mockKomootGpx(page, "111111111");
|
||||
|
||||
// Connect
|
||||
await page.goto("/settings/connections/komoot");
|
||||
await waitForHydration(page);
|
||||
await page.getByLabel("Komoot profile URL or user ID").fill(KOMOOT_PROFILE_URL);
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("connected in public mode", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Import
|
||||
await page.goto("/sync/import/komoot");
|
||||
await expect(page.getByText("Morning Hike")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Import" }).click();
|
||||
await expect(page.getByText("Imported")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await removeVirtualAuthenticator(cdp, authenticatorId);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue