Skip to content

Commit 149cb11

Browse files
committed
tests: internal-api-feature tests
1 parent 3aa4220 commit 149cb11

7 files changed

Lines changed: 304 additions & 17 deletions

File tree

e2e/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ E2E_MAILPIT_URL=https://mailpit.example.com
2020
E2E_MAILPIT_USER=admin
2121
E2E_MAILPIT_PASS=
2222

23+
# ── Internal API ──────────────────────────────────────────────────────────────
24+
# Required for internal-api.feature scenarios. Leave empty to skip them.
25+
# Must match EPDS_INTERNAL_SECRET on the pds-core service.
26+
# Copy from the root .env file — it mirrors the Railway environment.
27+
E2E_INTERNAL_SECRET=
28+
2329
# ── Optional ──────────────────────────────────────────────────────────────────
2430

2531
# Set to 'true' to run headless (no visible browser window). Default: false.

e2e/cucumber.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export default {
33
'features/passwordless-authentication.feature',
44
'features/automatic-account-creation.feature',
55
'features/consent-screen.feature',
6+
'features/internal-api.feature',
67
],
78
import: ['e2e/step-definitions/**/*.ts', 'e2e/support/**/*.ts'],
89
format: ['pretty', 'html:reports/e2e.html'],
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Step definitions for internal-api.feature.
3+
*
4+
* All scenarios are pure HTTP — no browser needed. Steps call the
5+
* /_internal/* endpoints on pds-core directly using the shared secret.
6+
*
7+
* Note: Scenario "PAR login hint retrieval" is tagged @manual — it requires
8+
* a real PKCE PAR request. Implement when PAR submission steps exist
9+
* (needed for login-hint-resolution.feature too).
10+
*/
11+
12+
import { Given, When, Then } from '@cucumber/cucumber'
13+
import type { EpdsWorld } from '../support/world.js'
14+
import { callInternalApi } from '../support/utils.js'
15+
16+
// ---------------------------------------------------------------------------
17+
// Background
18+
// ---------------------------------------------------------------------------
19+
20+
// The internalSecret guard also prevents account-creation Givens from running
21+
// in scenarios that require the secret — if the secret is absent the entire
22+
// scenario is marked pending before createAccountViaOAuth is called.
23+
Given('the internal API secret is configured', function (this: EpdsWorld) {
24+
return this.skipIfNoInternalSecret()
25+
})
26+
27+
// ---------------------------------------------------------------------------
28+
// Account-by-email
29+
// ---------------------------------------------------------------------------
30+
31+
When(
32+
'the internal API is queried for the account by email',
33+
async function (this: EpdsWorld) {
34+
if (!this.testEmail)
35+
throw new Error('No testEmail — account creation step must run first')
36+
const { status, body } = await callInternalApi(
37+
`/_internal/account-by-email?email=${encodeURIComponent(this.testEmail)}`,
38+
this.env.internalSecret,
39+
)
40+
this.lastHttpStatus = status
41+
this.lastApiResponse = body
42+
},
43+
)
44+
45+
Then("the response contains the account's DID", function (this: EpdsWorld) {
46+
if (!this.userDid)
47+
throw new Error('No userDid — account creation step must run first')
48+
if (!this.lastApiResponse)
49+
throw new Error('No API response — query step must run first')
50+
const did = this.lastApiResponse.did
51+
if (did !== this.userDid) {
52+
throw new Error(`Expected DID "${this.userDid}" but got "${String(did)}"`)
53+
}
54+
})
55+
56+
When(
57+
'the internal API is queried for account-by-email with an unknown address',
58+
async function (this: EpdsWorld) {
59+
const unknown = `unknown-${Date.now()}@example.com`
60+
const { status, body } = await callInternalApi(
61+
`/_internal/account-by-email?email=${encodeURIComponent(unknown)}`,
62+
this.env.internalSecret,
63+
)
64+
this.lastHttpStatus = status
65+
this.lastApiResponse = body
66+
},
67+
)
68+
69+
Then('the response status is 200', function (this: EpdsWorld) {
70+
if (this.lastHttpStatus !== 200) {
71+
throw new Error(
72+
`Expected status 200 but got ${String(this.lastHttpStatus)}`,
73+
)
74+
}
75+
})
76+
77+
Then('the response body has a null DID', function (this: EpdsWorld) {
78+
if (!this.lastApiResponse)
79+
throw new Error('No API response — query step must run first')
80+
if (this.lastApiResponse.did !== null) {
81+
throw new Error(
82+
`Expected did to be null but got "${String(this.lastApiResponse.did)}"`,
83+
)
84+
}
85+
})
86+
87+
// ---------------------------------------------------------------------------
88+
// Account-by-handle
89+
// ---------------------------------------------------------------------------
90+
91+
When(
92+
'the internal API is queried for the account by handle',
93+
async function (this: EpdsWorld) {
94+
if (!this.userHandle)
95+
throw new Error('No userHandle — account creation step must run first')
96+
const { status, body } = await callInternalApi(
97+
`/_internal/account-by-handle?handle=${encodeURIComponent(this.userHandle)}`,
98+
this.env.internalSecret,
99+
)
100+
this.lastHttpStatus = status
101+
this.lastApiResponse = body
102+
},
103+
)
104+
105+
Then("the response contains the account's email", function (this: EpdsWorld) {
106+
if (!this.testEmail)
107+
throw new Error('No testEmail — account creation step must run first')
108+
if (!this.lastApiResponse)
109+
throw new Error('No API response — query step must run first')
110+
const email = this.lastApiResponse.email
111+
if (email !== this.testEmail) {
112+
throw new Error(
113+
`Expected email "${this.testEmail}" but got "${String(email)}"`,
114+
)
115+
}
116+
})
117+
118+
// ---------------------------------------------------------------------------
119+
// Check-handle
120+
// ---------------------------------------------------------------------------
121+
122+
When(
123+
'the internal API is queried to check if the handle exists',
124+
async function (this: EpdsWorld) {
125+
if (!this.userHandle)
126+
throw new Error('No userHandle — account creation step must run first')
127+
const { status, body } = await callInternalApi(
128+
`/_internal/check-handle?handle=${encodeURIComponent(this.userHandle)}`,
129+
this.env.internalSecret,
130+
)
131+
this.lastHttpStatus = status
132+
this.lastApiResponse = body
133+
},
134+
)
135+
136+
Then('the response indicates the handle exists', function (this: EpdsWorld) {
137+
if (!this.lastApiResponse)
138+
throw new Error('No API response — query step must run first')
139+
if (this.lastApiResponse.exists !== true) {
140+
throw new Error(
141+
`Expected exists to be true but got "${String(this.lastApiResponse.exists)}"`,
142+
)
143+
}
144+
})
145+
146+
// ---------------------------------------------------------------------------
147+
// Auth errors
148+
// ---------------------------------------------------------------------------
149+
150+
When(
151+
'the check-handle endpoint is called with an incorrect secret',
152+
async function (this: EpdsWorld) {
153+
const { status, body } = await callInternalApi(
154+
`/_internal/check-handle?handle=somehandle.pds.test`,
155+
'definitely-wrong-secret',
156+
)
157+
this.lastHttpStatus = status
158+
this.lastApiResponse = body
159+
},
160+
)
161+
162+
When(
163+
'an internal endpoint is called without the secret header',
164+
async function (this: EpdsWorld) {
165+
const { status, body } = await callInternalApi(
166+
`/_internal/account-by-email?email=test@example.com`,
167+
null,
168+
)
169+
this.lastHttpStatus = status
170+
this.lastApiResponse = body
171+
},
172+
)
173+
174+
When(
175+
'an internal endpoint is called with an incorrect secret',
176+
async function (this: EpdsWorld) {
177+
const { status, body } = await callInternalApi(
178+
`/_internal/account-by-email?email=test@example.com`,
179+
'definitely-wrong-secret',
180+
)
181+
this.lastHttpStatus = status
182+
this.lastApiResponse = body
183+
},
184+
)
185+
186+
Then('the response status is 401', function (this: EpdsWorld) {
187+
if (this.lastHttpStatus !== 401) {
188+
throw new Error(
189+
`Expected status 401 but got ${String(this.lastHttpStatus)}`,
190+
)
191+
}
192+
})
193+
194+
Then('the response body contains an auth error', function (this: EpdsWorld) {
195+
if (!this.lastApiResponse)
196+
throw new Error('No API response — query step must run first')
197+
const error = this.lastApiResponse.error
198+
if (typeof error !== 'string' || !error) {
199+
throw new Error(
200+
`Expected response body to contain an error string but got "${String(error)}"`,
201+
)
202+
}
203+
})
204+
205+
// check-handle returns 403 (not 401) on bad secret — this is an intentional
206+
// server-side inconsistency documented in the route code
207+
Then('the response status is 403', function (this: EpdsWorld) {
208+
if (this.lastHttpStatus !== 403) {
209+
throw new Error(
210+
`Expected status 403 but got ${String(this.lastHttpStatus)}`,
211+
)
212+
}
213+
})

