Skip to content

Commit 7d64117

Browse files
authored
Merge pull request #1211 from fullsend-ai/ci/e2e-totp-support
feat(e2e): add TOTP support for 2FA-enabled GitHub accounts
2 parents 5ec949f + 2b16288 commit 7d64117

15 files changed

Lines changed: 391 additions & 48 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ jobs:
5757
env:
5858
E2E_SCREENSHOT_DIR: ${{ runner.temp }}/e2e-screenshots
5959
E2E_GITHUB_PASSWORD: ${{ secrets.E2E_GITHUB_PASSWORD }}
60+
E2E_GITHUB_TOTP_SECRET: ${{ secrets.E2E_GITHUB_TOTP_SECRET }}
6061

6162
- name: Upload debug screenshots
6263
if: always() && steps.secrets-check.outputs.available == 'true'

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ The e2e tests require GitHub credentials. There are three ways to provide them:
3535
- **`E2E_GITHUB_PASSWORD` env var:** Set directly with the password.
3636
- **`E2E_GITHUB_PASSWORD_FILE` env var:** Set to a file path containing the password (used in devaipod environments where secrets are mounted as files).
3737
- **`E2E_GITHUB_SESSION_FILE` env var:** Set to a pre-exported Playwright session file (skips login).
38+
- **`E2E_GITHUB_TOTP_SECRET` env var:** Optional. The TOTP secret (base32) for the test account's 2FA. Required only when the test account has 2FA enabled — used during session export and sudo confirmation.
3839

3940
If only `E2E_GITHUB_USERNAME` and a password source are available, `make e2e-test` will automatically generate a session file before running tests. See `make help` for all available targets.
4041

docs/ADRs/0010-stored-session-for-e2e-browser-auth.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Date: 2026-04-03
1818

1919
Accepted
2020

21+
Extended by [ADR 0039](0039-totp-automation-for-e2e-2fa.md).
22+
2123
## Context
2224

2325
The admin CLI e2e tests use Playwright to automate browser interactions with
@@ -86,8 +88,8 @@ The e2e tests need both because:
8688
baked into the stored session).
8789

8890
The `handleSudoIfPresent()` function detects the "Confirm access" page by
89-
title and enters the password automatically. It is called before PAT creation
90-
(both classic and fine-grained).
91+
title and enters either the password or a TOTP code (see [ADR 0039](0039-totp-automation-for-e2e-2fa.md)).
92+
It is called before PAT creation (both classic and fine-grained).
9193

9294
**Local development:** When `E2E_GITHUB_SESSION_FILE` is not set but
9395
`E2E_GITHUB_USERNAME` and `E2E_GITHUB_PASSWORD` are, `make e2e-test`
@@ -131,12 +133,13 @@ it does expire, a developer runs `make e2e-upload-session` to refresh it.
131133

