Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,12 @@ func NewClient(cfg *config.Config, log *debug.Logger) (*Client, error) {
host: host,
}

var rt http.RoundTripper = transport
if cfg.ReadOnly {
rt = &readOnlyTransport{base: transport}
}
c := &Client{
httpClient: &http.Client{Transport: transport},
httpClient: &http.Client{Transport: rt},
baseURL: baseURL,
debugLog: log,
}
Expand Down
34 changes: 34 additions & 0 deletions internal/api/read_only.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package api

import (
"fmt"
"net/http"
)

// ErrReadOnly is returned when a mutating request is attempted while the
// client is in read-only mode. The request is never sent.
type ErrReadOnly struct {
Method string
Path string
}

func (e *ErrReadOnly) Error() string {
return fmt.Sprintf(
"read-only mode enabled: refusing %s %s (unset --read-only / REDMINE_READ_ONLY to allow writes)",
e.Method, e.Path,
)
}

// readOnlyTransport rejects any non-read HTTP method before delegating to base.
type readOnlyTransport struct {
base http.RoundTripper
}

func (t *readOnlyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
switch req.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return t.base.RoundTrip(req)
default:
return nil, &ErrReadOnly{Method: req.Method, Path: req.URL.Path}
}
}
46 changes: 46 additions & 0 deletions internal/api/read_only_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package api

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/aarondpn/redmine-cli/v2/internal/config"
"github.com/aarondpn/redmine-cli/v2/internal/debug"
)

func TestReadOnlyBlocksWritesAllowsReads(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()

cfg := &config.Config{Server: srv.URL, APIKey: "k", AuthMethod: "apikey", ReadOnly: true}
c, err := NewClient(cfg, debug.New(nil))
if err != nil {
t.Fatal(err)
}

// Write is refused before reaching the server.
err = c.Post(context.Background(), "/issues.json", map[string]any{}, nil)
var roErr *ErrReadOnly
if !errors.As(err, &roErr) {
t.Fatalf("Post error = %v, want *ErrReadOnly", err)
}
if hits != 0 {
t.Fatalf("server received %d requests, want 0", hits)
}

// Read still works.
if err := c.Get(context.Background(), "/issues.json", nil, &struct{}{}); err != nil {
t.Fatalf("Get failed: %v", err)
}
if hits != 1 {
t.Fatalf("server received %d requests, want 1", hits)
}
}
5 changes: 5 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) {
verbose bool
cfgFile string
outputFormat string
readOnly bool
)

cmd := &cobra.Command{
Expand All @@ -72,6 +73,9 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) {
}
f.Verbose = verbose
f.OutputFormat = outputFormat
if cmd.Flags().Changed("read-only") {
f.ReadOnly = &readOnly
}
return nil
},
SilenceUsage: true,
Expand All @@ -86,6 +90,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) {
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug logging")
cmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Config file path (default ~/.redmine-cli.yaml)")
cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "", "Output format: table, json, csv")
cmd.PersistentFlags().BoolVar(&readOnly, "read-only", false, "Refuse all write requests (GET/HEAD only)")
_ = cmd.RegisterFlagCompletionFunc("output", cmdutil.CompleteOutputFormat)

// Version
Expand Down
7 changes: 7 additions & 0 deletions internal/cmdutil/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ type Factory struct {
APIKeyOverride string
NoColorOverride bool

// ReadOnly, when non-nil, is the --read-only flag value and overrides
// both REDMINE_READ_ONLY and the per-profile config (highest precedence).
ReadOnly *bool

// OutputFormat is set by the root persistent --output/-o flag and used
// as the default format when commands call Printer("").
OutputFormat string
Expand Down Expand Up @@ -94,6 +98,9 @@ func (f *Factory) Config() (*config.Config, error) {
if f.NoColorOverride {
cfg.NoColor = true
}
if f.ReadOnly != nil {
cfg.ReadOnly = *f.ReadOnly
}

f.config = cfg
return cfg, nil
Expand Down
38 changes: 38 additions & 0 deletions internal/cmdutil/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,41 @@ func testFactoryWithConfig(t *testing.T, body string) *Factory {
}

var _ = config.Config{}

func TestFactoryReadOnlyFlagBeatsConfig(t *testing.T) {
// File says read_only: true...
cfgPath := writeConfigFile(t, "active_profile: default\nprofiles:\n default:\n server: https://x\n api_key: k\n read_only: true\n")

// ...but the flag explicitly sets it false (highest precedence).
ff := false
f := NewFactory()
f.ConfigPath = cfgPath
f.ProfileOverride = "default"
f.ReadOnly = &ff

cfg, err := f.Config()
if err != nil {
t.Fatal(err)
}
if cfg.ReadOnly {
t.Fatal("flag --read-only=false must override config read_only: true")
}
}

func TestFactoryReadOnlyEnvWhenNoFlag(t *testing.T) {
cfgPath := writeConfigFile(t, "active_profile: default\nprofiles:\n default:\n server: https://x\n api_key: k\n")
t.Setenv("REDMINE_READ_ONLY", "true")

f := NewFactory()
f.ConfigPath = cfgPath
f.ProfileOverride = "default"
// f.ReadOnly stays nil (flag not set)

cfg, err := f.Config()
if err != nil {
t.Fatal(err)
}
if !cfg.ReadOnly {
t.Fatal("REDMINE_READ_ONLY=true must apply when --read-only is not set")
}
}
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,10 @@ func applyEnvOverrides(cfg *Config, log *debug.Logger) {
cfg.NoColor = true
}

if val := os.Getenv("REDMINE_READ_ONLY"); val != "" {
cfg.ReadOnly = parseBoolEnv(val)
}

mcpListEnv := map[string]*[]string{
"REDMINE_MCP_ENABLE_GROUPS": &cfg.MCP.EnableGroups,
"REDMINE_MCP_DISABLE_GROUPS": &cfg.MCP.DisableGroups,
Expand Down
15 changes: 15 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,18 @@ func TestApplyEnvOverrides_MCPAuthToken(t *testing.T) {
t.Errorf("AuthToken = %q, want from-env (env should override file)", cfg.MCP.AuthToken)
}
}

func TestReadOnlyEnvOverride(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(cfgPath, []byte("profiles:\n default:\n server: https://x\n api_key: k\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("REDMINE_READ_ONLY", "1")
cfg, err := Load(cfgPath, "default", debug.New(nil))
if err != nil {
t.Fatal(err)
}
if !cfg.ReadOnly {
t.Fatal("expected ReadOnly=true from REDMINE_READ_ONLY=1")
}
}
1 change: 1 addition & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Config struct {
DefaultProject string `mapstructure:"default_project" yaml:"default_project,omitempty"`
OutputFormat string `mapstructure:"output_format" yaml:"output_format,omitempty"` // "table", "json", "csv"
NoColor bool `mapstructure:"no_color" yaml:"no_color,omitempty"`
ReadOnly bool `mapstructure:"read_only" yaml:"read_only,omitempty"`
MCP MCPConfig `mapstructure:"mcp" yaml:"mcp,omitempty"`
}

Expand Down