|
7 | 7 | "path/filepath" |
8 | 8 | "strings" |
9 | 9 |
|
| 10 | + "github.com/fullsend-ai/fullsend/e2e/internal/otp" |
10 | 11 | "github.com/playwright-community/playwright-go" |
11 | 12 | ) |
12 | 13 |
|
@@ -34,61 +35,120 @@ func verifyGitHubSession(page playwright.Page, screenshotDir string, logf func(s |
34 | 35 | } |
35 | 36 |
|
36 | 37 | // 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) { |
41 | 43 | pageTitle, _ := page.Title() |
42 | 44 | if !strings.Contains(pageTitle, "Confirm access") && !strings.Contains(pageTitle, "Sudo") { |
43 | 45 | return false, nil |
44 | 46 | } |
45 | 47 |
|
46 | 48 | logf("[sudo] Detected sudo confirmation page (title: %s)", pageTitle) |
47 | 49 |
|
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. |
54 | 52 | passwordInput := page.Locator("#sudo_password") |
55 | | - if err := passwordInput.WaitFor(playwright.LocatorWaitForOptions{ |
| 53 | + passwordVisible := passwordInput.WaitFor(playwright.LocatorWaitForOptions{ |
56 | 54 | 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") |
65 | 121 | } |
| 122 | +} |
66 | 123 |
|
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) |
74 | 132 | } |
| 133 | + return handled, err |
| 134 | +} |
75 | 135 |
|
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) |
81 | 142 | } |
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) |
88 | 150 | } |
89 | | - |
90 | | - logf("[sudo] Sudo confirmation succeeded") |
91 | | - return true, nil |
| 151 | + return nil |
92 | 152 | } |
93 | 153 |
|
94 | 154 | // saveDebugScreenshot saves a screenshot to dir for debugging. |
|
0 commit comments