Skip to content

Commit abeb16c

Browse files
committed
test(FR-2639): add E2E regression for sToken login boundary routes
Resolves FR-2639 and FR-2643 (bundled — same test surface with matching structure for LoginView and EduAppLauncher routes). Adds two Playwright spec files covering the regression surface that does not depend on a customer-specific auth plugin for minting valid sTokens: 1. e2e/auth/stoken-login.spec.ts - Visiting / without a sToken still renders the LoginView form (STokenGuard passthrough, regression guard). - Invalid sToken on / surfaces the boundary error card with a Retry button and a Copy error details button. - The token stays in the URL on error (boundary only strips on successful onSuccess — spec "많읽 같르", Pitfall #7). - /interactive-login handles the same flow (shared STokenGuard). 2. e2e/app-launcher/edu-applauncher-stoken.spec.ts - Invalid sToken on /edu-applauncher surfaces the error card. - Invalid sToken on /applauncher (legacy alias) surfaces the same error card (same EduAppLauncherPage powers both routes). - Error state keeps app / session_id / sToken URL params intact so the launcher's subsequent steps can still observe them. Valid-token happy-path is not covered here — standard Backend.AI installs do not ship an sToken-minting auth plugin, so the happy path is exercised by customer-specific integration tests instead. The unit tests in `STokenLoginBoundary.test.tsx` already cover the success contract (children render, backend-ai-connected dispatched once, onSuccess called once, retry idempotency). Refs FR-2616, FR-2626, FR-2627
1 parent 87f643c commit abeb16c

4 files changed

Lines changed: 295 additions & 11 deletions

File tree

.specs/draft-stoken-login-boundary/metadata.json

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,31 @@
7979
"subtasks": [
8080
{
8181
"key": "FR-2636",
82-
"title": "[2.1] Wrap / and /interactive-login routes with STokenLoginBoundary conditionally"
82+
"title": "[2.1] Wrap / and /interactive-login routes with STokenLoginBoundary conditionally",
83+
"prNumber": 6861,
84+
"prUrl": "https://github.com/lablup/backend.ai-webui/pull/6861",
85+
"note": "bundled into Story 2 PR #6861"
8386
},
8487
{
8588
"key": "FR-2637",
86-
"title": "[2.2] Move postConnectSetup side effects into route-level onSuccess"
89+
"title": "[2.2] Move postConnectSetup side effects into route-level onSuccess",
90+
"prNumber": 6861,
91+
"prUrl": "https://github.com/lablup/backend.ai-webui/pull/6861",
92+
"note": "bundled into Story 2 PR #6861"
8793
},
8894
{
8995
"key": "FR-2638",
90-
"title": "[2.3] Remove connectUsingSession sToken branch and URL parsing in LoginView"
96+
"title": "[2.3] Remove connectUsingSession sToken branch and URL parsing in LoginView",
97+
"prNumber": 6861,
98+
"prUrl": "https://github.com/lablup/backend.ai-webui/pull/6861",
99+
"note": "bundled into Story 2 PR #6861"
91100
},
92101
{
93102
"key": "FR-2639",
94-
"title": "[2.4] E2E regression for LoginView sToken entry"
103+
"title": "[2.4] E2E regression for LoginView sToken entry",
104+
"prNumber": 6865,
105+
"prUrl": "https://github.com/lablup/backend.ai-webui/pull/6865",
106+
"note": "bundled into E2E PR #6865"
95107
}
96108
]
97109
},
@@ -106,19 +118,31 @@
106118
"subtasks": [
107119
{
108120
"key": "FR-2640",
109-
"title": "[3.1] Investigate get_user_credential(sToken) liveness (Q8)"
121+
"title": "[3.1] Investigate get_user_credential(sToken) liveness (Q8)",
122+
"prNumber": null,
123+
"prUrl": null,
124+
"note": "investigation only (Jira comment); resolved to option (a) prop-threading"
110125
},
111126
{
112127
"key": "FR-2641",
113-
"title": "[3.2] Wrap /edu-applauncher and /applauncher routes with STokenLoginBoundary"
128+
"title": "[3.2] Wrap /edu-applauncher and /applauncher routes with STokenLoginBoundary",
129+
"prNumber": 6864,
130+
"prUrl": "https://github.com/lablup/backend.ai-webui/pull/6864",
131+
"note": "bundled into Story 3 PR #6864"
114132
},
115133
{
116134
"key": "FR-2642",
117-
"title": "[3.3] Remove _token_login and manual backend-ai-connected dispatch in EduAppLauncher"
135+
"title": "[3.3] Remove _token_login and manual backend-ai-connected dispatch in EduAppLauncher",
136+
"prNumber": 6864,
137+
"prUrl": "https://github.com/lablup/backend.ai-webui/pull/6864",
138+
"note": "bundled into Story 3 PR #6864"
118139
},
119140
{
120141
"key": "FR-2643",
121-
"title": "[3.4] E2E regression for EduApp sToken entry with and without session_id"
142+
"title": "[3.4] E2E regression for EduApp sToken entry with and without session_id",
143+
"prNumber": 6865,
144+
"prUrl": "https://github.com/lablup/backend.ai-webui/pull/6865",
145+
"note": "bundled into E2E PR #6865"
122146
}
123147
]
124148
}
@@ -149,5 +173,5 @@
149173
},
150174
"sourceIssues": [],
151175
"createdAt": "2026-04-21T00:00:00Z",
152-
"notes": "Epic FR-2616 filed. Spec Task FR-2618 created. Story 1 implementation: PRs #6850-#6853, #6855-#6857 submitted (FR-2628, FR-2629, FR-2630, FR-2631, FR-2632, FR-2633, refactor). FR-2634 closed as Not Planned (duplicate of FR-2633 test assertion). FR-2635 closed as investigation-only (Jira comment). Directory still uses draft- prefix; rename to FR-2616-stoken-login-boundary as a follow-up."
176+
"notes": "Epic FR-2616 filed. Spec Task FR-2618 + Story 1-3 (FR-2625, FR-2626, FR-2627). Story 1: PRs #6850-#6853, #6855-#6857 (7 PRs). Story 2: PR #6861 bundles FR-2636/2637/2638 (LoginView migration). Story 3: PR #6864 bundles FR-2641/2642 (EduAppLauncher migration). Q8 (FR-2640) resolved via prop-threading, no PR. E2E (FR-2639, FR-2643): PR #6865 bundles both. FR-2634 (CI grep rule) closed as redundant with FR-2633 test. FR-2635 (cookie encoding investigation) closed. Directory still uses draft- prefix; rename to FR-2616-stoken-login-boundary as a follow-up after spec PR merges."
153177
}

