Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,3 @@ VITE_APP_URL="http://localhost:3005"

VITE_API_BASE_URL="http://localhost:9000"
VITE_MAP_ROULETTE_SERVER_WEBSOCKET_URL="ws://localhost:9000/ws"

# Must match MR_SUPER_KEY in docker-compose.test.yaml.
VITE_SERVER_API_KEY="super-secret-key"
44 changes: 22 additions & 22 deletions E2E_TESTS.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
# Playwright E2E Tests

What `npm run test:e2e` covers, grouped by feature. **43 tests across 21 spec
files**: 41 run, and 2 are skipped because they need a second *authenticated*
identity the harness cannot provision on its own (see
[Not covered](#not-covered)).
What `npm run test:e2e` covers, grouped by feature. **71 tests across 26 spec
files**, all of which run.

Specs live in [`e2e/`](e2e/); shared setup and API helpers are in
[`e2e/fixtures.ts`](e2e/fixtures.ts). Most fixtures build their data over HTTP;
`seedUser` is the exception and writes to the stack's database directly,
because the backend has no endpoint that creates users.

Every request runs as a seeded super user unless a test asks for someone else.
The backend no longer takes API keys, and a real login needs OpenStreetMap, so
the harness logs in by signing the backend's session cookie itself with the
test stack's `APPLICATION_SECRET` (`sessionCookie` in `e2e/fixtures.ts`). Any
seeded user can be acted as this way (`mapperRequest`, `reviewerPage`).
`e2e/global-setup.ts` seeds the super user and restarts the backend once so it
picks them up from `MR_SUPER_ACCOUNTS`, which it reads only at boot.

---

## Challenges
Expand Down Expand Up @@ -44,7 +50,7 @@ because the backend has no endpoint that creates users.

**Locking** — [`task-locking.spec.ts`](e2e/task-locking.spec.ts)
- A second user is told a task is locked when they open it after the first
user *(needs `E2E_REVIEWER_API_KEY`)*
user

## Projects

Expand Down Expand Up @@ -104,6 +110,15 @@ because the backend has no endpoint that creates users.
**Profile** — [`profile-settings.spec.ts`](e2e/profile-settings.spec.ts)
- A user can update their custom basemap URL setting

## Super admin

**Users** — [`super-admin.spec.ts`](e2e/super-admin.spec.ts)
- The users page lists the authenticated super user, and search filters it

**Challenge reports** — [`challenge-reports.spec.ts`](e2e/challenge-reports.spec.ts)
- Ordinary mappers file reports, and are held to one open report each
- Only a super admin sees reporters' contact details or can rule on a report

## Explore

**Location** — [`explore-location.spec.ts`](e2e/explore-location.spec.ts)
Expand All @@ -113,26 +128,11 @@ because the backend has no endpoint that creates users.

## Not covered

Every request the harness makes carries `MR_SUPER_KEY`, which the backend maps
to a single synthetic super user rather than to any row in the users table. No
endpoint creates users — real ones only arrive through OSM OAuth — so anything
needing a second person, or needing permissions to actually bite, is out of
reach.

- **Acting *as* a granted user.** A user can be seeded into the database to be
granted a role (`seedUser` in `e2e/fixtures.ts`), so granting, re-roling and
revoking are all covered. What that grant actually lets them *do* is not:
authenticating as them needs an API key. That is covered in the backend's
`ChallengeServiceSpec`.
- **Logging in.** The harness signs its own session cookies, so the OSM OAuth
flow itself is never exercised.
- **Applying a tag fix.** The apply step runs inside an embedded iD editor
against real OpenStreetMap data the test stack cannot reach. What a tag-fix
challenge and its tasks propose is covered; carrying it out is not.
- **Super admin** — [`super-admin.spec.ts`](e2e/super-admin.spec.ts) is
`test.skip`: the users page needs a real super-admin-granted identity.
`src/lib/SuperAdminGuard.test.ts` unit-tests the guard predicate instead.
- **Two-user task locking** — runs only when `E2E_REVIEWER_API_KEY` is set to a
real user's `"<userId>|<rawKey>"` key. Provisioning one is a manual,
out-of-band step documented in `e2e/fixtures.ts`.
- **Review workflow** — no specs at all; there is no reviewer UI yet.

## Running them
Expand Down
19 changes: 8 additions & 11 deletions docker-compose.test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,17 @@ services:
MR_DATABASE_URL: jdbc:postgresql://db:5432/maproulette
MR_DATABASE_USERNAME: maproulette
MR_DATABASE_PASSWORD: hunter2
# Must match VITE_SERVER_API_KEY in .env.test. Any request bearing this
# value in the `apiKey` header is treated by the backend as a super-user,
# so every request the E2E harness makes by default runs with full
# privileges — there's no built-in way to exercise a non-admin user or
# real permission boundaries (e.g. review workflows, /super-admin/*
# access checks) against this stack. See the REVIEWER_KEY fixture in
# e2e/fixtures.ts for the (manual, out-of-band) way to provision a
# second, non-super-user identity, and the skip comment in
# e2e/super-admin.spec.ts for a concrete case this limitation blocks.
MR_SUPER_KEY: super-secret-key
# Also the key Play signs session cookies with, so e2e/fixtures.ts signs
# its own with it to log in as seeded users. API keys, which the harness
# used before, were removed from the backend in 7a8cdb17.
APPLICATION_SECRET: test-application-secret-at-least-32-chars
MAPROULETTE_SECRET_KEY: test-maproulette-secret-key-at-least-32-chars
# The OSM id of the harness's super user (SUPER_USER_OSM_ID in
# e2e/fixtures.ts). The backend only promotes it at boot, so global setup
# seeds the user and then restarts the backend once.
MR_SUPER_ACCOUNTS: "800000000"
# OAuth values are required for the backend to boot but are not used
# (the tests all use API key auth)
# (the harness signs its own session cookies; see e2e/fixtures.ts)
MR_OSM_SERVER: https://master.apis.dev.openstreetmap.org
MR_OAUTH_CONSUMER_KEY: not_a_real_key
MR_OAUTH_CONSUMER_SECRET: not_a_real_secret
Expand Down
10 changes: 3 additions & 7 deletions e2e/challenge-managers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BACKEND_URL, expect, SUPER_KEY, test } from './fixtures'
import { BACKEND_URL, expect, test } from './fixtures'

// Granting an individual user a role on a single challenge, which reaches
// where the parent project's grants do not.
Expand Down Expand Up @@ -113,9 +113,7 @@ test("a manager's role can be changed, and they keep just the one", async ({
// A user holds one role per challenge: re-granting replaces rather than
// accumulates, so the server should report exactly one entry.
await expect(async () => {
const response = await request.get(`${BACKEND_URL}/api/v2/challenge/${challenge.id}/managers`, {
headers: { apiKey: SUPER_KEY },
})
const response = await request.get(`${BACKEND_URL}/api/v2/challenge/${challenge.id}/managers`)
const managers = (await response.json()) as Array<{ userId: number; role: number }>
expect(managers).toHaveLength(1)
expect(managers[0]).toMatchObject({ userId: seededUser.id, role: 1 })
Expand All @@ -136,9 +134,7 @@ test('a manager can be removed again', async ({ page, request, challenge, seeded

await expect(dialog.getByText('No managers yet.')).toBeVisible({ timeout: 15_000 })
await expect(async () => {
const response = await request.get(`${BACKEND_URL}/api/v2/challenge/${challenge.id}/managers`, {
headers: { apiKey: SUPER_KEY },
})
const response = await request.get(`${BACKEND_URL}/api/v2/challenge/${challenge.id}/managers`)
expect(await response.json()).toEqual([])
}).toPass({ timeout: 20_000 })
})
15 changes: 5 additions & 10 deletions e2e/challenge-reports.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BACKEND_URL, expect, SUPER_KEY, test } from './fixtures'
import { BACKEND_URL, expect, test } from './fixtures'

// Reporting a challenge, end to end: a mapper files one, a second mapper files
// their own, and a super admin triages them.
Expand Down Expand Up @@ -100,7 +100,6 @@ test('a mapper sees the reports on a challenge, without the admin side of them',

// The super admin does see it.
const asAdmin = await request.get(`${BACKEND_URL}/api/v2/challenge/reports`, {
headers: { apiKey: SUPER_KEY },
params: { challengeId: challenge.id },
})
const adminView = (await asAdmin.json()) as Array<{ reporterEmail?: string | null }>
Expand All @@ -118,12 +117,9 @@ test('two different mappers can each report the same challenge', async ({
})

// A second real identity, built the same way the fixture builds the first.
const { mintApiKey, seedUser, deleteSeededUser } = await import('./fixtures')
const { newSessionRequest, seedUser, deleteSeededUser } = await import('./fixtures')
const second = seedUser(`e2e-second-${Date.now()}`, 920_000_000 + Math.floor(Math.random() * 1e7))
const secondKey = await mintApiKey(request, second.id)
const secondContext = await playwright.request.newContext({
extraHTTPHeaders: { apiKey: secondKey, 'Content-Type': 'application/json' },
})
const secondContext = await newSessionRequest(playwright.request, second)

try {
const response = await secondContext.post(
Expand All @@ -134,7 +130,6 @@ test('two different mappers can each report the same challenge', async ({
expect(response.status()).toBe(201)

const all = await request.get(`${BACKEND_URL}/api/v2/challenge/reports`, {
headers: { apiKey: SUPER_KEY },
params: { challengeId: challenge.id },
})
const reports = (await all.json()) as Array<{ reporterId: number }>
Expand All @@ -159,7 +154,7 @@ test('a super admin can action a report, and the reporter sees the outcome only'
const report = (await filed.json()) as { id: number }

const decided = await request.put(`${BACKEND_URL}/api/v2/challenge/report/${report.id}/status`, {
headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
data: { status: 'actioned', reviewComment: 'archived the challenge' },
})
expect(decided.ok()).toBe(true)
Expand Down Expand Up @@ -194,7 +189,7 @@ test('resolving a report frees the reporter to raise the challenge again', async
const report = (await filed.json()) as { id: number }

await request.put(`${BACKEND_URL}/api/v2/challenge/report/${report.id}/status`, {
headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
data: { status: 'dismissed' },
})

Expand Down
8 changes: 3 additions & 5 deletions e2e/challenge-team-managers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BACKEND_URL, expect, SUPER_KEY, test } from './fixtures'
import { BACKEND_URL, expect, test } from './fixtures'

// Attaching a team to a single challenge, which reaches that challenge alone
// without handing over the whole project. Users could always be granted a role
Expand All @@ -13,8 +13,7 @@ const attachTeamToChallenge = async (
challengeId: number
) => {
const response = await request.post(
`${BACKEND_URL}/api/v2/team/${teamId}/challenge/${challengeId}/2`,
{ headers: { apiKey: SUPER_KEY } }
`${BACKEND_URL}/api/v2/team/${teamId}/challenge/${challengeId}/2`
)
if (!response.ok()) {
throw new Error(
Expand Down Expand Up @@ -99,8 +98,7 @@ test('attaching a team to a challenge leaves its project alone', async ({
// Reaching one challenge is the whole point of granting here rather than on
// the project, so the project must not pick the team up.
const response = await request.get(
`${BACKEND_URL}/api/v2/teams/projectManagers/${challenge.projectId}`,
{ headers: { apiKey: SUPER_KEY } }
`${BACKEND_URL}/api/v2/teams/projectManagers/${challenge.projectId}`
)
const body = (await response.json()) as Array<{ team: { id: number } }>
expect(body.some((entry) => entry.team.id === team.id)).toBe(false)
Expand Down
6 changes: 2 additions & 4 deletions e2e/challenge-teams.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BACKEND_URL, expect, giveChallengeToTeam, SUPER_KEY, test } from './fixtures'
import { BACKEND_URL, expect, giveChallengeToTeam, test } from './fixtures'

// Handing a challenge to a team: the team becomes its byline and the team's
// image goes on its card.
Expand Down Expand Up @@ -66,9 +66,7 @@ test('choosing a team on the form gives the challenge to that team', async ({
// The form's own round trip is what matters here: read the challenge back
// rather than trusting the page it navigates to.
await expect(async () => {
const response = await request.get(`${BACKEND_URL}/api/v2/challenge/${challenge.id}`, {
headers: { apiKey: SUPER_KEY },
})
const response = await request.get(`${BACKEND_URL}/api/v2/challenge/${challenge.id}`)
const body = (await response.json()) as { ownerTeamId?: number | null }
expect(body.ownerTeamId).toBe(team.id)
}).toPass({ timeout: 20_000 })
Expand Down
Loading
Loading