Skip to content
Open
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
423 changes: 410 additions & 13 deletions pkg/connector/login.go

Large diffs are not rendered by default.

196 changes: 196 additions & 0 deletions pkg/connector/login_live_probe_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//go:build liveprobe

package connector

import (
"context"
"errors"
"os"
"strings"
"testing"
"time"

"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/id"

"go.mau.fi/mautrix-twitter/pkg/twittermeow"
twitCookies "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies"
)

func TestLiveNativeLoginFlowProbe(t *testing.T) {
identifier := strings.TrimSpace(os.Getenv("TWITTER_LIVE_IDENTIFIER"))
password := os.Getenv("TWITTER_LIVE_PASSWORD")
if identifier == "" || password == "" {
t.Skip("TWITTER_LIVE_IDENTIFIER and TWITTER_LIVE_PASSWORD are required")
}

ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()

tc := &TwitterConnector{}
user := &bridgev2.User{
User: &database.User{
MXID: id.UserID("@highest:beeper.com"),
},
Log: zerolog.Nop(),
}
process, err := tc.CreateLogin(ctx, user, LoginFlowIDPassword)
if err != nil {
t.Fatalf("CreateLogin() failed: %v", err)
}
defer process.Cancel()

first, err := process.Start(ctx)
if err != nil {
t.Fatalf("Start() failed: %v", err)
}
if first == nil {
t.Fatal("Start() returned nil step")
}
t.Logf("first step: type=%s id=%s", first.Type, first.StepID)
if first.Type != bridgev2.LoginStepTypeUserInput || first.StepID != LoginStepIDCredentials {
t.Fatalf("unexpected first step: type=%s id=%s", first.Type, first.StepID)
}

next, err := process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{
loginFieldIdentifier: identifier,
loginFieldPassword: password,
})
if err != nil {
t.Fatalf("SubmitUserInput(credentials) failed: %v", err)
}
if next == nil {
t.Fatal("SubmitUserInput(credentials) returned nil step")
}
t.Logf("next step: type=%s id=%s instructions=%q", next.Type, next.StepID, next.Instructions)

switch next.StepID {
case LoginStepJuiceboxPIN, LoginStepIDVerification, LoginStepIDComplete:
verificationCode := strings.TrimSpace(os.Getenv("TWITTER_LIVE_VERIFICATION_CODE"))
if next.StepID == LoginStepIDVerification {
if verificationCode == "" {
return
}
next, err = process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{
loginFieldVerificationCode: verificationCode,
})
if err != nil {
t.Fatalf("SubmitUserInput(verification) failed: %v", err)
}
if next == nil {
t.Fatal("SubmitUserInput(verification) returned nil step")
}
t.Logf("after verification step: type=%s id=%s instructions=%q", next.Type, next.StepID, next.Instructions)
}
if verificationCode != "" && next.StepID == LoginStepIDVerification {
t.Fatalf("verification code was provided but flow stayed on verification: %s", next.Instructions)
}
if next.StepID != LoginStepJuiceboxPIN {
return
}
case LoginStepIDCredentials:
if strings.Contains(next.Instructions, "Wait a bit") ||
strings.Contains(next.Instructions, "cannot log") ||
strings.Contains(next.Instructions, "could not log") ||
strings.Contains(next.Instructions, "returned a login challenge") {
t.Fatalf("native credential submission returned retry/error step: %s", next.Instructions)
}
t.Fatalf("native credential submission did not advance past credentials: %s", next.Instructions)
default:
t.Fatalf("unexpected next step after credentials: type=%s id=%s instructions=%q", next.Type, next.StepID, next.Instructions)
}

pin := strings.TrimSpace(os.Getenv("TWITTER_LIVE_PIN"))
if pin == "" {
return
}
if next.StepID != LoginStepJuiceboxPIN {
t.Fatalf("PIN was provided but flow did not reach PIN step: type=%s id=%s instructions=%q", next.Type, next.StepID, next.Instructions)
}
final, err := process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{
"pin": pin,
})
if err != nil {
t.Fatalf("SubmitUserInput(pin) failed: %v", err)
}
if final == nil {
t.Fatal("SubmitUserInput(pin) returned nil step")
}
t.Logf("final step: type=%s id=%s", final.Type, final.StepID)
if final.Type != bridgev2.LoginStepTypeComplete || final.StepID != LoginStepIDComplete {
t.Fatalf("unexpected final step after pin: type=%s id=%s instructions=%q", final.Type, final.StepID, final.Instructions)
}
}

func TestLiveNativeLoginStageProbe(t *testing.T) {
identifier := strings.TrimSpace(os.Getenv("TWITTER_LIVE_IDENTIFIER"))
password := os.Getenv("TWITTER_LIVE_PASSWORD")
if identifier == "" || password == "" {
t.Skip("TWITTER_LIVE_IDENTIFIER and TWITTER_LIVE_PASSWORD are required")
}

ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()

client := twittermeow.NewClient(twitCookies.NewCookies(nil), nil, zerolog.Nop())
webLogin := twittermeow.NewWebLoginSession(client)

result, err := webLogin.Start(ctx)
logStage(t, "start", result, err)
if err != nil {
t.Fatalf("Start() failed: %v", err)
}

result, err = webLogin.SubmitCredentials(ctx, identifier, password)
logStage(t, "credentials", result, err)
if err != nil {
t.Fatalf("SubmitCredentials() failed: %v", err)
}
switch result.Status {
case twittermeow.WebLoginStatusComplete, twittermeow.WebLoginStatusNeedsText, twittermeow.WebLoginStatusNeedsPassword:
default:
t.Fatalf("SubmitCredentials() returned status %s, want complete, password, or text challenge", result.Status)
}
}

func TestLiveIdentifierOnlyProbe(t *testing.T) {
identifier := strings.TrimSpace(os.Getenv("TWITTER_IDENTIFIER_PROBE"))
if identifier == "" {
t.Skip("TWITTER_IDENTIFIER_PROBE is required")
}

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

client := twittermeow.NewClient(twitCookies.NewCookies(nil), nil, zerolog.Nop())
webLogin := twittermeow.NewWebLoginSession(client)

result, err := webLogin.Start(ctx)
logStage(t, "start", result, err)
if err != nil {
t.Fatalf("Start() failed: %v", err)
}

result, err = webLogin.SubmitIdentifier(ctx, identifier)
logStage(t, "identifier", result, err)
if err == nil && result.Status != twittermeow.WebLoginStatusNeedsPassword {
t.Fatalf("SubmitIdentifier() status = %s, want password step or a login error", result.Status)
}
}

func logStage(t *testing.T, stage string, result *twittermeow.WebLoginResult, err error) {
t.Helper()
if result != nil {
t.Logf("%s result: status=%s subtask=%s", stage, result.Status, result.CurrentSubtaskID)
}
if err != nil {
var webErr *twittermeow.WebLoginError
if errors.As(err, &webErr) {
t.Logf("%s error: code=%d message=%q", stage, webErr.Code, webErr.Message)
return
}
t.Logf("%s error: %T", stage, err)
}
}
142 changes: 142 additions & 0 deletions pkg/connector/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package connector

import (
"context"
"errors"
"strings"
"testing"

"maunium.net/go/mautrix/bridgev2"

"go.mau.fi/mautrix-twitter/pkg/twittermeow"
)

func TestSubmitUserInputRejectsMissingRequiredCredentialFields(t *testing.T) {
login := &TwitterLogin{}
tests := []map[string]string{
{},
{loginFieldIdentifier: "alice"},
{loginFieldPassword: "secret"},
{loginFieldIdentifier: " ", loginFieldPassword: "secret"},
{loginFieldIdentifier: "alice", loginFieldPassword: ""},
}

for _, input := range tests {
step, err := login.SubmitUserInput(context.Background(), input)
if step != nil {
t.Fatalf("SubmitUserInput(%#v) step = %#v, want nil", input, step)
}
if !errors.Is(err, ErrMissingLoginInput) {
t.Fatalf("SubmitUserInput(%#v) error = %v, want ErrMissingLoginInput", input, err)
}
}
}

func TestHandleWebLoginCredentialsErrorRetriesOnlyCredentialErrors(t *testing.T) {
step, err := handleWebLoginCredentialsError(&twittermeow.WebLoginError{
Code: 32,
Message: "Wrong password",
})
if err != nil {
t.Fatalf("handleWebLoginCredentialsError(wrong password) error = %v", err)
}
if step == nil || step.StepID != LoginStepIDCredentials {
t.Fatalf("handleWebLoginCredentialsError(wrong password) step = %#v, want credentials step", step)
}

step, err = handleWebLoginCredentialsError(&twittermeow.WebLoginError{
Code: 399,
Message: "We've temporarily limited your login. Please try again later.",
})
if step != nil {
t.Fatalf("handleWebLoginCredentialsError(temporary limit) step = %#v, want nil", step)
}
var respErr bridgev2.RespError
if !errors.As(err, &respErr) || respErr.ErrCode != ErrWebLoginFailed.ErrCode {
t.Fatalf("handleWebLoginCredentialsError(temporary limit) error = %#v, want ErrWebLoginFailed response", err)
}
}