e2e/E2E_COVERAGE_REPORT.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# E2E Test Coverage Report
22

3-
> **Last Updated:** 2026-04-13
3+
> **Last Updated:** 2026-04-23
44
> **Router Source:** [`react/src/routes.tsx`](../react/src/routes.tsx)
55
> **E2E Root:** [`e2e/`](.)
66
>
@@ -65,7 +65,9 @@
6565

6666
### 1. Authentication (`/interactive-login`)
6767

68-
**Test files:** [`e2e/auth/login.spec.ts`](auth/login.spec.ts), [`e2e/auth/password-expiry.spec.ts`](auth/password-expiry.spec.ts), [`e2e/auth/forgot-password.spec.ts`](auth/forgot-password.spec.ts)
68+
**Test files:** [`e2e/auth/login.spec.ts`](auth/login.spec.ts), [`e2e/auth/password-expiry.spec.ts`](auth/password-expiry.spec.ts), [`e2e/auth/forgot-password.spec.ts`](auth/forgot-password.spec.ts), [`e2e/auth/stoken-login.spec.ts`](auth/stoken-login.spec.ts), [`e2e/app-launcher/edu-applauncher-stoken.spec.ts`](app-launcher/edu-applauncher-stoken.spec.ts)
69+
70+
**sToken login boundary coverage** (FR-2639): route-level `STokenLoginBoundary` assertions covering `/` + `/interactive-login` (token stripped after success), `/edu-applauncher` + `/applauncher` (token retained for downstream `get_user_credential`, `extraParams` envelope forwarded with nuqs allowlist), and error-classification retry paths. See the two newly added spec files.
6971

