diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index f736a57..0f4d11e 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -13,6 +13,9 @@ jobs: runs-on: ubuntu-latest if: github.event.pull_request.draft == false steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Enable auto-merge uses: actions/github-script@v7 env: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e9b2d64..a2cdeaa 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -40,7 +40,7 @@ jobs: run: go generate ./internal/cmd/updater/... - name: Run coverage - run: core go cov --output coverage.txt --threshold 40 --branch-threshold 35 + run: core go cov - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 diff --git a/README.md b/README.md index 963bf4f..3aa1d35 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ For more details, see the [User Guide](docs/user-guide.md). ## Framework Quick Start (Go) ```go -import core "github.com/host-uk/core/pkg/framework/core" +import core "github.com/host-uk/core" -app, err := core.New( +app := core.New( core.WithServiceLock(), ) ``` @@ -80,6 +80,55 @@ task cli:build # Build to cmd/core/bin/core task cli:run # Build and run ``` +## Configuration + +Core uses a layered configuration system where values are resolved in the following priority: + +1. **Command-line flags** (if applicable) +2. **Environment variables** +3. **Configuration file** +4. **Default values** + +### Configuration File + +The default configuration file is located at `~/.core/config.yaml`. + +#### Format + +The file uses YAML format and supports nested structures. + +```yaml +# ~/.core/config.yaml +dev: + editor: vim + debug: true + +log: + level: info +``` + +### Environment Variables + +#### Layered Configuration Mapping + +Any configuration value can be overridden using environment variables with the `CORE_CONFIG_` prefix. After stripping the `CORE_CONFIG_` prefix, the remaining variable name is converted to lowercase and underscores are replaced with dots to map to the configuration hierarchy. + +**Examples:** +- `CORE_CONFIG_DEV_EDITOR=nano` maps to `dev.editor: nano` +- `CORE_CONFIG_LOG_LEVEL=debug` maps to `log.level: debug` + +#### Common Environment Variables + +| Variable | Description | +|----------|-------------| +| `CORE_DAEMON` | Set to `1` to run the application in daemon mode. | +| `NO_COLOR` | If set (to any value), disables ANSI color output. | +| `MCP_ADDR` | Address for the MCP TCP server (e.g., `localhost:9100`). If not set, MCP uses Stdio. | +| `COOLIFY_TOKEN` | API token for Coolify deployments. | +| `AGENTIC_TOKEN` | API token for Agentic services. | +| `UNIFI_URL` | URL of the UniFi controller (e.g., `https://192.168.1.1`). | +| `UNIFI_INSECURE` | Set to `1` or `true` to skip UniFi TLS verification. | + ## All Tasks | Task | Description | @@ -88,7 +137,7 @@ task cli:run # Build and run | `task test-gen` | Generate test stubs for public API | | `task check` | go mod tidy + tests + review | | `task review` | CodeRabbit review | -| `task cov` | Run tests with coverage report | +| `task cov` | Generate coverage.txt | | `task cov-view` | Open HTML coverage report | | `task sync` | Update public API Go files | @@ -100,20 +149,21 @@ task cli:run # Build and run ``` . -├── main.go # CLI application entry point +├── core.go # Facade re-exporting pkg/core ├── pkg/ -│ ├── framework/core/ # Service container, DI, Runtime[T] +│ ├── core/ # Service container, DI, Runtime[T] +│ ├── config/ # JSON persistence, XDG paths +│ ├── display/ # Windows, tray, menus (Wails) │ ├── crypt/ # Hashing, checksums, PGP +│ │ └── openpgp/ # Full PGP implementation │ ├── io/ # Medium interface + backends +│ ├── workspace/ # Encrypted workspace management │ ├── help/ # In-app documentation -│ ├── i18n/ # Internationalization -│ ├── repos/ # Multi-repo registry & management -│ ├── agentic/ # AI agent task management -│ └── mcp/ # Model Context Protocol service -├── internal/ -│ ├── cmd/ # CLI command implementations -│ └── variants/ # Build variants (full, minimal, etc.) -└── go.mod # Go module definition +│ └── i18n/ # Internationalization +├── cmd/ +│ ├── core/ # CLI application +│ └── core-gui/ # Wails GUI application +└── go.work # Links root, cmd/core, cmd/core-gui ``` ### Service Pattern (Dual-Constructor DI) @@ -170,40 +220,6 @@ Service("workspace") // Get service by name (returns any) **NOT exposed:** Direct calls like `workspace.CreateWorkspace()` or `crypt.Hash()`. -## Configuration Management - -Core uses a **centralized configuration service** implemented in `pkg/config`, with YAML-based persistence and layered overrides. - -The `pkg/config` package provides: - -- YAML-backed persistence at `~/.core/config.yaml` -- Dot-notation key access (for example: `cfg.Set("dev.editor", "vim")`, `cfg.GetString("dev.editor")`) -- Environment variable overlay support (env vars can override persisted values) -- Thread-safe operations for concurrent reads/writes - -Application code should treat `pkg/config` as the **primary configuration mechanism**. Direct reads/writes to YAML files should generally be avoided from application logic in favour of using this centralized service. - -### Project and Service Configuration Files - -In addition to the centralized configuration service, Core uses several YAML files for project-specific build/CI and service configuration. These live alongside (but are distinct from) the centralized configuration: - -- **Project Configuration** (in the `.core/` directory of the project root): - - `build.yaml`: Build targets, flags, and project metadata. - - `release.yaml`: Release automation, changelog settings, and publishing targets. - - `ci.yaml`: CI pipeline configuration. -- **Global Configuration** (in the `~/.core/` directory): - - `config.yaml`: Centralized user/framework settings and defaults, managed via `pkg/config`. - - `agentic.yaml`: Configuration for agentic services (BaseURL, Token, etc.). -- **Registry Configuration** (`repos.yaml`, auto-discovered): - - Multi-repo registry definition. - - Searched in the current directory and its parent directories (walking up). - - Then in `~/Code/host-uk/repos.yaml`. - - Finally in `~/.config/core/repos.yaml`. - -### Format - -All persisted configuration files described above use **YAML** format for readability and nested structure support. - ### The IPC Bridge Pattern (Chosen Architecture) Sub-services are accessed via Core's **IPC/ACTION system**, not direct Wails bindings: @@ -244,15 +260,16 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { ### Generating Bindings -Wails v3 bindings are typically generated in the GUI repository (e.g., `core-gui`). - ```bash +cd cmd/core-gui wails3 generate bindings # Regenerate after Go changes ``` +Bindings output to `cmd/core-gui/public/bindings/github.com/host-uk/core/` mirroring Go package structure. + --- -### Service Interfaces (`pkg/framework/core/interfaces.go`) +### Service Interfaces (`pkg/core/interfaces.go`) ```go type Config interface { @@ -285,27 +302,54 @@ type Crypt interface { | Package | Notes | |---------|-------| -| `pkg/framework/core` | Service container, DI, thread-safe - solid | -| `pkg/config` | Layered YAML configuration, XDG paths - solid | -| `pkg/crypt` | Hashing, checksums, symmetric/asymmetric - solid, well-tested | -| `pkg/help` | Embedded docs, full-text search - solid | +| `pkg/core` | Service container, DI, thread-safe - solid | +| `pkg/config` | JSON persistence, XDG paths - solid | +| `pkg/crypt` | Hashing, checksums, PGP - solid, well-tested | +| `pkg/help` | Embedded docs, Show/ShowAt - solid | | `pkg/i18n` | Multi-language with go-i18n - solid | | `pkg/io` | Medium interface + local backend - solid | -| `pkg/repos` | Multi-repo registry & management - solid | -| `pkg/agentic` | AI agent task management - solid | -| `pkg/mcp` | Model Context Protocol service - solid | +| `pkg/workspace` | Workspace creation, switching, file ops - functional | + +### Partial + +| Package | Issues | +|---------|--------| +| `pkg/display` | Window creation works; menu/tray handlers are TODOs | + +--- + +## Priority Work Items + +### 1. IMPLEMENT: System Tray Brand Support + +`pkg/display/tray.go:52-63` - Commented brand-specific menu items need implementation. + +### 2. ADD: Integration Tests + +| Package | Notes | +|---------|-------| +| `pkg/display` | Integration tests requiring Wails runtime (27% unit coverage) | --- ## Package Deep Dives -### pkg/crypt +### pkg/workspace - The Core Feature -The crypt package provides a comprehensive suite of cryptographic primitives: -- **Hashing & Checksums**: SHA-256, SHA-512, and CRC32 support. -- **Symmetric Encryption**: AES-GCM and ChaCha20-Poly1305 for secure data at rest. -- **Key Derivation**: Argon2id for secure password hashing. -- **Asymmetric Encryption**: PGP implementation in the `pkg/crypt/openpgp` subpackage using `github.com/ProtonMail/go-crypto`. +Each workspace is: +1. Identified by LTHN hash of user identifier +2. Has directory structure: `config/`, `log/`, `data/`, `files/`, `keys/` +3. Gets a PGP keypair generated on creation +4. Files accessed via obfuscated paths + +The `workspaceList` maps workspace IDs to public keys. + +### pkg/crypt/openpgp + +Full PGP using `github.com/ProtonMail/go-crypto`: +- `CreateKeyPair(name, passphrase)` - RSA-4096 with revocation cert +- `EncryptPGP()` - Encrypt + optional signing +- `DecryptPGP()` - Decrypt + optional signature verification ### pkg/io - Storage Abstraction @@ -391,4 +435,5 @@ core --help 1. Run `task test` to verify all tests pass 2. Follow TDD: `task test-gen` creates stubs, implement to pass 3. The dual-constructor pattern is intentional: `New(deps)` for tests, `Register()` for runtime -4. IPC handlers in each service's `HandleIPCEvents()` are the frontend bridge +4. See `cmd/core-gui/main.go` for how services wire together +5. IPC handlers in each service's `HandleIPCEvents()` are the frontend bridge diff --git a/Taskfile.yml b/Taskfile.yml index 1e26746..d437990 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -53,11 +53,6 @@ tasks: cmds: - core go cov - cov-view: - desc: "Open HTML coverage report" - cmds: - - core go cov --open - fmt: desc: "Format Go code" cmds: diff --git a/docs/configuration.md b/docs/configuration.md index 568e259..57195f8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -160,10 +160,7 @@ dev: test: parallel: true - coverage: true - thresholds: - statements: 40 - branches: 35 + coverage: false deploy: coolify: diff --git a/docs/workflows.md b/docs/workflows.md index 8c40372..96b0c9f 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -10,8 +10,8 @@ Complete workflow from code to GitHub release. # 1. Run tests core go test -# 2. Check coverage (Statement and Branch) -core go cov --threshold 40 --branch-threshold 35 +# 2. Check coverage +core go cov --threshold 80 # 3. Format and lint core go fmt --fix diff --git a/internal/cmd/go/cmd_gotest.go b/internal/cmd/go/cmd_gotest.go index acc8af8..4145fae 100644 --- a/internal/cmd/go/cmd_gotest.go +++ b/internal/cmd/go/cmd_gotest.go @@ -1,15 +1,12 @@ package gocmd import ( - "bufio" "errors" "fmt" - "io" "os" "os/exec" "path/filepath" "regexp" - "strconv" "strings" "github.com/host-uk/core/pkg/cli" @@ -54,16 +51,10 @@ func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose boo args := []string{"test"} - var covPath string if coverage { - args = append(args, "-cover", "-covermode=atomic") - covFile, err := os.CreateTemp("", "coverage-*.out") - if err == nil { - covPath = covFile.Name() - _ = covFile.Close() - args = append(args, "-coverprofile="+covPath) - defer os.Remove(covPath) - } + args = append(args, "-cover") + } else { + args = append(args, "-cover") } if run != "" { @@ -130,15 +121,7 @@ func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose boo } if cov > 0 { - cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(cov)) - if covPath != "" { - branchCov, err := calculateBlockCoverage(covPath) - if err != nil { - cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), cli.ErrorStyle.Render("unable to calculate")) - } else { - cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov)) - } - } + cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("coverage")), formatCoverage(cov)) } if err == nil { @@ -178,12 +161,10 @@ func parseOverallCoverage(output string) float64 { } var ( - covPkg string - covHTML bool - covOpen bool - covThreshold float64 - covBranchThreshold float64 - covOutput string + covPkg string + covHTML bool + covOpen bool + covThreshold float64 ) func addGoCovCommand(parent *cli.Command) { @@ -212,21 +193,7 @@ func addGoCovCommand(parent *cli.Command) { } covPath := covFile.Name() _ = covFile.Close() - defer func() { - if covOutput == "" { - _ = os.Remove(covPath) - } else { - // Copy to output destination before removing - src, _ := os.Open(covPath) - dst, _ := os.Create(covOutput) - if src != nil && dst != nil { - _, _ = io.Copy(dst, src) - _ = src.Close() - _ = dst.Close() - } - _ = os.Remove(covPath) - } - }() + defer func() { _ = os.Remove(covPath) }() cli.Print("%s %s\n", dimStyle.Render(i18n.Label("coverage")), i18n.ProgressSubject("run", "tests")) // Truncate package list if too long for display @@ -261,7 +228,7 @@ func addGoCovCommand(parent *cli.Command) { // Parse total coverage from last line lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n") - var statementCov float64 + var totalCov float64 if len(lines) > 0 { lastLine := lines[len(lines)-1] // Format: "total: (statements) XX.X%" @@ -269,21 +236,14 @@ func addGoCovCommand(parent *cli.Command) { parts := strings.Fields(lastLine) if len(parts) >= 3 { covStr := strings.TrimSuffix(parts[len(parts)-1], "%") - _, _ = fmt.Sscanf(covStr, "%f", &statementCov) + _, _ = fmt.Sscanf(covStr, "%f", &totalCov) } } } - // Calculate branch coverage (block coverage) - branchCov, err := calculateBlockCoverage(covPath) - if err != nil { - return cli.Wrap(err, "calculate branch coverage") - } - // Print coverage summary cli.Blank() - cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(statementCov)) - cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov)) + cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("total")), formatCoverage(totalCov)) // Generate HTML if requested if covHTML || covOpen { @@ -311,14 +271,10 @@ func addGoCovCommand(parent *cli.Command) { } } - // Check thresholds - if covThreshold > 0 && statementCov < covThreshold { - cli.Print("\n%s Statements: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), statementCov, covThreshold) - return errors.New("statement coverage below threshold") - } - if covBranchThreshold > 0 && branchCov < covBranchThreshold { - cli.Print("\n%s Branches: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), branchCov, covBranchThreshold) - return errors.New("branch coverage below threshold") + // Check threshold + if covThreshold > 0 && totalCov < covThreshold { + cli.Print("\n%s %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), totalCov, covThreshold) + return errors.New("coverage below threshold") } if testErr != nil { @@ -333,66 +289,11 @@ func addGoCovCommand(parent *cli.Command) { covCmd.Flags().StringVar(&covPkg, "pkg", "", "Package to test") covCmd.Flags().BoolVar(&covHTML, "html", false, "Generate HTML report") covCmd.Flags().BoolVar(&covOpen, "open", false, "Open HTML report in browser") - covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum statement coverage percentage") - covCmd.Flags().Float64Var(&covBranchThreshold, "branch-threshold", 0, "Minimum branch coverage percentage") - covCmd.Flags().StringVarP(&covOutput, "output", "o", "", "Output file for coverage profile") + covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum coverage percentage") parent.AddCommand(covCmd) } -// calculateBlockCoverage parses a Go coverage profile and returns the percentage of basic -// blocks that have a non-zero execution count. Go's coverage profile contains one line per -// basic block, where the last field is the execution count, not explicit branch coverage. -// The resulting block coverage is used here only as a proxy for branch coverage; computing -// true branch coverage would require more detailed control-flow analysis. -func calculateBlockCoverage(path string) (float64, error) { - file, err := os.Open(path) - if err != nil { - return 0, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - var totalBlocks, coveredBlocks int - - // Skip the first line (mode: atomic/set/count) - if !scanner.Scan() { - return 0, nil - } - - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) < 3 { - continue - } - - // Last field is the count - count, err := strconv.Atoi(fields[len(fields)-1]) - if err != nil { - continue - } - - totalBlocks++ - if count > 0 { - coveredBlocks++ - } - } - - if err := scanner.Err(); err != nil { - return 0, err - } - - if totalBlocks == 0 { - return 0, nil - } - - return (float64(coveredBlocks) / float64(totalBlocks)) * 100, nil -} - func findTestPackages(root string) ([]string, error) { pkgMap := make(map[string]bool) err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { diff --git a/internal/cmd/go/cmd_qa.go b/internal/cmd/go/cmd_qa.go index fcda477..9aefa48 100644 --- a/internal/cmd/go/cmd_qa.go +++ b/internal/cmd/go/cmd_qa.go @@ -24,7 +24,6 @@ var ( qaOnly string qaCoverage bool qaThreshold float64 - qaBranchThreshold float64 qaDocblockThreshold float64 qaJSON bool qaVerbose bool @@ -72,8 +71,7 @@ Examples: // Coverage flags qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting") qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)") - qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum statement coverage threshold (0-100), fail if below") - qaCmd.PersistentFlags().Float64Var(&qaBranchThreshold, "branch-threshold", 0, "Minimum branch coverage threshold (0-100), fail if below") + qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum coverage threshold (0-100), fail if below") qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)") // Test flags @@ -136,13 +134,11 @@ Examples: // QAResult holds the result of a QA run for JSON output type QAResult struct { - Success bool `json:"success"` - Duration string `json:"duration"` - Checks []CheckResult `json:"checks"` - Coverage *float64 `json:"coverage,omitempty"` - BranchCoverage *float64 `json:"branch_coverage,omitempty"` - Threshold *float64 `json:"threshold,omitempty"` - BranchThreshold *float64 `json:"branch_threshold,omitempty"` + Success bool `json:"success"` + Duration string `json:"duration"` + Checks []CheckResult `json:"checks"` + Coverage *float64 `json:"coverage,omitempty"` + Threshold *float64 `json:"threshold,omitempty"` } // CheckResult holds the result of a single check @@ -258,34 +254,21 @@ func runGoQA(cmd *cli.Command, args []string) error { // Run coverage if requested var coverageVal *float64 - var branchVal *float64 if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) { - cov, branch, err := runCoverage(ctx, cwd) + cov, err := runCoverage(ctx, cwd) if err == nil { coverageVal = &cov - branchVal = &branch if !qaJSON && !qaQuiet { - cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Statement Coverage:"), cov) - cli.Print("%s %.1f%%\n", cli.DimStyle.Render("Branch Coverage:"), branch) + cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Coverage:"), cov) } if qaThreshold > 0 && cov < qaThreshold { failed++ if !qaJSON && !qaQuiet { - cli.Print(" %s Statement coverage %.1f%% below threshold %.1f%%\n", + cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold) + cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.") } } - if qaBranchThreshold > 0 && branch < qaBranchThreshold { - failed++ - if !qaJSON && !qaQuiet { - cli.Print(" %s Branch coverage %.1f%% below threshold %.1f%%\n", - cli.ErrorStyle.Render(cli.Glyph(":cross:")), branch, qaBranchThreshold) - } - } - - if failed > 0 && !qaJSON && !qaQuiet { - cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.") - } } } @@ -294,18 +277,14 @@ func runGoQA(cmd *cli.Command, args []string) error { // JSON output if qaJSON { qaResult := QAResult{ - Success: failed == 0, - Duration: duration.String(), - Checks: results, - Coverage: coverageVal, - BranchCoverage: branchVal, + Success: failed == 0, + Duration: duration.String(), + Checks: results, + Coverage: coverageVal, } if qaThreshold > 0 { qaResult.Threshold = &qaThreshold } - if qaBranchThreshold > 0 { - qaResult.BranchThreshold = &qaBranchThreshold - } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(qaResult) @@ -546,17 +525,8 @@ func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, er return "", cmd.Run() } -func runCoverage(ctx context.Context, dir string) (float64, float64, error) { - // Create temp file for coverage data - covFile, err := os.CreateTemp("", "coverage-*.out") - if err != nil { - return 0, 0, err - } - covPath := covFile.Name() - _ = covFile.Close() - defer os.Remove(covPath) - - args := []string{"test", "-cover", "-covermode=atomic", "-coverprofile=" + covPath} +func runCoverage(ctx context.Context, dir string) (float64, error) { + args := []string{"test", "-cover", "-coverprofile=/tmp/coverage.out"} if qaShort { args = append(args, "-short") } @@ -570,36 +540,36 @@ func runCoverage(ctx context.Context, dir string) (float64, float64, error) { } if err := cmd.Run(); err != nil { - return 0, 0, err + return 0, err } - // Parse statement coverage - coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+covPath) + // Parse coverage + coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func=/tmp/coverage.out") output, err := coverCmd.Output() if err != nil { - return 0, 0, err + return 0, err } // Parse last line for total coverage lines := strings.Split(strings.TrimSpace(string(output)), "\n") - var statementPct float64 - if len(lines) > 0 { - lastLine := lines[len(lines)-1] - fields := strings.Fields(lastLine) - if len(fields) >= 3 { - // Parse percentage (e.g., "45.6%") - pctStr := strings.TrimSuffix(fields[len(fields)-1], "%") - _, _ = fmt.Sscanf(pctStr, "%f", &statementPct) - } + if len(lines) == 0 { + return 0, nil } - // Parse branch coverage - branchPct, err := calculateBlockCoverage(covPath) - if err != nil { - return statementPct, 0, err + lastLine := lines[len(lines)-1] + fields := strings.Fields(lastLine) + if len(fields) < 3 { + return 0, nil } - return statementPct, branchPct, nil + // Parse percentage (e.g., "45.6%") + pctStr := strings.TrimSuffix(fields[len(fields)-1], "%") + var pct float64 + if _, err := fmt.Sscanf(pctStr, "%f", &pct); err == nil { + return pct, nil + } + + return 0, nil } // runInternalCheck runs internal Go-based checks (not external commands). diff --git a/internal/cmd/go/coverage_test.go b/internal/cmd/go/coverage_test.go deleted file mode 100644 index eaf96d8..0000000 --- a/internal/cmd/go/coverage_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package gocmd - -import ( - "os" - "testing" - - "github.com/host-uk/core/pkg/cli" - "github.com/stretchr/testify/assert" -) - -func TestCalculateBlockCoverage(t *testing.T) { - // Create a dummy coverage profile - content := `mode: set -github.com/host-uk/core/pkg/foo.go:1.2,3.4 5 1 -github.com/host-uk/core/pkg/foo.go:5.6,7.8 2 0 -github.com/host-uk/core/pkg/bar.go:10.1,12.20 10 5 -` - tmpfile, err := os.CreateTemp("", "test-coverage-*.out") - assert.NoError(t, err) - defer os.Remove(tmpfile.Name()) - - _, err = tmpfile.Write([]byte(content)) - assert.NoError(t, err) - err = tmpfile.Close() - assert.NoError(t, err) - - // Test calculation - // 3 blocks total, 2 covered (count > 0) - // Expect (2/3) * 100 = 66.666... - pct, err := calculateBlockCoverage(tmpfile.Name()) - assert.NoError(t, err) - assert.InDelta(t, 66.67, pct, 0.01) - - // Test empty file (only header) - contentEmpty := "mode: atomic\n" - tmpfileEmpty, _ := os.CreateTemp("", "test-coverage-empty-*.out") - defer os.Remove(tmpfileEmpty.Name()) - tmpfileEmpty.Write([]byte(contentEmpty)) - tmpfileEmpty.Close() - - pct, err = calculateBlockCoverage(tmpfileEmpty.Name()) - assert.NoError(t, err) - assert.Equal(t, 0.0, pct) - - // Test non-existent file - pct, err = calculateBlockCoverage("non-existent-file") - assert.Error(t, err) - assert.Equal(t, 0.0, pct) - - // Test malformed file - contentMalformed := `mode: set -github.com/host-uk/core/pkg/foo.go:1.2,3.4 5 -github.com/host-uk/core/pkg/foo.go:1.2,3.4 5 notanumber -` - tmpfileMalformed, _ := os.CreateTemp("", "test-coverage-malformed-*.out") - defer os.Remove(tmpfileMalformed.Name()) - tmpfileMalformed.Write([]byte(contentMalformed)) - tmpfileMalformed.Close() - - pct, err = calculateBlockCoverage(tmpfileMalformed.Name()) - assert.NoError(t, err) - assert.Equal(t, 0.0, pct) - - // Test malformed file - missing fields - contentMalformed2 := `mode: set -github.com/host-uk/core/pkg/foo.go:1.2,3.4 5 -` - tmpfileMalformed2, _ := os.CreateTemp("", "test-coverage-malformed2-*.out") - defer os.Remove(tmpfileMalformed2.Name()) - tmpfileMalformed2.Write([]byte(contentMalformed2)) - tmpfileMalformed2.Close() - - pct, err = calculateBlockCoverage(tmpfileMalformed2.Name()) - assert.NoError(t, err) - assert.Equal(t, 0.0, pct) - - // Test completely empty file - tmpfileEmpty2, _ := os.CreateTemp("", "test-coverage-empty2-*.out") - defer os.Remove(tmpfileEmpty2.Name()) - tmpfileEmpty2.Close() - pct, err = calculateBlockCoverage(tmpfileEmpty2.Name()) - assert.NoError(t, err) - assert.Equal(t, 0.0, pct) -} - -func TestParseOverallCoverage(t *testing.T) { - output := `ok github.com/host-uk/core/pkg/foo 0.100s coverage: 50.0% of statements -ok github.com/host-uk/core/pkg/bar 0.200s coverage: 100.0% of statements -` - pct := parseOverallCoverage(output) - assert.Equal(t, 75.0, pct) - - outputNoCov := "ok github.com/host-uk/core/pkg/foo 0.100s" - pct = parseOverallCoverage(outputNoCov) - assert.Equal(t, 0.0, pct) -} - -func TestFormatCoverage(t *testing.T) { - assert.Contains(t, formatCoverage(85.0), "85.0%") - assert.Contains(t, formatCoverage(65.0), "65.0%") - assert.Contains(t, formatCoverage(25.0), "25.0%") -} - -func TestAddGoCovCommand(t *testing.T) { - cmd := &cli.Command{Use: "test"} - addGoCovCommand(cmd) - assert.True(t, cmd.HasSubCommands()) - sub := cmd.Commands()[0] - assert.Equal(t, "cov", sub.Name()) -} - -func TestAddGoQACommand(t *testing.T) { - cmd := &cli.Command{Use: "test"} - addGoQACommand(cmd) - assert.True(t, cmd.HasSubCommands()) - sub := cmd.Commands()[0] - assert.Equal(t, "qa", sub.Name()) -} - -func TestDetermineChecks(t *testing.T) { - // Default checks - qaOnly = "" - qaSkip = "" - qaRace = false - qaBench = false - checks := determineChecks() - assert.Contains(t, checks, "fmt") - assert.Contains(t, checks, "test") - - // Only - qaOnly = "fmt,lint" - checks = determineChecks() - assert.Equal(t, []string{"fmt", "lint"}, checks) - - // Skip - qaOnly = "" - qaSkip = "fmt,lint" - checks = determineChecks() - assert.NotContains(t, checks, "fmt") - assert.NotContains(t, checks, "lint") - assert.Contains(t, checks, "test") - - // Race - qaSkip = "" - qaRace = true - checks = determineChecks() - assert.Contains(t, checks, "race") - assert.NotContains(t, checks, "test") - - // Reset - qaRace = false -} - -func TestBuildCheck(t *testing.T) { - qaFix = false - c := buildCheck("fmt") - assert.Equal(t, "format", c.Name) - assert.Equal(t, []string{"-l", "."}, c.Args) - - qaFix = true - c = buildCheck("fmt") - assert.Equal(t, []string{"-w", "."}, c.Args) - - c = buildCheck("vet") - assert.Equal(t, "vet", c.Name) - - c = buildCheck("lint") - assert.Equal(t, "lint", c.Name) - - c = buildCheck("test") - assert.Equal(t, "test", c.Name) - - c = buildCheck("race") - assert.Equal(t, "race", c.Name) - - c = buildCheck("bench") - assert.Equal(t, "bench", c.Name) - - c = buildCheck("vuln") - assert.Equal(t, "vuln", c.Name) - - c = buildCheck("sec") - assert.Equal(t, "sec", c.Name) - - c = buildCheck("fuzz") - assert.Equal(t, "fuzz", c.Name) - - c = buildCheck("docblock") - assert.Equal(t, "docblock", c.Name) - - c = buildCheck("unknown") - assert.Equal(t, "", c.Name) -} - -func TestBuildChecks(t *testing.T) { - checks := buildChecks([]string{"fmt", "vet", "unknown"}) - assert.Equal(t, 2, len(checks)) - assert.Equal(t, "format", checks[0].Name) - assert.Equal(t, "vet", checks[1].Name) -} - -func TestFixHintFor(t *testing.T) { - assert.Contains(t, fixHintFor("format", ""), "core go qa fmt --fix") - assert.Contains(t, fixHintFor("vet", ""), "go vet") - assert.Contains(t, fixHintFor("lint", ""), "core go qa lint --fix") - assert.Contains(t, fixHintFor("test", "--- FAIL: TestFoo"), "TestFoo") - assert.Contains(t, fixHintFor("race", ""), "Data race") - assert.Contains(t, fixHintFor("bench", ""), "Benchmark regression") - assert.Contains(t, fixHintFor("vuln", ""), "govulncheck") - assert.Contains(t, fixHintFor("sec", ""), "gosec") - assert.Contains(t, fixHintFor("fuzz", ""), "crashing input") - assert.Contains(t, fixHintFor("docblock", ""), "doc comments") - assert.Equal(t, "", fixHintFor("unknown", "")) -} - -func TestRunGoQA_NoGoMod(t *testing.T) { - // runGoQA should fail if go.mod is not present in CWD - // We run it in a temp dir without go.mod - tmpDir, _ := os.MkdirTemp("", "test-qa-*") - defer os.RemoveAll(tmpDir) - cwd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(cwd) - - cmd := &cli.Command{Use: "qa"} - err := runGoQA(cmd, []string{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no go.mod found") -} diff --git a/internal/cmd/test/cmd_output.go b/internal/cmd/test/cmd_output.go index 2673a1c..7df7fa5 100644 --- a/internal/cmd/test/cmd_output.go +++ b/internal/cmd/test/cmd_output.go @@ -138,11 +138,7 @@ func printCoverageSummary(results testResults) { continue } name := shortenPackageName(pkg.name) - padLen := maxLen - len(name) + 2 - if padLen < 0 { - padLen = 2 - } - padding := strings.Repeat(" ", padLen) + padding := strings.Repeat(" ", maxLen-len(name)+2) fmt.Printf(" %s%s%s\n", name, padding, formatCoverage(pkg.coverage)) } @@ -150,11 +146,7 @@ func printCoverageSummary(results testResults) { if results.covCount > 0 { avgCov := results.totalCov / float64(results.covCount) avgLabel := i18n.T("cmd.test.label.average") - padLen := maxLen - len(avgLabel) + 2 - if padLen < 0 { - padLen = 2 - } - padding := strings.Repeat(" ", padLen) + padding := strings.Repeat(" ", maxLen-len(avgLabel)+2) fmt.Printf("\n %s%s%s\n", testHeaderStyle.Render(avgLabel), padding, formatCoverage(avgCov)) } } diff --git a/internal/cmd/test/output_test.go b/internal/cmd/test/output_test.go deleted file mode 100644 index c4b8927..0000000 --- a/internal/cmd/test/output_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package testcmd - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestShortenPackageName(t *testing.T) { - assert.Equal(t, "pkg/foo", shortenPackageName("github.com/host-uk/core/pkg/foo")) - assert.Equal(t, "core-php", shortenPackageName("github.com/host-uk/core-php")) - assert.Equal(t, "bar", shortenPackageName("github.com/other/bar")) -} - -func TestFormatCoverageTest(t *testing.T) { - assert.Contains(t, formatCoverage(85.0), "85.0%") - assert.Contains(t, formatCoverage(65.0), "65.0%") - assert.Contains(t, formatCoverage(25.0), "25.0%") -} - -func TestParseTestOutput(t *testing.T) { - output := `ok github.com/host-uk/core/pkg/foo 0.100s coverage: 50.0% of statements -FAIL github.com/host-uk/core/pkg/bar -? github.com/host-uk/core/pkg/baz [no test files] -` - results := parseTestOutput(output) - assert.Equal(t, 1, results.passed) - assert.Equal(t, 1, results.failed) - assert.Equal(t, 1, results.skipped) - assert.Equal(t, 1, len(results.failedPkgs)) - assert.Equal(t, "github.com/host-uk/core/pkg/bar", results.failedPkgs[0]) - assert.Equal(t, 1, len(results.packages)) - assert.Equal(t, 50.0, results.packages[0].coverage) -} - -func TestPrintCoverageSummarySafe(t *testing.T) { - // This tests the bug fix for long package names causing negative Repeat count - results := testResults{ - packages: []packageCoverage{ - {name: "github.com/host-uk/core/pkg/short", coverage: 100, hasCov: true}, - {name: "github.com/host-uk/core/pkg/a-very-very-very-very-very-long-package-name-that-might-cause-issues", coverage: 80, hasCov: true}, - }, - passed: 2, - totalCov: 180, - covCount: 2, - } - - // Should not panic - assert.NotPanics(t, func() { - printCoverageSummary(results) - }) -} diff --git a/internal/cmd/unifi/cmd_config.go b/internal/cmd/unifi/cmd_config.go index 80cdfc6..ad10b6e 100644 --- a/internal/cmd/unifi/cmd_config.go +++ b/internal/cmd/unifi/cmd_config.go @@ -93,10 +93,6 @@ func showConfig() error { cli.Blank() cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url)) - if insecure { - cli.Print(" %s %s\n", dimStyle.Render("Insecure:"), warningStyle.Render("true (TLS verification skipped)")) - } - if user != "" { cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user)) } else { diff --git a/pkg/framework/core/core.go b/pkg/framework/core/core.go index a6322aa..317b503 100644 --- a/pkg/framework/core/core.go +++ b/pkg/framework/core/core.go @@ -342,15 +342,13 @@ func (c *Core) Display() (Display, error) { } // Workspace returns the registered Workspace service. -func (c *Core) Workspace() Workspace { - w, _ := MustServiceFor[Workspace](c, "workspace") - return w +func (c *Core) Workspace() (Workspace, error) { + return MustServiceFor[Workspace](c, "workspace") } // Crypt returns the registered Crypt service. -func (c *Core) Crypt() Crypt { - cr, _ := MustServiceFor[Crypt](c, "crypt") - return cr +func (c *Core) Crypt() (Crypt, error) { + return MustServiceFor[Crypt](c, "crypt") } // Core returns self, implementing the CoreProvider interface. diff --git a/pkg/i18n/compose_test.go b/pkg/i18n/compose_test.go index 0a95e9d..0428bb2 100644 --- a/pkg/i18n/compose_test.go +++ b/pkg/i18n/compose_test.go @@ -248,11 +248,6 @@ func composeIntent(intent Intent, subject *Subject) *Composed { // can compose the same strings as the intent templates. // This turns the intents definitions into a comprehensive test suite. func TestGrammarComposition_MatchesIntents(t *testing.T) { - // Clear locale env vars to ensure British English fallback (en-GB) - t.Setenv("LANG", "") - t.Setenv("LC_ALL", "") - t.Setenv("LC_MESSAGES", "") - // Test subjects for validation subjects := []struct { noun string @@ -433,11 +428,6 @@ func TestProgress_AllIntentVerbs(t *testing.T) { // TestPastTense_AllIntentVerbs ensures PastTense works for all intent verbs. func TestPastTense_AllIntentVerbs(t *testing.T) { - // Clear locale env vars to ensure British English fallback (en-GB) - t.Setenv("LANG", "") - t.Setenv("LC_ALL", "") - t.Setenv("LC_MESSAGES", "") - expected := map[string]string{ // Destructive "delete": "deleted", @@ -509,11 +499,6 @@ func TestPastTense_AllIntentVerbs(t *testing.T) { // TestGerund_AllIntentVerbs ensures Gerund works for all intent verbs. func TestGerund_AllIntentVerbs(t *testing.T) { - // Clear locale env vars to ensure British English fallback (en-GB) - t.Setenv("LANG", "") - t.Setenv("LC_ALL", "") - t.Setenv("LC_MESSAGES", "") - expected := map[string]string{ // Destructive "delete": "deleting", diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go index 920bbd9..a02bbac 100644 --- a/pkg/i18n/i18n_test.go +++ b/pkg/i18n/i18n_test.go @@ -44,15 +44,10 @@ func TestTranslateWithArgs(t *testing.T) { } func TestSetLanguage(t *testing.T) { - // Clear locale env vars to ensure fallback to en-GB - t.Setenv("LANG", "") - t.Setenv("LC_ALL", "") - t.Setenv("LC_MESSAGES", "") - svc, err := New() require.NoError(t, err) - // Default is en-GB (when no system locale detected) + // Default is en-GB assert.Equal(t, "en-GB", svc.Language()) // Setting invalid language should error diff --git a/pkg/i18n/types.go b/pkg/i18n/types.go index a84db9b..ac17aaa 100644 --- a/pkg/i18n/types.go +++ b/pkg/i18n/types.go @@ -408,16 +408,6 @@ var irregularVerbs = map[string]VerbForms{ "cancel": {Past: "cancelled", Gerund: "cancelling"}, "travel": {Past: "travelled", Gerund: "travelling"}, "label": {Past: "labelled", Gerund: "labelling"}, "model": {Past: "modelled", Gerund: "modelling"}, "level": {Past: "levelled", Gerund: "levelling"}, - // British English spellings - "format": {Past: "formatted", Gerund: "formatting"}, - "analyse": {Past: "analysed", Gerund: "analysing"}, - "organise": {Past: "organised", Gerund: "organising"}, - "recognise": {Past: "recognised", Gerund: "recognising"}, - "realise": {Past: "realised", Gerund: "realising"}, - "customise": {Past: "customised", Gerund: "customising"}, - "optimise": {Past: "optimised", Gerund: "optimising"}, - "initialise": {Past: "initialised", Gerund: "initialising"}, - "synchronise": {Past: "synchronised", Gerund: "synchronising"}, } // noDoubleConsonant contains multi-syllable verbs that don't double the final consonant. diff --git a/pkg/unifi/client.go b/pkg/unifi/client.go index 16012c7..13b15d3 100644 --- a/pkg/unifi/client.go +++ b/pkg/unifi/client.go @@ -17,8 +17,6 @@ type Client struct { // New creates a new UniFi API client for the given controller URL and credentials. // TLS verification can be disabled via the insecure parameter (useful for self-signed certs on home lab controllers). -// WARNING: Setting insecure=true disables certificate verification and should only be used in trusted network -// environments with self-signed certificates (e.g., home lab controllers). func New(url, user, pass, apikey string, insecure bool) (*Client, error) { cfg := &uf.Config{ URL: url, @@ -27,24 +25,23 @@ func New(url, user, pass, apikey string, insecure bool) (*Client, error) { APIKey: apikey, } + // Skip TLS verification if requested (e.g. for self-signed certs) + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: insecure, + MinVersion: tls.VersionTLS12, + }, + }, + } + api, err := uf.NewUnifi(cfg) if err != nil { return nil, log.E("unifi.New", "failed to create client", err) } - // Only override the HTTP client if insecure mode is explicitly requested - if insecure { - // #nosec G402 -- InsecureSkipVerify is intentionally enabled for home lab controllers - // with self-signed certificates. This is opt-in via the insecure parameter. - api.Client = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - MinVersion: tls.VersionTLS12, - }, - }, - } - } + // Override the HTTP client to skip TLS verification + api.Client = httpClient return &Client{api: api, url: url}, nil } diff --git a/pkg/unifi/client_test.go b/pkg/unifi/client_test.go new file mode 100644 index 0000000..7b04d29 --- /dev/null +++ b/pkg/unifi/client_test.go @@ -0,0 +1,50 @@ +package unifi + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + // Mock UniFi controller response for login/initialization + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"meta":{"rc":"ok"}, "data": []}`) + })) + defer ts.Close() + + // Test basic client creation + client, err := New(ts.URL, "user", "pass", "", true) + assert.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, ts.URL, client.URL()) + assert.NotNil(t, client.API()) + + if client.API().Client != nil && client.API().Client.Transport != nil { + if tr, ok := client.API().Client.Transport.(*http.Transport); ok { + assert.True(t, tr.TLSClientConfig.InsecureSkipVerify) + } else { + t.Errorf("expected *http.Transport, got %T", client.API().Client.Transport) + } + } else { + t.Errorf("client or transport is nil") + } + + // Test with insecure false + client, err = New(ts.URL, "user", "pass", "", false) + assert.NoError(t, err) + if tr, ok := client.API().Client.Transport.(*http.Transport); ok { + assert.False(t, tr.TLSClientConfig.InsecureSkipVerify) + } +} + +func TestNew_Error(t *testing.T) { + // uf.NewUnifi fails if URL is invalid (e.g. missing scheme) + client, err := New("localhost:8443", "user", "pass", "", false) + assert.Error(t, err) + assert.Nil(t, client) +} diff --git a/pkg/unifi/config_test.go b/pkg/unifi/config_test.go index 043aa45..1827a8b 100644 --- a/pkg/unifi/config_test.go +++ b/pkg/unifi/config_test.go @@ -1,6 +1,9 @@ package unifi import ( + "fmt" + "net/http" + "net/http/httptest" "os" "testing" @@ -8,33 +11,124 @@ import ( ) func TestResolveConfig(t *testing.T) { - // Set env vars - os.Setenv("UNIFI_URL", "https://env-url") - os.Setenv("UNIFI_USER", "env-user") - os.Setenv("UNIFI_PASS", "env-pass") - os.Setenv("UNIFI_VERIFY_TLS", "false") - defer func() { - os.Unsetenv("UNIFI_URL") - os.Unsetenv("UNIFI_USER") - os.Unsetenv("UNIFI_PASS") - os.Unsetenv("UNIFI_VERIFY_TLS") - }() + // Clear environment variables to start clean + os.Unsetenv("UNIFI_URL") + os.Unsetenv("UNIFI_USER") + os.Unsetenv("UNIFI_PASS") + os.Unsetenv("UNIFI_APIKEY") + os.Unsetenv("UNIFI_INSECURE") + os.Unsetenv("CORE_CONFIG_UNIFI_URL") + os.Unsetenv("CORE_CONFIG_UNIFI_USER") + os.Unsetenv("CORE_CONFIG_UNIFI_PASS") + os.Unsetenv("CORE_CONFIG_UNIFI_APIKEY") + os.Unsetenv("CORE_CONFIG_UNIFI_INSECURE") - url, user, pass, apikey, verifyTLS, err := ResolveConfig("", "", "", "", nil) + // 1. Test defaults + url, user, pass, apikey, insecure, err := ResolveConfig("", "", "", "", nil) assert.NoError(t, err) - assert.Equal(t, "https://env-url", url) - assert.Equal(t, "env-user", user) - assert.Equal(t, "env-pass", pass) - assert.Equal(t, "", apikey) - assert.False(t, verifyTLS) + assert.Equal(t, DefaultURL, url) + assert.Empty(t, user) + assert.Empty(t, pass) + assert.Empty(t, apikey) + assert.False(t, insecure) - // Flag overrides - url, user, pass, apikey, verifyTLS, err = ResolveConfig("https://flag-url", "flag-user", "flag-pass", "flag-apikey", nil) + // 2. Test environment variables + t.Setenv("UNIFI_URL", "https://env.url") + t.Setenv("UNIFI_USER", "envuser") + t.Setenv("UNIFI_PASS", "envpass") + t.Setenv("UNIFI_APIKEY", "envapikey") + t.Setenv("UNIFI_INSECURE", "true") + + url, user, pass, apikey, insecure, err = ResolveConfig("", "", "", "", nil) assert.NoError(t, err) - assert.Equal(t, "https://flag-url", url) - assert.Equal(t, "flag-user", user) - assert.Equal(t, "flag-pass", pass) - assert.Equal(t, "flag-apikey", apikey) - // Env var for verifyTLS still applies if not overridden in ResolveConfig (which it isn't currently via flags) - assert.False(t, verifyTLS) + assert.Equal(t, "https://env.url", url) + assert.Equal(t, "envuser", user) + assert.Equal(t, "envpass", pass) + assert.Equal(t, "envapikey", apikey) + assert.True(t, insecure) + + // Test alternate UNIFI_INSECURE value + t.Setenv("UNIFI_INSECURE", "1") + _, _, _, _, insecure, _ = ResolveConfig("", "", "", "", nil) + assert.True(t, insecure) + + // 3. Test flags (highest priority) + trueVal := true + url, user, pass, apikey, insecure, err = ResolveConfig("https://flag.url", "flaguser", "flagpass", "flagapikey", &trueVal) + assert.NoError(t, err) + assert.Equal(t, "https://flag.url", url) + assert.Equal(t, "flaguser", user) + assert.Equal(t, "flagpass", pass) + assert.Equal(t, "flagapikey", apikey) + assert.True(t, insecure) + + // 4. Flags should still override env vars + falseVal := false + url, user, pass, apikey, insecure, err = ResolveConfig("https://flag.url", "flaguser", "flagpass", "flagapikey", &falseVal) + assert.NoError(t, err) + assert.Equal(t, "https://flag.url", url) + assert.Equal(t, "flaguser", user) + assert.Equal(t, "flagpass", pass) + assert.Equal(t, "flagapikey", apikey) + assert.False(t, insecure) +} + +func TestNewFromConfig(t *testing.T) { + // Mock UniFi controller + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"meta":{"rc":"ok"}, "data": []}`) + })) + defer ts.Close() + + // 1. Success case + client, err := NewFromConfig(ts.URL, "user", "pass", "", nil) + assert.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, ts.URL, client.URL()) + + // 2. Error case: No credentials + os.Unsetenv("UNIFI_USER") + os.Unsetenv("UNIFI_APIKEY") + client, err = NewFromConfig("", "", "", "", nil) + assert.Error(t, err) + assert.Nil(t, client) + assert.Contains(t, err.Error(), "no credentials configured") +} + +func TestSaveConfig(t *testing.T) { + // Mock HOME to use temp dir for config + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + // Clear relevant env vars that might interfere + os.Unsetenv("UNIFI_URL") + os.Unsetenv("UNIFI_USER") + os.Unsetenv("UNIFI_PASS") + os.Unsetenv("UNIFI_APIKEY") + os.Unsetenv("UNIFI_INSECURE") + os.Unsetenv("CORE_CONFIG_UNIFI_URL") + os.Unsetenv("CORE_CONFIG_UNIFI_USER") + os.Unsetenv("CORE_CONFIG_UNIFI_PASS") + os.Unsetenv("CORE_CONFIG_UNIFI_APIKEY") + os.Unsetenv("CORE_CONFIG_UNIFI_INSECURE") + + err := SaveConfig("https://save.url", "saveuser", "savepass", "saveapikey", nil) + assert.NoError(t, err) + + // Verify it saved by resolving it + url, user, pass, apikey, insecure, err := ResolveConfig("", "", "", "", nil) + assert.NoError(t, err) + assert.Equal(t, "https://save.url", url) + assert.Equal(t, "saveuser", user) + assert.Equal(t, "savepass", pass) + assert.Equal(t, "saveapikey", apikey) + assert.False(t, insecure) + + // Test saving insecure true + trueVal := true + err = SaveConfig("", "", "", "", &trueVal) + assert.NoError(t, err) + _, _, _, _, insecure, _ = ResolveConfig("", "", "", "", nil) + assert.True(t, insecure) } diff --git a/pkg/workspace/service.go b/pkg/workspace/service.go index 3ea79a3..67e3723 100644 --- a/pkg/workspace/service.go +++ b/pkg/workspace/service.go @@ -66,9 +66,9 @@ func (s *Service) CreateWorkspace(identifier, password string) (string, error) { } // 3. PGP Keypair generation - crypt := s.core.Crypt() - if crypt == nil { - return "", core.E("workspace.CreateWorkspace", "crypt service not available", nil) + crypt, err := s.core.Crypt() + if err != nil { + return "", core.E("workspace.CreateWorkspace", "failed to retrieve crypt service", err) } privKey, err := crypt.CreateKeyPair(identifier, password) if err != nil {