func TestMakeAuthMethodStepUsesNativeSelect(t *testing.T) {
methods := []twittermeow.WebLoginAuthMethod{
{ID: "Totp", Name: "Authenticator App", Supported: true},
{ID: "Sms", Name: "Text Message", Supported: false},
{ID: "BackupCode", Name: "Backup Code", Supported: true},
{ID: "U2fSecurityKey", Name: "Security Key PC", Supported: false},
}
step := makeAuthMethodStep(methods, "")

if step.Type != bridgev2.LoginStepTypeUserInput {
t.Fatalf("Type = %s, want user input", step.Type)
}
if step.StepID != LoginStepIDAuthMethod {
t.Fatalf("StepID = %s, want %s", step.StepID, LoginStepIDAuthMethod)
}
if step.UserInputParams == nil || len(step.UserInputParams.Fields) != 1 {
t.Fatalf("UserInputParams = %#v, want one field", step.UserInputParams)
}
field := step.UserInputParams.Fields[0]
if field.Type != bridgev2.LoginInputFieldTypeSelect {
t.Fatalf("field.Type = %s, want select", field.Type)
}
if field.ID != loginFieldAuthMethod {
t.Fatalf("field.ID = %s, want %s", field.ID, loginFieldAuthMethod)
}
if strings.Join(field.Options, ",") != "Authenticator App,Backup Code" {
t.Fatalf("field.Options = %#v", field.Options)
}
if strings.Contains(step.Instructions, "not supported") {
t.Fatalf("Instructions = %q, want no unsupported caveat", step.Instructions)
}
}

func TestWebLoginUnsupportedInstructionsUsesChallengeDescription(t *testing.T) {
result := &twittermeow.WebLoginResult{
Status: twittermeow.WebLoginStatusUnsupported,
Challenge: &twittermeow.WebLoginChallenge{
Description: "Text message verification is coming soon.",
},
}

if got := webLoginUnsupportedInstructions(result); got != "Text message verification is coming soon." {
t.Fatalf("webLoginUnsupportedInstructions() = %q", got)
}
}

func TestFindWebLoginAuthMethodMatchesNameOrID(t *testing.T) {
methods := []twittermeow.WebLoginAuthMethod{
{ID: "Totp", Name: "Authenticator App", Supported: true},
{ID: "Sms", Name: "Text Message", Supported: true},
{ID: "BackupCode", Name: "Backup Code", Supported: true},
}
if method, ok := findWebLoginAuthMethod(methods, "Authenticator App"); !ok || method.ID != "Totp" {
t.Fatalf("find by label = %#v %t, want Totp", method, ok)
}
if method, ok := findWebLoginAuthMethod(methods, "backup_code"); !ok || method.ID != "BackupCode" {
t.Fatalf("find by normalized ID = %#v %t, want BackupCode", method, ok)
}
if method, ok := findWebLoginAuthMethod(methods, "text_message"); !ok || method.ID != "Sms" {
t.Fatalf("find by normalized ID = %#v %t, want Sms", method, ok)
}
}

func TestMakeVerificationStepUsesPhoneNumberInput(t *testing.T) {
step := makeVerificationStep(&twittermeow.WebLoginChallenge{
Description: "Enter the phone number associated with your X account.",
InputKind: twittermeow.WebLoginChallengeInputKindPhoneNumber,
}, "")

if step.UserInputParams == nil || len(step.UserInputParams.Fields) != 1 {
t.Fatalf("UserInputParams = %#v, want one field", step.UserInputParams)
}
field := step.UserInputParams.Fields[0]
if field.Type != bridgev2.LoginInputFieldTypePhoneNumber {
t.Fatalf("field.Type = %s, want phone_number", field.Type)
}
if field.Name != "Phone number" {
t.Fatalf("field.Name = %q, want Phone number", field.Name)
}
if !strings.Contains(step.Instructions, "phone number") {
t.Fatalf("Instructions = %q, want phone number prompt", step.Instructions)
}
}
Loading
Loading