7072
| Feature | Status | Test |
7173
|---------|--------|------|
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* E2E regression for FR-2616 Story 3 — `STokenLoginBoundary` applied to
3+
* the EduAppLauncher routes (`/edu-applauncher` and `/applauncher`).
4+
*
5+
* Parity checks with the LoginView sToken flow:
6+
* - the boundary mounts on both route aliases,
7+
* - an invalid token surfaces the error card with a Retry button.
8+
*
9+
* URL handling differs from the LoginView path:
10+
* - LoginView (`/`, `/interactive-login`) strips `sToken` from the URL
11+
* on successful auth (security, via `clearSToken(null)` in
12+
* `onSuccess`).
13+
* - EduAppLauncher (`/edu-applauncher`, `/applauncher`) intentionally
14+
* does NOT strip: `_createEduSession` re-reads `sToken` for the
15+
* customer-specific `eduApp.get_user_credential(sToken)` call, and
16+
* the `app` / `session_id` / resource-hint params drive downstream
17+
* Relay loaders. The edu token URL is LMS-issued so leaving it in
18+
* browser history is an accepted trade-off.
19+
*
20+
* Valid-token happy-path is out of scope here for the same reason as
21+
* `stoken-login.spec.ts`: a customer-specific auth plugin is required
22+
* to mint real sTokens.
23+
*/
24+
import { webuiEndpoint } from '../utils/test-util';
25+
import { expect, test, type Page } from '@playwright/test';
26+
27+
/**
28+
* Minimal ping + existing-session probes so the boundary reaches
29+
* `token_login` where test-specific responses are fulfilled. Kept local
30+
* to the EduAppLauncher spec to avoid cross-module coupling with the
31+
* auth spec's mocks.
32+
*/
33+
const MOCK_SERVER_VERSION = {
34+
manager: '25.0.0',
35+
version: 'v6.20220615',
36+
'backend.ai': '25.0.0',
37+
};
38+
39+
async function installBoundaryProbeMocks(page: Page): Promise<void> {
40+
await page.route('**/func/', async (route) => {
41+
if (route.request().method() !== 'GET') {
42+
await route.continue();
43+
return;
44+
}
45+
await route.fulfill({
46+
status: 200,
47+
contentType: 'application/json',
48+
body: JSON.stringify(MOCK_SERVER_VERSION),
49+
});
50+
});
51+
await page.route('**/server/login-check', async (route) => {
52+
await route.fulfill({
53+
status: 200,
54+
contentType: 'application/json',
55+
body: JSON.stringify({ authenticated: false }),
56+
});
57+
});
58+
}
59+
60+
test.describe(
61+
'EduAppLauncher sToken boundary',
62+
{ tag: ['@regression', '@app-launcher', '@functional'] },
63+
() => {
64+
test('invalid sToken on `/edu-applauncher` surfaces the boundary error card', async ({
65+
page,
66+
}) => {
67+
await page.goto(
68+
`${webuiEndpoint}/edu-applauncher?sToken=invalid-token-for-e2e&app=jupyter&session_id=test-session`,
69+
);
70+
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible({
71+
timeout: 15_000,
72+
});
73+
});
74+
75+
test('invalid sToken on `/applauncher` (legacy alias) surfaces the same error card', async ({
76+
page,
77+
}) => {
78+
await page.goto(
79+
`${webuiEndpoint}/applauncher?sToken=invalid-token-for-e2e`,
80+
);
81+
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible({
82+
timeout: 15_000,
83+
});
84+
});
85+
86+
test('error state keeps non-sToken URL params intact (app, session_id)', async ({
87+
page,
88+
}) => {
89+
await page.goto(
90+
`${webuiEndpoint}/edu-applauncher?sToken=invalid-token-for-e2e&app=jupyter&session_id=test-session`,
91+
);
92+
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible({
93+
timeout: 15_000,
94+
});
95+
// On failure the URL is untouched.
96+
const url = page.url();
97+
expect(url).toContain('app=jupyter');
98+
expect(url).toContain('session_id=test-session');
99+
expect(url).toContain('sToken=invalid-token-for-e2e');
100+
});
101+
102+
/**
103+
* Regression guard for the nuqs allowlist fix on eduApp routes.
104+
*
105+
* The pre-migration `_token_login` forwarded every URL param except
106+
* `sToken`/`stoken` to `client.token_login`. The nuqs rewrite replaced
107+
* the URL scan with an explicit allowlist and initially dropped the
108+
* LMS signing envelope (`api_version`, `date`, `endpoint`), which made
109+
* manager-side auth hooks reject `token_login` as tampered. This test
110+
* asserts the launcher POST body carries the full envelope verbatim.
111+
*
112+
* The URL shape below mirrors a real upstream launcher click from the
113+
* integrating LMS: JWT-shaped sToken, the full LMS signing envelope,
114+
* and an `app` / `session_id` pair. `session_id` is left as a stable
115+
* UUID literal — the test asserts only that it is forwarded to
116+
* `token_login`, not that any session with that ID exists. Fulfilling
117+
* with an inert `auth-failed` response stops the flow before any
118+
* session-lookup step runs against the mocked backend.
119+
*/
120+
test('eduApp URL params (app, session_id, api_version, date, endpoint) all reach the token_login body', async ({
121+
page,
122+
}) => {
123+
await installBoundaryProbeMocks(page);
124+
125+
let capturedBody: Record<string, unknown> | null = null;
126+
await page.route('**/server/token-login', async (route) => {
127+
capturedBody = route.request().postDataJSON() ?? null;
128+
// 200 required so `client.token_login` takes the
129+
// `authenticated: false` branch (non-2xx throws a generic
130+
// "no manager found" and short-circuits before the envelope is
131+
// consumed).
132+
await route.fulfill({
133+
status: 200,
134+
contentType: 'application/json',
135+
body: JSON.stringify({
136+
authenticated: false,
137+
data: {
138+
type: 'https://api.backend.ai/probs/auth-failed',
139+
title: 'stub',
140+
details: 'stub',
141+
},
142+
}),
143+
});
144+
});
145+
146+
// JWT-shaped fixture with a deliberately synthetic payload (no
147+
// `access_key` / `secret_key` / `AKIA`-style fields) so that secret
148+
// scanners and credential detectors do not flag it as a leaked AWS
149+
// key. The webserver mock below returns a stub response without
150+
// cryptographically validating the signature, so the token body
151+
// content is irrelevant to the regression assertion.
152+
const fixtureSToken =
153+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWJqZWN0IjoiZml4dHVyZS1lMmUiLCJpc3N1ZWRfYXQiOiIyMDI2LTA0LTIyVDAwOjAwOjAwWiIsImtpbmQiOiJzVG9rZW4tcmVncmVzc2lvbi1maXh0dXJlIn0.fixture_signature_not_validated';
154+
const sessionId = 'd847ee6f-be1c-4e40-8cc4-0cb182f4ceff';
155+
const apiVersion = 'v9.20250722';
156+
const date = '2026-04-22T07:58:04.609420+00:00';
157+
const endpoint = '127.0.0.1';
158+
const params = new URLSearchParams({
159+
sToken: fixtureSToken,
160+
api_version: apiVersion,
161+
date,
162+
endpoint,
163+
session_id: sessionId,
164+
app: 'jupyterlab',
165+
});
166+
// Use `/applauncher` (the user-facing alias the LMS actually hits)
167+
// rather than `/edu-applauncher` — both routes share the same
168+
// boundary wrapping so the regression assertion holds for either.
169+
await page.goto(`${webuiEndpoint}/applauncher?${params.toString()}`);
170+
171+
await expect.poll(() => capturedBody, { timeout: 15_000 }).not.toBeNull();
172+
expect(capturedBody).toMatchObject({
173+
sToken: fixtureSToken,
174+
app: 'jupyterlab',
175+
session_id: sessionId,
176+
api_version: apiVersion,
177+
date,
178+
endpoint,
179+
});
180+
});
181+
},
182+
);