e2e/support/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const testEnv = {
2323
'https://mailpit-e2e-karma-test.up.railway.app',
2424
mailpitUser: process.env.E2E_MAILPIT_USER ?? 'karma',
2525
mailpitPass: process.env.E2E_MAILPIT_PASS ?? '',
26+
internalSecret: process.env.E2E_INTERNAL_SECRET ?? '',
2627
otpLength: Math.min(12, Math.max(4, Number(process.env.OTP_LENGTH ?? '8'))),
2728
otpCharset: (process.env.OTP_CHARSET ?? 'numeric') as
2829
| 'numeric'

e2e/support/utils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import type { EpdsWorld } from './world.js'
6+
import { testEnv } from './env.js'
67

78
/**
89
* Returns the Playwright Page from the world, throwing a clear error if
@@ -14,3 +15,32 @@ export function getPage(world: EpdsWorld) {
1415
if (!page) throw new Error('page is not initialised')
1516
return page
1617
}
18+
19+
/**
20+
* Makes an HTTP call to a /_internal/* endpoint on pds-core.
21+
*
22+
* - Pass `secret` as a string to include the x-internal-secret header.
23+
* - Pass `null` to omit the header entirely (for testing missing-secret scenarios).
24+
* - Safely handles non-JSON responses (e.g. 502 proxy errors) by falling back
25+
* to `{ raw: <text> }` rather than throwing a SyntaxError.
26+
*/
27+
export async function callInternalApi(
28+
path: string,
29+
secret: string | null,
30+
): Promise<{ status: number; body: Record<string, unknown> }> {
31+
const headers: Record<string, string> = {
32+
'Content-Type': 'application/json',
33+
}
34+
if (secret !== null) {
35+
headers['x-internal-secret'] = secret
36+
}
37+
const res = await fetch(`${testEnv.pdsUrl}${path}`, { headers })
38+
let body: Record<string, unknown>
39+
try {
40+
body = (await res.json()) as Record<string, unknown>
41+
} catch {
42+
const raw = await res.text().catch(() => '<unreadable>')
43+
body = { raw }
44+
}
45+
return { status: res.status, body }
46+
}

e2e/support/world.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ export class EpdsWorld extends World {
2525
/** HTTP status code from the most recent direct API call — set by API steps. */
2626
lastHttpStatus?: number
2727

28+
/** Response body from the most recent internal API call — set by internal-api steps. */
29+
lastApiResponse?: Record<string, unknown>
30+
31+
/** Most recent PAR request_uri — set by PAR submission steps. */
32+
lastRequestUri?: string
33+
2834
get env() {
2935
return testEnv
3036
}
@@ -39,6 +45,18 @@ export class EpdsWorld extends World {
3945
return 'pending'
4046
}
4147
}
48+
49+
/**
50+
* Call in any step that requires the internal API secret. If
51+
* E2E_INTERNAL_SECRET is not set, marks the step as pending and
52+
* cucumber-js skips remaining steps in the scenario.
53+
* When the secret is available, this is a no-op and the step executes normally.
54+
*/
55+
skipIfNoInternalSecret(): 'pending' | undefined {
56+
if (!testEnv.internalSecret) {
57+
return 'pending'
58+
}
59+
}
4260
}
4361