132134
- Two repo secrets are required in CI: `E2E_GITHUB_SESSION` (base64-encoded
133135
storageState JSON for login bypass) and `E2E_GITHUB_PASSWORD` (for sudo
134-
confirmation on sensitive pages).
136+
confirmation on sensitive pages). A third secret, `E2E_GITHUB_TOTP_SECRET`,
137+
is required when the test account has 2FA enabled (see [ADR 0039](0039-totp-automation-for-e2e-2fa.md)).
135138
- If the test account's password changes, the stored session must be
136139
re-exported (password change invalidates all sessions) and the
137140
`E2E_GITHUB_PASSWORD` secret must be updated.
138-
- If the test account enables 2FA, the session export must happen after the 2FA
139-
step.
141+
- If the test account has 2FA enabled, set `E2E_GITHUB_TOTP_SECRET` so
142+
`make e2e-export-session` and sudo confirmation can handle TOTP automatically.
140143
- The login function becomes a session-loading function -- simpler and more
141144
reliable.
142145
- Session refresh is `make e2e-upload-session`, expected to be needed at most
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
title: "39. TOTP automation for e2e 2FA"
3+
status: Accepted
4+
relates_to:
5+
- testing-agents
6+
topics:
7+
- e2e
8+
- ci
9+
- authentication
10+
- 2fa
11+
---
12+
13+
# 39. TOTP automation for e2e 2FA
14+
15+
Date: 2026-05-19
16+
17+
## Status
18+
19+
Accepted
20+
21+
Extends [ADR 0010](0010-stored-session-for-e2e-browser-auth.md).
22+
23+
## Context
24+
25+
[ADR 0010](0010-stored-session-for-e2e-browser-auth.md) established stored
26+
Playwright sessions for e2e CI authentication. When that ADR was written, the
27+
test account did not have 2FA enabled. With 2FA on, two new authentication
28+
prompts appear that the existing infrastructure could not handle:
29+
30+
1. A TOTP challenge during `make e2e-export-session` (local login).
31+
2. A TOTP challenge on sudo pages in CI (GitHub may present TOTP instead of
32+
a password field for 2FA-enabled accounts).
33+
34+
## Decision
35+
36+
Automate TOTP entry in Playwright using a shared `e2e/internal/otp` package
37+
that wraps `pquerna/otp` to generate time-based codes. The TOTP secret is
38+
supplied via the `E2E_GITHUB_TOTP_SECRET` environment variable (base32-encoded).
39+
40+
- `handleTOTPIfPresent()` in the login helpers detects TOTP form fields and
41+
enters the current code. `handleSudoIfPresent()` now handles both password
42+
and TOTP sudo prompts.
43+
- The `export-session` command detects and completes the 2FA prompt after
44+
password login.
45+
- `E2E_GITHUB_TOTP_SECRET` is optional — omitting it preserves the existing
46+
non-2FA flow.
47+
48+
## Consequences
49+
50+
- Three repo secrets are now required in CI when the test account has 2FA:
51+
`E2E_GITHUB_SESSION`, `E2E_GITHUB_PASSWORD`, and `E2E_GITHUB_TOTP_SECRET`.
52+
- Session export and sudo confirmation work automatically for 2FA accounts.
53+
- The TOTP secret is a long-lived credential that must be protected like the
54+
password.
55+
- If GitHub changes its TOTP form markup, the Playwright selectors will need
56+
updating.