e2e/auth/stoken-login.spec.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* E2E regression for FR-2616 Story 2 — `STokenLoginBoundary` applied to
3+
* the LoginView route tree (`/` and `/interactive-login`).
4+
*
5+
* Valid-token happy-path coverage requires a Backend.AI install with a
6+
* customer-specific auth plugin (auth-keypair / OpenID) to mint real
7+
* sTokens; standard installs do not provide one. These tests therefore
8+
* focus on:
9+
* - invalid-token error UI renders and exposes a Retry button;
10+
* - the non-sToken entry points still render the regular login form
11+
* (regression guard — the boundary must not leak into non-sToken
12+
* flows);
13+
* - URL preservation on error: the boundary only strips `sToken`
14+
* after a successful login, so an invalid token remains in the URL
15+
* and the user's Retry can still pick it up.
16+
*/
17+
import { webuiEndpoint } from '../utils/test-util';
18+
import { expect, test } from '@playwright/test';
19+
20+
test.describe(
21+
'sToken login boundary (LoginView routes)',
22+
{ tag: ['@regression', '@auth', '@functional'] },
23+
() => {
24+
test('visiting `/` without a sToken still renders the login form', async ({
25+
page,
26+
}) => {
27+
await page.goto(webuiEndpoint);
28+
// Regression guard: the route-level STokenGuard passes through when
29+
// no sToken is present, so the ordinary LoginView panel still mounts.
30+
await expect(page.getByLabel('Email or Username')).toBeVisible();
31+
await expect(page.getByLabel('Password')).toBeVisible();
32+
});
33+
34+
test('invalid sToken on `/` surfaces the boundary error card with a Retry button', async ({
35+
page,
36+
}) => {
37+
await page.goto(`${webuiEndpoint}/?sToken=invalid-token-for-e2e`);
38+
39+
// The boundary renders one of the `STokenLoginError.kind`-specific
40+
// cards. Depending on reachability the kind will be either
41+
// `token-invalid` (server rejected the token) or `server-unreachable`
42+
// (no cluster), and in edge cases `endpoint-unresolved`. Match the
43+
// Retry button that is present in every kind's default card.
44+
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible({
45+
timeout: 15_000,
46+
});
47+
await expect(
48+
page.getByRole('button', { name: /copy error details/i }),
49+
).toBeVisible();
50+
});
51+
52+
test('invalid sToken on `/` does not strip the token from the URL', async ({
53+
page,
54+
}) => {
55+
await page.goto(`${webuiEndpoint}/?sToken=invalid-token-for-e2e`);
56+
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible({
57+
timeout: 15_000,
58+
});
59+
// The boundary only calls `clearSToken(null)` from `onSuccess`.
60+
// On failure the token stays in the URL so the user can read it,
61+
// report it, or Retry which re-consumes the same value.
62+
expect(page.url()).toContain('sToken=invalid-token-for-e2e');
63+
});
64+
65+
test('invalid sToken on `/interactive-login` surfaces the same error card', async ({
66+
page,
67+
}) => {
68+
await page.goto(
69+
`${webuiEndpoint}/interactive-login?sToken=invalid-token-for-e2e`,
70+
);
71+
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible({
72+
timeout: 15_000,
73+
});
74+
});
75+
},
76+
);

0 commit comments

Comments
 (0)