4462
setWorldConstructor(EpdsWorld)

features/internal-api.feature

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,49 @@ Feature: Internal service-to-service API
66

77
Background:
88
Given the ePDS test environment is running
9-
And the internal API is accessible (e.g. via Docker network)
9+
And the internal API secret is configured
1010

11-
Scenario: Account lookup by email returns DID
12-
Given "alice@example.com" has a PDS account with DID "did:plc:alice"
13-
When GET /_internal/account-by-email?email=alice@example.com is called with valid x-internal-secret
14-
Then the response is 200 with { "did": "did:plc:alice" }
11+
Scenario: Account lookup by email returns the account's DID
12+
Given a new user has registered via the demo client
13+
When the internal API is queried for the account by email
14+
Then the response contains the account's DID
1515

16-
Scenario: Account lookup by email returns null for unknown email
17-
When GET /_internal/account-by-email?email=unknown@example.com is called with valid x-internal-secret
18-
Then the response is 200 with { "did": null }
16+
Scenario: Account lookup by email returns null for an unknown email
17+
When the internal API is queried for account-by-email with an unknown address
18+
Then the response status is 200
19+
And the response body has a null DID
1920

20-
Scenario: Handle resolution returns email
21-
Given a PDS account with handle "alice.pds.test" and email "alice@example.com"
22-
When GET /_internal/account-by-handle?handle=alice.pds.test is called with valid x-internal-secret
23-
Then the response is 200 with { "email": "alice@example.com" }
21+
Scenario: Handle resolution returns the account's email
22+
Given a new user has registered via the demo client
23+
When the internal API is queried for the account by handle
24+
Then the response contains the account's email
2425