e2e/admin/admin_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ func setupE2ETest(t *testing.T) *e2eEnv {
8787
// Generate a PAT for API access.
8888
patNote := fmt.Sprintf("fullsend-e2e-%d", time.Now().Unix())
8989
t.Logf("Creating PAT: %s", patNote)
90-
token, err := createPAT(page, patNote, cfg.password, screenshotDir, t.Logf)
90+
token, err := createPAT(page, patNote, cfg.password, cfg.totpSecret, screenshotDir, t.Logf)
9191
require.NoError(t, err, "creating PAT")
9292
t.Cleanup(func() {
9393
t.Log("Deleting PAT...")

e2e/admin/browser.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ func (b *PlaywrightBrowserOpener) Open(_ context.Context, url string) error {
5656
case strings.Contains(pageURL, "/settings/apps/new"),
5757
strings.Contains(pageURL, "/settings/apps/manifest"):
5858
return b.handleCreateAppPage()
59+
case strings.Contains(pageURL, "/installations/select_target"):
60+
return b.handleSelectTargetPage()
5961
case strings.Contains(pageURL, "/installations/new"):
6062
return b.handleInstallAppPage()
6163
default:
@@ -204,6 +206,29 @@ func (b *PlaywrightBrowserOpener) handleCreateAppPage() error {
204206
return nil
205207
}
206208

209+
// handleSelectTargetPage selects the target organization on GitHub's
210+
// "select installation target" page, then proceeds to the install page.
211+
func (b *PlaywrightBrowserOpener) handleSelectTargetPage() error {
212+
b.logf("[browser] handleSelectTargetPage at URL: %s", b.page.URL())
213+
214+
orgLink := b.page.Locator(fmt.Sprintf("a:has-text('%s')", testOrg))
215+
if err := orgLink.First().Click(playwright.LocatorClickOptions{
216+
Timeout: playwright.Float(5000),
217+
}); err != nil {
218+
saveDebugScreenshot(b.page, b.screenshotDir, "browser-select-target-failed", b.logf)
219+
return fmt.Errorf("selecting target org %s: %w", testOrg, err)
220+
}
221+
222+
if err := b.page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{
223+
State: playwright.LoadStateDomcontentloaded,
224+
}); err != nil {
225+
return fmt.Errorf("waiting for install page after selecting org: %w", err)
226+
}
227+
228+
b.logf("[browser] Selected org %s, now at: %s", testOrg, b.page.URL())
229+
return b.handleInstallAppPage()
230+
}
231+
207232
// handleInstallAppPage clicks "Install" on the GitHub App installation page.
208233
// Retries navigation if the page 404s (GitHub needs time to provision the app).
209234
func (b *PlaywrightBrowserOpener) handleInstallAppPage() error {

e2e/admin/login.go

Lines changed: 100 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"path/filepath"
88
"strings"
99

10+
"github.com/fullsend-ai/fullsend/e2e/internal/otp"
1011
"github.com/playwright-community/playwright-go"
1112
)
1213

@@ -34,61 +35,120 @@ func verifyGitHubSession(page playwright.Page, screenshotDir string, logf func(s
3435
}
3536

3637
// handleSudoIfPresent detects GitHub's "Confirm access" sudo page and
37-
// enters the password to proceed. GitHub requires sudo confirmation when
38-
// accessing sensitive settings pages (token management, app settings)
39-
// even with a valid session. Returns true if sudo was handled.
40-
func handleSudoIfPresent(page playwright.Page, password, screenshotDir string, logf func(string, ...any)) (bool, error) {
38+
// enters the password (or TOTP code if 2FA is enabled) to proceed.
39+
// GitHub requires sudo confirmation when accessing sensitive settings pages
40+
// (token management, app settings) even with a valid session.
41+
// Returns true if sudo was handled.
42+
func handleSudoIfPresent(page playwright.Page, password, totpSecret, screenshotDir string, logf func(string, ...any)) (bool, error) {
4143
pageTitle, _ := page.Title()
4244
if !strings.Contains(pageTitle, "Confirm access") && !strings.Contains(pageTitle, "Sudo") {
4345
return false, nil
4446
}
4547

4648
logf("[sudo] Detected sudo confirmation page (title: %s)", pageTitle)
4749

48-
if password == "" {
49-
saveDebugScreenshot(page, screenshotDir, "sudo-no-password", logf)
50-
return false, fmt.Errorf("sudo confirmation required but no password available — set E2E_GITHUB_PASSWORD")
51-
}
52-
53-
// GitHub's sudo form uses #sudo_password for the password field.
50+
// GitHub may show a password field or a TOTP field (or both with a toggle).
51+
// Try password first, fall back to TOTP.
5452
passwordInput := page.Locator("#sudo_password")
55-
if err := passwordInput.WaitFor(playwright.LocatorWaitForOptions{
53+
passwordVisible := passwordInput.WaitFor(playwright.LocatorWaitForOptions{
5654
State: playwright.WaitForSelectorStateVisible,
57-
Timeout: playwright.Float(5000),
58-
}); err != nil {
59-
saveDebugScreenshot(page, screenshotDir, "sudo-password-field-missing", logf)
60-
return false, fmt.Errorf("sudo password field not found: %w", err)
61-
}
62-
63-
if err := passwordInput.Fill(password); err != nil {
64-
return false, fmt.Errorf("filling sudo password: %w", err)
55+
Timeout: playwright.Float(2000),
56+
}) == nil
57+
58+
if passwordVisible && password != "" {
59+
if err := passwordInput.Fill(password); err != nil {
60+
return false, fmt.Errorf("filling sudo password: %w", err)
61+
}
62+
63+
confirmBtn := page.Locator("button[type='submit']:has-text('Confirm'), button[type='submit']:has-text('Confirm password'), button[type='submit']")
64+
if err := confirmBtn.First().Click(playwright.LocatorClickOptions{
65+
Timeout: playwright.Float(5000),
66+
}); err != nil {
67+
saveDebugScreenshot(page, screenshotDir, "sudo-confirm-click-failed", logf)
68+
return false, fmt.Errorf("clicking sudo confirm button: %w", err)
69+
}
70+
71+
if err := waitForPageToLeave(page, "Confirm access", "Sudo"); err != nil {
72+
if totpSecret != "" {
73+
logf("[sudo] Password did not clear sudo page, falling back to TOTP")
74+
if handled, totpErr := handleTOTPIfPresent(page, totpSecret, screenshotDir, logf); totpErr != nil {
75+
return false, fmt.Errorf("TOTP fallback after password failed: %w", totpErr)
76+
} else if handled {
77+
if err := waitForPageToLeave(page, "Confirm access", "Sudo"); err != nil {
78+
saveDebugScreenshot(page, screenshotDir, "sudo-totp-fallback-still-on-page", logf)
79+
return false, err
80+
}
81+
logf("[sudo] Sudo confirmation succeeded via TOTP fallback")
82+
return true, nil
83+
}
84+
}
85+
saveDebugScreenshot(page, screenshotDir, "sudo-still-on-page", logf)
86+
return false, err
87+
}
88+
89+
logf("[sudo] Sudo confirmation succeeded via password")
90+
return true, nil
91+
} else if passwordVisible && totpSecret != "" {
92+
if handled, err := handleTOTPIfPresent(page, totpSecret, screenshotDir, logf); err != nil {
93+
return false, fmt.Errorf("TOTP on sudo page (password field present but empty): %w", err)
94+
} else if handled {
95+
if err := waitForPageToLeave(page, "Confirm access", "Sudo"); err != nil {
96+
saveDebugScreenshot(page, screenshotDir, "sudo-totp-still-on-page", logf)
97+
return false, err
98+
}
99+
return true, nil
100+
}
101+
saveDebugScreenshot(page, screenshotDir, "sudo-password-not-set", logf)
102+
return false, fmt.Errorf("sudo page shows password field but neither password nor TOTP succeeded")
103+
} else if passwordVisible {
104+
saveDebugScreenshot(page, screenshotDir, "sudo-password-not-set", logf)
105+
return false, fmt.Errorf("sudo page shows password field but E2E_GITHUB_PASSWORD is not set")
106+
} else if totpSecret != "" {
107+
if handled, err := handleTOTPIfPresent(page, totpSecret, screenshotDir, logf); err != nil {
108+
return false, fmt.Errorf("TOTP on sudo page: %w", err)
109+
} else if !handled {
110+
saveDebugScreenshot(page, screenshotDir, "sudo-no-auth-method", logf)
111+
return false, fmt.Errorf("sudo page has no visible password or TOTP field")
112+
}
113+
if err := waitForPageToLeave(page, "Confirm access", "Sudo"); err != nil {
114+
saveDebugScreenshot(page, screenshotDir, "sudo-totp-still-on-page", logf)
115+
return false, err
116+
}
117+
return true, nil
118+
} else {
119+
saveDebugScreenshot(page, screenshotDir, "sudo-no-credentials", logf)
120+
return false, fmt.Errorf("sudo confirmation required but no password or TOTP secret available — set E2E_GITHUB_PASSWORD or E2E_GITHUB_TOTP_SECRET")
65121
}
122+
}
66123

67-
// Click the confirm button.
68-
confirmBtn := page.Locator("button[type='submit']:has-text('Confirm'), button[type='submit']:has-text('Confirm password'), button[type='submit']")
69-
if err := confirmBtn.First().Click(playwright.LocatorClickOptions{
70-
Timeout: playwright.Float(5000),
71-
}); err != nil {
72-
saveDebugScreenshot(page, screenshotDir, "sudo-confirm-click-failed", logf)
73-
return false, fmt.Errorf("clicking sudo confirm button: %w", err)
124+
// handleTOTPIfPresent detects a GitHub 2FA/TOTP input on the current page
125+
// and fills in a generated code. Works on both the post-login 2FA page
126+
// (/sessions/two-factor) and the sudo TOTP prompt. Returns true if a TOTP
127+
// form was found and submitted.
128+
func handleTOTPIfPresent(page playwright.Page, totpSecret, screenshotDir string, logf func(string, ...any)) (bool, error) {
129+
handled, err := otp.EnterTOTPCode(page, totpSecret, logf)
130+
if err != nil {
131+
saveDebugScreenshot(page, screenshotDir, "totp-failed", logf)
74132
}
133+
return handled, err
134+
}
75135

76-
// Wait for the page to navigate away from the sudo page.
77-
if err := page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{
78-
State: playwright.LoadStateDomcontentloaded,
79-
}); err != nil {
80-
return false, fmt.Errorf("waiting for post-sudo navigation: %w", err)
136+
// waitForPageToLeave waits until the page title no longer contains any of
137+
// the given substrings, or until the timeout (10s) is reached.
138+
func waitForPageToLeave(page playwright.Page, titleSubstrings ...string) error {
139+
checks := make([]string, len(titleSubstrings))
140+
for i, sub := range titleSubstrings {
141+
checks[i] = fmt.Sprintf("!document.title.includes(%q)", sub)
81142
}
82-
83-
// Verify we're past sudo.
84-
newTitle, _ := page.Title()
85-
if strings.Contains(newTitle, "Confirm access") || strings.Contains(newTitle, "Sudo") {
86-
saveDebugScreenshot(page, screenshotDir, "sudo-still-on-page", logf)
87-
return false, fmt.Errorf("sudo confirmation failed — still on confirmation page (title: %s)", newTitle)
143+
jsExpr := "() => " + strings.Join(checks, " && ")
144+
_, err := page.WaitForFunction(jsExpr, nil, playwright.PageWaitForFunctionOptions{
145+
Timeout: playwright.Float(10000),
146+
})
147+
if err != nil {
148+
title, _ := page.Title()
149+
return fmt.Errorf("still on page after 10s (title: %s)", title)
88150
}
89-
90-
logf("[sudo] Sudo confirmation succeeded")
91-
return true, nil
151+
return nil
92152
}
93153

94154
// saveDebugScreenshot saves a screenshot to dir for debugging.

e2e/admin/pat.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ var patScopes = []string{
2020
// createPAT creates a classic GitHub Personal Access Token via the browser.
2121
// The token is created with a 7-day expiry and the scopes needed for e2e tests.
2222
// Returns the token string.
23-
func createPAT(page playwright.Page, note, password, screenshotDir string, logf func(string, ...any)) (string, error) {
23+
func createPAT(page playwright.Page, note, password, totpSecret, screenshotDir string, logf func(string, ...any)) (string, error) {
2424
url := "https://github.com/settings/tokens/new"
2525
if _, err := page.Goto(url, playwright.PageGotoOptions{
2626
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
@@ -39,7 +39,7 @@ func createPAT(page playwright.Page, note, password, screenshotDir string, logf
3939
}
4040

4141
// Handle sudo confirmation if GitHub requires re-authentication.
42-
if handled, err := handleSudoIfPresent(page, password, screenshotDir, logf); err != nil {
42+
if handled, err := handleSudoIfPresent(page, password, totpSecret, screenshotDir, logf); err != nil {
4343
return "", fmt.Errorf("sudo confirmation for PAT creation: %w", err)
4444
} else if handled {
4545
// After sudo, we may need to re-navigate to the token page.

e2e/admin/testutil.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ var defaultRoles = []string{"fullsend", "triage", "coder", "review"}
4444
type envConfig struct {
4545
sessionFile string
4646
password string
47+
totpSecret string
4748
lockTimeout time.Duration
4849
}
4950

@@ -62,6 +63,7 @@ func loadEnvConfig(t *testing.T) envConfig {
6263
}
6364

6465
password := os.Getenv("E2E_GITHUB_PASSWORD")
66+
totpSecret := os.Getenv("E2E_GITHUB_TOTP_SECRET")
6567

6668
lockTimeout := defaultLockTimeout
6769
if v := os.Getenv("E2E_LOCK_TIMEOUT"); v != "" {
@@ -75,6 +77,7 @@ func loadEnvConfig(t *testing.T) envConfig {
7577
return envConfig{
7678
sessionFile: sessionFile,
7779
password: password,
80+
totpSecret: totpSecret,
7881
lockTimeout: lockTimeout,
7982
}
8083
}

0 commit comments

Comments
 (0)