diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 081abab..4f0b509 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -8,8 +8,6 @@ import ( "os" "sort" "strings" - - "forge.lclr.dev/AI/mcp-framework/secretstore" ) const ( @@ -168,14 +166,6 @@ func Run(ctx context.Context, opts Options) error { } _, err := fmt.Fprintln(normalized.Stdout, normalized.Version) return err - case CommandLogin: - _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ - ServiceName: normalized.BinaryName, - Stdin: normalized.Stdin, - Stdout: normalized.Stdout, - Stderr: normalized.Stderr, - }) - return err default: return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command) } diff --git a/bootstrap/configtest.go b/bootstrap/configtest.go new file mode 100644 index 0000000..9e235a8 --- /dev/null +++ b/bootstrap/configtest.go @@ -0,0 +1,56 @@ +package bootstrap + +import ( + "context" + "fmt" + + fwcli "forge.lclr.dev/AI/mcp-framework/cli" + "forge.lclr.dev/AI/mcp-framework/secretstore" +) + +// StandardConfigTestOptions configure le handler de config test standard. +// Aucun champ n'est obligatoire — omettez ceux qui ne s'appliquent pas à l'application. +type StandardConfigTestOptions struct { + // ConfigCheck vérifie que le fichier de configuration est lisible. + // Construire avec cli.NewConfigCheck(store). + ConfigCheck fwcli.DoctorCheck + + // OpenStore ouvre le secret store pour vérifier sa disponibilité. + // Si fourni, un SecretStoreAvailabilityCheck est automatiquement inclus. + OpenStore func() (secretstore.Store, error) + + // ConnectivityCheck vérifie la connectivité applicative (IMAP, HTTP, etc.). + ConnectivityCheck fwcli.DoctorCheck + + // ExtraChecks contient des vérifications supplémentaires spécifiques à l'application. + ExtraChecks []fwcli.DoctorCheck +} + +// StandardConfigTestHandler retourne un Handler pour la commande config test. +// Il inclut : config (si fourni), secret store (si fourni), connectivité (si fournie), +// checks supplémentaires. Le ManifestCheck est intentionnellement absent : le manifest +// est un artefact de build, pas une contrainte runtime. +func StandardConfigTestHandler(opts StandardConfigTestOptions) Handler { + return func(ctx context.Context, inv Invocation) error { + doctorOpts := fwcli.DoctorOptions{ + ConfigCheck: opts.ConfigCheck, + ConnectivityCheck: opts.ConnectivityCheck, + ExtraChecks: opts.ExtraChecks, + } + + if opts.OpenStore != nil { + doctorOpts.SecretStoreCheck = fwcli.SecretStoreAvailabilityCheck(opts.OpenStore) + } + + report := fwcli.RunDoctor(ctx, doctorOpts) + + if err := fwcli.RenderDoctorReport(inv.Stdout, report); err != nil { + return err + } + + if report.HasFailures() { + return fmt.Errorf("config checks failed") + } + return nil + } +} diff --git a/bootstrap/configtest_test.go b/bootstrap/configtest_test.go new file mode 100644 index 0000000..a0912c7 --- /dev/null +++ b/bootstrap/configtest_test.go @@ -0,0 +1,155 @@ +package bootstrap + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + fwcli "forge.lclr.dev/AI/mcp-framework/cli" + "forge.lclr.dev/AI/mcp-framework/secretstore" +) + +func TestStandardConfigTestHandlerRendersChecks(t *testing.T) { + var stdout bytes.Buffer + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + ConfigCheck: func(context.Context) fwcli.DoctorResult { + return fwcli.DoctorResult{Name: "config", Status: fwcli.DoctorStatusOK, Summary: "config ok"} + }, + ConnectivityCheck: func(context.Context) fwcli.DoctorResult { + return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusOK, Summary: "reachable"} + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "[OK] config") { + t.Fatalf("stdout = %q, want [OK] config", out) + } + if !strings.Contains(out, "[OK] connectivity") { + t.Fatalf("stdout = %q, want [OK] connectivity", out) + } + if strings.Contains(out, "manifest") { + t.Fatalf("stdout should not contain manifest check, got:\n%s", out) + } +} + +func TestStandardConfigTestHandlerIncludesSecretStoreCheck(t *testing.T) { + var stdout bytes.Buffer + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + OpenStore: func() (secretstore.Store, error) { + return secretstore.Open(secretstore.Options{ + BackendPolicy: secretstore.BackendEnvOnly, + LookupEnv: func(string) (string, bool) { return "", false }, + }) + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + if !strings.Contains(stdout.String(), "secret-store") { + t.Fatalf("stdout = %q, want secret-store check", stdout.String()) + } +} + +func TestStandardConfigTestHandlerReturnsErrorOnFailure(t *testing.T) { + var stdout bytes.Buffer + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + ConnectivityCheck: func(context.Context) fwcli.DoctorResult { + return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusFail, Summary: "unreachable"} + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err == nil { + t.Fatal("handler should return error when checks fail") + } + if !strings.Contains(err.Error(), "config checks failed") { + t.Fatalf("err = %q, want 'config checks failed'", err.Error()) + } + if !strings.Contains(stdout.String(), "[FAIL] connectivity") { + t.Fatalf("stdout = %q, want [FAIL] connectivity", stdout.String()) + } +} + +func TestStandardConfigTestHandlerOmitsManifestCheck(t *testing.T) { + var stdout bytes.Buffer + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + ExtraChecks: []fwcli.DoctorCheck{ + func(context.Context) fwcli.DoctorResult { + return fwcli.DoctorResult{Name: "custom", Status: fwcli.DoctorStatusOK, Summary: "ok"} + }, + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + if strings.Contains(stdout.String(), "manifest") { + t.Fatalf("manifest check should not appear, got:\n%s", stdout.String()) + } +} + +func TestStandardConfigTestHandlerRunsViaBootstrap(t *testing.T) { + var stdout bytes.Buffer + openCalled := false + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "test"}, + Stdout: &stdout, + Hooks: Hooks{ + ConfigTest: StandardConfigTestHandler(StandardConfigTestOptions{ + OpenStore: func() (secretstore.Store, error) { + openCalled = true + return secretstore.Open(secretstore.Options{ + BackendPolicy: secretstore.BackendEnvOnly, + LookupEnv: func(string) (string, bool) { return "", false }, + }) + }, + }), + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if !openCalled { + t.Fatal("OpenStore should have been called") + } + if !strings.Contains(stdout.String(), "secret-store") { + t.Fatalf("stdout = %q, want secret-store in output", stdout.String()) + } +} + +func TestStandardConfigTestHandlerSecretStoreFailurePropagates(t *testing.T) { + var stdout bytes.Buffer + storeErr := errors.New("bitwarden unavailable") + + handler := StandardConfigTestHandler(StandardConfigTestOptions{ + OpenStore: func() (secretstore.Store, error) { + return nil, storeErr + }, + }) + + err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout}) + if err == nil { + t.Fatal("handler should return error when store fails") + } + if !strings.Contains(stdout.String(), "[FAIL] secret-store") { + t.Fatalf("stdout = %q, want [FAIL] secret-store", stdout.String()) + } +} diff --git a/bootstrap/login.go b/bootstrap/login.go new file mode 100644 index 0000000..fada999 --- /dev/null +++ b/bootstrap/login.go @@ -0,0 +1,30 @@ +package bootstrap + +import ( + "context" + "fmt" + "strings" + + "forge.lclr.dev/AI/mcp-framework/secretstore" +) + +var loginBitwarden = secretstore.LoginBitwarden + +// DefaultLoginHandler retourne un Handler qui authentifie et déverrouille +// Bitwarden, persiste la session BW_SESSION, et confirme le résultat. +// Utiliser comme hook Login lorsqu'aucune logique personnalisée n'est requise. +func DefaultLoginHandler(binaryName string) Handler { + name := strings.TrimSpace(binaryName) + return func(_ context.Context, inv Invocation) error { + if _, err := loginBitwarden(secretstore.BitwardenLoginOptions{ + ServiceName: name, + Stdin: inv.Stdin, + Stdout: inv.Stdout, + Stderr: inv.Stderr, + }); err != nil { + return err + } + _, err := fmt.Fprintf(inv.Stdout, "Session Bitwarden persistée pour %q.\n", name) + return err + } +} diff --git a/bootstrap/login_test.go b/bootstrap/login_test.go new file mode 100644 index 0000000..f609117 --- /dev/null +++ b/bootstrap/login_test.go @@ -0,0 +1,103 @@ +package bootstrap + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "forge.lclr.dev/AI/mcp-framework/secretstore" +) + +func withLoginBitwarden(t *testing.T, fn func(secretstore.BitwardenLoginOptions) (string, error)) { + t.Helper() + previous := loginBitwarden + loginBitwarden = fn + t.Cleanup(func() { loginBitwarden = previous }) +} + +func TestDefaultLoginHandlerPrintsConfirmation(t *testing.T) { + var stdout bytes.Buffer + + withLoginBitwarden(t, func(opts secretstore.BitwardenLoginOptions) (string, error) { + if opts.ServiceName != "my-mcp" { + t.Fatalf("ServiceName = %q, want %q", opts.ServiceName, "my-mcp") + } + return "session-token", nil + }) + + handler := DefaultLoginHandler("my-mcp") + err := handler(context.Background(), Invocation{ + Command: CommandLogin, + Stdout: &stdout, + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, `"my-mcp"`) { + t.Fatalf("stdout = %q, want mention of binary name", out) + } + if !strings.Contains(out, "persistée") { + t.Fatalf("stdout = %q, want confirmation message", out) + } +} + +func TestDefaultLoginHandlerPropagatesError(t *testing.T) { + var stdout bytes.Buffer + loginErr := errors.New("vault locked") + + withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) { + return "", loginErr + }) + + handler := DefaultLoginHandler("my-mcp") + err := handler(context.Background(), Invocation{ + Command: CommandLogin, + Stdout: &stdout, + }) + if !errors.Is(err, loginErr) { + t.Fatalf("err = %v, want %v", err, loginErr) + } + if stdout.Len() > 0 { + t.Fatalf("stdout should be empty on error, got %q", stdout.String()) + } +} + +func TestRunUsesDefaultLoginHandlerWhenHookSet(t *testing.T) { + var stdout bytes.Buffer + + withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) { + return "tok", nil + }) + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"login"}, + Stdout: &stdout, + Hooks: Hooks{ + Login: DefaultLoginHandler("my-mcp"), + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if !strings.Contains(stdout.String(), "persistée") { + t.Fatalf("stdout = %q, want confirmation", stdout.String()) + } +} + +func TestRunLoginAutoHiddenWithoutHook(t *testing.T) { + var stdout bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"login"}, + Stdout: &stdout, + }) + if !errors.Is(err, ErrUnknownCommand) { + t.Fatalf("err = %v, want ErrUnknownCommand", err) + } +} diff --git a/cli/doctor.go b/cli/doctor.go index df6e768..da5d5ac 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -86,7 +86,7 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport { } if options.ManifestCheck != nil { checks = append(checks, options.ManifestCheck) - } else { + } else if strings.TrimSpace(options.ManifestDir) != "" { checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator)) } if options.ConnectivityCheck != nil { diff --git a/cli/doctor_test.go b/cli/doctor_test.go index ba9acba..2982d63 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -198,6 +198,24 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { } } +func TestRunDoctorOmitsManifestCheckWhenDirNotSet(t *testing.T) { + report := RunDoctor(context.Background(), DoctorOptions{ + ConnectivityCheck: func(context.Context) DoctorResult { + return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "ok"} + }, + // ManifestDir intentionally empty, ManifestCheck intentionally nil. + }) + + for _, r := range report.Results { + if r.Name == "manifest" { + t.Fatalf("manifest check should not be included when ManifestDir is empty, got: %+v", r) + } + } + if len(report.Results) != 1 { + t.Fatalf("result count = %d, want 1 (connectivity only)", len(report.Results)) + } +} + func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) { prev := checkBitwardenReady t.Cleanup(func() {