26+
Scenario: Check-handle returns true for an existing handle
27+
Given a new user has registered via the demo client
28+
When the internal API is queried to check if the handle exists
29+
Then the response indicates the handle exists
30+
31+
# @manual: requires a real PKCE PAR request — implement when PAR submission
32+
# steps exist (also needed for login-hint-resolution.feature)
33+
@manual
2534
Scenario: PAR login hint retrieval
26-
Given an OAuth client submitted a PAR request with login_hint "alice.pds.test"
27-
When GET /_internal/par-login-hint?request_uri=<the-request-uri> is called with valid x-internal-secret
28-
Then the response is 200 with { "login_hint": "alice.pds.test" }
35+
Given a new user has registered via the demo client
36+
When the demo client submits a PAR request with the user's handle as login_hint
37+
And the internal API is queried for the PAR login hint
38+
Then the response contains the user's handle as the login hint
2939

3040
Scenario: Missing internal secret returns 401
31-
When any /_internal/* endpoint is called without the x-internal-secret header
41+
When an internal endpoint is called without the secret header
3242
Then the response status is 401
43+
And the response body contains an auth error
3344

3445
Scenario: Wrong internal secret returns 401
35-
When any /_internal/* endpoint is called with an incorrect x-internal-secret
46+
When an internal endpoint is called with an incorrect secret
3647
Then the response status is 401
48+
And the response body contains an auth error
49+
50+
# check-handle returns 403 (not 401) on bad secret — server-side inconsistency
51+
Scenario: Wrong internal secret on check-handle returns 403
52+
When the check-handle endpoint is called with an incorrect secret
53+
Then the response status is 403
54+
And the response body contains an auth error

0 commit comments

Comments
 (0)