diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..1ac6ba25a --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,113 @@ +name: E2E Tests + +on: + workflow_dispatch: # Allow manual trigger + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + +jobs: + # Check if comment is /e2e and commenter has write permission + check-trigger: + runs-on: ubuntu-22.04 + if: github.event_name == 'issue_comment' && github.event.issue.pull_request + outputs: + should-run: ${{ steps.check.outputs.should-run }} + pr-ref: ${{ steps.pr.outputs.ref }} + steps: + - name: Check comment and author + id: check + env: + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + REPO: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Check if comment contains /e2e + if [[ "$COMMENT_BODY" != *"/e2e"* ]]; then + echo "should-run=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Check if author has write permission (owner or maintainer) + PERMISSION=$(gh api repos/$REPO/collaborators/$COMMENT_AUTHOR/permission --jq '.permission') + + if [[ "$PERMISSION" == "admin" ]] || [[ "$PERMISSION" == "write" ]]; then + echo "should-run=true" >> $GITHUB_OUTPUT + echo "✅ User $COMMENT_AUTHOR has permission: $PERMISSION" + else + echo "should-run=false" >> $GITHUB_OUTPUT + echo "❌ Only repo owners and maintainers can trigger E2E tests (current permission: $PERMISSION)" + fi + + - name: Get PR ref + if: steps.check.outputs.should-run == 'true' + id: pr + env: + PR_NUMBER: ${{ github.event.issue.number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER) + REF=$(echo "$PR_DATA" | jq -r .head.ref) + echo "ref=$REF" >> $GITHUB_OUTPUT + + # Run E2E tests + e2e-test: + runs-on: ubuntu-22.04 + needs: [check-trigger] + environment: Main + if: | + always() && + (github.event_name == 'workflow_dispatch' || + needs.check-trigger.outputs.should-run == 'true') + steps: + - name: Add reaction + if: github.event_name == 'issue_comment' + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ github.event.comment.id }} + reactions: rocket + + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ needs.check-trigger.outputs.pr-ref || github.ref }} + + - name: Setup Golang env + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Install Ginkgo + run: go install github.com/onsi/ginkgo/v2/ginkgo + + - name: Build + run: go build + + - name: Restore credentials from secrets + env: + TG_CREDENTIALS: ${{ secrets.TG_CREDENTIALS }} + run: | + echo "$TG_CREDENTIALS" > test/credentials.json + chmod 600 test/credentials.json + + - name: E2E Test + env: + TDL_TEST_CREDENTIALS_FILE: ${{ github.workspace }}/test/credentials.json + run: ginkgo -v -r --timeout=3m ./test + + - name: Comment result on PR + if: github.event_name == 'issue_comment' && always() + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + body: | + ## E2E Test Results + + **Status:** ${{ job.status == 'success' && '✅ Passed' || '❌ Failed' }} + **Triggered by:** @${{ github.event.comment.user.login }} + **Run:** [View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 0d4a70800..271c219dc 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -50,26 +50,3 @@ jobs: run: go build - name: Unit Test run: go test -v $(go list ./... | grep -v /test) # skip e2e test - e2e-test: - runs-on: ubuntu-22.04 - steps: - - name: Checkout - uses: actions/checkout@v5 - - name: Setup Golang env - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - name: Install Ginkgo - run: go install github.com/onsi/ginkgo/v2/ginkgo - - name: Setup Teamgram Env - run: | - git clone https://github.com/iyear/teamgram-server.git - cd teamgram-server - git checkout 10151bb92555aa1bedcba9f8f24b0e7deac22dee - sudo docker compose -f ./docker-compose-env.yaml up -d --quiet-pull - sudo docker compose up -d --quiet-pull - - name: Build - run: go build - - name: E2E Test - run: ginkgo -v -r ./test diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 000000000..5e8e7e0b6 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1 @@ +**/*.json diff --git a/test/README.md b/test/README.md new file mode 100644 index 000000000..3f01114de --- /dev/null +++ b/test/README.md @@ -0,0 +1,33 @@ +# E2E Testing + +End-to-end tests for tdl using exported credentials. + +## Usage + +```bash +# 1. Export credentials +cd tools +go run export_credentials.go -namespace default -output ../test/credentials.json + +# 2. Run tests +cd .. +TDL_TEST_CREDENTIALS_FILE=$(pwd)/test/credentials.json go test ./test/... -v +``` + +## GitHub Actions + +E2E tests run via `.github/workflows/e2e.yml`: + +- **Trigger**: Comment `/e2e` on PR (requires write permission) or manual run +- **Credentials**: Stored in `secrets.TG_CREDENTIALS` + +## GitHub Actions + +E2E tests run via `.github/workflows/e2e.yml`: + +- **Trigger**: Comment `/e2e` on PR (requires write permission) or manual run +- **Credentials**: Stored in `secrets.TG_CREDENTIALS` + +## Security + +- Never commit credentials to version control diff --git a/test/import_credentials.go b/test/import_credentials.go new file mode 100644 index 000000000..64b9d996e --- /dev/null +++ b/test/import_credentials.go @@ -0,0 +1,122 @@ +package test + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/go-faster/errors" + + "github.com/iyear/tdl/pkg/kv" +) + +// CredentialImport represents imported credentials +type CredentialImport struct { + Namespace string `json:"namespace"` + Data map[string][]byte `json:"data"` +} + +// SetupWithImportedCredentials sets up e2e tests with imported credentials from file +func SetupWithImportedCredentials(ctx context.Context) (account string, sessionFile string, _ error) { + credFile := os.Getenv("TDL_TEST_CREDENTIALS_FILE") + if credFile == "" { + return "", "", errors.New("TDL_TEST_CREDENTIALS_FILE is required") + } + + log.Printf("Loading credentials from file: %s", credFile) + return setupFromFile(ctx, credFile) +} + +func setupFromFile(ctx context.Context, credFile string) (string, string, error) { + data, err := os.ReadFile(credFile) + if err != nil { + return "", "", errors.Wrap(err, "read credentials file") + } + + return setupFromJSON(ctx, string(data)) +} + +func setupFromJSON(ctx context.Context, credJSON string) (string, string, error) { + var cred CredentialImport + if err := json.Unmarshal([]byte(credJSON), &cred); err != nil { + return "", "", errors.Wrap(err, "unmarshal credentials") + } + + if cred.Namespace == "" { + return "", "", errors.New("namespace is empty in credentials") + } + + if len(cred.Data) == 0 { + return "", "", errors.New("no data in credentials") + } + + // Verify session data exists + if _, ok := cred.Data["session"]; !ok { + return "", "", errors.New("session data not found in credentials") + } + + // Create temporary session file + account := fmt.Sprintf("e2e-imported-%s", cred.Namespace) + sessionFile := filepath.Join(os.TempDir(), "tdl-e2e", account) + + // Create session directory + if err := os.MkdirAll(filepath.Dir(sessionFile), 0o755); err != nil { + return "", "", errors.Wrap(err, "create session directory") + } + + log.Printf("Creating session file: %s", sessionFile) + + // Create KV storage + kvd, err := kv.New(kv.DriverFile, map[string]any{ + "path": sessionFile, + }) + if err != nil { + return "", "", errors.Wrap(err, "create kv storage") + } + defer kvd.Close() + + // Open namespace + stg, err := kvd.Open(account) + if err != nil { + return "", "", errors.Wrap(err, "open namespace") + } + + // Import all data + for key, value := range cred.Data { + if err := stg.Set(ctx, key, value); err != nil { + return "", "", errors.Wrapf(err, "set key: %s", key) + } + log.Printf("Imported key: %s (%d bytes)", key, len(value)) + } + + log.Printf("Successfully imported credentials for namespace: %s", cred.Namespace) + log.Printf("Account: %s, Session file: %s", account, sessionFile) + + return account, sessionFile, nil +} + +// ValidateCredentials validates that the credentials contain necessary data +func ValidateCredentials(credJSON string) error { + var cred CredentialImport + if err := json.Unmarshal([]byte(credJSON), &cred); err != nil { + return errors.Wrap(err, "unmarshal credentials") + } + + if cred.Namespace == "" { + return errors.New("namespace is empty") + } + + if _, ok := cred.Data["session"]; !ok { + return errors.New("session data not found") + } + + // Optional but recommended + if _, ok := cred.Data["app"]; !ok { + log.Println("Warning: app type not found in credentials") + } + + return nil +} diff --git a/test/suite_test.go b/test/suite_test.go index 9e410d39d..68510672c 100644 --- a/test/suite_test.go +++ b/test/suite_test.go @@ -6,15 +6,14 @@ import ( "fmt" "io" "log" - "math/rand" "os" "testing" + "time" "github.com/fatih/color" "github.com/spf13/cobra" tcmd "github.com/iyear/tdl/cmd" - "github.com/iyear/tdl/test/testserver" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -35,7 +34,14 @@ var ( var _ = BeforeSuite(func(ctx context.Context) { var err error - testAccount, sessionFile, err = testserver.Setup(ctx, rand.NewSource(GinkgoRandomSeed())) + + // Use imported credentials from file + if os.Getenv("TDL_TEST_CREDENTIALS_FILE") == "" { + log.Fatal("TDL_TEST_CREDENTIALS_FILE is required") + } + + log.Println("Using imported credentials for e2e tests") + testAccount, sessionFile, err = SetupWithImportedCredentials(ctx) Expect(err).To(Succeed()) log.SetOutput(GinkgoWriter) @@ -45,6 +51,11 @@ var _ = BeforeEach(func() { cmd = tcmd.New() }) +var _ = AfterEach(func() { + // Add 1 second delay between tests to avoid rate limiting + time.Sleep(1 * time.Second) +}) + func exec(cmd *cobra.Command, args []string, success bool) { r, w, err := os.Pipe() Expect(err).To(Succeed()) diff --git a/test/test.json b/test/test.json new file mode 100644 index 000000000..e69de29bb diff --git a/test/testserver/public_key.pem b/test/testserver/public_key.pem deleted file mode 100644 index 668040710..000000000 --- a/test/testserver/public_key.pem +++ /dev/null @@ -1,8 +0,0 @@ ------BEGIN RSA PUBLIC KEY----- -MIIBCgKCAQEAvKLEOWTzt9Hn3/9Kdp/RdHcEhzmd8xXeLSpHIIzaXTLJDw8BhJy1 -jR/iqeG8Je5yrtVabqMSkA6ltIpgylH///FojMsX1BHu4EPYOXQgB0qOi6kr08iX -ZIH9/iOPQOWDsL+Lt8gDG0xBy+sPe/2ZHdzKMjX6O9B4sOsxjFrk5qDoWDrioJor -AJ7eFAfPpOBf2w73ohXudSrJE0lbQ8pCWNpMY8cB9i8r+WBitcvouLDAvmtnTX7a -khoDzmKgpJBYliAY4qA73v7u5UIepE8QgV0jCOhxJCPubP8dg+/PlLLVKyxU5Cdi -QtZj2EMy4s9xlNKzX8XezE0MHEa6bQpnFwIDAQAB ------END RSA PUBLIC KEY----- diff --git a/test/testserver/testserver.go b/test/testserver/testserver.go deleted file mode 100644 index 7dc3a047b..000000000 --- a/test/testserver/testserver.go +++ /dev/null @@ -1,141 +0,0 @@ -package testserver - -import ( - "context" - _ "embed" - "log" - "math/rand" - "os" - "path/filepath" - "strconv" - - "github.com/go-faster/errors" - "github.com/gotd/td/crypto" - "github.com/gotd/td/exchange" - "github.com/gotd/td/telegram" - "github.com/gotd/td/telegram/auth" - "github.com/gotd/td/telegram/dcs" - "github.com/gotd/td/tg" - - "github.com/iyear/tdl/core/dcpool" - "github.com/iyear/tdl/core/storage" - tclientcore "github.com/iyear/tdl/core/tclient" - "github.com/iyear/tdl/pkg/kv" - "github.com/iyear/tdl/pkg/tclient" -) - -//go:embed public_key.pem -var publicKeyData []byte - -var ( - dc = 1 - dcList = dcs.List{ - Options: []tg.DCOption{ - { - ID: 1, - IPAddress: "127.0.0.1", - Port: 10443, - }, - }, - Domains: nil, - Test: false, - } - publicKeys []exchange.PublicKey - phone = "+86 13858528382" -) - -func init() { - keys, _ := crypto.ParseRSAPublicKeys(publicKeyData) - for _, k := range keys { - publicKeys = append(publicKeys, exchange.PublicKey{RSA: k}) - } -} - -// Setup creates test user and returns account and session file path. Namespace is the value of account. -func Setup(ctx context.Context, rnd rand.Source) (account string, sessionFile string, _ error) { - tclientcore.DC = dc - tclientcore.DCList = dcList - tclientcore.PublicKeys = publicKeys - - dcpool.EnableTestMode() - - account = strconv.FormatInt(rand.Int63(), 10) - sessionFile = filepath.Join(os.TempDir(), "tdl", account) - - return account, sessionFile, setupTestUser(ctx, rand.New(rnd), account, sessionFile) -} - -func setupTestUser(ctx context.Context, rnd *rand.Rand, account, sessionFile string) error { - kvd, err := kv.New(kv.DriverFile, map[string]any{ - "path": sessionFile, - }) - if err != nil { - return errors.Wrapf(err, "create kv storage: %s", sessionFile) - } - log.Printf("session file: %s", sessionFile) - - stg, err := kvd.Open(account) - if err != nil { - return errors.Wrap(err, "open test namespace") - } - - sess := storage.NewSession(stg, true) - - opts := telegram.Options{ - DC: dc, - DCList: dcList, - PublicKeys: publicKeys, - SessionStorage: sess, - } - - app := tclient.Apps[tclient.AppDesktop] - c := telegram.NewClient(app.AppID, app.AppHash, opts) - - if err = c.Run(ctx, func(ctx context.Context) error { - if err = c.Ping(ctx); err != nil { - return err - } - - authClient := auth.NewClient(c.API(), rnd, app.AppID, app.AppHash) - - if err = auth.NewFlow( - testAuth{phone: phone}, - auth.SendCodeOptions{}, - ).Run(ctx, authClient); err != nil { - return errors.Wrap(err, "register test user") - } - - user, err := c.Self(ctx) - if err != nil { - return errors.Wrap(err, "get self") - } - - log.Printf("user: %v, %v, %v", user.ID, user.FirstName, user.LastName) - return nil - }); err != nil { - return errors.Wrap(err, "run auth") - } - - return nil -} - -type testAuth struct { - phone string -} - -func (t testAuth) Phone(_ context.Context) (string, error) { return t.phone, nil } -func (t testAuth) Password(_ context.Context) (string, error) { return "", auth.ErrPasswordNotProvided } -func (t testAuth) Code(_ context.Context, _ *tg.AuthSentCode) (string, error) { - return "12345", nil -} - -func (t testAuth) AcceptTermsOfService(_ context.Context, _ tg.HelpTermsOfService) error { - return nil -} - -func (t testAuth) SignUp(_ context.Context) (auth.UserInfo, error) { - return auth.UserInfo{ - FirstName: "Test", - LastName: "User", - }, nil -} diff --git a/tools/export_credentials/.gitignore b/tools/export_credentials/.gitignore new file mode 100644 index 000000000..5e8e7e0b6 --- /dev/null +++ b/tools/export_credentials/.gitignore @@ -0,0 +1 @@ +**/*.json diff --git a/tools/export_credentials/README.md b/tools/export_credentials/README.md new file mode 100644 index 000000000..a699ae3b1 --- /dev/null +++ b/tools/export_credentials/README.md @@ -0,0 +1,58 @@ +# Export Credentials + +Export credentials from tdl storage to JSON format. + +### Usage + +```bash +# Export to file +go run export_credentials.go -namespace default -output credentials.json + +# Export to stdout +go run export_credentials.go -namespace default + +# Export from custom storage path +go run export_credentials.go -path ~/.tdl/data -namespace default -output backup.json +``` + +### Options + +- `-path` - Path to tdl storage (default: `~/.tdl/data`) +- `-namespace` - Namespace to export (default: `default`) +- `-output` - Output file path (default: stdout) + +### Output Format + +```json +{ + "namespace": "default", + "data": { + "session": "...", + "app": "desktop" + } +} +``` + +### Use Cases + +**1. E2E Testing** + +Export credentials and use them in tests: + +```bash +# Export +go run export_credentials.go -namespace default -output ../test/test.json + +# Run tests with exported credentials +cd .. +TDL_TEST_CREDENTIALS_FILE=test/test.json go test ./test/... +``` + +## Security Warning + +⚠️ **Exported credentials grant full access to your Telegram account!** + +- Never commit credentials to version control +- Store with restricted permissions (`chmod 600`) +- Never share publicly +- Delete after use if temporary diff --git a/tools/export_credentials/export_credentials.go b/tools/export_credentials/export_credentials.go new file mode 100644 index 000000000..3121ef071 --- /dev/null +++ b/tools/export_credentials/export_credentials.go @@ -0,0 +1,106 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/iyear/tdl/pkg/kv" +) + +// CredentialExport represents exported credentials +type CredentialExport struct { + Namespace string `json:"namespace"` + Data map[string][]byte `json:"data"` +} + +func main() { + var ( + storagePath string + namespace string + outputFile string + ) + + homeDir, _ := os.UserHomeDir() + defaultPath := filepath.Join(homeDir, ".tdl", "data") + + flag.StringVar(&storagePath, "path", defaultPath, "Path to tdl storage") + flag.StringVar(&namespace, "namespace", "default", "Namespace to export") + flag.StringVar(&outputFile, "output", "", "Output file (default: stdout)") + flag.Parse() + + if err := exportCredentials(storagePath, namespace, outputFile); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func exportCredentials(storagePath, namespace, outputFile string) error { + // Detect storage type + driver, storagePath, err := detectStorageType(storagePath) + if err != nil { + return err + } + + // Open storage + var kvStorage kv.Storage + switch driver { + case kv.DriverFile: + kvStorage, err = kv.New(kv.DriverFile, map[string]any{"path": storagePath}) + case kv.DriverBolt: + kvStorage, err = kv.New(kv.DriverBolt, map[string]any{"path": storagePath}) + default: + return fmt.Errorf("unsupported storage type: %s", driver) + } + if err != nil { + return fmt.Errorf("open storage: %w", err) + } + defer kvStorage.Close() + + // Export all data + meta, err := kvStorage.MigrateTo() + if err != nil { + return fmt.Errorf("export data: %w", err) + } + + nsData, ok := meta[namespace] + if !ok { + return fmt.Errorf("namespace %s not found", namespace) + } + + export := CredentialExport{ + Namespace: namespace, + Data: nsData, + } + + // Marshal to JSON + jsonData, err := json.MarshalIndent(export, "", " ") + if err != nil { + return fmt.Errorf("marshal JSON: %w", err) + } + + // Write to file or stdout + if outputFile != "" { + if err := os.WriteFile(outputFile, jsonData, 0o600); err != nil { + return fmt.Errorf("write file: %w", err) + } + fmt.Fprintf(os.Stderr, "Credentials exported to: %s\n", outputFile) + fmt.Fprintf(os.Stderr, "⚠️ Keep this file secure! It contains your account credentials.\n") + } else { + fmt.Println(string(jsonData)) + } + + return nil +} + +func detectStorageType(path string) (kv.Driver, string, error) { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + return kv.DriverFile, path, nil + } + if info, err := os.Stat(path); err == nil && info.IsDir() { + return kv.DriverBolt, path, nil + } + return "", "", fmt.Errorf("cannot determine storage type for: %s", path) +}