diff --git a/cmd/env-sync/main.go b/cmd/env-sync/main.go index 9b2f570..134b59b 100644 --- a/cmd/env-sync/main.go +++ b/cmd/env-sync/main.go @@ -203,6 +203,20 @@ func run() error { return config.RunSetup(args[1:], printUsage) } + if len(args) > 0 && args[0] == "validate" { + // --lang フラグと AppConfig の language フィールドも言語解決に含める。 + // PrescanLang はフラグ解析前に --lang/--language の値だけを先読みする。 + prescannedLang := config.PrescanLang(args[1:]) + // config 読み込み失敗時は configLang を "" として継続する。 + // エラーは後続の本処理(validate ロジック内の LoadAppConfig 呼び出し等)で顕在化する。 + var configLang string + if appCfg, err := config.LoadAppConfig(); err == nil { + configLang = appCfg.Language + } + i18n.SetLang(string(i18n.Resolve(prescannedLang, os.Getenv("ENV_SYNC_LANG"), configLang))) + return runValidate(args[1:], printUsage) + } + printVersion := func() { v, c, d := versionInfo() fmt.Printf("env-sync version %s (commit: %s, built: %s)\n", v, c, d) diff --git a/cmd/env-sync/validate.go b/cmd/env-sync/validate.go new file mode 100644 index 0000000..0345976 --- /dev/null +++ b/cmd/env-sync/validate.go @@ -0,0 +1,39 @@ +// validate.go は validate サブコマンドの実装を提供する。 +// 読み取り専用(GET のみ)で認証・ターゲット設定・API 到達確認を行い、書き込みは行わない。 +package main + +import ( + "fmt" + "os" + + "github.com/ptyhard/env-sync/internal/config" + "github.com/ptyhard/env-sync/internal/i18n" + "github.com/ptyhard/env-sync/internal/provider" +) + +// runValidate は validate サブコマンドを実行する。 +// --provider で指定した provider(デフォルト: vercel)の Validator を呼び出して +// 認証トークン・ターゲット設定・API 到達確認を行う。 +// def / env ファイルは使用しないため、不在でも fatal にはならない。 +func runValidate(args []string, printUsage func()) error { + printVersion := func() { + v, c, d := versionInfo() + fmt.Printf("env-sync version %s (commit: %s, built: %s)\n", v, c, d) + } + opts := config.ParseFlags(args, printUsage, printVersion) + + pname := opts.Provider + p, ok := provider.LookupProvider(pname) + if !ok { + fmt.Fprint(os.Stderr, i18n.T(i18n.MsgValidateProviderUnsupported, pname)) + return nil + } + + v, ok := p.(provider.Validator) + if !ok { + fmt.Fprint(os.Stderr, i18n.T(i18n.MsgValidateProviderUnsupported, pname)) + return nil + } + + return v.Validate(opts, nil) +} diff --git a/cmd/env-sync/validate_test.go b/cmd/env-sync/validate_test.go new file mode 100644 index 0000000..56bf59f --- /dev/null +++ b/cmd/env-sync/validate_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// TestValidateSubcommand_HelpExitsZero は validate --help が exit 0 で終了することを確認する。 +func TestValidateSubcommand_HelpExitsZero(t *testing.T) { + bin := t.TempDir() + "/env-sync-test" + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("ビルド失敗: %s\n%s", err, out) + } + cmd := exec.Command(bin, "validate", "--help") + if err := cmd.Run(); err != nil { + t.Errorf("validate --help は exit 0 であるべき: %s", err) + } +} + +// TestValidateSubcommand_NoDefOrEnv は def/env ファイルが存在しなくても fatal にならないことを確認する。 +// token / project_id が未設定のため exit 1 になるが、ファイル不在でパニックしないことを確認する。 +func TestValidateSubcommand_NoDefOrEnv(t *testing.T) { + bin := t.TempDir() + "/env-sync-test" + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("ビルド失敗: %s\n%s", err, out) + } + + dir := t.TempDir() + cmd := exec.Command(bin, "validate", + "--env", dir+"/nonexistent.env", + "--def", dir+"/nonexistent.yaml", + ) + // VERCEL_PROJECT_ID を未設定にして API 呼び出しを回避 + env := []string{ + "VERCEL_TOKEN=", + "VERCEL_PROJECT_ID=", + "GITHUB_TOKEN=", + "GITHUB_REPO=", + "XDG_CONFIG_HOME=" + dir + "/no-global", + } + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + output := string(out) + + // ファイル不在のエラー(fatal)ではなく、警告を出して継続することを確認 + // def/env の不在警告が出ることを確認 + if strings.Contains(output, "panic") { + t.Errorf("panic が発生した: %s", output) + } + // def/env 不在の警告または validate ヘッダが出ることを確認 + // (exit 1 は許容: token 未設定のため) + _ = err // exit 1 は許容 +} + +// TestValidateSubcommand_InUsage は --help に validate の説明が含まれることを確認する。 +func TestValidateSubcommand_InUsage(t *testing.T) { + bin := t.TempDir() + "/env-sync-test" + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("ビルド失敗: %s\n%s", err, out) + } + out, err := exec.Command(bin, "--help").CombinedOutput() + if err != nil { + t.Fatalf("--help 実行失敗: %s", err) + } + if !strings.Contains(string(out), "validate") { + t.Errorf("--help の出力に validate が含まれない: %s", out) + } +} + +// TestRunValidate_ProviderNotValidator_ReturnsNil は Validator 未実装の provider(gcp)を +// 指定した場合に runValidate が nil を返す(型アサーション失敗の分岐)ことを確認する。 +// 未登録名は ParseFlags 側で os.Exit(1) になるため、ここでは登録済みだが Validator 未実装の gcp を使う。 +func TestRunValidate_ProviderNotValidator_ReturnsNil(t *testing.T) { + args := []string{"--provider", "gcp"} + if err := runValidate(args, func() {}); err != nil { + t.Errorf("Validator 未実装 provider 指定時は nil を期待: %v", err) + } +} diff --git a/internal/config/appconfig.go b/internal/config/appconfig.go index f75a7ce..fb103c8 100644 --- a/internal/config/appconfig.go +++ b/internal/config/appconfig.go @@ -45,18 +45,23 @@ type GitHubRepoConf struct { // VercelTarget は ResolveVercelTargets が返す解決済みターゲット。 type VercelTarget struct { // Name は config 上のターゲット名。単一解決の場合は空になる。 - Name string - ProjectID string - TeamID string - Token string + Name string + ProjectID string + TeamID string + Token string + TokenSource string // 取得元: "env" / "config" / ""(未設定) + ProjectIDSource string // 取得元: "env" / "config" / "project_json" / ""(未設定) + TeamIDSource string // 取得元: "env" / "config" / "project_json" / ""(未設定) } // GitHubTarget は ResolveGitHubTargets が返す解決済みターゲット。 type GitHubTarget struct { // Name は config 上のターゲット名。単一解決の場合は空になる。 - Name string - Repo string - Token string + Name string + Repo string + Token string + TokenSource string // 取得元: "env" / "config" / ""(未設定) + RepoSource string // 取得元: "env" / "config" / "git_remote" / ""(未設定) } // AppVercelConfig は Vercel の認証情報・ID。 @@ -325,6 +330,61 @@ func (cfg *AppConfig) ResolveGitHubRepo() string { return cfg.GitHub.Repo } +// ResolveVercelTokenWithSource は token とその取得元 ("env"/"config"/"") を返す。 +func (cfg *AppConfig) ResolveVercelTokenWithSource() (val, source string) { + if v := os.Getenv("VERCEL_TOKEN"); v != "" { + return v, "env" + } + if cfg.Vercel.Token != "" { + return cfg.Vercel.Token, "config" + } + return "", "" +} + +// ResolveVercelProjectIDWithSource は project ID とその取得元 ("env"/"config"/"") を返す。 +func (cfg *AppConfig) ResolveVercelProjectIDWithSource() (val, source string) { + if v := os.Getenv("VERCEL_PROJECT_ID"); v != "" { + return v, "env" + } + if cfg.Vercel.ProjectID != "" { + return cfg.Vercel.ProjectID, "config" + } + return "", "" +} + +// ResolveVercelTeamIDWithSource は team ID とその取得元 ("env"/"config"/"") を返す。 +func (cfg *AppConfig) ResolveVercelTeamIDWithSource() (val, source string) { + if v := os.Getenv("VERCEL_TEAM_ID"); v != "" { + return v, "env" + } + if cfg.Vercel.TeamID != "" { + return cfg.Vercel.TeamID, "config" + } + return "", "" +} + +// ResolveGitHubTokenWithSource は token とその取得元 ("env"/"config"/"") を返す。 +func (cfg *AppConfig) ResolveGitHubTokenWithSource() (val, source string) { + if v := os.Getenv("GITHUB_TOKEN"); v != "" { + return v, "env" + } + if cfg.GitHub.Token != "" { + return cfg.GitHub.Token, "config" + } + return "", "" +} + +// ResolveGitHubRepoWithSource は repo とその取得元 ("env"/"config"/"") を返す。 +func (cfg *AppConfig) ResolveGitHubRepoWithSource() (val, source string) { + if v := os.Getenv("GITHUB_REPO"); v != "" { + return v, "env" + } + if cfg.GitHub.Repo != "" { + return cfg.GitHub.Repo, "config" + } + return "", "" +} + // resolveVercelToken はトークンの解決優先順位(per-target > 環境変数 > top-level config)を実装する。 // perTargetToken が非空ならそれを返す。空なら top-level の ResolveVercelToken()(環境変数 > config)を使う。 func (cfg *AppConfig) resolveVercelToken(perTargetToken string) string { @@ -334,6 +394,30 @@ func (cfg *AppConfig) resolveVercelToken(perTargetToken string) string { return cfg.ResolveVercelToken() } +// resolveVercelTokenWithSource はトークンと取得元を返す(per-target > 環境変数 > top-level config)。 +func (cfg *AppConfig) resolveVercelTokenWithSource(perTargetToken string) (string, string) { + if perTargetToken != "" { + return perTargetToken, "config" + } + return cfg.ResolveVercelTokenWithSource() +} + +// resolveVercelTeamIDWithSource はチーム ID と取得元を返す(per-target > 環境変数 > top-level config)。 +func (cfg *AppConfig) resolveVercelTeamIDWithSource(perTargetTeamID string) (string, string) { + if perTargetTeamID != "" { + return perTargetTeamID, "config" + } + return cfg.ResolveVercelTeamIDWithSource() +} + +// resolveGitHubTokenWithSource はトークンと取得元を返す(per-target > 環境変数 > top-level config)。 +func (cfg *AppConfig) resolveGitHubTokenWithSource(perTargetToken string) (string, string) { + if perTargetToken != "" { + return perTargetToken, "config" + } + return cfg.ResolveGitHubTokenWithSource() +} + // resolveVercelTeamID はチーム ID の解決優先順位(per-target > 環境変数 > top-level config)を実装する。 // perTargetTeamID が非空ならそれを返す。空なら ResolveVercelTeamID()(環境変数 > config)を使う。 func (cfg *AppConfig) resolveVercelTeamID(perTargetTeamID string) string { @@ -363,12 +447,18 @@ func (cfg *AppConfig) ResolveVercelTargets(selectName string) ([]VercelTarget, e if selectName != "" { return nil, fmt.Errorf("%s", i18n.T(i18n.MsgVercelProjectsNotDefined)) } - // 後方互換: 従来の単一解決 + // 後方互換: 従来の単一解決(取得元も付与) + tok, tokSrc := cfg.ResolveVercelTokenWithSource() + pid, pidSrc := cfg.ResolveVercelProjectIDWithSource() + tid, tidSrc := cfg.ResolveVercelTeamIDWithSource() return []VercelTarget{ { - ProjectID: cfg.ResolveVercelProjectID(), - TeamID: cfg.ResolveVercelTeamID(), - Token: cfg.ResolveVercelToken(), + ProjectID: pid, + ProjectIDSource: pidSrc, + TeamID: tid, + TeamIDSource: tidSrc, + Token: tok, + TokenSource: tokSrc, }, }, nil } @@ -385,11 +475,16 @@ func (cfg *AppConfig) ResolveVercelTargets(selectName string) ([]VercelTarget, e if selectName != "" && p.Name != selectName { continue } + tok, tokSrc := cfg.resolveVercelTokenWithSource(p.Token) + tid, tidSrc := cfg.resolveVercelTeamIDWithSource(p.TeamID) targets = append(targets, VercelTarget{ - Name: p.Name, - ProjectID: p.ProjectID, - TeamID: cfg.resolveVercelTeamID(p.TeamID), - Token: cfg.resolveVercelToken(p.Token), + Name: p.Name, + ProjectID: p.ProjectID, + ProjectIDSource: "config", + TeamID: tid, + TeamIDSource: tidSrc, + Token: tok, + TokenSource: tokSrc, }) } @@ -430,11 +525,15 @@ func (cfg *AppConfig) ResolveGitHubTargets(selectName string) ([]GitHubTarget, e if selectName != "" { return nil, fmt.Errorf("%s", i18n.T(i18n.MsgGitHubReposNotDefined)) } - // 後方互換: 従来の単一解決 + // 後方互換: 従来の単一解決(取得元も付与) + tok, tokSrc := cfg.ResolveGitHubTokenWithSource() + repo, repoSrc := cfg.ResolveGitHubRepoWithSource() return []GitHubTarget{ { - Repo: cfg.ResolveGitHubRepo(), - Token: cfg.ResolveGitHubToken(), + Repo: repo, + RepoSource: repoSrc, + Token: tok, + TokenSource: tokSrc, }, }, nil } @@ -451,10 +550,13 @@ func (cfg *AppConfig) ResolveGitHubTargets(selectName string) ([]GitHubTarget, e if selectName != "" && r.Name != selectName { continue } + tok, tokSrc := cfg.resolveGitHubTokenWithSource(r.Token) targets = append(targets, GitHubTarget{ - Name: r.Name, - Repo: r.Repo, - Token: cfg.resolveGitHubToken(r.Token), + Name: r.Name, + Repo: r.Repo, + RepoSource: "config", + Token: tok, + TokenSource: tokSrc, }) } diff --git a/internal/config/appconfig_test.go b/internal/config/appconfig_test.go index 3729e3c..46a90f2 100644 --- a/internal/config/appconfig_test.go +++ b/internal/config/appconfig_test.go @@ -1154,3 +1154,239 @@ func TestResolveGitHubTargets_EmptyRepo_SelectName_Error(t *testing.T) { t.Errorf("エラーメッセージに repo が含まれることを期待: %v", err) } } + +// --- 取得元(source)付きリゾルバのテスト --- + +func TestResolveVercelTokenWithSource_Env(t *testing.T) { + t.Setenv("VERCEL_TOKEN", "env-tok") + cfg := &config.AppConfig{} + cfg.Vercel.Token = "cfg-tok" + val, src := cfg.ResolveVercelTokenWithSource() + if val != "env-tok" { + t.Errorf("val = %q, want env-tok", val) + } + if src != "env" { + t.Errorf("src = %q, want env", src) + } +} + +func TestResolveVercelTokenWithSource_Config(t *testing.T) { + t.Setenv("VERCEL_TOKEN", "") + cfg := &config.AppConfig{} + cfg.Vercel.Token = "cfg-tok" + val, src := cfg.ResolveVercelTokenWithSource() + if val != "cfg-tok" { + t.Errorf("val = %q, want cfg-tok", val) + } + if src != "config" { + t.Errorf("src = %q, want config", src) + } +} + +func TestResolveVercelTokenWithSource_Unset(t *testing.T) { + t.Setenv("VERCEL_TOKEN", "") + cfg := &config.AppConfig{} + val, src := cfg.ResolveVercelTokenWithSource() + if val != "" { + t.Errorf("val = %q, want empty", val) + } + if src != "" { + t.Errorf("src = %q, want empty", src) + } +} + +func TestResolveVercelProjectIDWithSource_Env(t *testing.T) { + t.Setenv("VERCEL_PROJECT_ID", "env-pid") + cfg := &config.AppConfig{} + cfg.Vercel.ProjectID = "cfg-pid" + val, src := cfg.ResolveVercelProjectIDWithSource() + if val != "env-pid" { + t.Errorf("val = %q, want env-pid", val) + } + if src != "env" { + t.Errorf("src = %q, want env", src) + } +} + +func TestResolveVercelProjectIDWithSource_Config(t *testing.T) { + t.Setenv("VERCEL_PROJECT_ID", "") + cfg := &config.AppConfig{} + cfg.Vercel.ProjectID = "cfg-pid" + val, src := cfg.ResolveVercelProjectIDWithSource() + if val != "cfg-pid" { + t.Errorf("val = %q, want cfg-pid", val) + } + if src != "config" { + t.Errorf("src = %q, want config", src) + } +} + +func TestResolveVercelTeamIDWithSource_Env(t *testing.T) { + t.Setenv("VERCEL_TEAM_ID", "env-team") + cfg := &config.AppConfig{} + cfg.Vercel.TeamID = "cfg-team" + val, src := cfg.ResolveVercelTeamIDWithSource() + if val != "env-team" { + t.Errorf("val = %q, want env-team", val) + } + if src != "env" { + t.Errorf("src = %q, want env", src) + } +} + +func TestResolveGitHubTokenWithSource_Env(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "env-gh-tok") + cfg := &config.AppConfig{} + cfg.GitHub.Token = "cfg-gh-tok" + val, src := cfg.ResolveGitHubTokenWithSource() + if val != "env-gh-tok" { + t.Errorf("val = %q, want env-gh-tok", val) + } + if src != "env" { + t.Errorf("src = %q, want env", src) + } +} + +func TestResolveGitHubTokenWithSource_Config(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "") + cfg := &config.AppConfig{} + cfg.GitHub.Token = "cfg-gh-tok" + val, src := cfg.ResolveGitHubTokenWithSource() + if val != "cfg-gh-tok" { + t.Errorf("val = %q, want cfg-gh-tok", val) + } + if src != "config" { + t.Errorf("src = %q, want config", src) + } +} + +func TestResolveGitHubRepoWithSource_Env(t *testing.T) { + t.Setenv("GITHUB_REPO", "env/repo") + cfg := &config.AppConfig{} + cfg.GitHub.Repo = "cfg/repo" + val, src := cfg.ResolveGitHubRepoWithSource() + if val != "env/repo" { + t.Errorf("val = %q, want env/repo", val) + } + if src != "env" { + t.Errorf("src = %q, want env", src) + } +} + +func TestResolveGitHubRepoWithSource_Config(t *testing.T) { + t.Setenv("GITHUB_REPO", "") + cfg := &config.AppConfig{} + cfg.GitHub.Repo = "cfg/repo" + val, src := cfg.ResolveGitHubRepoWithSource() + if val != "cfg/repo" { + t.Errorf("val = %q, want cfg/repo", val) + } + if src != "config" { + t.Errorf("src = %q, want config", src) + } +} + +func TestResolveVercelTargets_SourceFields_SingleTarget(t *testing.T) { + // 単一ターゲット(projects 未定義)の source フィールドが設定されることを確認する + t.Setenv("VERCEL_TOKEN", "env-tok") + t.Setenv("VERCEL_PROJECT_ID", "env-pid") + t.Setenv("VERCEL_TEAM_ID", "") + cfg := &config.AppConfig{} + cfg.Vercel.TeamID = "cfg-team" + targets, err := cfg.ResolveVercelTargets("") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if len(targets) != 1 { + t.Fatalf("targets len = %d, want 1", len(targets)) + } + if targets[0].TokenSource != "env" { + t.Errorf("TokenSource = %q, want env", targets[0].TokenSource) + } + if targets[0].ProjectIDSource != "env" { + t.Errorf("ProjectIDSource = %q, want env", targets[0].ProjectIDSource) + } + if targets[0].TeamIDSource != "config" { + t.Errorf("TeamIDSource = %q, want config", targets[0].TeamIDSource) + } +} + +func TestResolveVercelTargets_SourceFields_MultiTarget(t *testing.T) { + // 複数ターゲット(projects 定義あり)の source フィールドが設定されることを確認する + t.Setenv("VERCEL_TOKEN", "env-tok") + t.Setenv("VERCEL_PROJECT_ID", "") + t.Setenv("VERCEL_TEAM_ID", "") + cfg := &config.AppConfig{} + cfg.Vercel.Projects = []config.VercelProjectConf{ + {Name: "app-a", ProjectID: "pid-a"}, // per-target token なし → env fallback + {Name: "app-b", ProjectID: "pid-b", Token: "per-b-tok"}, // per-target token あり + } + targets, err := cfg.ResolveVercelTargets("") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if len(targets) != 2 { + t.Fatalf("targets len = %d, want 2", len(targets)) + } + // app-a: VERCEL_TOKEN (env) からフォールバック + if targets[0].TokenSource != "env" { + t.Errorf("targets[0].TokenSource = %q, want env", targets[0].TokenSource) + } + if targets[0].ProjectIDSource != "config" { + t.Errorf("targets[0].ProjectIDSource = %q, want config", targets[0].ProjectIDSource) + } + // app-b: per-target token は config 由来 + if targets[1].TokenSource != "config" { + t.Errorf("targets[1].TokenSource = %q, want config", targets[1].TokenSource) + } +} + +func TestResolveGitHubTargets_SourceFields_SingleTarget(t *testing.T) { + // 単一ターゲット(repos 未定義)の source フィールドが設定されることを確認する + t.Setenv("GITHUB_TOKEN", "env-gh-tok") + t.Setenv("GITHUB_REPO", "") + cfg := &config.AppConfig{} + cfg.GitHub.Repo = "cfg/repo" + targets, err := cfg.ResolveGitHubTargets("") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if len(targets) != 1 { + t.Fatalf("targets len = %d, want 1", len(targets)) + } + if targets[0].TokenSource != "env" { + t.Errorf("TokenSource = %q, want env", targets[0].TokenSource) + } + if targets[0].RepoSource != "config" { + t.Errorf("RepoSource = %q, want config", targets[0].RepoSource) + } +} + +func TestResolveGitHubTargets_SourceFields_MultiTarget(t *testing.T) { + // 複数ターゲット(repos 定義あり)の source フィールドが設定されることを確認する + t.Setenv("GITHUB_TOKEN", "env-gh-tok") + t.Setenv("GITHUB_REPO", "") + cfg := &config.AppConfig{} + cfg.GitHub.Repos = []config.GitHubRepoConf{ + {Name: "frontend", Repo: "org/frontend"}, + {Name: "backend", Repo: "org/backend", Token: "per-backend-tok"}, + } + targets, err := cfg.ResolveGitHubTargets("") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if len(targets) != 2 { + t.Fatalf("targets len = %d, want 2", len(targets)) + } + // frontend: GITHUB_TOKEN (env) からフォールバック + if targets[0].TokenSource != "env" { + t.Errorf("targets[0].TokenSource = %q, want env", targets[0].TokenSource) + } + if targets[0].RepoSource != "config" { + t.Errorf("targets[0].RepoSource = %q, want config", targets[0].RepoSource) + } + // backend: per-target token は config 由来 + if targets[1].TokenSource != "config" { + t.Errorf("targets[1].TokenSource = %q, want config", targets[1].TokenSource) + } +} diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 53a7710..ea4cb1d 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -21,8 +21,9 @@ var enCatalog = map[MsgKey]string{ MsgUsage: `env-sync - sync environment variables declared in a definition file to Vercel or GitHub Actions Subcommands: - init generate env-sync.yaml template from .env - setup interactively generate an auth config file (.env-sync.config.yaml / ~/.config/env-sync/config.yaml) + init generate env-sync.yaml template from .env + setup interactively generate an auth config file (.env-sync.config.yaml / ~/.config/env-sync/config.yaml) + validate verify token / projectId / repo and check API reachability (read-only, no writes) Usage: VERCEL_TOKEN=xxxxx env-sync [options] @@ -275,6 +276,32 @@ YAML schema (definition file env-sync.yaml): MsgGCPSecretLabelUpdateFail: "failed to update Secret labels: %s", MsgGCPSecretVersionAddFail: "failed to add Secret version: %s", + // ----- Validate サブコマンド ----- + MsgValidateHeader: "=== validate: %s ===\n", + MsgValidateProviderUnsupported: " [skip] %s: validate not supported\n", + MsgValidateSourceEnv: "env var", + MsgValidateSourceConfig: "config file", + MsgValidateSourceProjectJSON: ".vercel/project.json", + MsgValidateSourceGitRemote: "git remote", + MsgValidateSourceUnset: "(unset)", + MsgValidateTokenMasked: "[set] (source: %s)", + MsgValidateTokenUnset: "[unset]", + MsgValidateHTTPStatus: "HTTP %d", + MsgValidateOK: "OK", + MsgValidateTokenUnsetSkip: " token is not set, skipping API check\n", + MsgValidateProjectIDUnsetSkip: " projectId is not set, skipping API check\n", + MsgValidateRepoUnresolvableSkip: " repo could not be resolved, skipping API check\n", + MsgValidateVercelCause404: " Possible cause: teamId not set, or projectId mismatch\n", + MsgValidateVercelCause401: " Possible cause: token is invalid\n", + MsgValidateVercelCause403: " Possible cause: token lacks required scope\n", + MsgValidateGitHubCause404: " Possible cause: repo does not exist or token lacks access to private repo\n", + MsgValidateGitHubCause401: " Possible cause: token is invalid\n", + MsgValidateGitHubCause403: " Possible cause: token lacks required scope or rate limit exceeded\n", + MsgValidateResult: "validate: success %d / failed %d\n", + MsgValidateVercelProjectID: " projectId : %s (source: %s)\n", + MsgValidateVercelTeamID: " teamId : %s (source: %s)\n", + MsgValidateGitHubRepo: " repo : %s (source: %s)\n", + // ----- Sync / Entry 解決 ----- MsgDefaultsProviderInvalid: "defaults.provider: invalid provider value %q (must be one of: %s)", MsgDefaultsProviderEmpty: "defaults.provider: empty array specified (must be one of: %s)", diff --git a/internal/i18n/catalog_ja.go b/internal/i18n/catalog_ja.go index 97447e6..b890fa1 100644 --- a/internal/i18n/catalog_ja.go +++ b/internal/i18n/catalog_ja.go @@ -21,8 +21,9 @@ var jaCatalog = map[MsgKey]string{ MsgUsage: `env-sync - 定義ファイルで宣言した環境変数を Vercel または GitHub Actions へ一括登録(同期)する サブコマンド: - init .env から env-sync.yaml の雛形を生成する - setup 認証情報 config ファイル(.env-sync.config.yaml / ~/.config/env-sync/config.yaml)を対話生成する + init .env から env-sync.yaml の雛形を生成する + setup 認証情報 config ファイル(.env-sync.config.yaml / ~/.config/env-sync/config.yaml)を対話生成する + validate token / projectId / repo の設定確認と API 到達確認(読み取り専用、書き込みなし) 使い方: VERCEL_TOKEN=xxxxx env-sync [オプション] @@ -275,6 +276,32 @@ YAML スキーマ(定義ファイル env-sync.yaml): MsgGCPSecretLabelUpdateFail: "Secret のラベル更新に失敗: %s", MsgGCPSecretVersionAddFail: "Secret バージョンの追加に失敗: %s", + // ----- Validate サブコマンド ----- + MsgValidateHeader: "=== validate: %s ===\n", + MsgValidateProviderUnsupported: " [スキップ] %s: validate 未対応\n", + MsgValidateSourceEnv: "環境変数", + MsgValidateSourceConfig: "config ファイル", + MsgValidateSourceProjectJSON: ".vercel/project.json", + MsgValidateSourceGitRemote: "git remote", + MsgValidateSourceUnset: "(未設定)", + MsgValidateTokenMasked: "[設定済み] (取得元: %s)", + MsgValidateTokenUnset: "[未設定]", + MsgValidateHTTPStatus: "HTTP %d", + MsgValidateOK: "OK", + MsgValidateTokenUnsetSkip: " token が未設定のため API 確認をスキップします\n", + MsgValidateProjectIDUnsetSkip: " projectId が未設定のため API 確認をスキップします\n", + MsgValidateRepoUnresolvableSkip: " repo を解決できなかったため API 確認をスキップします\n", + MsgValidateVercelCause404: " 推定原因: teamId 未設定、または projectId が一致しない\n", + MsgValidateVercelCause401: " 推定原因: token が無効\n", + MsgValidateVercelCause403: " 推定原因: token のスコープが不足\n", + MsgValidateGitHubCause404: " 推定原因: リポジトリが存在しない、または private リポジトリへのアクセス不可\n", + MsgValidateGitHubCause401: " 推定原因: token が無効\n", + MsgValidateGitHubCause403: " 推定原因: token のスコープ不足または rate limit\n", + MsgValidateResult: "validate: 成功 %d / 失敗 %d\n", + MsgValidateVercelProjectID: " projectId : %s (取得元: %s)\n", + MsgValidateVercelTeamID: " teamId : %s (取得元: %s)\n", + MsgValidateGitHubRepo: " repo : %s (取得元: %s)\n", + // ----- Sync / Entry 解決 ----- MsgDefaultsProviderInvalid: "defaults.provider: 不正な provider 値 %q(%s のいずれかを指定してください)", MsgDefaultsProviderEmpty: "defaults.provider に空配列が指定されています(%s のいずれかを指定してください)", diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index af92c84..f593abb 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -283,6 +283,56 @@ const ( // MsgGCPSecretVersionAddFail は Secret バージョン追加失敗(書式: エラー)。 MsgGCPSecretVersionAddFail MsgKey = "gcp.secret_version_add_fail" + // ----- Validate サブコマンド ----- + + // MsgValidateHeader は validate のターゲットヘッダ(書式: ターゲットラベル = name / projectId / owner-repo など)。 + MsgValidateHeader MsgKey = "validate.header" + // MsgValidateProviderUnsupported は Validator 未実装 provider のスキップメッセージ(書式: プロバイダー名)。 + MsgValidateProviderUnsupported MsgKey = "validate.provider_unsupported" + // MsgValidateSourceEnv は取得元が環境変数であることを示すラベル。 + MsgValidateSourceEnv MsgKey = "validate.source_env" + // MsgValidateSourceConfig は取得元が config ファイルであることを示すラベル。 + MsgValidateSourceConfig MsgKey = "validate.source_config" + // MsgValidateSourceProjectJSON は取得元が .vercel/project.json であることを示すラベル。 + MsgValidateSourceProjectJSON MsgKey = "validate.source_project_json" + // MsgValidateSourceGitRemote は取得元が git remote であることを示すラベル。 + MsgValidateSourceGitRemote MsgKey = "validate.source_git_remote" + // MsgValidateSourceUnset は値が未設定であることを示すラベル。 + MsgValidateSourceUnset MsgKey = "validate.source_unset" + // MsgValidateTokenMasked はトークンがマスクされていることを示すラベル(書式: 取得元)。 + MsgValidateTokenMasked MsgKey = "validate.token_masked" + // MsgValidateTokenUnset はトークン未設定のラベル。 + MsgValidateTokenUnset MsgKey = "validate.token_unset" + // MsgValidateHTTPStatus は API 到達確認のステータス表示(書式: ステータスコード)。 + MsgValidateHTTPStatus MsgKey = "validate.http_status" + // MsgValidateOK は到達確認成功のラベル。 + MsgValidateOK MsgKey = "validate.ok" + // MsgValidateTokenUnsetSkip はトークン未設定のため API 確認をスキップするメッセージ。 + MsgValidateTokenUnsetSkip MsgKey = "validate.token_unset_skip" + // MsgValidateProjectIDUnsetSkip は projectId 未設定のため API 確認をスキップするメッセージ(Vercel)。 + MsgValidateProjectIDUnsetSkip MsgKey = "validate.project_id_unset_skip" + // MsgValidateRepoUnresolvableSkip は repo 解決失敗のため API 確認をスキップするメッセージ(GitHub)。 + MsgValidateRepoUnresolvableSkip MsgKey = "validate.repo_unresolvable_skip" + // MsgValidateVercelCause404 は Vercel 404 の推定原因。 + MsgValidateVercelCause404 MsgKey = "validate.vercel_cause_404" + // MsgValidateVercelCause401 は Vercel 401 の推定原因。 + MsgValidateVercelCause401 MsgKey = "validate.vercel_cause_401" + // MsgValidateVercelCause403 は Vercel 403 の推定原因。 + MsgValidateVercelCause403 MsgKey = "validate.vercel_cause_403" + // MsgValidateGitHubCause404 は GitHub 404 の推定原因。 + MsgValidateGitHubCause404 MsgKey = "validate.github_cause_404" + // MsgValidateGitHubCause401 は GitHub 401 の推定原因。 + MsgValidateGitHubCause401 MsgKey = "validate.github_cause_401" + // MsgValidateGitHubCause403 は GitHub 403 の推定原因。 + MsgValidateGitHubCause403 MsgKey = "validate.github_cause_403" + // MsgValidateResult は検証結果サマリ(書式: 成功数, 失敗数)。 + MsgValidateResult MsgKey = "validate.result" + // MsgValidateVercelProjectID は Vercel プロジェクト ID の表示(書式: ID, 取得元)。 + MsgValidateVercelProjectID MsgKey = "validate.vercel_project_id" + // MsgValidateVercelTeamID は Vercel チーム ID の表示(書式: ID or 未設定ラベル, 取得元)。 + MsgValidateVercelTeamID MsgKey = "validate.vercel_team_id" + // MsgValidateGitHubRepo は GitHub リポジトリの表示(書式: owner/repo, 取得元)。 + MsgValidateGitHubRepo MsgKey = "validate.github_repo" // ----- Sync / Entry 解決 ----- // MsgDefaultsProviderInvalid は defaults.provider の不正値エラー(書式: 値, 候補一覧)。 diff --git a/internal/provider/github/github_validate.go b/internal/provider/github/github_validate.go new file mode 100644 index 0000000..93a3065 --- /dev/null +++ b/internal/provider/github/github_validate.go @@ -0,0 +1,145 @@ +package github + +import ( + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/ptyhard/env-sync/internal/config" + "github.com/ptyhard/env-sync/internal/i18n" + "github.com/ptyhard/env-sync/internal/provider" +) + +// githubValidateOsExit はテストで差し替え可能な終了関数。 +var githubValidateOsExit = os.Exit + +// githubStdoutWriter はテストで差し替え可能な標準出力先。 +// Validate の全出力はこのライタへ書く。 +var githubStdoutWriter io.Writer = os.Stdout + +// Validate は GitHub ターゲットの認証・到達確認を読み取り専用で行う。 +// GET /repos/{owner}/{repo} のみを使用し、環境変数の登録・変更は行わない。 +func (g *githubProvider) Validate(opts provider.Options, entries []provider.Entry) error { + appCfg, err := config.LoadAppConfig() + if err != nil { + return err + } + + targets, err := appCfg.ResolveGitHubTargets(opts.GitHubRepo) + if err != nil { + return err + } + + client := &http.Client{Timeout: httpTimeout} + okCount, ngCount := 0, 0 + + for _, tgt := range targets { + // owner/repo を解決 + ownerStr, repoStr, resolveErr := resolveOwnerRepo(tgt, appCfg) + + // リポジトリの取得元を決定(git remote フォールバックが使われた場合) + repoSrc := tgt.RepoSource + if tgt.Repo == "" && resolveErr == nil { + repoSrc = "git_remote" + } + + targetLabel := tgt.Name + if targetLabel == "" { + if resolveErr == nil { + targetLabel = ownerStr + "/" + repoStr + } else { + targetLabel = i18n.T(i18n.MsgValidateSourceUnset) + } + } + fmt.Fprint(githubStdoutWriter, i18n.T(i18n.MsgValidateHeader, targetLabel)) + + // token 表示(値は出さずマスク) + if tgt.Token == "" { + fmt.Fprintf(githubStdoutWriter, " token : %s\n", i18n.T(i18n.MsgValidateTokenUnset)) + } else { + fmt.Fprintf(githubStdoutWriter, " token : %s\n", i18n.T(i18n.MsgValidateTokenMasked, githubSourceLabel(tgt.TokenSource))) + } + + // repo 表示(解決失敗時も MsgValidateGitHubRepo テンプレートで統一し二重カッコを回避) + if resolveErr != nil { + fmt.Fprint(githubStdoutWriter, i18n.T(i18n.MsgValidateGitHubRepo, i18n.T(i18n.MsgValidateSourceUnset), githubSourceLabel(repoSrc))) + fmt.Fprint(githubStdoutWriter, i18n.T(i18n.MsgValidateRepoUnresolvableSkip)) + ngCount++ + continue + } + fmt.Fprint(githubStdoutWriter, i18n.T(i18n.MsgValidateGitHubRepo, ownerStr+"/"+repoStr, githubSourceLabel(repoSrc))) + + // token が未設定なら API 確認をスキップ + if tgt.Token == "" { + fmt.Fprint(githubStdoutWriter, i18n.T(i18n.MsgValidateTokenUnsetSkip)) + ngCount++ + continue + } + + status, checkErr := githubCheckAccess(client, tgt.Token, ownerStr, repoStr) + if checkErr != nil { + fmt.Fprintf(githubStdoutWriter, " API check : error: %s\n", checkErr) + ngCount++ + continue + } + + if status >= 200 && status < 300 { + fmt.Fprintf(githubStdoutWriter, " API check : %s %s\n", i18n.T(i18n.MsgValidateHTTPStatus, status), i18n.T(i18n.MsgValidateOK)) + okCount++ + } else { + fmt.Fprintf(githubStdoutWriter, " API check : %s\n", i18n.T(i18n.MsgValidateHTTPStatus, status)) + switch status { + case 404: + fmt.Fprint(githubStdoutWriter, i18n.T(i18n.MsgValidateGitHubCause404)) + case 401: + fmt.Fprint(githubStdoutWriter, i18n.T(i18n.MsgValidateGitHubCause401)) + case 403: + fmt.Fprint(githubStdoutWriter, i18n.T(i18n.MsgValidateGitHubCause403)) + } + ngCount++ + } + } + + fmt.Fprint(githubStdoutWriter, i18n.T(i18n.MsgValidateResult, okCount, ngCount)) + if ngCount > 0 { + githubValidateOsExit(1) + } + return nil +} + +// githubCheckAccess は GET /repos/{owner}/{repo} で GitHub API への到達確認を行う。 +// 成功・失敗に関わらず (statusCode, nil) を返す。HTTP 以外のエラーは err に返す。 +func githubCheckAccess(client *http.Client, token, owner, repo string) (statusCode int, err error) { + apiURL := fmt.Sprintf("%s/repos/%s/%s", + githubAPIBase, url.PathEscape(owner), url.PathEscape(repo)) + + req, err := http.NewRequest(http.MethodGet, apiURL, nil) + if err != nil { + return 0, err + } + setGitHubHeaders(req, token) + + res, err := client.Do(req) + if err != nil { + return 0, err + } + defer res.Body.Close() + io.Copy(io.Discard, res.Body) //nolint:errcheck // drain で接続を再利用可能にする + return res.StatusCode, nil +} + +// githubSourceLabel は取得元識別子をユーザー表示ラベルに変換する。 +func githubSourceLabel(src string) string { + switch src { + case "env": + return i18n.T(i18n.MsgValidateSourceEnv) + case "config": + return i18n.T(i18n.MsgValidateSourceConfig) + case "git_remote": + return i18n.T(i18n.MsgValidateSourceGitRemote) + default: + return i18n.T(i18n.MsgValidateSourceUnset) + } +} diff --git a/internal/provider/github/github_validate_test.go b/internal/provider/github/github_validate_test.go new file mode 100644 index 0000000..d5c2638 --- /dev/null +++ b/internal/provider/github/github_validate_test.go @@ -0,0 +1,369 @@ +package github + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/ptyhard/env-sync/internal/i18n" + "github.com/ptyhard/env-sync/internal/provider" +) + +// captureGitHubOsExit はテスト中に githubValidateOsExit を差し替えてキャプチャする。 +func captureGitHubOsExit(t *testing.T) *int { + t.Helper() + code := -1 + orig := githubValidateOsExit + githubValidateOsExit = func(c int) { code = c } + t.Cleanup(func() { githubValidateOsExit = orig }) + return &code +} + +// TestGitHubCheckAccess_200 は githubCheckAccess が 200 を返すことを確認する。 +func TestGitHubCheckAccess_200(t *testing.T) { + var methods []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1,"full_name":"owner/repo"}`)) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + client := &http.Client{} + status, err := githubCheckAccess(client, "tok", "owner", "repo") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if status != http.StatusOK { + t.Errorf("status = %d, want 200", status) + } + // GET のみ使用されることを確認 + for _, m := range methods { + if m != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", m) + } + } +} + +// TestGitHubCheckAccess_404 は githubCheckAccess が 404 を返すことを確認する。 +func TestGitHubCheckAccess_404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + client := &http.Client{} + status, err := githubCheckAccess(client, "tok", "owner", "repo") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404", status) + } +} + +// TestGitHubCheckAccess_401 は githubCheckAccess が 401 を返すことを確認する。 +func TestGitHubCheckAccess_401(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + client := &http.Client{} + status, err := githubCheckAccess(client, "bad-tok", "owner", "repo") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if status != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", status) + } +} + +// TestGitHubCheckAccess_403 は githubCheckAccess が 403 を返すことを確認する。 +func TestGitHubCheckAccess_403(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + client := &http.Client{} + status, err := githubCheckAccess(client, "tok", "owner", "repo") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if status != http.StatusForbidden { + t.Errorf("status = %d, want 403", status) + } +} + +// TestGitHubValidate_GETOnly は Validate が GET のみを発行することを確認する。 +func TestGitHubValidate_GETOnly(t *testing.T) { + var methods []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1,"full_name":"owner/repo"}`)) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + exitCode := captureGitHubOsExit(t) + + t.Setenv("GITHUB_TOKEN", "test-tok") + t.Setenv("GITHUB_REPO", "owner/repo") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + g := &githubProvider{} + opts := provider.Options{ + Env: ".env", + Def: "env-sync.yaml", + } + _ = g.Validate(opts, nil) + + // GET のみ使用されることを確認 + for _, m := range methods { + if m != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", m) + } + } + // 200 で成功した場合 exit は呼ばれない + if *exitCode != -1 { + t.Errorf("成功時は exit 不要, exitCode = %d", *exitCode) + } +} + +// TestGitHubValidate_404_ExitsOne は 404 のとき exit(1) が呼ばれることを確認する。 +func TestGitHubValidate_404_ExitsOne(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + exitCode := captureGitHubOsExit(t) + + t.Setenv("GITHUB_TOKEN", "test-tok") + t.Setenv("GITHUB_REPO", "owner/repo") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + g := &githubProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = g.Validate(opts, nil) + + if *exitCode != 1 { + t.Errorf("404 時は exit(1) を期待, exitCode = %d", *exitCode) + } +} + +// TestGitHubValidate_401_ExitsOne は 401 のとき exit(1) が呼ばれることを確認する。 +func TestGitHubValidate_401_ExitsOne(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + exitCode := captureGitHubOsExit(t) + + t.Setenv("GITHUB_TOKEN", "bad-tok") + t.Setenv("GITHUB_REPO", "owner/repo") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + g := &githubProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = g.Validate(opts, nil) + + if *exitCode != 1 { + t.Errorf("401 時は exit(1) を期待, exitCode = %d", *exitCode) + } +} + +// TestGitHubValidate_403_ExitsOne は 403 のとき exit(1) が呼ばれることを確認する。 +func TestGitHubValidate_403_ExitsOne(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + exitCode := captureGitHubOsExit(t) + + t.Setenv("GITHUB_TOKEN", "test-tok") + t.Setenv("GITHUB_REPO", "owner/repo") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + g := &githubProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = g.Validate(opts, nil) + + if *exitCode != 1 { + t.Errorf("403 時は exit(1) を期待, exitCode = %d", *exitCode) + } +} + +// TestGitHubCheckAccess_RequestPathAndHeaders は githubCheckAccess が +// GET /repos/{owner}/{repo} に認証・API バージョンヘッダを付けて送ることを確認する。 +func TestGitHubCheckAccess_RequestPathAndHeaders(t *testing.T) { + var gotPath, gotAuth, gotAccept, gotVersion string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotAccept = r.Header.Get("Accept") + gotVersion = r.Header.Get("X-GitHub-Api-Version") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1,"full_name":"owner/repo"}`)) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + client := &http.Client{} + if _, err := githubCheckAccess(client, "secret-tok", "owner", "repo"); err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + + if gotPath != "/repos/owner/repo" { + t.Errorf("リクエストパス = %q, want /repos/owner/repo", gotPath) + } + if gotAuth != "Bearer secret-tok" { + t.Errorf("Authorization ヘッダ = %q, want Bearer secret-tok", gotAuth) + } + if gotAccept != "application/vnd.github+json" { + t.Errorf("Accept ヘッダ = %q, want application/vnd.github+json", gotAccept) + } + if gotVersion != "2022-11-28" { + t.Errorf("X-GitHub-Api-Version ヘッダ = %q, want 2022-11-28", gotVersion) + } +} + +// TestGitHubCheckAccess_NetworkError は接続不可のとき err が返ることを確認する。 +func TestGitHubCheckAccess_NetworkError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + base := srv.URL + srv.Close() // 即座に閉じて接続不可にする + withGitHubAPIBase(t, base) + + client := &http.Client{} + _, err := githubCheckAccess(client, "tok", "owner", "repo") + if err == nil { + t.Error("接続不可のとき err を期待したが nil") + } +} + +// TestGitHubValidate_RepoUnresolved_SkipsAPI は repo を解決できない場合に +// API 確認をスキップして exit(1) になることを確認する。 +// GITHUB_REPO 未設定・config なし・git remote も取得できない一時ディレクトリで実行する。 +func TestGitHubValidate_RepoUnresolved_SkipsAPI(t *testing.T) { + apiCalled := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + exitCode := captureGitHubOsExit(t) + + t.Setenv("GITHUB_TOKEN", "test-tok") + t.Setenv("GITHUB_REPO", "") // repo 未設定 + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + // git remote が取得できない一時ディレクトリへ移動(リポジトリ外) + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + g := &githubProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = g.Validate(opts, nil) + + if apiCalled { + t.Error("repo 未解決のとき API が呼ばれてはいけない") + } + if *exitCode != 1 { + t.Errorf("repo 未解決時は exit(1) を期待, exitCode = %d", *exitCode) + } +} + +// TestGitHubSourceLabel は取得元識別子が i18n ラベルに変換されることを確認する。 +func TestGitHubSourceLabel(t *testing.T) { + cases := []struct { + src string + want i18n.MsgKey + }{ + {"env", i18n.MsgValidateSourceEnv}, + {"config", i18n.MsgValidateSourceConfig}, + {"git_remote", i18n.MsgValidateSourceGitRemote}, + {"", i18n.MsgValidateSourceUnset}, + {"unknown-source", i18n.MsgValidateSourceUnset}, + } + for _, c := range cases { + got := githubSourceLabel(c.src) + want := i18n.T(c.want) + if got != want { + t.Errorf("githubSourceLabel(%q) = %q, want %q", c.src, got, want) + } + } +} + +// TestGitHubValidate_TokenUnset_SkipsAPI は token 未設定のとき API 確認がスキップされることを確認する。 +func TestGitHubValidate_TokenUnset_SkipsAPI(t *testing.T) { + apiCalled := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + withGitHubAPIBase(t, srv.URL) + + exitCode := captureGitHubOsExit(t) + + t.Setenv("GITHUB_TOKEN", "") // token 未設定 + t.Setenv("GITHUB_REPO", "owner/repo") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + g := &githubProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = g.Validate(opts, nil) + + if apiCalled { + t.Error("token 未設定のとき API が呼ばれてはいけない") + } + if *exitCode != 1 { + t.Errorf("token 未設定時は exit(1) を期待, exitCode = %d", *exitCode) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index ed37e36..a0da93e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -33,6 +33,12 @@ type Provider interface { Sync(opts Options, entries []Entry) error } +// Validator は読み取り専用で認証・ターゲット解決を検証できる provider が実装する任意インターフェース。 +// Validate は GET のみを使用し、環境変数の登録・変更を行わない。 +type Validator interface { + Validate(opts Options, entries []Entry) error +} + // providerRegistry は名前 → ファクトリ関数のマップ。 var providerRegistry = map[string]func() Provider{} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 544c90d..eb621a9 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -107,3 +107,30 @@ func (m *mockProvider) Name() string { return m.name } func (m *mockProvider) Sync(opts provider.Options, entries []provider.Entry) error { return m.syncFn(opts, entries) } + +// TestValidator_OptionalInterface は Validator インターフェースの型アサーションが機能することを確認する。 +// Validator は任意インターフェースなので、実装しない Provider は型アサーションが false になる。 +func TestValidator_OptionalInterface(t *testing.T) { + // Validate を実装しない mockProvider は Validator ではない + mock := &mockProvider{name: "mock", syncFn: func(_ provider.Options, _ []provider.Entry) error { return nil }} + _, ok := any(mock).(provider.Validator) + if ok { + t.Error("Validator 未実装の mockProvider が Validator として型アサーションされてはいけない") + } + + // Validate を実装した mockValidatorProvider は Validator になる + v := &mockValidatorProvider{} + _, ok = any(v).(provider.Validator) + if !ok { + t.Error("Validator を実装した mockValidatorProvider が provider.Validator として型アサーションできない") + } +} + +// mockValidatorProvider は Validate を実装したテスト専用 Provider。 +type mockValidatorProvider struct{} + +func (m *mockValidatorProvider) Name() string { return "mock-validator" } +func (m *mockValidatorProvider) Sync(_ provider.Options, _ []provider.Entry) error { return nil } +func (m *mockValidatorProvider) Validate(_ provider.Options, _ []provider.Entry) error { + return nil +} diff --git a/internal/provider/vercel/vercel.go b/internal/provider/vercel/vercel.go index e76158a..695875f 100644 --- a/internal/provider/vercel/vercel.go +++ b/internal/provider/vercel/vercel.go @@ -74,24 +74,11 @@ func (v *vercelProvider) Sync(opts provider.Options, entries []provider.Entry) e client := &http.Client{Timeout: httpTimeout} // ---- ProjectID の解決(単一ターゲット時のみ .vercel/project.json フォールバック) ---- + if _, err := applyProjectJSONFallback(targets); err != nil { + return err + } if len(targets) == 1 && targets[0].ProjectID == "" { - pjText, err := os.ReadFile(".vercel/project.json") - if err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%s", i18n.T(i18n.MsgVercelProjectJSONReadFail, err)) - } - if err == nil { - var pj projectJSON - if err := json.Unmarshal(pjText, &pj); err != nil { - return fmt.Errorf("%s", i18n.T(i18n.MsgVercelProjectJSONParseFail, err)) - } - targets[0].ProjectID = pj.ProjectID - if targets[0].TeamID == "" { - targets[0].TeamID = pj.OrgID - } - } - if targets[0].ProjectID == "" { - return fmt.Errorf("%s", i18n.T(i18n.MsgVercelProjectIDMissing)) - } + return fmt.Errorf("%s", i18n.T(i18n.MsgVercelProjectIDMissing)) } // ---- 各ターゲットに対して一覧表示と分類(dry-run も同様)---- // perTargetClassified はターゲット順に分類結果を保持し、確認・送信フェーズで再利用する。 @@ -470,6 +457,71 @@ type projectJSON struct { OrgID string `json:"orgId"` } +// applyProjectJSONFallback は単一ターゲット時の .vercel/project.json フォールバックを行う。 +// targets[0].ProjectID が空の場合に .vercel/project.json から取得を試みる。 +// 成功時は targets[0].ProjectID / TeamID / ProjectIDSource / TeamIDSource を更新し used=true を返す。 +// .vercel/project.json が存在しない場合は (false, nil) を返す(エラーではない)。 +func applyProjectJSONFallback(targets []config.VercelTarget) (usedProjectJSON bool, err error) { + if len(targets) != 1 || targets[0].ProjectID != "" { + return false, nil + } + pjText, readErr := os.ReadFile(".vercel/project.json") + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return false, fmt.Errorf("%s", i18n.T(i18n.MsgVercelProjectJSONReadFail, readErr)) + } + if readErr == nil { + var pj projectJSON + if err := json.Unmarshal(pjText, &pj); err != nil { + return false, fmt.Errorf("%s", i18n.T(i18n.MsgVercelProjectJSONParseFail, err)) + } + // projectId が実際に含まれている場合のみ ProjectIDSource を更新する。 + // project.json に projectId フィールドが無い場合は source を "project_json" にしない。 + if pj.ProjectID != "" { + targets[0].ProjectID = pj.ProjectID + targets[0].ProjectIDSource = "project_json" + } + if targets[0].TeamID == "" && pj.OrgID != "" { + targets[0].TeamID = pj.OrgID + targets[0].TeamIDSource = "project_json" + } + return true, nil + } + return false, nil +} + +// vercelCheckAccess は GET /v10/projects/{id}/env で Vercel API への到達確認を行う。 +// 成功・失敗に関わらず (statusCode, detail, nil) を返す。HTTP 以外のエラーは err に返す。 +func vercelCheckAccess(client *http.Client, token, projectID, teamID string) (statusCode int, detail string, err error) { + u, err := url.Parse(fmt.Sprintf("%s/v10/projects/%s/env", apiBase, url.PathEscape(projectID))) + if err != nil { + return 0, "", err + } + q := u.Query() + if teamID != "" { + q.Set("teamId", teamID) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return 0, "", err + } + req.Header.Set("Authorization", "Bearer "+token) + + res, err := client.Do(req) + if err != nil { + return 0, "", err + } + defer res.Body.Close() + // 2xx 以外の場合のみエラー詳細を読む。2xx 時はレスポンスボディが大きくなり得るためドレインのみ行う。 + if res.StatusCode >= 200 && res.StatusCode < 300 { + io.Copy(io.Discard, res.Body) //nolint:errcheck // drain で接続を再利用可能にする + return res.StatusCode, "", nil + } + d := parseErrorBody(res.Body) + return res.StatusCode, d, nil +} + // parseErrorBody は Vercel のエラーレスポンス本文からメッセージを取り出す。 func parseErrorBody(r io.Reader) string { data, err := io.ReadAll(r) diff --git a/internal/provider/vercel/vercel_validate.go b/internal/provider/vercel/vercel_validate.go new file mode 100644 index 0000000..9b48632 --- /dev/null +++ b/internal/provider/vercel/vercel_validate.go @@ -0,0 +1,135 @@ +// vercel_validate.go は Vercel provider の validate サブコマンド実装を提供する。 +// 読み取り専用(GET のみ)で認証・到達確認を行い、書き込みは行わない。 +package vercel + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/ptyhard/env-sync/internal/config" + "github.com/ptyhard/env-sync/internal/i18n" + "github.com/ptyhard/env-sync/internal/provider" +) + +// validateOsExit はテストで差し替え可能な終了関数。 +var validateOsExit = os.Exit + +// stdoutWriter はテストで差し替え可能な標準出力先。 +// Validate の全出力はこのライタへ書く。 +var stdoutWriter io.Writer = os.Stdout + +// Validate は Vercel ターゲットの認証・到達確認を読み取り専用で行う。 +// GET /v10/projects/{id}/env のみを使用し、環境変数の登録・変更は行わない。 +func (v *vercelProvider) Validate(opts provider.Options, _ []provider.Entry) error { + appCfg, err := config.LoadAppConfig() + if err != nil { + return err + } + + targets, err := appCfg.ResolveVercelTargets(opts.VercelProject) + if err != nil { + return err + } + + // 単一ターゲット時の .vercel/project.json フォールバック + if _, err := applyProjectJSONFallback(targets); err != nil { + return err + } + + client := &http.Client{Timeout: httpTimeout} + okCount, ngCount := 0, 0 + + for _, tgt := range targets { + // name と projectId が両方未設定の場合は "(未設定)" をフォールバックラベルとして使う + targetLabel := tgt.ProjectID + if tgt.Name != "" { + targetLabel = tgt.Name + } + if targetLabel == "" { + targetLabel = i18n.T(i18n.MsgValidateSourceUnset) + } + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateHeader, targetLabel)) + + // token 表示(値は出さずマスク) + if tgt.Token == "" { + fmt.Fprintf(stdoutWriter, " token : %s\n", i18n.T(i18n.MsgValidateTokenUnset)) + } else { + fmt.Fprintf(stdoutWriter, " token : %s\n", i18n.T(i18n.MsgValidateTokenMasked, vercelSourceLabel(tgt.TokenSource))) + } + + // projectId 表示(設定・未設定いずれも MsgValidateVercelProjectID テンプレートで統一) + pid := tgt.ProjectID + if pid == "" { + pid = i18n.T(i18n.MsgValidateSourceUnset) + } + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateVercelProjectID, pid, vercelSourceLabel(tgt.ProjectIDSource))) + + // teamId 表示 + if tgt.TeamID == "" { + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateVercelTeamID, i18n.T(i18n.MsgValidateSourceUnset), vercelSourceLabel(tgt.TeamIDSource))) + } else { + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateVercelTeamID, tgt.TeamID, vercelSourceLabel(tgt.TeamIDSource))) + } + + // token / projectId が未設定なら API 確認をスキップ(それぞれ個別にメッセージを出す) + if tgt.Token == "" { + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateTokenUnsetSkip)) + ngCount++ + continue + } + if tgt.ProjectID == "" { + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateProjectIDUnsetSkip)) + ngCount++ + continue + } + + status, _, checkErr := vercelCheckAccess(client, tgt.Token, tgt.ProjectID, tgt.TeamID) + if checkErr != nil { + fmt.Fprintf(stdoutWriter, " API check : error: %s\n", checkErr) + ngCount++ + continue + } + + if status >= 200 && status < 300 { + fmt.Fprintf(stdoutWriter, " API check : %s %s\n", + i18n.T(i18n.MsgValidateHTTPStatus, status), + i18n.T(i18n.MsgValidateOK)) + okCount++ + } else { + fmt.Fprintf(stdoutWriter, " API check : %s\n", i18n.T(i18n.MsgValidateHTTPStatus, status)) + switch status { + case 404: + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateVercelCause404)) + case 401: + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateVercelCause401)) + case 403: + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateVercelCause403)) + } + ngCount++ + } + } + + fmt.Fprint(stdoutWriter, i18n.T(i18n.MsgValidateResult, okCount, ngCount)) + if ngCount > 0 { + validateOsExit(1) + } + return nil +} + +// vercelSourceLabel は取得元識別子をユーザー表示ラベルに変換する。 +func vercelSourceLabel(src string) string { + switch src { + case "env": + return i18n.T(i18n.MsgValidateSourceEnv) + case "config": + return i18n.T(i18n.MsgValidateSourceConfig) + case "project_json": + return i18n.T(i18n.MsgValidateSourceProjectJSON) + case "git_remote": + return i18n.T(i18n.MsgValidateSourceGitRemote) + default: + return i18n.T(i18n.MsgValidateSourceUnset) + } +} diff --git a/internal/provider/vercel/vercel_validate_test.go b/internal/provider/vercel/vercel_validate_test.go new file mode 100644 index 0000000..f564dce --- /dev/null +++ b/internal/provider/vercel/vercel_validate_test.go @@ -0,0 +1,494 @@ +package vercel + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/ptyhard/env-sync/internal/config" + "github.com/ptyhard/env-sync/internal/i18n" + "github.com/ptyhard/env-sync/internal/provider" +) + +// withAPIBase はテスト中だけ apiBase をテストサーバに差し替える。 +func withAPIBase(t *testing.T, base string) { + t.Helper() + orig := apiBase + apiBase = base + t.Cleanup(func() { apiBase = orig }) +} + +// captureOsExit はテスト中に validateOsExit を差し替えてキャプチャする。 +func captureOsExit(t *testing.T) *int { + t.Helper() + code := -1 + orig := validateOsExit + validateOsExit = func(c int) { code = c } + t.Cleanup(func() { validateOsExit = orig }) + return &code +} + +// TestVercelCheckAccess_200 は vercelCheckAccess が 200 を返すことを確認する。 +func TestVercelCheckAccess_200(t *testing.T) { + var methods []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"envs":[]}`)) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + client := &http.Client{} + status, _, err := vercelCheckAccess(client, "tok", "pid", "") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if status != http.StatusOK { + t.Errorf("status = %d, want 200", status) + } + // GET のみ使用されることを確認 + for _, m := range methods { + if m != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", m) + } + } +} + +// TestVercelCheckAccess_404 は vercelCheckAccess が 404 を返すことを確認する。 +func TestVercelCheckAccess_404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GET のみ許可 + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"message":"Project not found."}}`)) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + client := &http.Client{} + status, detail, err := vercelCheckAccess(client, "tok", "pid", "team1") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if status != http.StatusNotFound { + t.Errorf("status = %d, want 404", status) + } + if detail == "" { + t.Error("detail は空でないことを期待(エラーメッセージが含まれる)") + } +} + +// TestVercelCheckAccess_401 は vercelCheckAccess が 401 を返すことを確認する。 +func TestVercelCheckAccess_401(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + client := &http.Client{} + status, _, err := vercelCheckAccess(client, "bad-tok", "pid", "") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if status != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", status) + } +} + +// TestVercelCheckAccess_403 は vercelCheckAccess が 403 を返すことを確認する。 +func TestVercelCheckAccess_403(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + client := &http.Client{} + status, _, err := vercelCheckAccess(client, "tok", "pid", "") + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if status != http.StatusForbidden { + t.Errorf("status = %d, want 403", status) + } +} + +// TestVercelValidate_GETOnly は Validate が GET のみを発行することを確認する。 +func TestVercelValidate_GETOnly(t *testing.T) { + var methods []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"envs":[]}`)) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + exitCode := captureOsExit(t) + + t.Setenv("VERCEL_TOKEN", "test-tok") + t.Setenv("VERCEL_PROJECT_ID", "test-pid") + t.Setenv("VERCEL_TEAM_ID", "") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + v := &vercelProvider{} + opts := provider.Options{ + Env: ".env", + Def: "env-sync.yaml", + } + _ = v.Validate(opts, nil) + + // GET のみ使用されることを確認 + for _, m := range methods { + if m != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", m) + } + } + // 200 で成功した場合 exit は呼ばれない + if *exitCode != -1 { + t.Errorf("成功時は exit 不要, exitCode = %d", *exitCode) + } +} + +// TestVercelValidate_404_ExitsOne は 404 のとき exit(1) が呼ばれることを確認する。 +func TestVercelValidate_404_ExitsOne(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"message":"Project not found."}}`)) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + exitCode := captureOsExit(t) + + t.Setenv("VERCEL_TOKEN", "test-tok") + t.Setenv("VERCEL_PROJECT_ID", "test-pid") + t.Setenv("VERCEL_TEAM_ID", "") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + v := &vercelProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = v.Validate(opts, nil) + + if *exitCode != 1 { + t.Errorf("404 時は exit(1) を期待, exitCode = %d", *exitCode) + } +} + +// TestVercelValidate_401_ExitsOne は 401 のとき exit(1) が呼ばれることを確認する。 +func TestVercelValidate_401_ExitsOne(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + exitCode := captureOsExit(t) + + t.Setenv("VERCEL_TOKEN", "test-tok") + t.Setenv("VERCEL_PROJECT_ID", "test-pid") + t.Setenv("VERCEL_TEAM_ID", "") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + v := &vercelProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = v.Validate(opts, nil) + + if *exitCode != 1 { + t.Errorf("401 時は exit(1) を期待, exitCode = %d", *exitCode) + } +} + +// TestVercelValidate_TokenUnset_SkipsAPI は token 未設定のとき API 確認がスキップされることを確認する。 +// API サーバが呼ばれないことを確認する。 +func TestVercelValidate_TokenUnset_SkipsAPI(t *testing.T) { + apiCalled := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + exitCode := captureOsExit(t) + + t.Setenv("VERCEL_TOKEN", "") // token 未設定 + t.Setenv("VERCEL_PROJECT_ID", "test-pid") + t.Setenv("VERCEL_TEAM_ID", "") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + v := &vercelProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = v.Validate(opts, nil) + + if apiCalled { + t.Error("token 未設定のとき API が呼ばれてはいけない") + } + // token 未設定は失敗扱い + if *exitCode != 1 { + t.Errorf("token 未設定時は exit(1) を期待, exitCode = %d", *exitCode) + } +} + +// TestVercelValidate_403_ExitsOne は 403 のとき exit(1) が呼ばれることを確認する。 +func TestVercelValidate_403_ExitsOne(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("GET 以外のメソッドが使用された: %s", r.Method) + } + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + exitCode := captureOsExit(t) + + t.Setenv("VERCEL_TOKEN", "test-tok") + t.Setenv("VERCEL_PROJECT_ID", "test-pid") + t.Setenv("VERCEL_TEAM_ID", "") + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + + v := &vercelProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = v.Validate(opts, nil) + + if *exitCode != 1 { + t.Errorf("403 時は exit(1) を期待, exitCode = %d", *exitCode) + } +} + +// TestVercelCheckAccess_RequestPathAndHeaders は vercelCheckAccess が +// GET /v10/projects/{id}/env に teamId クエリと Bearer トークンを付けて送ることを確認する。 +func TestVercelCheckAccess_RequestPathAndHeaders(t *testing.T) { + var gotPath, gotTeamID, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotTeamID = r.URL.Query().Get("teamId") + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"envs":[]}`)) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + client := &http.Client{} + if _, _, err := vercelCheckAccess(client, "secret-tok", "pid-123", "team-xyz"); err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + + if gotPath != "/v10/projects/pid-123/env" { + t.Errorf("リクエストパス = %q, want /v10/projects/pid-123/env", gotPath) + } + if gotTeamID != "team-xyz" { + t.Errorf("teamId クエリ = %q, want team-xyz", gotTeamID) + } + if gotAuth != "Bearer secret-tok" { + t.Errorf("Authorization ヘッダ = %q, want Bearer secret-tok", gotAuth) + } +} + +// TestVercelCheckAccess_NoTeamID_OmitsQuery は teamID が空のとき teamId クエリを付けないことを確認する。 +func TestVercelCheckAccess_NoTeamID_OmitsQuery(t *testing.T) { + var hasTeamID bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hasTeamID = r.URL.Query()["teamId"] + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + client := &http.Client{} + if _, _, err := vercelCheckAccess(client, "tok", "pid", ""); err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if hasTeamID { + t.Error("teamID が空のとき teamId クエリは付与されてはいけない") + } +} + +// TestVercelCheckAccess_NetworkError は接続不可のとき err が返ることを確認する。 +func TestVercelCheckAccess_NetworkError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + base := srv.URL + srv.Close() // 即座に閉じて接続不可にする + withAPIBase(t, base) + + client := &http.Client{} + _, _, err := vercelCheckAccess(client, "tok", "pid", "") + if err == nil { + t.Error("接続不可のとき err を期待したが nil") + } +} + +// TestVercelSourceLabel は取得元識別子が i18n ラベルに変換されることを確認する。 +func TestVercelSourceLabel(t *testing.T) { + cases := []struct { + src string + want i18n.MsgKey + }{ + {"env", i18n.MsgValidateSourceEnv}, + {"config", i18n.MsgValidateSourceConfig}, + {"project_json", i18n.MsgValidateSourceProjectJSON}, + {"git_remote", i18n.MsgValidateSourceGitRemote}, + {"", i18n.MsgValidateSourceUnset}, + {"unknown-source", i18n.MsgValidateSourceUnset}, + } + for _, c := range cases { + got := vercelSourceLabel(c.src) + want := i18n.T(c.want) + if got != want { + t.Errorf("vercelSourceLabel(%q) = %q, want %q", c.src, got, want) + } + } +} + +// TestVercelValidate_MixedTargets_ExitsOne は複数ターゲットで一部が NG のとき +// exit(1) が呼ばれ、出力に成功/失敗の集計が含まれることを確認する。 +func TestVercelValidate_MixedTargets_ExitsOne(t *testing.T) { + // project_id を URL から判定して app-a は 200、app-b は 404 を返す + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "pid-b") { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"message":"Project not found."}}`)) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"envs":[]}`)) + })) + defer srv.Close() + withAPIBase(t, srv.URL) + + exitCode := captureOsExit(t) + + // 出力をキャプチャ + var buf strings.Builder + origOut := stdoutWriter + stdoutWriter = &buf + t.Cleanup(func() { stdoutWriter = origOut }) + + t.Setenv("VERCEL_TOKEN", "") + t.Setenv("VERCEL_PROJECT_ID", "") + t.Setenv("VERCEL_TEAM_ID", "") + + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir+"/no-global") + if err := os.WriteFile(dir+"/.env-sync.config.yaml", []byte(` +vercel: + token: cfg-tok + projects: + - name: app-a + project_id: pid-a + - name: app-b + project_id: pid-b +`), 0600); err != nil { + t.Fatal(err) + } + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + v := &vercelProvider{} + opts := provider.Options{Env: ".env", Def: "env-sync.yaml"} + _ = v.Validate(opts, nil) + + if *exitCode != 1 { + t.Errorf("一部 NG 時は exit(1) を期待, exitCode = %d", *exitCode) + } + out := buf.String() + if !strings.Contains(out, "app-a") || !strings.Contains(out, "app-b") { + t.Errorf("出力に両ターゲットのラベルが含まれることを期待: %q", out) + } +} + +// TestApplyProjectJSONFallback_NoFile は .vercel/project.json が存在しない場合に何もしないことを確認する。 +func TestApplyProjectJSONFallback_NoFile(t *testing.T) { + dir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + targets := []config.VercelTarget{{ProjectID: ""}} + used, err := applyProjectJSONFallback(targets) + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if used { + t.Error("project.json が存在しないので used=false を期待") + } + if targets[0].ProjectID != "" { + t.Errorf("ProjectID は空のまま: %q", targets[0].ProjectID) + } +} + +// TestApplyProjectJSONFallback_WithFile は .vercel/project.json が存在する場合に ProjectID が設定されることを確認する。 +func TestApplyProjectJSONFallback_WithFile(t *testing.T) { + dir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // .vercel/project.json を作成 + if err := os.MkdirAll(dir+"/.vercel", 0700); err != nil { + t.Fatal(err) + } + jsonContent := `{"projectId":"pj-from-file","orgId":"org-from-file"}` + if err := os.WriteFile(dir+"/.vercel/project.json", []byte(jsonContent), 0600); err != nil { + t.Fatal(err) + } + + targets := []config.VercelTarget{{ProjectID: ""}} + used, err := applyProjectJSONFallback(targets) + if err != nil { + t.Fatalf("エラーなしを期待: %v", err) + } + if !used { + t.Error("project.json が存在するので used=true を期待") + } + if targets[0].ProjectID != "pj-from-file" { + t.Errorf("ProjectID = %q, want pj-from-file", targets[0].ProjectID) + } + if targets[0].TeamID != "org-from-file" { + t.Errorf("TeamID = %q, want org-from-file", targets[0].TeamID) + } + if targets[0].ProjectIDSource != "project_json" { + t.Errorf("ProjectIDSource = %q, want project_json", targets[0].ProjectIDSource) + } +}