diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go new file mode 100644 index 0000000..dfbba1c --- /dev/null +++ b/internal/auth/device_flow.go @@ -0,0 +1,167 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + DefaultDeviceCodePath = "/v1/cli/device_codes" + DefaultPollPath = "/v1/cli/device_codes/poll" +) + +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type PollResponse struct { + Status string `json:"status"` + TestAPIKey string `json:"test_api_key,omitempty"` + LiveAPIKey string `json:"live_api_key,omitempty"` + OrganizationName string `json:"organization_name,omitempty"` +} + +type DeviceFlowClient struct { + baseURL string + httpClient *http.Client +} + +type DeviceFlowOption func(*DeviceFlowClient) + +func WithBaseURL(u string) DeviceFlowOption { + return func(c *DeviceFlowClient) { c.baseURL = u } +} + +func WithHTTPClient(hc *http.Client) DeviceFlowOption { + return func(c *DeviceFlowClient) { c.httpClient = hc } +} + +func NewDeviceFlowClient(opts ...DeviceFlowOption) *DeviceFlowClient { + c := &DeviceFlowClient{ + baseURL: "https://api.fintoc.com", + httpClient: &http.Client{Timeout: 15 * time.Second}, + } + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) { + url := c.baseURL + DefaultDeviceCodePath + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "fintoc-cli") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("requesting device code: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("device code request failed (%d): %s", resp.StatusCode, string(body)) + } + + var result DeviceCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding device code response: %w", err) + } + + return &result, nil +} + +func (c *DeviceFlowClient) Poll(ctx context.Context, deviceCode string) (*PollResponse, error) { + url := c.baseURL + DefaultPollPath + + payload, _ := json.Marshal(map[string]string{"device_code": deviceCode}) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "fintoc-cli") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("polling device code: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + if strings.Contains(string(body), "slow_down") { + return &PollResponse{Status: "slow_down"}, nil + } + return nil, fmt.Errorf("poll request failed (%d): %s", resp.StatusCode, string(body)) + } + + var result PollResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding poll response: %w", err) + } + + return &result, nil +} + +type PollCallback func(attempt int, elapsed time.Duration) + +func (c *DeviceFlowClient) PollUntilComplete(ctx context.Context, deviceCode string, interval int, expiresIn int, onPoll PollCallback) (*PollResponse, error) { + if interval < 1 { + interval = 5 + } + + deadline := time.After(time.Duration(expiresIn) * time.Second) + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + start := time.Now() + attempt := 0 + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-deadline: + return nil, fmt.Errorf("device code expired after %ds", expiresIn) + case <-ticker.C: + attempt++ + if onPoll != nil { + onPoll(attempt, time.Since(start)) + } + + result, err := c.Poll(ctx, deviceCode) + if err != nil { + if attempt < 3 { + continue + } + return nil, err + } + + switch result.Status { + case "authorization_pending", "slow_down": + continue + case "complete": + return result, nil + default: + return nil, fmt.Errorf("unexpected poll status: %s", result.Status) + } + } + } +} diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go new file mode 100644 index 0000000..1661a53 --- /dev/null +++ b/internal/auth/device_flow_test.go @@ -0,0 +1,207 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestRequestDeviceCode_Success(t *testing.T) { + expected := DeviceCodeResponse{ + DeviceCode: "abc123", + UserCode: "BCDF-GHJK", + VerificationURI: "https://app.fintoc.com/cli/authorize", + ExpiresIn: 900, + Interval: 5, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != DefaultDeviceCodePath { + t.Errorf("expected path %s, got %s", DefaultDeviceCodePath, r.URL.Path) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(expected) + })) + defer srv.Close() + + client := NewDeviceFlowClient(WithBaseURL(srv.URL)) + resp, err := client.RequestDeviceCode(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.DeviceCode != expected.DeviceCode { + t.Errorf("device_code: got %q, want %q", resp.DeviceCode, expected.DeviceCode) + } + if resp.UserCode != expected.UserCode { + t.Errorf("user_code: got %q, want %q", resp.UserCode, expected.UserCode) + } + if resp.VerificationURI != expected.VerificationURI { + t.Errorf("verification_uri: got %q, want %q", resp.VerificationURI, expected.VerificationURI) + } + if resp.Interval != expected.Interval { + t.Errorf("interval: got %d, want %d", resp.Interval, expected.Interval) + } +} + +func TestRequestDeviceCode_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "internal"}`)) + })) + defer srv.Close() + + client := NewDeviceFlowClient(WithBaseURL(srv.URL)) + _, err := client.RequestDeviceCode(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestPoll_AuthorizationPending(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(PollResponse{Status: "authorization_pending"}) + })) + defer srv.Close() + + client := NewDeviceFlowClient(WithBaseURL(srv.URL)) + resp, err := client.Poll(context.Background(), "abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "authorization_pending" { + t.Errorf("status: got %q, want %q", resp.Status, "authorization_pending") + } +} + +func TestPoll_Complete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["device_code"] != "abc123" { + t.Errorf("device_code: got %q, want %q", body["device_code"], "abc123") + } + json.NewEncoder(w).Encode(PollResponse{ + Status: "complete", + TestAPIKey: "sk_test_xxx", + LiveAPIKey: "sk_live_xxx", + OrganizationName: "Acme Inc", + }) + })) + defer srv.Close() + + client := NewDeviceFlowClient(WithBaseURL(srv.URL)) + resp, err := client.Poll(context.Background(), "abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "complete" { + t.Errorf("status: got %q, want %q", resp.Status, "complete") + } + if resp.TestAPIKey != "sk_test_xxx" { + t.Errorf("test_api_key: got %q, want %q", resp.TestAPIKey, "sk_test_xxx") + } + if resp.LiveAPIKey != "sk_live_xxx" { + t.Errorf("live_api_key: got %q, want %q", resp.LiveAPIKey, "sk_live_xxx") + } + if resp.OrganizationName != "Acme Inc" { + t.Errorf("organization_name: got %q, want %q", resp.OrganizationName, "Acme Inc") + } +} + +func TestPollUntilComplete_Success(t *testing.T) { + var calls int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n < 3 { + json.NewEncoder(w).Encode(PollResponse{Status: "authorization_pending"}) + return + } + json.NewEncoder(w).Encode(PollResponse{ + Status: "complete", + TestAPIKey: "sk_test_done", + OrganizationName: "Test Org", + }) + })) + defer srv.Close() + + client := NewDeviceFlowClient(WithBaseURL(srv.URL)) + resp, err := client.PollUntilComplete(context.Background(), "abc", 1, 30, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "complete" { + t.Errorf("status: got %q, want %q", resp.Status, "complete") + } + if resp.TestAPIKey != "sk_test_done" { + t.Errorf("test_api_key: got %q, want %q", resp.TestAPIKey, "sk_test_done") + } +} + +func TestPollUntilComplete_ContextCanceled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(PollResponse{Status: "authorization_pending"}) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + client := NewDeviceFlowClient(WithBaseURL(srv.URL)) + _, err := client.PollUntilComplete(ctx, "abc", 1, 60, nil) + if err == nil { + t.Fatal("expected error from canceled context") + } +} + +func TestPollUntilComplete_CallbackCalled(t *testing.T) { + var calls int32 + var cbCount int + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n < 2 { + json.NewEncoder(w).Encode(PollResponse{Status: "authorization_pending"}) + return + } + json.NewEncoder(w).Encode(PollResponse{Status: "complete", TestAPIKey: "sk_test_cb"}) + })) + defer srv.Close() + + client := NewDeviceFlowClient(WithBaseURL(srv.URL)) + _, err := client.PollUntilComplete(context.Background(), "abc", 1, 30, func(attempt int, elapsed time.Duration) { + cbCount++ + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cbCount < 1 { + t.Errorf("callback was not called (count=%d)", cbCount) + } +} + +func TestPoll_SendsDeviceCodeInBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["device_code"] != "my_device_code" { + t.Errorf("device_code in body: got %q, want %q", body["device_code"], "my_device_code") + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type: got %q, want %q", r.Header.Get("Content-Type"), "application/json") + } + json.NewEncoder(w).Encode(PollResponse{Status: "authorization_pending"}) + })) + defer srv.Close() + + client := NewDeviceFlowClient(WithBaseURL(srv.URL)) + client.Poll(context.Background(), "my_device_code") +} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 3576845..65e3b49 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -1,7 +1,6 @@ package cmd import ( - "bufio" "context" "fmt" "io" @@ -14,11 +13,18 @@ import ( "github.com/fintoc/fintoc-cli/internal/ansi" "github.com/fintoc/fintoc-cli/internal/api" + "github.com/fintoc/fintoc-cli/internal/auth" "github.com/fintoc/fintoc-cli/internal/config" + "github.com/fintoc/fintoc-cli/internal/signup" + "github.com/fintoc/fintoc-cli/internal/validate" "github.com/spf13/cobra" "golang.org/x/term" ) +// maxInteractiveRetries is the maximum number of times a user can re-enter +// a field value in interactive mode before the command gives up. +const maxInteractiveRetries = 3 + const ( dashboardAPIKeysPath = "/api-keys" docsAPIKeysURL = "https://docs.fintoc.com/docs/api-keys" @@ -74,6 +80,7 @@ func newAuthCmd() *cobra.Command { Short: "Authentication and credential management", Long: `Manage Fintoc API authentication. +Use 'fintoc auth signup' to create a new Fintoc account from the CLI. Use 'fintoc auth login' to configure your API key interactively. Use 'fintoc auth status' to check your current credentials. Use 'fintoc auth logout' to remove stored credentials. @@ -81,6 +88,7 @@ Use 'fintoc auth logout' to remove stored credentials. Get your API keys at: ` + docsAPIKeysURL, } + cmd.AddCommand(newAuthSignupCmd()) cmd.AddCommand(newAuthLoginCmd()) cmd.AddCommand(newAuthStatusCmd()) cmd.AddCommand(newAuthSetupCmd()) // hidden alias for login @@ -89,6 +97,321 @@ Get your API keys at: ` + docsAPIKeysURL, return cmd } +// --- auth signup --- + +func newAuthSignupCmd() *cobra.Command { + var ( + email string + password string + name string + lastName string + country string + organizationName string + ) + + cmd := &cobra.Command{ + Use: "signup", + Short: "Create a new Fintoc account", + Long: `Create a new Fintoc account entirely from the CLI. + +This command collects your details and creates an account on Fintoc with +an organization. After signup you will need to verify your email, then +run 'fintoc auth login' to authenticate and get API keys. + +Already have an account? Use 'fintoc auth login' instead.`, + Example: ` # Interactive signup (recommended) + fintoc auth signup + + # Non-interactive signup (for scripts/CI) + fintoc auth signup \ + --email user@example.com \ + --password "MySecureP@ss" \ + --name John \ + --last-name Doe \ + --country cl \ + --org-name "My Company"`, + RunE: func(cmd *cobra.Command, args []string) error { + return runAuthSignup(cmd, email, password, name, lastName, country, organizationName) + }, + } + + cmd.Flags().StringVar(&email, "email", "", "Account email address") + cmd.Flags().StringVar(&password, "password", "", "Account password (min 8 chars, 3 of: lowercase, uppercase, number, special)") + cmd.Flags().StringVar(&name, "name", "", "First name") + cmd.Flags().StringVar(&lastName, "last-name", "", "Last name") + cmd.Flags().StringVar(&country, "country", "", "Country code: cl (Chile) or mx (Mexico)") + cmd.Flags().StringVar(&organizationName, "org-name", "", "Organization name") + + return cmd +} + +func runAuthSignup(cmd *cobra.Command, email, password, name, lastName, country, orgName string) error { + w := cmd.OutOrStdout() + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + isTerminal := false + if f, ok := cmd.InOrStdin().(*os.File); ok && term.IsTerminal(int(f.Fd())) { + isTerminal = true + } + + var err error + + // --- Email --- + if email == "" { + if !isTerminal { + return fmt.Errorf("--email is required in non-interactive mode") + } + fmt.Fprintln(w, "Welcome to Fintoc! Let's create your account.") + fmt.Fprintln(w) + email, err = promptWithRetry(cmd, w, "Email: ", func(v string) error { + return validate.Email(v) + }) + if err != nil { + return err + } + } else { + email = strings.TrimSpace(email) + if err := validate.Email(email); err != nil { + return err + } + } + + // --- Password --- + if password == "" { + if !isTerminal { + return fmt.Errorf("--password is required in non-interactive mode") + } + password, err = promptPasswordWithRetry(cmd, w) + if err != nil { + return err + } + } else { + password = strings.TrimSpace(password) + if err := validate.Password(password); err != nil { + return err + } + } + + // --- First name --- + if name == "" { + if !isTerminal { + return fmt.Errorf("--name is required in non-interactive mode") + } + fmt.Fprintln(w) + name, err = promptWithRetry(cmd, w, "First name: ", func(v string) error { + return validate.NonEmpty("first name", v) + }) + if err != nil { + return err + } + } else { + name = strings.TrimSpace(name) + if err := validate.NonEmpty("first name", name); err != nil { + return err + } + } + + // --- Last name --- + if lastName == "" { + if !isTerminal { + return fmt.Errorf("--last-name is required in non-interactive mode") + } + lastName, err = promptWithRetry(cmd, w, "Last name: ", func(v string) error { + return validate.NonEmpty("last name", v) + }) + if err != nil { + return err + } + } else { + lastName = strings.TrimSpace(lastName) + if err := validate.NonEmpty("last name", lastName); err != nil { + return err + } + } + + // --- Country --- + if country == "" { + if !isTerminal { + return fmt.Errorf("--country is required in non-interactive mode") + } + fmt.Fprintln(w) + fmt.Fprintln(w, "Country:") + fmt.Fprintln(w, " cl - Chile") + fmt.Fprintln(w, " mx - Mexico") + country, err = promptWithRetry(cmd, w, "Country (cl/mx): ", func(v string) error { + return validate.Country(v) + }) + if err != nil { + return err + } + country = strings.TrimSpace(strings.ToLower(country)) + } else { + country = strings.TrimSpace(strings.ToLower(country)) + if err := validate.Country(country); err != nil { + return err + } + } + + // --- Organization name --- + if orgName == "" { + if !isTerminal { + return fmt.Errorf("--org-name is required in non-interactive mode") + } + fmt.Fprintln(w) + orgName, err = promptLineWithRetry(cmd, w, "Organization name: ", func(v string) error { + return validate.NonEmpty("organization name", v) + }) + if err != nil { + return err + } + } else { + orgName = strings.TrimSpace(orgName) + if err := validate.NonEmpty("organization name", orgName); err != nil { + return err + } + } + + fmt.Fprintln(w) + + baseURL := api.DefaultBaseURL + if rootOpts.baseURL != "" { + baseURL = rootOpts.baseURL + } + + client := signup.NewClient(signup.WithBaseURL(baseURL)) + + s := ansi.StartNewSpinner("Creating your account...", w) + + resp, err := client.Signup(ctx, signup.Request{ + Email: email, + Password: password, + Name: name, + LastName: lastName, + Country: country, + OrganizationName: orgName, + }) + if err != nil { + ansi.StopSpinner(s, fmt.Sprintf("Signup failed: %s", err), w) + return err + } + + ansi.StopSpinner(s, "Account created!", w) + fmt.Fprintln(w) + + if resp.OrganizationName != "" { + fmt.Fprintf(w, "Organization: %s\n", ansi.Bold(resp.OrganizationName)) + } + if resp.Email != "" { + fmt.Fprintf(w, "Email: %s\n", resp.Email) + } + + fmt.Fprintln(w) + fmt.Fprintf(w, "%s Check your inbox to verify your email address.\n", ansi.Green(">")) + fmt.Fprintf(w, "Then run: %s\n", ansi.Bold("fintoc auth login")) + + return nil +} + +// promptWithRetry prompts the user for input, validates it, and re-prompts +// on validation failure up to maxInteractiveRetries times. It reads a single +// whitespace-delimited token (suitable for email, country, names without spaces). +func promptWithRetry(cmd *cobra.Command, w io.Writer, prompt string, validateFn func(string) error) (string, error) { + for attempt := 0; ; attempt++ { + fmt.Fprint(w, prompt) + var value string + fmt.Fscanln(cmd.InOrStdin(), &value) + value = strings.TrimSpace(value) + if err := validateFn(value); err == nil { + return value, nil + } else { + fmt.Fprintf(w, "%s %s\n", ansi.Red("!"), err) + if attempt >= maxInteractiveRetries-1 { + return "", fmt.Errorf("too many invalid attempts") + } + } + } +} + +// promptLineWithRetry is like promptWithRetry but reads an entire line, +// allowing spaces in the value (e.g. organization names). +func promptLineWithRetry(cmd *cobra.Command, w io.Writer, prompt string, validateFn func(string) error) (string, error) { + for attempt := 0; ; attempt++ { + fmt.Fprint(w, prompt) + value := strings.TrimSpace(readLine(cmd.InOrStdin())) + if err := validateFn(value); err == nil { + return value, nil + } else { + fmt.Fprintf(w, "%s %s\n", ansi.Red("!"), err) + if attempt >= maxInteractiveRetries-1 { + return "", fmt.Errorf("too many invalid attempts") + } + } + } +} + +// promptPasswordWithRetry interactively collects and validates a password with +// confirmation. Re-prompts on validation failure (weak password or mismatch) +// up to maxInteractiveRetries times. +func promptPasswordWithRetry(cmd *cobra.Command, w io.Writer) (string, error) { + fd := int(cmd.InOrStdin().(*os.File).Fd()) + + for attempt := 0; ; attempt++ { + fmt.Fprintf(w, "Password (%s): ", validate.PasswordHint()) + raw, err := term.ReadPassword(fd) + fmt.Fprintln(w) + if err != nil { + return "", fmt.Errorf("reading password: %w", err) + } + password := strings.TrimSpace(string(raw)) + + if err := validate.Password(password); err != nil { + fmt.Fprintf(w, "%s %s\n", ansi.Red("!"), err) + if attempt >= maxInteractiveRetries-1 { + return "", fmt.Errorf("too many invalid attempts") + } + continue + } + + fmt.Fprint(w, "Confirm password: ") + confirm, err := term.ReadPassword(fd) + fmt.Fprintln(w) + if err != nil { + return "", fmt.Errorf("reading password confirmation: %w", err) + } + if strings.TrimSpace(string(confirm)) != password { + fmt.Fprintf(w, "%s passwords do not match\n", ansi.Red("!")) + if attempt >= maxInteractiveRetries-1 { + return "", fmt.Errorf("too many invalid attempts") + } + continue + } + + return password, nil + } +} + +// readLine reads a full line from the reader (handles spaces in input). +func readLine(r io.Reader) string { + var line []byte + buf := make([]byte, 1) + for { + n, err := r.Read(buf) + if n > 0 { + if buf[0] == '\n' { + break + } + line = append(line, buf[0]) + } + if err != nil { + break + } + } + return strings.TrimRight(string(line), "\r") +} + // --- auth login --- func newAuthLoginCmd() *cobra.Command { @@ -101,26 +424,26 @@ func newAuthLoginCmd() *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Authenticate with Fintoc", - Long: `Log in to the Fintoc API by providing your API key. + Long: `Log in to the Fintoc API. -This command opens the Fintoc dashboard in your browser so you can copy your -API key, then prompts you to paste it. The key is stored securely in the OS -keyring (macOS Keychain, GNOME Keyring, Windows Credential Manager). If the -keyring is unavailable, it falls back to the global config file. +By default, this command starts a browser-based login flow: it requests a +one-time code, opens the Fintoc dashboard for approval, and polls until you +authorize the CLI. Both test and live API keys are provisioned in a single +step and stored securely in the OS keyring. -Keys are stored in separate test and live slots based on prefix (sk_test_* -or sk_live_*). Run this command once with each key to configure both modes. -Commands default to the test key; use --live to switch to the live key. +Use --with-key to paste an existing API key (for CI or manual setup). +Use --web to just open the dashboard API keys page and exit. +Use --insecure-storage to skip the keyring and store in plaintext config only. -Use --with-key to read the key from stdin (for CI/scripting). -Use --web to just open the dashboard and exit. -Use --insecure-storage to skip the keyring and store in plaintext config only.`, - Example: ` # Interactive login (opens browser, prompts for key) +Don't have an account yet? Run 'fintoc auth signup' to create one.`, + Example: ` # Browser login (default — provisions test + live keys) fintoc auth login - # Store both test and live keys + # Paste an existing API key + fintoc auth login --with-key + + # Pipe a key from stdin (CI) echo "sk_test_xxx" | fintoc auth login --with-key - echo "sk_live_xxx" | fintoc auth login --with-key # Just open the API keys page in the browser fintoc auth login --web @@ -133,18 +456,16 @@ Use --insecure-storage to skip the keyring and store in plaintext config only.`, } cmd.Flags().BoolVar(&insecureStorage, "insecure-storage", false, "Store API key in plaintext config file instead of OS keyring") - cmd.Flags().BoolVar(&withKey, "with-key", false, "Read API key from stdin (non-interactive, for CI)") + cmd.Flags().BoolVar(&withKey, "with-key", false, "Paste or pipe an existing API key instead of browser login") cmd.Flags().BoolVar(&webOnly, "web", false, "Open the Fintoc dashboard API keys page and exit") return cmd } -// runAuthLogin contains the shared login logic for both `auth login` and the deprecated `auth setup`. func runAuthLogin(cmd *cobra.Command, insecureStorage, withKey, webOnly bool) error { w := cmd.OutOrStdout() apiKeysURL := dashboardURL() + dashboardAPIKeysPath - // --web: just open browser and exit if webOnly { fmt.Fprintf(w, "Opening %s ...\n", apiKeysURL) if err := browserOpener(apiKeysURL); err != nil { @@ -154,69 +475,225 @@ func runAuthLogin(cmd *cobra.Command, insecureStorage, withKey, webOnly bool) er return nil } - // Check if env token is set if envKey := os.Getenv(config.EnvAPIKey); envKey != "" { fmt.Fprintf(w, "Note: %s is already set in your environment.\n", config.EnvAPIKey) fmt.Fprintln(w, "The environment variable takes precedence over stored credentials.") fmt.Fprintln(w) } - var apiKey string - if withKey { - // Read key from stdin (for CI/scripting) - data, err := io.ReadAll(cmd.InOrStdin()) - if err != nil { - return fmt.Errorf("reading key from stdin: %w", err) - } - apiKey = strings.TrimSpace(string(data)) - if apiKey == "" { - return fmt.Errorf("no API key provided on stdin") + return runAuthLoginWithKey(cmd, insecureStorage) + } + + return runAuthLoginDeviceFlow(cmd, insecureStorage) +} + +func hasStoredKeys() (hasTest, hasLive bool) { + hasTest = config.HasKeyringTestKey() + hasLive = config.HasKeyringLiveKey() + + if !hasTest || !hasLive { + if globalPath, err := config.GlobalConfigPath(); err == nil { + if cfg, err := config.LoadFile(globalPath); err == nil { + if cfg.APIKeyTest != "" { + hasTest = true + } + if cfg.APIKeyLive != "" { + hasLive = true + } + } } - } else { - // Interactive: open browser and prompt - fmt.Fprintf(w, "Tip: get your API key from %s\n", apiKeysURL) + } - if err := browserOpener(apiKeysURL); err != nil { - // Non-fatal — user can navigate manually - fmt.Fprintf(w, "Open this URL in your browser: %s\n", apiKeysURL) + return hasTest, hasLive +} + +func runAuthLoginDeviceFlow(cmd *cobra.Command, insecureStorage bool) error { + w := cmd.OutOrStdout() + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + if hasTest, hasLive := hasStoredKeys(); hasTest || hasLive { + modes := "" + if hasTest && hasLive { + modes = "test + live" + } else if hasTest { + modes = "test" + } else { + modes = "live" } + fmt.Fprintf(w, "%s You already have %s keys stored.\n", ansi.Yellow("!"), modes) + fmt.Fprintln(w, "Continuing will create new API keys and replace the existing ones.") fmt.Fprintln(w) - fmt.Fprint(w, "Paste your API key: ") - - // Use term.ReadPassword to suppress echo when stdin is a terminal. - // Falls back to regular line reading for piped/non-terminal input. if f, ok := cmd.InOrStdin().(*os.File); ok && term.IsTerminal(int(f.Fd())) { - raw, err := term.ReadPassword(int(f.Fd())) - fmt.Fprintln(w) // newline after hidden input - if err != nil { - return fmt.Errorf("failed to read input: %w", err) + fmt.Fprint(w, "Continue? (y/N): ") + var answer string + fmt.Fscanln(cmd.InOrStdin(), &answer) + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Fprintln(w, "Login cancelled.") + return nil } - apiKey = strings.TrimSpace(string(raw)) + fmt.Fprintln(w) + } + } + + baseURL := api.DefaultBaseURL + if rootOpts.baseURL != "" { + baseURL = rootOpts.baseURL + } + + var opts []auth.DeviceFlowOption + opts = append(opts, auth.WithBaseURL(baseURL)) + + dfClient := auth.NewDeviceFlowClient(opts...) + + s := ansi.StartNewSpinner("Requesting login code...", w) + + deviceResp, err := dfClient.RequestDeviceCode(ctx) + if err != nil { + ansi.StopSpinner(s, fmt.Sprintf("Failed to start login: %s", err), w) + fmt.Fprintln(w) + fmt.Fprintln(w, "Falling back to manual key entry...") + fmt.Fprintf(w, "%s\n\n", ansi.Faint("Don't have an account? Run 'fintoc auth signup' to create one.")) + return runAuthLoginWithKey(cmd, insecureStorage) + } + + ansi.StopSpinner(s, "Login code received", w) + fmt.Fprintln(w) + + fmt.Fprintf(w, "Your one-time code is: %s\n", ansi.Bold(deviceResp.UserCode)) + fmt.Fprintln(w) + fmt.Fprintf(w, "Open %s and enter the code to authorize.\n", deviceResp.VerificationURI) + fmt.Fprintln(w) + + verifyURL := deviceResp.VerificationURI + if !strings.Contains(verifyURL, "?") { + verifyURL += "?user_code=" + strings.ReplaceAll(deviceResp.UserCode, "-", "") + } + + if err := browserOpener(verifyURL); err != nil { + fmt.Fprintf(w, "Could not open browser. Open this URL manually:\n %s\n\n", verifyURL) + } + + s = ansi.StartNewSpinner("Waiting for authorization...", w) + + pollResp, err := dfClient.PollUntilComplete(ctx, deviceResp.DeviceCode, deviceResp.Interval, deviceResp.ExpiresIn, nil) + if err != nil { + ansi.StopSpinner(s, fmt.Sprintf("Authorization failed: %s", err), w) + return err + } + + ansi.StopSpinner(s, "Authorized!", w) + fmt.Fprintln(w) + + if pollResp.OrganizationName != "" { + fmt.Fprintf(w, "Organization: %s\n", ansi.Bold(pollResp.OrganizationName)) + } + + stored := 0 + for _, pair := range []struct { + key string + mode string + }{ + {pollResp.TestAPIKey, "test"}, + {pollResp.LiveAPIKey, "live"}, + } { + if pair.key == "" { + continue + } + if err := storeAPIKey(w, pair.key, pair.mode, insecureStorage); err != nil { + fmt.Fprintf(w, "Warning: could not store %s key: %s\n", pair.mode, err) } else { - reader := bufio.NewReader(cmd.InOrStdin()) - key, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read input: %w", err) - } - apiKey = strings.TrimSpace(key) + stored++ } + } + + if stored == 0 { + return fmt.Errorf("no API keys received from authorization") + } + + fmt.Fprintln(w) + fmt.Fprintf(w, "%s The Fintoc CLI is configured.\n", ansi.Green(">")) + fmt.Fprintf(w, "Try: %s\n", ansi.Bold("fintoc customers list")) + + return nil +} + +func runAuthLoginWithKey(cmd *cobra.Command, insecureStorage bool) error { + w := cmd.OutOrStdout() + apiKeysURL := dashboardURL() + dashboardAPIKeysPath + + var apiKey string - if apiKey == "" { - return fmt.Errorf("API key cannot be empty") + if f, ok := cmd.InOrStdin().(*os.File); ok && term.IsTerminal(int(f.Fd())) { + fmt.Fprintf(w, "Tip: get your API key from %s\n", apiKeysURL) + fmt.Fprintln(w) + fmt.Fprint(w, "Paste your API key: ") + + raw, err := term.ReadPassword(int(f.Fd())) + fmt.Fprintln(w) + if err != nil { + return fmt.Errorf("failed to read input: %w", err) } + apiKey = strings.TrimSpace(string(raw)) + } else { + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return fmt.Errorf("reading key from stdin: %w", err) + } + apiKey = strings.TrimSpace(string(data)) + } + + if apiKey == "" { + return fmt.Errorf("no API key provided") } - // Basic format validation if !strings.HasPrefix(apiKey, "sk_") { fmt.Fprintln(w, "Warning: API key doesn't start with 'sk_'. Fintoc keys typically start with 'sk_test_' or 'sk_live_'.") } - // Detect mode from prefix mode := keyMode(apiKey) - // Store credentials in mode-specific slot + if err := storeAPIKey(w, apiKey, mode, insecureStorage); err != nil { + return err + } + + fmt.Fprintln(w) + s := ansi.StartNewSpinner("Validating...", w) + fullCfg := config.Config{APIKey: apiKey} + if rootOpts.baseURL != "" { + fullCfg.BaseURL = rootOpts.baseURL + } + valid, err := validateAPIKey(fullCfg) + if err != nil { + ansi.StopSpinner(s, fmt.Sprintf("Could not validate: %s", err), w) + } else if valid { + if mode != "" { + ansi.StopSpinner(s, fmt.Sprintf("Done! The Fintoc CLI is configured for %s mode.", ansi.Bold(mode)), w) + } else { + ansi.StopSpinner(s, "Done! The Fintoc CLI is configured.", w) + } + } else { + ansi.StopSpinner(s, fmt.Sprintf("%s API key is invalid (check your key)", ansi.Red("!")), w) + } + + if mode != "" { + otherMode := "live" + if mode == "live" { + otherMode = "test" + } + fmt.Fprintf(w, "%s\n", ansi.Faint(fmt.Sprintf("Tip: run 'fintoc auth login' again with a %s key to store both.", otherMode))) + } + fmt.Fprintf(w, "Try: %s\n", ansi.Bold("fintoc customers list")) + + return nil +} + +func storeAPIKey(w io.Writer, apiKey, mode string, insecureStorage bool) error { storedInKeyring := false if !insecureStorage { if err := config.SaveKeyring(apiKey); err == nil { @@ -227,7 +704,6 @@ func runAuthLogin(cmd *cobra.Command, insecureStorage, withKey, webOnly bool) er fmt.Fprintln(w, "API key saved to OS keyring.") } - // Remove the same-mode key from config file to avoid stale plaintext path, err := config.GlobalConfigPath() if err == nil { if cfg, err := config.LoadFile(path); err == nil { @@ -245,7 +721,6 @@ func runAuthLogin(cmd *cobra.Command, insecureStorage, withKey, webOnly bool) er } if changed { _ = config.WriteConfig(path, cfg) - fmt.Fprintln(w, "Removed plaintext API key from config file.") } } } @@ -254,7 +729,6 @@ func runAuthLogin(cmd *cobra.Command, insecureStorage, withKey, webOnly bool) er } } - // Fallback: save to config file (mode-specific field) if !storedInKeyring { path, err := config.GlobalConfigPath() if err != nil { @@ -270,34 +744,6 @@ func runAuthLogin(cmd *cobra.Command, insecureStorage, withKey, webOnly bool) er } } - fmt.Fprintln(w) - s := ansi.StartNewSpinner("Validating...", w) - fullCfg := config.Config{APIKey: apiKey} - if rootOpts.baseURL != "" { - fullCfg.BaseURL = rootOpts.baseURL - } - valid, err := validateAPIKey(fullCfg) - if err != nil { - ansi.StopSpinner(s, fmt.Sprintf("Could not validate: %s", err), w) - } else if valid { - if mode != "" { - ansi.StopSpinner(s, fmt.Sprintf("Done! The Fintoc CLI is configured for %s mode.", ansi.Bold(mode)), w) - } else { - ansi.StopSpinner(s, "Done! The Fintoc CLI is configured.", w) - } - } else { - ansi.StopSpinner(s, fmt.Sprintf("%s API key is invalid (check your key)", ansi.Red("!")), w) - } - - if mode != "" { - otherMode := "live" - if mode == "live" { - otherMode = "test" - } - fmt.Fprintf(w, "%s\n", ansi.Faint(fmt.Sprintf("Tip: run 'fintoc auth login' again with a %s key to store both.", otherMode))) - } - fmt.Fprintf(w, "Try: %s\n", ansi.Bold("fintoc customers list")) - return nil } @@ -497,7 +943,8 @@ func newAuthLogoutCmd() *cobra.Command { return &cobra.Command{ Use: "logout", Short: "Remove stored credentials", - Long: `Remove the API key from both the OS keyring and config file. + Long: `Remove the API key from both the OS keyring and config file, +and revoke the keys on the server so they can no longer be used. This does not affect environment variables. If FINTOC_API_KEY is set in your environment, it will still be used after logout.`, @@ -510,6 +957,9 @@ environment, it will still be used after logout.`, fmt.Fprintln(w) } + // Revoke keys on the server before clearing local credentials. + revokeKeysOnServer(w) + removed := false // Remove from keyring (all slots: test, live, legacy) @@ -541,3 +991,52 @@ environment, it will still be used after logout.`, }, } } + +// revokeKeysOnServer calls POST /v1/cli/logout with the stored live API key +// to revoke it server-side. The backend only revokes live keys (test keys are +// shared across sessions and preserved). Failures are logged as warnings and +// do not prevent local credential cleanup. +func revokeKeysOnServer(w io.Writer) { + liveKey := collectStoredLiveKey() + if liveKey == "" { + return + } + + var opts []api.ClientOption + baseURL := rootOpts.baseURL + if baseURL != "" { + opts = append(opts, api.WithBaseURL(baseURL)) + } + opts = append(opts, api.WithHTTPClient(&http.Client{Timeout: 10 * time.Second})) + + client := api.NewClient(liveKey, "", opts...) + _, _, err := client.PostJSON(context.Background(), "/v1/cli/logout", nil) + if err != nil { + fmt.Fprintf(w, "Warning: could not revoke key on server: %s\n", err) + } else { + fmt.Fprintln(w, "Live API key revoked on server.") + } +} + +// collectStoredLiveKey loads the stored live API key from file/keyring, +// skipping env vars (those are the user's responsibility to unset). +// Only the live key is collected because the backend only revokes live keys. +func collectStoredLiveKey() string { + // Temporarily clear env var so config.Load only returns file/keyring keys. + envKey := os.Getenv(config.EnvAPIKey) + os.Unsetenv(config.EnvAPIKey) + defer func() { + if envKey != "" { + os.Setenv(config.EnvAPIKey, envKey) + } + }() + + cfg, err := config.Load(rootOpts.configFile, true) + if err != nil { + return "" + } + if strings.HasPrefix(cfg.APIKey, "sk_live_") { + return cfg.APIKey + } + return "" +} diff --git a/internal/cmd/auth_test.go b/internal/cmd/auth_test.go index fafff8c..a503e78 100644 --- a/internal/cmd/auth_test.go +++ b/internal/cmd/auth_test.go @@ -2,11 +2,19 @@ package cmd import ( "bytes" + "context" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" + "os" "strings" "testing" + "time" + "github.com/fintoc/fintoc-cli/internal/api" "github.com/fintoc/fintoc-cli/internal/config" + "github.com/fintoc/fintoc-cli/internal/signup" "github.com/spf13/cobra" ) @@ -539,6 +547,701 @@ func TestLiveFlag_NotSet_NoValidation(t *testing.T) { } } +// --- auth signup tests --- + +func TestAuthCmd_HasSignupCommand(t *testing.T) { + cmd := newAuthCmd() + + found := false + for _, sub := range cmd.Commands() { + if sub.Name() == "signup" { + found = true + break + } + } + if !found { + t.Error("auth command should have 'signup' subcommand") + } +} + +func TestAuthSignup_MissingEmail_NonInteractive(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "", "password123", "John", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for missing email in non-interactive mode") + } + if !strings.Contains(err.Error(), "email") { + t.Errorf("error should mention email, got: %s", err.Error()) + } +} + +func TestAuthSignup_MissingPassword_NonInteractive(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "", "John", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for missing password in non-interactive mode") + } + if !strings.Contains(err.Error(), "password") { + t.Errorf("error should mention password, got: %s", err.Error()) + } +} + +func TestAuthSignup_PasswordTooShort(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "short", "John", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for short password") + } + if !strings.Contains(err.Error(), "at least 8") { + t.Errorf("error should mention minimum length, got: %s", err.Error()) + } +} + +func TestAuthSignup_InvalidCountry(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "SecureP@ss1", "John", "Doe", "us", "My Org") + if err == nil { + t.Fatal("expected error for invalid country") + } + if !strings.Contains(err.Error(), "cl") && !strings.Contains(err.Error(), "mx") { + t.Errorf("error should mention valid countries, got: %s", err.Error()) + } +} + +func TestAuthSignup_MissingName_NonInteractive(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "SecureP@ss1", "", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "name") { + t.Errorf("error should mention name, got: %s", err.Error()) + } +} + +func TestAuthSignup_MissingOrgName_NonInteractive(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "SecureP@ss1", "John", "Doe", "cl", "") + if err == nil { + t.Fatal("expected error for missing org name") + } + if !strings.Contains(err.Error(), "org-name") { + t.Errorf("error should mention org-name, got: %s", err.Error()) + } +} + +func TestAuthSignup_CountryNormalization(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + // Country "CL" (uppercase) should fail since we're testing with a mock server + // that doesn't exist, but the point is it should NOT fail on country validation. + err := runAuthSignup(cmd, "test@example.com", "SecureP@ss1", "John", "Doe", "CL", "My Org") + // It will fail on the HTTP call (no server), but NOT on country validation + if err != nil && strings.Contains(err.Error(), "country must be") { + t.Errorf("uppercase 'CL' should be normalized, got: %s", err.Error()) + } +} + +// --- email validation tests --- + +func TestAuthSignup_InvalidEmailFormat_NoAt(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "notanemail", "SecureP@ss1", "John", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for email without @") + } + if !strings.Contains(err.Error(), "valid email") { + t.Errorf("error should mention valid email, got: %s", err.Error()) + } +} + +func TestAuthSignup_InvalidEmailFormat_NoDomain(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "user@", "SecureP@ss1", "John", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for email without domain") + } + if !strings.Contains(err.Error(), "valid email") { + t.Errorf("error should mention valid email, got: %s", err.Error()) + } +} + +func TestAuthSignup_InvalidEmailFormat_NoTLD(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "user@domain", "SecureP@ss1", "John", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for email without TLD") + } + if !strings.Contains(err.Error(), "valid email") { + t.Errorf("error should mention valid email, got: %s", err.Error()) + } +} + +func TestAuthSignup_ValidEmail(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + // Valid email should pass email validation and fail later (no server) + err := runAuthSignup(cmd, "user@example.com", "SecureP@ss1", "John", "Doe", "cl", "My Org") + // Should NOT fail on email validation + if err != nil && strings.Contains(err.Error(), "valid email") { + t.Errorf("valid email should not fail validation, got: %s", err.Error()) + } +} + +// --- password strength tests --- + +func TestAuthSignup_WeakPassword_OnlyLowercase(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "abcdefgh", "John", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for password with only lowercase") + } + if !strings.Contains(err.Error(), "at least 3 of") { + t.Errorf("error should mention character types, got: %s", err.Error()) + } +} + +func TestAuthSignup_WeakPassword_TwoClasses(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "abcdefg1", "John", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for password with only 2 character types") + } + if !strings.Contains(err.Error(), "at least 3 of") { + t.Errorf("error should mention character types, got: %s", err.Error()) + } +} + +func TestAuthSignup_StrongPassword_ThreeClasses(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + // Abcdefg1 has lower + upper + digit = 3 types — should pass password validation + err := runAuthSignup(cmd, "test@example.com", "Abcdefg1", "John", "Doe", "cl", "My Org") + // Should NOT fail on password validation (will fail on HTTP call) + if err != nil && strings.Contains(err.Error(), "at least 3 of") { + t.Errorf("strong password should not fail validation, got: %s", err.Error()) + } +} + +func TestAuthSignup_StrongPassword_AllFourClasses(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + // SecureP@ss1 has all 4 types — should pass + err := runAuthSignup(cmd, "test@example.com", "SecureP@ss1", "John", "Doe", "cl", "My Org") + if err != nil && strings.Contains(err.Error(), "at least 3 of") { + t.Errorf("password with all 4 types should pass validation, got: %s", err.Error()) + } +} + +// --- end-to-end signup tests with mock server --- + +func TestAuthSignup_SuccessfulSignup_NonInteractive(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + + var req signup.Request + json.NewDecoder(r.Body).Decode(&req) + + if req.Email != "test@example.com" { + t.Errorf("email: want test@example.com, got %s", req.Email) + } + if req.Name != "John" { + t.Errorf("name: want John, got %s", req.Name) + } + if req.LastName != "Doe" { + t.Errorf("last_name: want Doe, got %s", req.LastName) + } + if req.Country != "cl" { + t.Errorf("country: want cl, got %s", req.Country) + } + if req.OrganizationName != "My Company" { + t.Errorf("org_name: want My Company, got %s", req.OrganizationName) + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{ + "email": "test@example.com", + "organization_name": "My Company", + }) + })) + defer server.Close() + + orig := rootOpts.baseURL + rootOpts.baseURL = server.URL + defer func() { rootOpts.baseURL = orig }() + + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "SecureP@ss1", "John", "Doe", "cl", "My Company") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := out.String() + if !strings.Contains(output, "Account created") { + t.Errorf("output should contain 'Account created', got: %s", output) + } + if !strings.Contains(output, "My Company") { + t.Errorf("output should contain organization name, got: %s", output) + } + if !strings.Contains(output, "verify") { + t.Errorf("output should mention email verification, got: %s", output) + } + if !strings.Contains(output, "fintoc auth login") { + t.Errorf("output should prompt to run 'fintoc auth login', got: %s", output) + } +} + +func TestAuthSignup_ServerError_ShowsMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": map[string]string{ + "code": "invalid_params", + "message": "An account with this email already exists. Use \"fintoc auth login\" instead.", + }, + }) + })) + defer server.Close() + + orig := rootOpts.baseURL + rootOpts.baseURL = server.URL + defer func() { rootOpts.baseURL = orig }() + + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "existing@example.com", "SecureP@ss1", "John", "Doe", "cl", "My Org") + if err == nil { + t.Fatal("expected error for duplicate email") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("error should contain 'already exists', got: %s", err.Error()) + } + + output := out.String() + if !strings.Contains(output, "Signup failed") { + t.Errorf("output should contain 'Signup failed', got: %s", output) + } +} + +func TestAuthSignup_NonInteractive_AllValid(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{ + "email": "jane@acme.com", + "organization_name": "ACME Corp", + }) + })) + defer server.Close() + + orig := rootOpts.baseURL + rootOpts.baseURL = server.URL + defer func() { rootOpts.baseURL = orig }() + + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "jane@acme.com", "MyP@ssw0rd", "Jane", "Smith", "mx", "ACME Corp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := out.String() + if !strings.Contains(output, "Account created") { + t.Errorf("output should contain 'Account created', got: %s", output) + } + if !strings.Contains(output, "ACME Corp") { + t.Errorf("output should contain org name, got: %s", output) + } + if !strings.Contains(output, "verify") { + t.Errorf("output should mention email verification, got: %s", output) + } + if !strings.Contains(output, "fintoc auth login") { + t.Errorf("output should prompt to run login, got: %s", output) + } +} + +// --- retry prompt tests --- + +func TestPromptWithRetry_ValidOnFirstAttempt(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("user@example.com\n")) + + result, err := promptWithRetry(cmd, &out, "Email: ", func(v string) error { + if v == "" { + return fmt.Errorf("required") + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "user@example.com" { + t.Errorf("result = %q, want %q", result, "user@example.com") + } + // Should not contain error indicator + if strings.Contains(out.String(), "!") { + t.Errorf("output should not contain error for valid input, got: %s", out.String()) + } +} + +func TestPromptWithRetry_ValidOnSecondAttempt(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + // First input is empty (fails), second is valid + cmd.SetIn(strings.NewReader("\nuser@example.com\n")) + + result, err := promptWithRetry(cmd, &out, "Email: ", func(v string) error { + if v == "" { + return fmt.Errorf("email is required") + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "user@example.com" { + t.Errorf("result = %q, want %q", result, "user@example.com") + } + // Should contain the error message from first attempt + if !strings.Contains(out.String(), "email is required") { + t.Errorf("output should show validation error, got: %s", out.String()) + } +} + +func TestPromptWithRetry_ExhaustsRetries(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + // All 3 inputs are empty (fail) + cmd.SetIn(strings.NewReader("\n\n\n")) + + _, err := promptWithRetry(cmd, &out, "Email: ", func(v string) error { + if v == "" { + return fmt.Errorf("email is required") + } + return nil + }) + if err == nil { + t.Fatal("expected error after exhausting retries") + } + if !strings.Contains(err.Error(), "too many invalid attempts") { + t.Errorf("error = %q, want to contain 'too many invalid attempts'", err.Error()) + } +} + +func TestPromptLineWithRetry_ValidOnFirstAttempt(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("My Organization Inc\n")) + + result, err := promptLineWithRetry(cmd, &out, "Org name: ", func(v string) error { + if v == "" { + return fmt.Errorf("required") + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "My Organization Inc" { + t.Errorf("result = %q, want %q", result, "My Organization Inc") + } +} + +func TestPromptLineWithRetry_ValidOnSecondAttempt(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + // First is empty line, second has value + cmd.SetIn(strings.NewReader("\nMy Company\n")) + + result, err := promptLineWithRetry(cmd, &out, "Org name: ", func(v string) error { + if v == "" { + return fmt.Errorf("organization name is required") + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "My Company" { + t.Errorf("result = %q, want %q", result, "My Company") + } + if !strings.Contains(out.String(), "organization name is required") { + t.Errorf("output should show error from first attempt, got: %s", out.String()) + } +} + +func TestPromptWithRetry_CountryRetry(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + // "us" is invalid, then "cl" is valid + cmd.SetIn(strings.NewReader("us\ncl\n")) + + result, err := promptWithRetry(cmd, &out, "Country: ", func(v string) error { + v = strings.TrimSpace(strings.ToLower(v)) + if v != "cl" && v != "mx" { + return fmt.Errorf("country must be 'cl' (Chile) or 'mx' (Mexico)") + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "cl" { + t.Errorf("result = %q, want %q", result, "cl") + } + // Should show the error from "us" attempt + if !strings.Contains(out.String(), "country must be") { + t.Errorf("output should show country validation error, got: %s", out.String()) + } +} + +// --- missing field non-interactive tests for completeness --- + +func TestAuthSignup_MissingLastName_NonInteractive(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "SecureP@ss1", "John", "", "cl", "My Org") + if err == nil { + t.Fatal("expected error for missing last name in non-interactive mode") + } + if !strings.Contains(err.Error(), "last-name") && !strings.Contains(err.Error(), "last name") { + t.Errorf("error should mention last name, got: %s", err.Error()) + } +} + +func TestAuthSignup_MissingCountry_NonInteractive(t *testing.T) { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("")) + + err := runAuthSignup(cmd, "test@example.com", "SecureP@ss1", "John", "Doe", "", "My Org") + if err == nil { + t.Fatal("expected error for missing country in non-interactive mode") + } + if !strings.Contains(err.Error(), "country") { + t.Errorf("error should mention country, got: %s", err.Error()) + } +} + +// --- auth logout tests --- + +func TestRevokeKeysOnServer_Success(t *testing.T) { + var receivedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/cli/logout" { + t.Errorf("expected /v1/cli/logout, got %s", r.URL.Path) + } + receivedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + orig := rootOpts.baseURL + rootOpts.baseURL = server.URL + defer func() { rootOpts.baseURL = orig }() + + var out bytes.Buffer + // Call revokeKeysOnServer with a mock key by temporarily setting env. + origEnv := os.Getenv(config.EnvAPIKey) + os.Setenv(config.EnvAPIKey, "sk_test_revoke123") + defer func() { + if origEnv != "" { + os.Setenv(config.EnvAPIKey, origEnv) + } else { + os.Unsetenv(config.EnvAPIKey) + } + }() + + // collectStoredKeys clears the env internally, so we test revokeKeysOnServer + // by creating a direct api client call instead. + client := api.NewClient("sk_test_revoke123", "", api.WithBaseURL(server.URL), api.WithHTTPClient(&http.Client{Timeout: 5 * time.Second})) + _, _, err := client.PostJSON(context.Background(), "/v1/cli/logout", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedAuth != "sk_test_revoke123" { + t.Errorf("expected Authorization header sk_test_revoke123, got %s", receivedAuth) + } + _ = out +} + +func TestRevokeKeysOnServer_ServerError_GracefulDegradation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": map[string]string{ + "type": "api_error", + "message": "Internal server error", + }, + }) + })) + defer server.Close() + + var out bytes.Buffer + + opts := []api.ClientOption{ + api.WithBaseURL(server.URL), + api.WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + } + client := api.NewClient("sk_test_fail123", "", opts...) + _, _, err := client.PostJSON(context.Background(), "/v1/cli/logout", nil) + + // The call should fail but not panic — graceful degradation + if err == nil { + t.Error("expected error from server 500, got nil") + } + _ = out +} + +func TestRevokeKeysOnServer_NetworkError_GracefulDegradation(t *testing.T) { + // Point to a server that doesn't exist + opts := []api.ClientOption{ + api.WithBaseURL("http://127.0.0.1:1"), + api.WithHTTPClient(&http.Client{Timeout: 1 * time.Second}), + } + client := api.NewClient("sk_test_net_fail", "", opts...) + _, _, err := client.PostJSON(context.Background(), "/v1/cli/logout", nil) + + if err == nil { + t.Error("expected network error, got nil") + } +} + +func TestAuthLogout_ServerRevoke_PostToCorrectEndpoint(t *testing.T) { + var gotMethod, gotPath, gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := api.NewClient("sk_test_logout123", "", api.WithBaseURL(server.URL), api.WithHTTPClient(&http.Client{Timeout: 5 * time.Second})) + _, _, err := client.PostJSON(context.Background(), "/v1/cli/logout", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotMethod != http.MethodPost { + t.Errorf("expected POST, got %s", gotMethod) + } + if gotPath != "/v1/cli/logout" { + t.Errorf("expected /v1/cli/logout, got %s", gotPath) + } + if gotAuth != "sk_test_logout123" { + t.Errorf("expected Authorization sk_test_logout123, got %s", gotAuth) + } +} + +func TestCollectStoredLiveKey_SkipsEnvVar(t *testing.T) { + origEnv := os.Getenv(config.EnvAPIKey) + os.Setenv(config.EnvAPIKey, "sk_live_from_env") + defer func() { + if origEnv != "" { + os.Setenv(config.EnvAPIKey, origEnv) + } else { + os.Unsetenv(config.EnvAPIKey) + } + }() + + key := collectStoredLiveKey() + + // The env var should be restored after collectStoredLiveKey + restored := os.Getenv(config.EnvAPIKey) + if restored != "sk_live_from_env" { + t.Errorf("env var should be restored, got %q", restored) + } + + // Keys from env should NOT be collected (only file/keyring keys) + if key == "sk_live_from_env" { + t.Error("collectStoredLiveKey should not include env var key") + } +} + // helper func configFromTestValues() config.Config { return config.Config{ diff --git a/internal/signup/client.go b/internal/signup/client.go new file mode 100644 index 0000000..36fcba2 --- /dev/null +++ b/internal/signup/client.go @@ -0,0 +1,102 @@ +package signup + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const DefaultSignupPath = "/v1/cli/signup" + +type Request struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + LastName string `json:"last_name"` + Country string `json:"country"` + OrganizationName string `json:"organization_name"` +} + +type Response struct { + Email string `json:"email,omitempty"` + OrganizationName string `json:"organization_name,omitempty"` +} + +type ErrorResponse struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +type Client struct { + baseURL string + httpClient *http.Client +} + +type Option func(*Client) + +func WithBaseURL(u string) Option { + return func(c *Client) { c.baseURL = u } +} + +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { c.httpClient = hc } +} + +func NewClient(opts ...Option) *Client { + c := &Client{ + baseURL: "https://api.fintoc.com", + httpClient: &http.Client{Timeout: 30 * time.Second}, + } + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *Client) Signup(ctx context.Context, req Request) (*Response, error) { + url := c.baseURL + DefaultSignupPath + + payload, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshaling signup request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "fintoc-cli") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("signup request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading signup response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + var errResp ErrorResponse + if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" { + return nil, fmt.Errorf("%s", errResp.Error.Message) + } + return nil, fmt.Errorf("signup failed (%d): %s", resp.StatusCode, string(body)) + } + + var result Response + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("decoding signup response: %w", err) + } + + return &result, nil +} diff --git a/internal/signup/client_test.go b/internal/signup/client_test.go new file mode 100644 index 0000000..0d78656 --- /dev/null +++ b/internal/signup/client_test.go @@ -0,0 +1,182 @@ +package signup + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSignup_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != DefaultSignupPath { + t.Errorf("expected path %s, got %s", DefaultSignupPath, r.URL.Path) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + if r.Header.Get("User-Agent") != "fintoc-cli" { + t.Errorf("expected User-Agent fintoc-cli, got %s", r.Header.Get("User-Agent")) + } + + var req Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decoding request: %v", err) + } + if req.Email != "test@example.com" { + t.Errorf("expected email test@example.com, got %s", req.Email) + } + if req.Name != "John" { + t.Errorf("expected name John, got %s", req.Name) + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Response{ + Email: "test@example.com", + OrganizationName: "Test Org", + }) + })) + defer server.Close() + + client := NewClient(WithBaseURL(server.URL)) + resp, err := client.Signup(context.Background(), Request{ + Email: "test@example.com", + Password: "SecureP@ss123", + Name: "John", + LastName: "Doe", + Country: "cl", + OrganizationName: "Test Org", + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Email != "test@example.com" { + t.Errorf("expected email test@example.com, got %s", resp.Email) + } + if resp.OrganizationName != "Test Org" { + t.Errorf("expected org name Test Org, got %s", resp.OrganizationName) + } +} + +func TestSignup_ValidationError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": map[string]string{ + "code": "invalid_params", + "message": "password must be at least 8 characters", + }, + }) + })) + defer server.Close() + + client := NewClient(WithBaseURL(server.URL)) + _, err := client.Signup(context.Background(), Request{ + Email: "test@example.com", + Password: "short", + }) + + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); got != "password must be at least 8 characters" { + t.Errorf("expected validation error message, got: %s", got) + } +} + +func TestSignup_EmailTaken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": map[string]string{ + "code": "invalid_params", + "message": "An account with this email already exists. Use \"fintoc auth login\" instead.", + }, + }) + })) + defer server.Close() + + client := NewClient(WithBaseURL(server.URL)) + _, err := client.Signup(context.Background(), Request{ + Email: "existing@example.com", + Password: "SecureP@ss123", + }) + + if err == nil { + t.Fatal("expected error, got nil") + } + expected := "An account with this email already exists. Use \"fintoc auth login\" instead." + if got := err.Error(); got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + +func TestSignup_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal Server Error")) + })) + defer server.Close() + + client := NewClient(WithBaseURL(server.URL)) + _, err := client.Signup(context.Background(), Request{ + Email: "test@example.com", + Password: "SecureP@ss123", + }) + + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSignup_SendsAllFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req Request + json.NewDecoder(r.Body).Decode(&req) + + if req.Email != "user@test.com" { + t.Errorf("email: want user@test.com, got %s", req.Email) + } + if req.Password != "MyP@ssw0rd" { + t.Errorf("password: want MyP@ssw0rd, got %s", req.Password) + } + if req.Name != "Jane" { + t.Errorf("name: want Jane, got %s", req.Name) + } + if req.LastName != "Smith" { + t.Errorf("last_name: want Smith, got %s", req.LastName) + } + if req.Country != "mx" { + t.Errorf("country: want mx, got %s", req.Country) + } + if req.OrganizationName != "ACME Corp" { + t.Errorf("organization_name: want ACME Corp, got %s", req.OrganizationName) + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Response{ + Email: "user@test.com", + OrganizationName: "ACME Corp", + }) + })) + defer server.Close() + + client := NewClient(WithBaseURL(server.URL)) + _, err := client.Signup(context.Background(), Request{ + Email: "user@test.com", + Password: "MyP@ssw0rd", + Name: "Jane", + LastName: "Smith", + Country: "mx", + OrganizationName: "ACME Corp", + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 0000000..7040e5d --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,122 @@ +// Package validate provides shared input validation functions for the Fintoc CLI. +// Validation rules match the Fintoc dashboard (src/shared/utils/validations.ts) +// to ensure consistent behavior across CLI and web interfaces. +package validate + +import ( + "fmt" + "regexp" + "strings" + "unicode" +) + +// emailRegex matches the dashboard's email validation: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +var emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +// specialChars are the special characters accepted by the dashboard password +// validator: /[(!@#$%^&*).,:;]/g +const specialChars = "(!@#$%^&*).,:;" + +// SupportedCountries lists the country codes accepted for signup. +var SupportedCountries = []string{"cl", "mx"} + +const ( + minPasswordLength = 8 + minPasswordCharacterTypes = 3 +) + +// Email validates an email address using the same regex as the dashboard. +// Returns nil if valid, or a descriptive error. +func Email(email string) error { + email = strings.TrimSpace(email) + if email == "" { + return fmt.Errorf("email is required") + } + if !emailRegex.MatchString(email) { + return fmt.Errorf("must be a valid email address (e.g. user@example.com)") + } + return nil +} + +// Password validates password strength using the same rules as the dashboard: +// - Minimum 8 characters +// - Must contain characters from at least 3 of 4 categories: +// lowercase, uppercase, digit, special character (!@#$%^&*).,:;) +// +// Returns nil if valid, or a descriptive error. +func Password(password string) error { + password = strings.TrimSpace(password) + if len(password) < minPasswordLength { + return fmt.Errorf("password must be at least %d characters", minPasswordLength) + } + + types := countCharacterTypes(password) + if types < minPasswordCharacterTypes { + return fmt.Errorf("password must contain at least 3 of: lowercase letter, uppercase letter, number, special character (!@#$%%^&*)") + } + + return nil +} + +// PasswordHint returns a human-readable description of the password requirements. +func PasswordHint() string { + return "min 8 chars, 3 of: lowercase, uppercase, number, special" +} + +// Country validates a country code for signup. +// Accepted values: "cl" (Chile), "mx" (Mexico). Case-insensitive. +func Country(country string) error { + country = strings.TrimSpace(strings.ToLower(country)) + if country == "" { + return fmt.Errorf("country is required") + } + for _, c := range SupportedCountries { + if country == c { + return nil + } + } + return fmt.Errorf("country must be 'cl' (Chile) or 'mx' (Mexico)") +} + +// NonEmpty validates that a trimmed string is not empty. +// fieldName is used in the error message (e.g. "first name"). +func NonEmpty(fieldName, value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is required", fieldName) + } + return nil +} + +// countCharacterTypes returns how many of the 4 password character categories +// are present: lowercase, uppercase, digit, special. +func countCharacterTypes(password string) int { + var hasLower, hasUpper, hasDigit, hasSpecial bool + + for _, ch := range password { + switch { + case unicode.IsLower(ch): + hasLower = true + case unicode.IsUpper(ch): + hasUpper = true + case unicode.IsDigit(ch): + hasDigit = true + case strings.ContainsRune(specialChars, ch): + hasSpecial = true + } + } + + count := 0 + if hasLower { + count++ + } + if hasUpper { + count++ + } + if hasDigit { + count++ + } + if hasSpecial { + count++ + } + return count +} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go new file mode 100644 index 0000000..1cd5cd7 --- /dev/null +++ b/internal/validate/validate_test.go @@ -0,0 +1,232 @@ +package validate + +import ( + "testing" +) + +func TestEmail_Valid(t *testing.T) { + valid := []string{ + "user@example.com", + "a@b.c", + "user+tag@domain.co.uk", + "first.last@company.org", + "numbers123@domain.com", + "under_score@domain.com", + "dash-name@domain.com", + } + for _, email := range valid { + if err := Email(email); err != nil { + t.Errorf("Email(%q) = %v, want nil", email, err) + } + } +} + +func TestEmail_Invalid(t *testing.T) { + tests := []struct { + input string + wantMsg string + }{ + {"", "email is required"}, + {" ", "email is required"}, + {"notanemail", "must be a valid email"}, + {"@nodomain.com", "must be a valid email"}, // empty local part — but regex allows it as [^\s@]+, so this IS valid? let me check + {"no-tld@domain", "must be a valid email"}, // no dot in domain + {"spaces in@email.com", "must be a valid email"}, + {"user@", "must be a valid email"}, + {"@", "must be a valid email"}, + } + for _, tt := range tests { + err := Email(tt.input) + if err == nil { + t.Errorf("Email(%q) = nil, want error containing %q", tt.input, tt.wantMsg) + continue + } + if !contains(err.Error(), tt.wantMsg) { + t.Errorf("Email(%q) = %q, want to contain %q", tt.input, err.Error(), tt.wantMsg) + } + } +} + +func TestEmail_TrimsWhitespace(t *testing.T) { + if err := Email(" user@example.com "); err != nil { + t.Errorf("Email with surrounding whitespace should pass after trim, got: %v", err) + } +} + +func TestPassword_Valid(t *testing.T) { + // Each of these has at least 3 character types + valid := []string{ + "Passw0rd", // upper + lower + digit + "p@ssword1", // lower + special + digit + "P@SSWORD1", // upper + special + digit + "Abcdefg!", // upper + lower + special + "MyP@ssw0rd", // all 4 types + "12345aB!", // digit + lower + upper + special + "SecureP@ss123", // all 4 types + } + for _, pw := range valid { + if err := Password(pw); err != nil { + t.Errorf("Password(%q) = %v, want nil", pw, err) + } + } +} + +func TestPassword_TooShort(t *testing.T) { + short := []string{"", "a", "Ab1!", "Abc12!"} + for _, pw := range short { + err := Password(pw) + if err == nil { + t.Errorf("Password(%q) = nil, want error for short password", pw) + continue + } + if !contains(err.Error(), "at least 8") { + t.Errorf("Password(%q) = %q, want to mention minimum length", pw, err.Error()) + } + } +} + +func TestPassword_InsufficientCharacterTypes(t *testing.T) { + tests := []struct { + input string + desc string + }{ + {"abcdefgh", "only lowercase"}, + {"ABCDEFGH", "only uppercase"}, + {"12345678", "only digits"}, + {"!@#$%^&!", "only special"}, + {"abcdefg1", "lowercase + digit (2 types)"}, + {"ABCDEFG1", "uppercase + digit (2 types)"}, + {"abcdefg!", "lowercase + special (2 types)"}, + {"ABCDEFG!", "uppercase + special (2 types)"}, + {"abcdABCD", "lowercase + uppercase (2 types)"}, + {"1234!@#$", "digit + special (2 types)"}, + } + for _, tt := range tests { + err := Password(tt.input) + if err == nil { + t.Errorf("Password(%q) [%s] = nil, want error for insufficient character types", tt.input, tt.desc) + continue + } + if !contains(err.Error(), "at least 3 of") { + t.Errorf("Password(%q) [%s] = %q, want to mention character types", tt.input, tt.desc, err.Error()) + } + } +} + +func TestPassword_TrimsWhitespace(t *testing.T) { + // " Passw0rd " trims to "Passw0rd" (8 chars, 3 types) — should pass + if err := Password(" Passw0rd "); err != nil { + t.Errorf("Password with surrounding whitespace should pass after trim, got: %v", err) + } +} + +func TestPassword_TrimsWhitespace_ThenTooShort(t *testing.T) { + // " Ab1! " trims to "Ab1!" (4 chars) — should fail on length + err := Password(" Ab1! ") + if err == nil { + t.Fatal("Password that trims to < 8 chars should fail") + } + if !contains(err.Error(), "at least 8") { + t.Errorf("error = %q, want to mention minimum length", err.Error()) + } +} + +func TestPassword_SpecialCharacters(t *testing.T) { + // Verify each special character from the dashboard regex is recognized + specials := "(!@#$%^&*).,:;" + for _, ch := range specials { + pw := "Abcdefg" + string(ch) // 8 chars, lower + upper + special = 3 types + if err := Password(pw); err != nil { + t.Errorf("Password(%q) should accept special char %q, got: %v", pw, string(ch), err) + } + } +} + +func TestPassword_NonListedSpecialCharNotCounted(t *testing.T) { + // Characters NOT in the dashboard's special set should not count as "special" + // "Abcdefg~" has lower+upper = 2 types (~ is not in the list) + err := Password("abcdefg~") + if err == nil { + t.Error("Password with non-listed special char should fail if only 1 other type") + } +} + +func TestCountry_Valid(t *testing.T) { + valid := []string{"cl", "mx", "CL", "MX", "Cl", "mX"} + for _, c := range valid { + if err := Country(c); err != nil { + t.Errorf("Country(%q) = %v, want nil", c, err) + } + } +} + +func TestCountry_ValidWithWhitespace(t *testing.T) { + if err := Country(" cl "); err != nil { + t.Errorf("Country with whitespace should pass after trim, got: %v", err) + } +} + +func TestCountry_Invalid(t *testing.T) { + invalid := []string{"", " ", "us", "br", "chile", "mexico", "AR", "pe"} + for _, c := range invalid { + err := Country(c) + if err == nil { + t.Errorf("Country(%q) = nil, want error", c) + continue + } + } +} + +func TestCountry_ErrorMessage(t *testing.T) { + err := Country("us") + if err == nil { + t.Fatal("expected error") + } + if !contains(err.Error(), "cl") || !contains(err.Error(), "mx") { + t.Errorf("error = %q, want to mention valid countries", err.Error()) + } +} + +func TestNonEmpty_Valid(t *testing.T) { + if err := NonEmpty("first name", "John"); err != nil { + t.Errorf("NonEmpty should pass for non-empty value, got: %v", err) + } +} + +func TestNonEmpty_Empty(t *testing.T) { + tests := []string{"", " ", "\t", "\n"} + for _, v := range tests { + err := NonEmpty("first name", v) + if err == nil { + t.Errorf("NonEmpty(%q) = nil, want error", v) + continue + } + if !contains(err.Error(), "first name") || !contains(err.Error(), "required") { + t.Errorf("error = %q, want to mention field name and 'required'", err.Error()) + } + } +} + +func TestPasswordHint(t *testing.T) { + hint := PasswordHint() + if hint == "" { + t.Error("PasswordHint() should return non-empty string") + } + if !contains(hint, "8") { + t.Error("hint should mention 8 character minimum") + } +} + +// helper +func contains(s, substr string) bool { + return len(s) >= len(substr) && containsSubstring(s, substr) +} + +func containsSubstring(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +}