diff --git a/pkg/auth/BUILD.bazel b/pkg/auth/BUILD.bazel index 89425ca..9abc67e 100644 --- a/pkg/auth/BUILD.bazel +++ b/pkg/auth/BUILD.bazel @@ -20,6 +20,13 @@ go_library( go_test( name = "auth_test", - srcs = ["service_test.go"], + srcs = [ + "auth_test.go", + "service_test.go", + ], embed = [":auth"], + deps = [ + "//pkg", + "@org_golang_x_oauth2//:oauth2", + ], ) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 86c13a7..2cfb20a 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -188,9 +188,9 @@ func (s *svc) startWebServer(redirectURL string) (chan string, error) { } func (s *svc) getCodeFromPrompt(authURL string) (code string, err error) { - fmt.Printf(openBrowserHint, authURL) - fmt.Print(manualInputHint) - _, err = fmt.Scan(&code) + _, _ = fmt.Fprintf(s.out, openBrowserHint, authURL) + _, _ = fmt.Fprint(s.out, manualInputHint) + _, err = fmt.Fscan(s.in, &code) if err != nil { return "", fmt.Errorf("%s: %w", readPromptFailed, err) } @@ -211,7 +211,7 @@ func (s *svc) getTokenFromWeb( var code string if err := utils.OpenURL(authURL); err == nil { - fmt.Printf(browserOpenedHint, authURL) + _, _ = fmt.Fprintf(s.out, browserOpenedHint, authURL) code = <-codeCh } diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 0000000..2a52315 --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,217 @@ +package auth + +import ( + "bytes" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/eat-pray-ai/yutu/pkg" + "golang.org/x/oauth2" +) + +// helper to build a minimal valid Google OAuth2 credential JSON. +func validCredentialJSON(redirectURL string) string { + cred := map[string]map[string]any{ + "web": { + "client_id": "test-client-id", + "project_id": "test-project", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "test-secret", + "redirect_uris": []string{redirectURL}, + }, + } + b, _ := json.Marshal(cred) + return string(b) +} + +func TestGetConfig_Success(t *testing.T) { + s := NewY2BService( + WithCredential(validCredentialJSON("http://localhost:8216"), os.DirFS(".")), + ).(*svc) + + config, err := s.getConfig() + if err != nil { + t.Fatalf("getConfig returned error: %v", err) + } + + if config.ClientID != "test-client-id" { + t.Errorf( + "unexpected ClientID: got %q, want %q", config.ClientID, "test-client-id", + ) + } + if config.ClientSecret != "test-secret" { + t.Errorf( + "unexpected ClientSecret: got %q, want %q", config.ClientSecret, + "test-secret", + ) + } + if config.RedirectURL != "http://localhost:8216" { + t.Errorf( + "unexpected RedirectURL: got %q, want %q", config.RedirectURL, + "http://localhost:8216", + ) + } + if len(config.Scopes) == 0 { + t.Fatalf("expected scopes to be populated, got none") + } +} + +func TestGetConfig_InvalidJSON(t *testing.T) { + s := NewY2BService( + WithCredential("not-a-json", os.DirFS(".")), + ).(*svc) + + _, err := s.getConfig() + if err == nil { + t.Fatalf("expected error from getConfig with invalid JSON, got nil") + } + if err.Error() == "" { + t.Fatalf("expected non-empty error message") + } +} + +func TestStartWebServer_InvalidURL(t *testing.T) { + s := NewY2BService().(*svc) + s.state = "state" + + _, err := s.startWebServer("://bad-url") + if err == nil { + t.Fatalf("expected error for invalid URL, got nil") + } + if err.Error() == "" { + t.Fatalf("expected non-empty error message") + } +} + +func TestStartWebServer_PortConflict(t *testing.T) { + // Occupy a TCP port first. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on random port: %v", err) + } + defer func(ln net.Listener) { + _ = ln.Close() + }(ln) + + addr := ln.Addr().String() + redirectURL := fmt.Sprintf("http://%s", addr) + + s := NewY2BService().(*svc) + s.state = "state" + + _, err = s.startWebServer(redirectURL) + if err == nil { + t.Fatalf("expected error due to port conflict, got nil") + } +} + +func TestStartWebServer_StateMismatch(t *testing.T) { + // Use an ephemeral port so startWebServer succeeds. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on random port: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + + redirectURL := fmt.Sprintf("http://%s", addr) + + s := NewY2BService().(*svc) + s.state = "expected-state" + codeCh, err := s.startWebServer(redirectURL) + if err != nil { + t.Fatalf("startWebServer returned error: %v", err) + } + + // Send a request with mismatched state. + resp, err := http.Get(redirectURL + "/?state=wrong&code=code123") + if err != nil { + t.Fatalf("failed to send HTTP request: %v", err) + } + _ = resp.Body.Close() + + select { + case <-codeCh: + t.Fatalf("expected no code to be sent on state mismatch") + case <-time.After(100 * time.Millisecond): + // ok: channel should remain empty + } +} + +func TestGetCodeFromPrompt_Success(t *testing.T) { + var out bytes.Buffer + in := strings.NewReader("some-code") + + s := NewY2BService(WithIO(in, &out)).(*svc) + + code, err := s.getCodeFromPrompt("http://example.com/auth") + if err != nil { + t.Fatalf("getCodeFromPrompt returned error: %v", err) + } + if code != "some-code" { + t.Fatalf("unexpected code: got %q, want %q", code, "some-code") + } + + outStr := out.String() + if !strings.Contains(outStr, openBrowserHint[:20]) { // basic sanity check that hint was written + t.Fatalf("expected openBrowserHint to be written to out, got %q", outStr) + } + if !strings.Contains(outStr, "After completing the authorization flow") { + t.Fatalf("expected manualInputHint to be written to out, got %q", outStr) + } +} + +func TestGetCodeFromPrompt_URLDecode(t *testing.T) { + var out bytes.Buffer + in := strings.NewReader("4%2Fabc") + + s := NewY2BService(WithIO(in, &out)).(*svc) + + code, err := s.getCodeFromPrompt("http://example.com/auth") + if err != nil { + t.Fatalf("getCodeFromPrompt returned error: %v", err) + } + if code != "4/abc" { + t.Fatalf("unexpected code: got %q, want %q", code, "4/abc") + } +} + +func TestSaveToken_InvalidPath(t *testing.T) { + // Point pkg.Root to a directory without write permission so that + // the underlying filesystem operations fail. + tmpDir := t.TempDir() + if err := os.Chmod(tmpDir, 0o555); err != nil { + t.Fatalf("failed to chmod temp dir: %v", err) + } + + origRootDir := *pkg.RootDir + origRoot := pkg.Root + defer func() { + pkg.RootDir = &origRootDir + pkg.Root = origRoot + }() + + pkg.RootDir = &tmpDir + root, err := os.OpenRoot(tmpDir) + if err != nil { + t.Fatalf("failed to open root: %v", err) + } + pkg.Root = root + + s := NewY2BService().(*svc) + s.tokenFile = "token.json" + tok := &oauth2.Token{AccessToken: "abc"} + + err = s.saveToken(tok) + if err == nil { + t.Fatalf("expected error from saveToken with invalid path, got nil") + } +} diff --git a/pkg/auth/service.go b/pkg/auth/service.go index 8cb9da6..e04875c 100644 --- a/pkg/auth/service.go +++ b/pkg/auth/service.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" "fmt" + "io" "io/fs" "log/slog" "os" @@ -30,6 +31,8 @@ type svc struct { credFile string tokenFile string initErr error + in io.Reader + out io.Writer service *youtube.Service ctx context.Context @@ -47,6 +50,8 @@ func NewY2BService(opts ...Option) Svc { s.ctx = context.Background() s.credFile = "client_secret.json" s.state = utils.RandomStage() + s.in = os.Stdin + s.out = os.Stdout for _, opt := range opts { opt(s) @@ -54,6 +59,13 @@ func NewY2BService(opts ...Option) Svc { return s } +func WithIO(in io.Reader, out io.Writer) Option { + return func(s *svc) { + s.in = in + s.out = out + } +} + func WithCredential(cred string, fsys fs.FS) Option { return func(s *svc) { // cred > YUTU_CREDENTIAL diff --git a/pkg/auth/service_test.go b/pkg/auth/service_test.go index 112ecdd..bf3c074 100644 --- a/pkg/auth/service_test.go +++ b/pkg/auth/service_test.go @@ -4,11 +4,13 @@ package auth import ( + "bytes" "context" "io/fs" "os" "path/filepath" "reflect" + "strings" "testing" "testing/fstest" ) @@ -31,6 +33,8 @@ func TestNewY2BService(t *testing.T) { credFile: &fstest.MapFile{Data: []byte(credential)}, tokenFile: &fstest.MapFile{Data: []byte(cacheToken)}, } + reader := strings.NewReader("in") + writer := &bytes.Buffer{} type args struct { opts []Option @@ -47,6 +51,7 @@ func TestNewY2BService(t *testing.T) { opts: []Option{ WithCredential(credential, mockFS), WithCacheToken(cacheToken, mockFS), + WithIO(reader, writer), }, }, want: &svc{ @@ -54,6 +59,8 @@ func TestNewY2BService(t *testing.T) { CacheToken: cacheToken, credFile: credFile, ctx: context.Background(), + in: reader, + out: writer, }, }, { @@ -62,6 +69,7 @@ func TestNewY2BService(t *testing.T) { opts: []Option{ WithCredential(credB64, mockFS), WithCacheToken(tokenB64, mockFS), + WithIO(reader, writer), }, }, want: &svc{ @@ -69,6 +77,8 @@ func TestNewY2BService(t *testing.T) { CacheToken: cacheToken, credFile: credFile, ctx: context.Background(), + in: reader, + out: writer, }, }, { @@ -77,6 +87,7 @@ func TestNewY2BService(t *testing.T) { opts: []Option{ WithCredential(absCred, mockFS), WithCacheToken(absToken, mockFS), + WithIO(reader, writer), }, }, want: &svc{ @@ -85,6 +96,8 @@ func TestNewY2BService(t *testing.T) { credFile: absCred, tokenFile: tokenFile, ctx: context.Background(), + in: reader, + out: writer, }, }, { @@ -95,6 +108,8 @@ func TestNewY2BService(t *testing.T) { want: &svc{ credFile: credFile, ctx: context.Background(), + in: os.Stdin, + out: os.Stdout, }, }, } @@ -102,12 +117,29 @@ func TestNewY2BService(t *testing.T) { for _, tt := range tests { t.Run( tt.name, func(t *testing.T) { - if got := NewY2BService(tt.args.opts...); !reflect.DeepEqual( - got, tt.want, - ) { - t.Errorf("NewY2BService() = %v, want %v", got, tt.want) + got := NewY2BService(tt.args.opts...).(*svc) + got.state = "" + want := tt.want.(*svc) + want.state = "" + if !reflect.DeepEqual(got, want) { + t.Errorf("NewY2BService() = %v, want %v", got, want) } }, ) } } + +func TestGetService_Errors(t *testing.T) { + fsys := fstest.MapFS{} + s := NewY2BService(WithCredential("not-a-valid-json-or-b64", fsys)).(*svc) + + _, err := s.GetService() + if err == nil { + t.Fatalf("GetService() error = nil, want non-nil") + } + if !strings.Contains( + err.Error(), parseSecretFailed, + ) && !strings.Contains(err.Error(), "failed to parse client secret") { + t.Fatalf("GetService() error = %v, want contains %q", err, parseSecretFailed) + } +}