373 lines
9.1 KiB
Go
373 lines
9.1 KiB
Go
// Copyright 2026 eat-pray-ai & OpenWaygate
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json/v2"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"testing/iotest"
|
|
"time"
|
|
|
|
"github.com/eat-pray-ai/yutu/pkg"
|
|
"golang.org/x/oauth2"
|
|
"google.golang.org/api/youtube/v3"
|
|
)
|
|
|
|
// helper to build a minimal valid Google OAuth2 credential JSON.
|
|
func validCredentialJSON(redirectURL string) string {
|
|
cred := map[string]map[string]any{
|
|
"installed": {
|
|
"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"), 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" {
|
|
t.Errorf(
|
|
"unexpected RedirectURL: got %q, want %q", config.RedirectURL,
|
|
"http://localhost",
|
|
)
|
|
}
|
|
if len(config.Scopes) == 0 {
|
|
t.Fatalf("expected scopes to be populated, got none")
|
|
}
|
|
}
|
|
|
|
func TestGetConfig_WithRedirectURL(t *testing.T) {
|
|
s := NewY2BService(
|
|
WithCredential(validCredentialJSON("http://localhost"), os.DirFS(".")),
|
|
WithRedirectURL("http://localhost:8216"),
|
|
).(*svc)
|
|
|
|
config, err := s.getConfig()
|
|
if err != nil {
|
|
t.Fatalf("getConfig returned error: %v", err)
|
|
}
|
|
|
|
if config.RedirectURL != "http://localhost:8216" {
|
|
t.Errorf(
|
|
"unexpected RedirectURL: got %q, want %q", config.RedirectURL,
|
|
"http://localhost:8216",
|
|
)
|
|
}
|
|
}
|
|
|
|
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", "http://localhost:8216",
|
|
)
|
|
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)
|
|
}
|
|
if !strings.Contains(outStr, "http://localhost:8216/?state=DONOT-COPY") {
|
|
t.Fatalf("expected redirect URL in manualInputHint, 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", "http://localhost:8216",
|
|
)
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestStartWebServer_Success(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("failed to listen: %v", err)
|
|
}
|
|
addr := ln.Addr().String()
|
|
_ = ln.Close()
|
|
|
|
redirectURL := fmt.Sprintf("http://%s", addr)
|
|
s := NewY2BService().(*svc)
|
|
s.state = "test-state"
|
|
|
|
codeCh, err := s.startWebServer(redirectURL)
|
|
if err != nil {
|
|
t.Fatalf("startWebServer error: %v", err)
|
|
}
|
|
|
|
// Send the HTTP request in a goroutine to avoid deadlock:
|
|
// the handler blocks on codeCh <- code until someone reads from codeCh.
|
|
go func() {
|
|
resp, err := http.Get(redirectURL + "/?state=test-state&code=auth-code-123")
|
|
if err == nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case code := <-codeCh:
|
|
if code != "auth-code-123" {
|
|
t.Errorf("expected code=auth-code-123, got %s", code)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for code")
|
|
}
|
|
}
|
|
|
|
func TestSaveToken_Success(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
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: "test-token"}
|
|
err = s.saveToken(tok)
|
|
if err != nil {
|
|
t.Fatalf("saveToken returned error: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(tmpDir + "/token.json")
|
|
if err != nil {
|
|
t.Fatalf("failed to read saved token file: %v", err)
|
|
}
|
|
|
|
var savedToken oauth2.Token
|
|
if err := json.Unmarshal(data, &savedToken); err != nil {
|
|
t.Fatalf("failed to unmarshal saved token: %v", err)
|
|
}
|
|
if savedToken.AccessToken != "test-token" {
|
|
t.Errorf("expected AccessToken=test-token, got %s", savedToken.AccessToken)
|
|
}
|
|
}
|
|
|
|
func TestGetConfig_Scopes(t *testing.T) {
|
|
s := NewY2BService(
|
|
WithCredential(validCredentialJSON("http://localhost"), os.DirFS(".")),
|
|
).(*svc)
|
|
|
|
config, err := s.getConfig()
|
|
if err != nil {
|
|
t.Fatalf("getConfig returned error: %v", err)
|
|
}
|
|
|
|
if len(config.Scopes) != 3 {
|
|
t.Fatalf("expected 3 scopes, got %d", len(config.Scopes))
|
|
}
|
|
|
|
expectedScopes := []string{
|
|
youtube.YoutubeScope,
|
|
youtube.YoutubeForceSslScope,
|
|
youtube.YoutubeChannelMembershipsCreatorScope,
|
|
}
|
|
for i, expected := range expectedScopes {
|
|
if config.Scopes[i] != expected {
|
|
t.Errorf("scope[%d] = %q, want %q", i, config.Scopes[i], expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetCodeFromPrompt_ReadError(t *testing.T) {
|
|
errReader := iotest.ErrReader(fmt.Errorf("read error"))
|
|
var out bytes.Buffer
|
|
|
|
s := NewY2BService(WithIO(errReader, &out)).(*svc)
|
|
|
|
_, err := s.getCodeFromPrompt(
|
|
"http://example.com/auth", "http://localhost:8216",
|
|
)
|
|
if err == nil {
|
|
t.Fatalf("expected error from getCodeFromPrompt, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to read prompt") {
|
|
t.Errorf(
|
|
"expected error to contain %q, got %q", "failed to read prompt",
|
|
err.Error(),
|
|
)
|
|
}
|
|
}
|