diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1be2dc2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: Lint & Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + lint_and_test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + - name: Build + run: go build ./... + + - name: Test + run: go test ./... diff --git a/README.md b/README.md index 1d25b40..9d3408d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ tyk --version ```bash git clone https://github.com/sedkis/tyk-cli.git cd tyk-cli -go build -o tyk . +go build -o tyk ./cmd/ sudo mv tyk /usr/local/bin/ ``` @@ -121,6 +121,30 @@ tyk api delete --yes # Delete without confirmation tyk api convert --file api.yaml --format apidef # Convert OAS to Tyk format ``` +### Policy Management +```bash +# Scaffold a new policy file +tyk policy init --id gold --name "Gold Plan" + +# List policies +tyk policy list # Paginated table +tyk policy list --json # JSON output + +# Get a policy (outputs CLI-schema YAML) +tyk policy get # YAML to stdout, summary to stderr +tyk policy get --json # JSON output + +# Apply a policy (idempotent upsert — creates or updates) +tyk policy apply -f policy.yaml # From file +tyk policy apply -f - # From stdin + +# Delete a policy +tyk policy delete # Interactive confirmation +tyk policy delete --yes # Skip confirmation +``` + +Policies use a human-friendly **CLI schema** with readable durations (`30d`, `1h`) and API selectors by name, listen path, or tags. The CLI converts to Dashboard wire format on apply. See the [Policy Guide](https://sedkis.github.io/tyk-cli/manage-policies) for the full YAML reference. + ## ⚙️ Configuration The Tyk CLI uses a unified environment/configuration system with the following precedence (highest to lowest): @@ -196,7 +220,7 @@ cd tyk-cli go mod download # Build the CLI -go build -o tyk . +go build -o tyk ./cmd/ # Run tests go test ./... @@ -210,20 +234,22 @@ make test ``` tyk-cli/ -├── cmd/ # CLI commands and subcommands -├── internal/ # Internal packages -│ ├── config/ # Configuration management -│ ├── client/ # HTTP client for Tyk Dashboard API -│ └── util/ # Utilities and helpers -├── pkg/ # Public packages (if any) -├── test/ # Integration tests -└── docs/ # Documentation +├── cmd/ # Entry point +├── internal/ # Internal packages +│ ├── cli/ # Cobra command definitions (api, policy, config) +│ ├── config/ # Configuration management +│ ├── client/ # HTTP client for Tyk Dashboard API +│ ├── policy/ # Policy domain logic (duration, validation, selectors, conversion) +│ ├── oas/ # OAS file handling +│ └── filehandler/ # File utilities +├── pkg/types/ # Shared types (API, policy, config) +├── test/ # Integration tests +└── docs/ # Documentation (Jekyll site) ``` ## 🗺️ Roadmap ### 🔧 Other lifecycle objects -- Tyk Security Policies - Tyk API Tokens / Credentials ### 🔧 Enhanced Features diff --git a/docs/api-design.md b/docs/api-design.md new file mode 100644 index 0000000..93e6262 --- /dev/null +++ b/docs/api-design.md @@ -0,0 +1,377 @@ +# API Management Design for Tyk CLI + +This document describes the design of OAS-first API management in the Tyk CLI. It covers the architecture, workflows, and design decisions behind the `tyk api` command group. + +## Goals + +- Treat OpenAPI Specification (OAS) files as the source of truth for API definitions. +- Support multiple ingestion paths: quick creation from flags, import from external OAS specs, and declarative apply from Tyk-enhanced OAS files. +- Provide an idempotent `apply` flow for CI/CD and GitOps workflows. +- Separate human-readable output (stderr) from machine-parseable data (stdout) so commands compose cleanly with pipes. +- Keep the client layer thin: the Dashboard API is the authority; the CLI is a UX layer on top. + +## Resource Model + +- Kind: OAS API (OpenAPI 3.0 document with optional `x-tyk-api-gateway` extensions). +- Identity: + - `x-tyk-api-gateway.info.id` (string, assigned by the Dashboard on creation or declared in file for idempotent upsert). + - `info.title` (human-readable name, extracted from OAS `info` section). +- Key fields extracted from extensions: + - `x-tyk-api-gateway.info.name` — API name as known to the Gateway. + - `x-tyk-api-gateway.server.listenPath.value` — the path the Gateway listens on. + - `x-tyk-api-gateway.upstream.url` — the upstream service URL. + - `x-tyk-api-gateway.info.state.active` — whether the API is active. +- Versioning: APIs support multiple versions; each version can carry its own OAS document. The `default_version` field controls which version responds when no version header is sent. + +## Three Ingestion Paths + +The CLI provides three distinct ways to get an API into the Dashboard, each suited to a different stage of the workflow: + +### 1. `tyk api create` — Quick scaffold + +Creates an API from CLI flags. Internally generates a minimal OAS document with Tyk extensions and posts it to the Dashboard. + +```bash +tyk api create --name "User Service" --upstream-url https://users.api.com +``` + +When `--listen-path` is omitted, the CLI auto-generates one from the name (e.g. `"User Service"` becomes `/user-service/`). This uses a slugification algorithm in `internal/oas/transform.go` that lowercases, replaces non-alphanumeric characters with hyphens, and wraps with `/`. + +Best for: bootstrapping a new API quickly during local development. + +### 2. `tyk api import-oas` — External spec ingestion + +Imports a clean OpenAPI specification (no Tyk extensions) from a file or URL. The CLI auto-generates `x-tyk-api-gateway` extensions (name, listen path, upstream, active state) and creates a new API. The API always gets a new Dashboard-assigned ID. + +```bash +tyk api import-oas --file petstore.yaml +tyk api import-oas --url https://api.example.com/openapi.json +``` + +The import path strips any pre-existing `x-tyk-api-gateway.info.id` to guarantee a fresh API. This is intentional: `import-oas` is a "create new" operation, not an upsert. + +Best for: onboarding third-party or externally-authored API specs. + +### 3. `tyk api apply` — Declarative upsert (GitOps) + +Applies a Tyk-enhanced OAS file idempotently. This is the primary GitOps command. + +```bash +tyk api apply --file enhanced-api.yaml # From file +tyk api apply --file - # From stdin (piping) +``` + +Behavior depends on whether `x-tyk-api-gateway.info.id` is present: + +- **ID present**: Check if the API exists on the Dashboard. + - Exists: `PUT` (update). + - Not found: `POST` (create with the declared ID) — enables idempotent upsert. +- **ID absent**: Always creates a new API (Dashboard assigns the ID). + +The file **must** contain `x-tyk-api-gateway` extensions. If it doesn't, `apply` rejects the file with exit code 2 and suggests using `import-oas` or `update-oas` instead. This is a deliberate guardrail: `apply` is for fully-specified configurations, not for auto-enhancing plain specs. + +Best for: CI/CD pipelines, GitOps, and infrastructure-as-code workflows. + +### Why three paths? + +A single `apply` command could theoretically handle all cases (detect missing extensions, generate them, upsert). We split into three for clarity of intent: + +1. **`create`** — imperative, quick, local-dev-friendly. No file needed. +2. **`import-oas`** — one-time ingestion of external specs. Always creates new. +3. **`apply`** — declarative, idempotent, CI-safe. Requires explicit Tyk configuration. + +Each command has clear, non-overlapping semantics. Users don't need to reason about "what will happen if my file does/doesn't have extensions?" — the command name tells them. + +### `tyk api update-oas` — Surgical spec update + +Updates only the OAS portion of an existing API while preserving all Tyk-specific middleware and configuration. Takes a clean OpenAPI spec and merges it with the existing `x-tyk-api-gateway` extensions on the server. + +```bash +tyk api update-oas --file new-spec.yaml +tyk api update-oas --url https://api.example.com/openapi.json +``` + +Best for: updating the API contract (paths, schemas, descriptions) without touching gateway configuration. + +## OAS Extension Handling + +### Auto-generation (`internal/oas/transform.go`) + +When a plain OAS document needs Tyk extensions (for `create` and `import-oas`), the CLI generates minimal extensions: + +- `info.name` from OAS `info.title`. +- `info.state.active = true`. +- `upstream.url` from the first entry in OAS `servers`. +- `server.listenPath.value` from slugified title; `strip = true`. + +No ID is set — the Dashboard assigns one on creation. + +### Extension detection + +`HasTykExtensions` simply checks for the presence of the `x-tyk-api-gateway` key. It does not validate the extension structure. This is intentional: the Dashboard performs authoritative validation. The CLI only checks enough to route to the correct code path. + +### ID extraction + +`ExtractAPIIDFromTykExtensions` navigates the nested map `x-tyk-api-gateway.info.id`. It returns `("", false)` for any structural issue (missing keys, wrong types, empty string). The caller decides whether a missing ID means "create new" or "error". + +## CLI Commands + +Top-level: `tyk api`. + +| Command | Args / Flags | Behavior | +|---------|-------------|----------| +| `list` | `--page N`, `-i` (interactive) | Paginated listing; interactive mode with arrow-key navigation | +| `get ` | `--version-name`, `--oas-only` | Fetch API; default output is YAML OAS to stdout | +| `create` | `--name`, `--upstream-url`, `--listen-path`, `--version-name`, `--custom-domain`, `--description` | Quick-create from flags | +| `import-oas` | `--file` or `--url` | Import external OAS spec | +| `apply` | `--file` (required), `--version-name`, `--set-default` | Declarative upsert | +| `update-oas ` | `--file` or `--url` | Update OAS portion only | +| `delete ` | `--yes` | Delete with confirmation (or skip with `--yes`) | + +Planned (post-v0): +- `tyk api versions list|create|switch-default` — version management. +- `tyk api convert` — format conversion between OAS and Tyk API definition. + +## Output Conventions + +All commands follow the same pattern: + +- **Human messages** go to **stderr** (summaries, prompts, banners, navigation hints). +- **Machine-readable data** goes to **stdout** (YAML, JSON, table rows). + +This means `tyk api get --oas-only > api.yaml` produces a clean file without summaries leaking in. + +The `--json` output format (set via `--output json` on the root command) switches stdout output to JSON. Stderr messages are unaffected. + +### Interactive list mode + +`tyk api list -i` enters a full-screen interactive mode with: + +- Terminal raw mode for keystroke capture. +- Arrow keys / A/D for page navigation, R for refresh, Q to quit. +- Adaptive table layout that adjusts column widths to terminal size. +- Cursor hiding during repainting to prevent flicker. + +Interactive mode is incompatible with JSON output (fails fast with an error). + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success (or user-cancelled delete) | +| 1 | Network / server / unexpected error | +| 2 | Validation error, bad input, missing extensions | +| 3 | Resource not found (get/delete with bad ID) | +| 4 | Conflict (e.g., duplicate API on create) | + +## File Loading + +### Local files + +Files are loaded via `internal/filehandler/filehandler.go`. The handler: + +- Resolves relative paths to absolute. +- Detects format (JSON or YAML) by content, not extension. +- Returns a parsed `map[string]interface{}` representation. + +### Remote URLs + +`loadOASFromURL` fetches the document over HTTP(S) with a 30-second timeout. It attempts JSON parsing first, then YAML. This order is intentional: JSON parsing is strict (rejects YAML-only constructs), while YAML parsing accepts JSON as a subset. + +### Stdin + +`apply -f -` reads from stdin, enabling piping: + +```bash +cat api.yaml | envsubst | tyk api apply -f - +``` + +The `-` convention follows `kubectl`, `docker`, and other CLI tools. + +## Client Layer (`internal/client/client.go`) + +### Dashboard API endpoints + +| Operation | Method | Path | +|-----------|--------|------| +| List (OAS) | GET | `/api/apis/oas?p={page}` | +| List (aggregate) | GET | `/api/apis?p={page}` | +| Get | GET | `/api/apis/oas/{apiId}` | +| Create | POST | `/api/apis/oas` | +| Update | PUT | `/api/apis/oas/{apiId}` | +| Delete | DELETE | `/api/apis/oas/{apiId}` | +| List versions | GET | `/api/apis/oas/{apiId}/versions` | + +### Two list endpoints + +The CLI uses `ListAPIsDashboard` (the `/api/apis` aggregate endpoint) rather than `ListOASAPIs` (the `/api/apis/oas` endpoint) for the `list` command. The aggregate endpoint returns both classic and OAS APIs in a unified format, providing broader compatibility across Dashboard versions. The response structure differs: + +- `/api/apis` returns `{ apis: [{ api_definition: { api_id, name, proxy: { listen_path } } }] }`. +- `/api/apis/oas` returns `{ apis: [] }`. + +The client maps the aggregate response into the same `[]*types.OASAPI` slice used by the CLI layer. + +### Create/Update pattern + +Both `CreateOASAPI` and `UpdateOASAPI` follow a two-step pattern: + +1. Send the OAS document (POST or PUT). +2. The Dashboard returns only basic info (`{ Status, ID }`). +3. Immediately `GET` the full API to return complete metadata to the caller. + +This ensures the caller always gets a fully-populated `*types.OASAPI`, regardless of what the Dashboard returns in the mutation response. + +### Error handling + +HTTP responses with status >= 400 are parsed into `*types.ErrorResponse`. The client attempts JSON parsing first; if the body isn't JSON, the raw status text and body become the error message. + +For the `apply` upsert flow, the CLI distinguishes between "not found" (create path) and actual errors. It checks both typed `*types.ErrorResponse` with status 404 and string-based fallbacks for Dashboards that return 400 with "could not retrieve api" messages. + +## Type System (`pkg/types/api.go`) + +The type system is deliberately minimal: + +- `OASAPI` — the CLI's view of an API. Contains extracted metadata (ID, name, listen path, upstream, versions) plus the raw OAS document as `map[string]interface{}`. +- `APIVersion` — per-version data including its own OAS document. +- `CreateOASAPIRequest` / `UpdateOASAPIRequest` — wire-format request types. +- `ErrorResponse` — structured error with status code, message, and optional details. + +The `OAS` field is `map[string]interface{}` rather than a typed struct. This is deliberate: OAS documents are extensible and vary widely. Parsing into a strict struct would lose extensions, custom fields, and vendor properties. The CLI only needs to read/write a few known paths (`x-tyk-api-gateway`, `info`, `servers`); everything else passes through untouched. + +## Upsert Semantics in `apply` + +The apply pipeline for `tyk api apply -f api.yaml`: + +``` +Read file/stdin + | + v +Parse YAML/JSON -> map[string]interface{} + | + v +Check for x-tyk-api-gateway extensions + | missing -> exit 2 with guidance + v +Extract API ID from extensions + | + +-- ID present -> Check if API exists (GET) + | | + | +-- exists -> PUT (update) + | +-- not found -> POST (create with declared ID) + | + +-- ID absent -> POST (create, Dashboard assigns ID) + | + v + Print result (created/updated) to stderr + Print API details to stdout +``` + +### Why not a separate `create` and `update`? + +Because the primary use case is GitOps: you commit an API file, CI runs `apply` on every push, and it converges to the desired state. Having separate commands forces the user to track whether the API already exists — state that belongs to the server, not to the user's workflow. + +### Conflict handling + +Exit code 4 is reserved for conflicts (HTTP 409). This can happen when the Dashboard detects a listen-path collision or other uniqueness violation. The error message from the Dashboard is surfaced directly. + +## Package Structure + +``` +pkg/types/ Shared types (OASAPI, request/response types, ErrorResponse) + No business logic, just data structures + +internal/oas/ OAS document utilities + transform.go Extension detection, ID extraction, auto-generation, slug generation + +internal/filehandler/ File loading + filehandler.go Loads JSON/YAML files into map[string]interface{} + +internal/client/ HTTP client (CRUD against Dashboard API) + client.go All OAS API operations, response parsing, error mapping + +internal/cli/ Cobra command definitions (CLI layer, wiring) + api.go Command handlers, output formatting, interactive list +``` + +### Dependency direction + +``` +cli -> client -> types +cli -> oas -> (stdlib only) +cli -> filehandler -> (stdlib only) +``` + +`internal/oas` and `internal/filehandler` have no dependency on `internal/client` or `internal/cli`. They are pure utility packages. + +## Testing Strategy + +### Unit tests + +- `internal/oas/transform_test.go` — extension detection, ID extraction, slug generation, auto-enhancement edge cases. +- `internal/filehandler/filehandler_test.go` — file loading for JSON, YAML, invalid content. + +### Integration tests (with httptest) + +- `internal/cli/api_list_test.go` — list command with pagination, JSON output, empty results. +- `internal/cli/api_get_test.go` — get command with version selection, OAS-only mode, not-found handling. +- `internal/cli/api_create_test.go` — create command with various flag combinations, conflict handling. +- `internal/cli/api_import_update_test.go` — import-oas and update-oas workflows. +- `internal/cli/api_interactive_test.go` — interactive list mode keystroke handling. +- `internal/client/client_test.go` — HTTP client methods against `httptest.Server`. + +### Testing pattern + +CLI tests create Cobra commands, inject config via context, mock the Dashboard with `httptest.Server`, and capture stdout/stderr separately. Exit codes are verified via the `ExitError` type. + +## Relationship to Other Features + +### Policies + +Policies reference APIs by ID in their `access_rights` map. The policy system's selector resolution (`internal/policy/selector.go`) uses `ListAPIsDashboard` to map human-friendly selectors (name, listen path, tags) to API IDs. This makes policies portable: policy files reference APIs by name, and the CLI resolves to IDs at apply time. + +`tyk api who-uses ` (planned) will list policies that reference a given API, surfacing dependencies before changes or deletions. + +### Credentials + +Credentials (API keys, OAuth clients) bind to policies, which in turn reference APIs. The credential system does not interact with the API management layer directly — the policy is the intermediary. + +### Portal + +Portal products reference APIs by `api_id`. When publishing an API to the portal, the product spec includes the Dashboard API ID. The portal design proposes resolving API selectors (name, listen path) to IDs via the Dashboard, similar to how policy selectors work. + +## Future Considerations + +### API versioning commands + +The version subcommands (`versions list|create|switch-default`) are scaffolded but not yet implemented. They will use the existing `/api/apis/oas/{apiId}/versions` endpoint. Design considerations: + +- `versions create` should accept either a new OAS file or clone the current default version. +- `switch-default` is a lightweight PATCH operation. +- Version deletion is intentionally not planned (versions are immutable records). + +### `tyk api convert` + +Convert between OAS and Tyk classic API definition formats. Useful for migration from classic APIs to OAS-first. + +### Diff and dry-run + +A `tyk api diff --file api.yaml` command that shows what would change without applying. Similar to `kubectl diff`. Requires fetching the current state and computing a structural diff against the file. + +### Bulk apply + +`tyk api apply --dir apis/` to apply all OAS files in a directory. Ordering would be alphabetical; dependencies between APIs (if any) would need explicit sequencing or a manifest file. + +### API validation / linting + +Client-side validation of OAS documents before sending to the Dashboard. Could use standard OAS validators or Tyk-specific rules (e.g., listen path format, upstream URL reachability). + +## Open Questions + +- Confirm exact version management endpoints and their behavior across Dashboard versions. +- Decide on the `convert` command's scope: full round-trip fidelity or best-effort migration? +- Consider whether `apply` should support `--dry-run` natively (Dashboard may not support a dry-run mode). +- Evaluate whether to add `--prune` support to remove APIs not present in a directory of files. + +--- + +With this design, OAS APIs are first-class, version-controllable resources. The three ingestion paths cover the full spectrum from quick prototyping to full GitOps, while the idempotent `apply` command ensures CI/CD pipelines converge safely to the desired state. diff --git a/docs/getting-started.md b/docs/getting-started.md index 6cb3278..b70a702 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -60,8 +60,47 @@ Next steps: tyk api import-oas --file path/to/my-api.yaml ``` -5) Check it worked -- See your API in the Tyk Dashboard +## 5) Create your first policy + +Generate a scaffold: +```bash +tyk policy init --id gold --name "Gold Plan" +``` + +Edit `policies/gold.yaml` to configure rate limits and API access: +```yaml +apiVersion: tyk.tyktech/v1 +kind: Policy +metadata: + id: gold + name: Gold Plan +spec: + rateLimit: + requests: 1000 + per: 1m + quota: + limit: 100000 + period: 30d + access: + - name: httpbin # Resolves by API name + versions: [Default] +``` + +Apply it: +```bash +tyk policy apply -f policies/gold.yaml +``` + +Verify: +```bash +tyk policy list +tyk policy get gold +``` + +> See the full [Policy Guide]({{ site.baseurl }}/manage-policies) for selectors, durations, and advanced usage. + +## 6) Check it worked +- See your API and policy in the Tyk Dashboard - Hit a simple endpoint or health route Tips diff --git a/docs/index.md b/docs/index.md index 89594a0..9a1a407 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,7 +21,9 @@ brew tap sedkis/tyk && brew install tyk Quick links - Getting Started: {{ site.baseurl }}/getting-started - Configuration: {{ site.baseurl }}/configuration - - Manage APIs: {{ site.baseurl }}/manage-apis/ +- Manage APIs: {{ site.baseurl }}/manage-apis/ +- Manage Policies: {{ site.baseurl }}/manage-policies +- Policy Design: {{ site.baseurl }}/policy-design - Examples: {{ site.baseurl }}/examples/ - FAQ: {{ site.baseurl }}/faq - Contributing: {{ site.baseurl }}/contributing diff --git a/docs/manage-policies.md b/docs/manage-policies.md new file mode 100644 index 0000000..3d0cd6d --- /dev/null +++ b/docs/manage-policies.md @@ -0,0 +1,207 @@ +--- +title: Manage Policies +nav_order: 4 +--- + +# Manage Policies + +Security policies control rate limits, quotas, and API access for your consumers. The CLI lets you author policies as human-friendly YAML files and sync them to your Tyk Dashboard. + +> **Requires Tyk Dashboard v5.12.0+.** The policy commands rely on the Dashboard's `id` field for direct lookups. Earlier versions only resolve the `_id` (MongoDB ObjectID) in API paths, so friendly IDs will not work. + +## Quick start + +```bash +# Scaffold a new policy +tyk policy init --id gold --name "Gold Plan" + +# Edit policies/gold.yaml, then apply +tyk policy apply -f policies/gold.yaml + +# Verify +tyk policy list +tyk policy get gold +``` + +## Policy YAML schema + +Policies use a **CLI schema** that is converted to Dashboard wire format on apply. Durations are human-readable (`30d`, `1h`, `60s`) and APIs are referenced by name, listen path, or tags instead of raw IDs. + +```yaml +id: gold +name: Gold Plan +tags: + - paid + - gold +rateLimit: + requests: 1000 + per: 1m # 1000 requests per minute +quota: + limit: 100000 + period: 30d # 100k requests per 30 days +keyTTL: "0" # 0 = keys never expire +access: + - name: users-api # Match by API name + versions: [v1] + - listenPath: /orders/ # Match by listen path + versions: [v1, v2] + - tags: [public, v1] # Match ALL tags, expands to all matching APIs + versions: [Default] + - id: abc123def456 # Match by exact API ID +``` + +### Friendly IDs + +The `id` field is a **human-readable identifier** that you choose. Use short, descriptive names: + +```yaml +id: gold # Good +id: free-tier # Good +id: rate-limit-v2 # Good +``` + +**Format rules:** +- Lowercase letters, numbers, dots, hyphens, and underscores only +- Must start with a letter or number +- Maximum 64 characters +- Must not look like a MongoDB ObjectID (24-character hex strings are rejected) + +The CLI passes this value directly into the Dashboard's wire `id` field — no prefix, no transformation. Starting in Dashboard v5.12.0+, the `id` field is a first-class identifier used for all API lookups (GET, PUT, DELETE). The Dashboard handles the internal `_id` (MongoDB ObjectID) invisibly; you only work with the friendly name. + +### Durations + +| Format | Meaning | +|--------|---------| +| `30d` | 30 days | +| `24h` | 24 hours | +| `1m` | 1 minute | +| `60s` | 60 seconds | +| `60` | 60 seconds (plain integer) | +| `0` | Disabled / no expiry | + +### API selectors + +Each entry in `access` uses exactly **one** selector to identify APIs: + +| Selector | Behaviour | +|----------|-----------| +| `name` | Exact match on API name. Must resolve to exactly one API. | +| `listenPath` | Exact match on listen path. Must resolve to exactly one API. | +| `id` | Exact match on API ID. Must resolve to exactly one API. | +| `tags` | Match ALL specified tags. Expands to every API that has all tags. | + +If a name or listen path matches zero APIs, the CLI suggests the 3 closest matches. + +## Commands + +### `tyk policy list` + +List policies in a paginated table. The ID column shows the `id` field, or the internal `_id` for policies that have no `id` set (e.g. created outside the CLI). + +```bash +tyk policy list # Table with ID, Name, APIs, Tags +tyk policy list --page 2 # Page 2 +tyk policy list --json # JSON to stdout +``` + +Example output: + +``` +ID Name APIs Tags +--- --- --- --- +gold Gold Plan 3 gold, paid +free-tier Free Tier 1 free +``` + +### `tyk policy get ` + +Retrieve a policy, convert from wire format to CLI schema, and output as YAML. + +```bash +tyk policy get gold # YAML to stdout, summary to stderr +tyk policy get gold --json # JSON to stdout +``` + +The CLI reverse-resolves API IDs back to name selectors where possible. If an API ID cannot be resolved (e.g. the API was deleted), it falls back to an `id` selector. + +### `tyk policy apply -f ` + +Apply a policy with **idempotent upsert** semantics: creates the policy if the friendly ID is not found on the server, updates if it exists. + +```bash +tyk policy apply -f policies/gold.yaml +tyk policy apply -f - # Read from stdin +``` + +The apply pipeline: +1. Parse YAML and validate schema (required fields, duration format, selector format, friendly ID format) +2. Resolve selectors against the live API list +3. Parse durations to seconds +4. Convert CLI schema to wire format +5. Look up the policy by friendly ID (single API call, O(1)) +6. Create (if new) or update (if existing) the policy + +On create, the CLI omits the internal `_id` and lets the Dashboard generate it. On update, the CLI uses the friendly `id` directly in the API call. The `_id` is handled invisibly by the Dashboard. + +Validation and resolution errors return **exit code 2** with all errors listed so you can fix them in one pass. + +### `tyk policy delete ` + +Delete a policy by ID. Fetches the policy first to show its name in the confirmation prompt. + +```bash +tyk policy delete gold # Interactive confirmation +tyk policy delete gold --yes # Skip confirmation +tyk policy delete gold --yes --json # JSON result to stdout +``` + +Exit codes: `0` = success or cancelled, `3` = policy not found. + +### `tyk policy init` + +Generate a scaffold YAML file with sensible defaults. No Dashboard connectivity required. + +```bash +tyk policy init --id gold --name "Gold Plan" +tyk policy init --id silver --name "Silver Plan" --dir ./project +``` + +Creates `policies/{id}.yaml` relative to `--dir` (default: current directory). Refuses to overwrite an existing file. + +## GitOps workflow + +Policies are designed for version control. A typical workflow: + +```bash +# One-time: scaffold +tyk policy init --id gold --name "Gold Plan" + +# Edit in your editor +vim policies/gold.yaml + +# Apply to dev +tyk config use dev +tyk policy apply -f policies/gold.yaml + +# Commit +git add policies/gold.yaml && git commit -m "Add gold policy" + +# Apply to staging +tyk config use staging +tyk policy apply -f policies/gold.yaml +``` + +## Backward compatibility + +Policies created before the friendly ID feature (via the Dashboard UI or older CLI versions) may have an empty `id` field. These policies still appear in `tyk policy list` with their internal `_id` shown in the ID column. + +To manage an existing policy with a friendly ID, re-apply it with a policy file that has the desired `id`. This creates a new CLI-managed policy -- it does not modify the original. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Network or server error | +| `2` | Validation error (bad input, unresolved selectors) | +| `3` | Resource not found | diff --git a/docs/policy-design.md b/docs/policy-design.md new file mode 100644 index 0000000..5fdf962 --- /dev/null +++ b/docs/policy-design.md @@ -0,0 +1,365 @@ +--- +title: Policy Design +nav_order: 10 +--- + +# Policy System Design + +This document explains the architecture, design decisions, and trade-offs behind the `tyk policy` feature. It is aimed at contributors and anyone who needs to understand *why* the system works the way it does. + +## The core problem + +The Tyk Dashboard API uses a JSON wire format for policies that is hostile to humans. API access is keyed by opaque 24-character hex IDs. Durations are raw seconds. There is no validation client-side — the server silently accepts garbage and produces broken policies. None of this is version-control friendly. + +The goal: let users author policies as readable YAML files that they can commit, review, and diff, while the CLI handles all the ugly translation. + +## Two-format architecture + +The system has two distinct representations for the same policy: + +``` +┌──────────────────┐ ┌──────────────────┐ +│ CLI Schema │ apply │ Wire Format │ +│ (PolicyFile) │ ──────► │ (DashboardPolicy)│ +│ Human-written │ │ Dashboard API │ +│ YAML files │ ◄────── │ JSON │ +└──────────────────┘ get └──────────────────┘ +``` + +**CLI schema** (`pkg/types/PolicyFile`) — what users write: +- Durations as strings: `"30d"`, `"1h"`, `"1m"` +- APIs referenced by name, listen path, or tags +- Flat YAML structure, easy to read and diff + +**Wire format** (`pkg/types/DashboardPolicy`) — what the Dashboard expects: +- Durations as integer seconds: `2592000`, `3600`, `60` +- APIs keyed by hex ID in an `access_rights` map +- Nested JSON with fields like `_id`, `quota_renewal_rate`, `key_expires_in` +- `allowed_urls` must be `[]` not `null`, `limit` must be explicit `null` — the Dashboard is picky + +The conversion lives in `internal/policy/convert.go`. It is bidirectional: +- `CLIToWire` — used by `apply` to push to the Dashboard. Passes the user's `id` directly into the wire `id` field and leaves `_id` empty (the Dashboard generates it). +- `WireToCLI` — used by `get` to pull from the Dashboard into readable YAML. Uses the wire `id` field directly as the friendly ID, falling back to `_id` for unmanaged policies (those with an empty `id` field). + +### Why not just use the wire format? + +We considered having users write Dashboard JSON directly (or a thin YAML wrapper over it). Rejected because: + +1. **API IDs are meaningless.** `"a1b2c3d4e5f6"` tells you nothing. `name: users-api` tells you everything. IDs also change between environments, so a policy file with hardcoded IDs cannot be promoted from dev to staging. + +2. **Durations are error-prone.** Is `2592000` thirty days or some other number? `"30d"` is unambiguous. + +3. **The wire format is unstable.** Tyk has added and renamed fields across versions. The CLI schema acts as a stable interface — if the wire format changes, only `convert.go` needs updating. + +4. **Diffability.** `access_rights` is a map keyed by API ID, so the ordering is non-deterministic. The CLI schema uses an ordered list. Git diffs are clean. + +## Duration system + +`internal/policy/duration.go` + +### Parsing rules + +Accepted: `"30d"`, `"24h"`, `"1m"`, `"60s"`, `"60"`, `"0"` + +Rejected: negative (`"-1d"`), fractional (`"1.5h"`), spaces (`"1 d"`), mixed units (`"1h30m"`), bare suffix (`"m"`), empty string. + +### Why no mixed units? + +Go's `time.ParseDuration` supports `"1h30m"`. We deliberately do not. Policy durations are configuration values, not arbitrary time spans. Mixed units create ambiguity in diffs (`"1h30m"` vs `"90m"`) and make it harder to enforce "largest clean unit" formatting on output. Every duration can be expressed in a single unit. + +### Formatting: largest clean unit + +`FormatDuration` converts seconds back to the largest unit that divides evenly: +- `86400` → `"1d"` (not `"24h"` or `"1440m"`) +- `3600` → `"1h"` +- `90` → `"90s"` (not `"1m30s"` — no mixed units) +- `0` → `"0"` + +This ensures `get → edit → apply` round-trips produce minimal YAML diffs. + +### Why a custom `Duration` type? + +The `Duration` type in `pkg/types` is just `type Duration string` with a custom YAML unmarshaler. It exists because YAML treats bare `60` as an integer and `"60"` as a string. Without the custom unmarshaler, a policy file with `per: 60` (no quotes) would fail to parse into a string field. The unmarshaler reads the raw YAML node value regardless of tag type, so both `per: 60` and `per: "1m"` work. + +## Selector resolution + +`internal/policy/selector.go` + +The apply pipeline needs to convert user-friendly API references into the hex IDs the Dashboard expects. This is the selector resolution step. + +### Selector types + +| Selector | Cardinality | Match semantics | +|----------|-------------|-----------------| +| `name` | Exactly 1 | Exact string match on API name | +| `listenPath` | Exactly 1 | Exact string match on listen path | +| `id` | Exactly 1 | Exact string match on API ID | +| `tags` | 1 or more | AND match — API must have ALL listed tags | + +`name`, `listenPath`, and `id` are single-API selectors. They must resolve to exactly one API. `tags` is a multi-API selector — it expands to every API matching all the specified tags. + +### Why restrict to exactly one selector per entry? + +Each `AccessEntry` must have exactly one of `id`, `name`, `listenPath`, or `tags` set. This is validated in `internal/policy/validate.go` (the `selectorCount` function counts non-zero fields). + +Allowing multiple selectors per entry (e.g. both `name` and `tags`) would create ambiguity: does the user want the intersection? The union? If they conflict, which wins? One selector per entry is unambiguous and easy to reason about. + +### Fuzzy suggestions + +When a name selector resolves to zero APIs, instead of just saying "not found", the CLI computes Levenshtein edit distance against all known API names and suggests the 3 closest matches: + +``` +no API found for name "user-api". Did you mean: users-api (a1b2c3), user-service (d4e5f6), user-mgmt (g7h8i9) +``` + +This is implemented with a single-row-optimized Levenshtein algorithm in `selector.go`. The fuzzy matching only kicks in for `name` selectors where typos are common — `listenPath` and `id` selectors fail without suggestions because those values are typically copied, not typed. + +### The `ResolverAPI` decoupling + +The selector resolver does not depend on `types.OASAPI` directly. Instead it takes `[]ResolverAPI`, a minimal struct with just `ID`, `Name`, `ListenPath`, and `Tags`. The CLI layer converts `[]*types.OASAPI` to `[]ResolverAPI` via the `toResolverAPIs` helper. + +This decoupling means: +- The resolver is testable without HTTP mocks — just pass in a slice of `ResolverAPI` +- If the API type changes (e.g. Tyk adds new fields), the resolver doesn't need updating +- The resolver could work with any data source, not just the Dashboard API + +## Validation: collect all errors + +`internal/policy/validate.go` + +Validation collects *all* errors before returning, rather than failing on the first one. This is deliberate: + +```go +var errs types.ValidationErrors +// ... check each field, append to errs ... +return errs +``` + +The alternative — returning on the first error — forces users into an annoying cycle: fix one error, re-run, hit the next error, fix it, re-run. With multi-error collection, the user sees everything wrong at once and can fix it in a single pass. + +### Validation layers + +Validation happens in three layers during `apply`: + +1. **Schema validation** (`validate.go`) — required fields, selector constraints. Runs offline, no network. +2. **Duration validation** (`validate.go`) — checks all duration fields parse correctly. Also offline. +3. **Selector resolution** (`selector.go`) — resolves selectors against the live API list. Requires network. Errors here include "not found" and "ambiguous" with suggestions. + +The layers are ordered by cost: cheap checks first, network calls last. If schema validation fails, we never hit the Dashboard. + +### The `ValidationError` type + +Each error carries three fields: +- `Field` — JSON path like `"access[2]"` or `"rateLimit.per"` +- `Message` — human-readable description +- `Kind` — category: `"schema"`, `"duration"`, or `"selector"` + +The `Kind` field exists so tooling or CI can filter errors by category. + +## The apply pipeline + +`internal/cli/policy.go` — `runPolicyApply` + +The full pipeline for `tyk policy apply -f policy.yaml`: + +``` +Read file/stdin + │ + ▼ +Parse YAML → PolicyFile + │ + ▼ +Validate schema + durations + friendly ID format (offline) + │ fail → exit 2 with all errors + ▼ +Fetch live API list from Dashboard + │ fail → exit 1 + ▼ +Resolve selectors against API list + │ fail → exit 2 with suggestions + ▼ +Convert CLI schema → wire format (CLIToWire) + │ Sets dp.ID = friendly id, leaves dp.MID empty + │ fail → exit 2 + ▼ +Resolve friendly ID: GET /api/portal/policies/{id} (O(1)) + │ + ├── exists (200) → PUT /api/portal/policies/{id} + └── not found (404) → POST /api/portal/policies + │ + ▼ + Print confirmation to stderr +``` + +### Idempotent upsert + +`apply` is a single command that creates or updates. There is no separate `create` or `update`. The existence check is a GET followed by either POST or PUT. + +Why not a `--create-only` or `--update-only` flag? Because the primary use case is GitOps: you run `apply` in CI on every push, and it should converge to the desired state regardless of whether the policy already exists. Idempotent operations are safe to retry. + +### Stdin support + +`apply -f -` reads from stdin. This enables piping: + +```bash +cat policy.yaml | envsubst | tyk policy apply -f - +``` + +The `-` convention follows `kubectl`, `docker`, and other CLI tools. + +## Output conventions + +All commands follow the same pattern established by `api.go`: + +- **Human messages** go to **stderr** (summaries, prompts, "Policy created.") +- **Machine-readable data** goes to **stdout** (YAML, JSON, table rows) + +This means you can pipe `tyk policy get gold > policy.yaml` and the summary line doesn't end up in the file. + +The `--json` flag switches stdout output from the default format (YAML for `get`, table for `list`) to JSON. Stderr messages are unaffected. + +## Exit codes + +| Code | Constant | Meaning | +|------|----------|---------| +| 0 | — | Success (or user-cancelled delete) | +| 1 | — | Network / server / unexpected error | +| 2 | `ExitBadArgs` | Validation error, resolution failure, bad input | +| 3 | `ExitNotFound` | Resource not found (get/delete with bad ID) | + +Exit code 2 for validation errors (not 1) allows scripts to distinguish "your input is wrong" from "the server is down". + +## Wire format quirks + +### `allowed_urls` must be `[]`, not `null` + +The Dashboard API rejects `"allowed_urls": null` but accepts `"allowed_urls": []`. Go's default JSON marshaling produces `null` for a nil slice. The custom `MarshalJSON` on `AccessRight` (`pkg/types/policy.go:142`) handles this: + +```go +if ar.AllowedURLs == nil { + a.AllowedURLs = []AllowedURL{} // serialize as [] +} +``` + +### `limit` must be explicit `null` + +Similarly, per-API rate/quota limits (the `limit` field on `AccessRight`) must be explicitly `null` in the JSON, not omitted. The custom marshaler uses `json.RawMessage("null")` for nil limits. + +### `_id` vs `id` — friendly ID resolution + +The Dashboard stores two identifier fields on every policy: + +- `_id` — the MongoDB document ID. A 24-character hex string generated by the Dashboard on creation. Handled invisibly by the Dashboard. +- `id` — the wire `id` field. Starting in Dashboard v5.12.0+, this is a first-class identifier that can be used directly for all CRUD operations. + +The CLI passes the user's friendly `id` directly into the wire `id` field with no prefix or transformation: + +```yaml +# User writes: +id: gold + +# Wire format: +{"id": "gold", "name": "Gold Plan", ...} +``` + +**Key rules:** + +1. The CLI **never sets or reads `_id`**. The Dashboard generates and manages it invisibly. +2. All API calls (GET, PUT, DELETE) use the `id` field value directly in the URL path. +3. On display (`get`, `list`), the CLI uses `id` directly. If `id` is empty (unmanaged policy), it falls back to `_id`. +4. User tags are untouched — the CLI does not inject managed metadata into tags. + +### O(1) resolution + +The Dashboard's `GET /api/portal/policies/{id}` endpoint (v5.12.0+) resolves the `{id}` parameter against the wire `id` field. This means the CLI can look up a policy by its friendly ID in a single API call: + +``` +GET /api/portal/policies/gold -> 200 (found) or 404 (not found) +``` + +No list+scan, no pagination, no filtering. All single-policy operations (`get`, `delete`, `apply` existence check) are O(1). + +### `access_rights` map key = API ID + +The wire format stores access rights as `map[string]*AccessRight` where the key is the API ID. This is redundant — each `AccessRight` also has an `api_id` field. But the Dashboard requires the key. `CLIToWire` sets both. + +## Reverse resolution (get) + +When `tyk policy get` fetches a policy from the Dashboard, it converts wire format back to CLI schema. The interesting part is reverse-resolving API IDs to names. + +The CLI fetches the current API list and builds a lookup table (`apiByID`). For each `access_rights` entry: +- If the API ID is found in the lookup, use `name` selector +- If the API ID is not found (e.g. API was deleted), fall back to `id` selector + +This is **best-effort** and **non-fatal**. If the API list fetch fails entirely, all entries fall back to `id` selectors. The user still gets a valid, re-appliable policy file — just with IDs instead of names. + +The access entries are sorted by API ID for deterministic YAML output across runs. + +## Package structure + +``` +pkg/types/ Shared types (both formats, validation error type) + No business logic, just data structures + marshaling + +internal/policy/ Pure domain logic (no HTTP, no CLI, no I/O) + duration.go Parse "30d" → 2592000, format 2592000 → "30d" + validate.go Schema + duration + friendly ID validation, error collection + selector.go API selector resolution + fuzzy suggestions + convert.go CLI schema ↔ wire format conversion + resolve.go API access entry resolution types (ResolverAPI, ResolvedAccess, ResolveRequest) + +internal/client/ HTTP client methods (CRUD against Dashboard API) + policy.go ListPolicies, GetPolicy, CreatePolicy, UpdatePolicy, DeletePolicy + +internal/cli/ Cobra command definitions (CLI layer, wiring) + policy.go Command handlers, pipeline orchestration, I/O + root.go Registers policy command in root tree +``` + +### Dependency direction + +``` +cli → client → types +cli → policy → types +``` + +`internal/policy` has zero dependency on `internal/client` or `internal/cli`. It depends only on `pkg/types`. This means all domain logic (parsing, validation, resolution, conversion) is testable with zero HTTP mocking — just pass in structs. + +`internal/cli` is the integration layer that wires everything together: it calls the client, feeds results into the policy package, and handles I/O. + +## Testing strategy + +### Unit tests (no HTTP) + +- `pkg/types/policy_test.go` — YAML/JSON round-trip, `Duration` unmarshaling, `MarshalJSON` nil handling +- `internal/policy/duration_test.go` — parse/format edge cases (negative, fractional, overflow, suffixes) +- `internal/policy/validate_test.go` — multi-error collection, field paths, selector count +- `internal/policy/selector_test.go` — resolve by name/path/id/tags, fuzzy suggestions, ambiguity, zero-match +- `internal/policy/convert_test.go` — CLI→wire→CLI round-trip, duration conversion, access rights mapping, ID pass-through + +### Integration tests (with httptest) + +- `internal/client/policy_test.go` — CRUD methods against `httptest.Server`, verifying request paths/methods/bodies +- `internal/cli/policy_test.go` — Full command tests: create Cobra commands, inject config via context, mock Dashboard with `httptest.Server`, capture stdout/stderr, verify exit codes + +### Walking skeleton + +`TestPolicyIntegration_FullLifecycle` in `policy_test.go` runs the complete lifecycle: list (empty) -> apply (create) -> list (shows policy with friendly ID) -> get (returns CLI schema with friendly ID) -> apply (update, idempotent) -> delete -> list (empty again). This is the acceptance test that proves all commands work together end-to-end, including O(1) resolution by `id`. + +## Future considerations + +### Per-API rate/quota limits + +The wire format supports per-API `limit` overrides via the `AccessRight.Limit` field. The CLI schema currently does not expose this — `Limit` is always `nil` in the wire output. Adding per-API limits would mean extending `AccessEntry` with optional `rateLimit` and `quota` fields and updating `CLIToWire`/`WireToCLI`. + +### URL-level access control + +`AccessRight.AllowedURLs` supports restricting access to specific URL patterns within an API. Currently unused — always `[]`. If exposed, it would add an `allowedURLs` list to `AccessEntry`. + +### Policy partials / inheritance + +Some users want a "base policy" with overrides per tier (gold inherits from base, adds higher limits). This is not supported today. It could be implemented as a CLI-side feature (merge YAML files before apply) without changing the wire format. + +### Multi-environment promotion + +Policies reference APIs by name, which should be consistent across environments. But if API names differ between dev and staging, `apply` will fail with resolution errors. A future `--env-map` flag or mapping file could handle this. diff --git a/internal/cli/api.go b/internal/cli/api.go index 6f33b43..8841b3f 100644 --- a/internal/cli/api.go +++ b/internal/cli/api.go @@ -36,13 +36,6 @@ func truncateWithEllipsis(s string, max int) string { return s[:max-3] + "..." } -func min(a, b int) int { - if a < b { - return a - } - return b -} - // computeTableLayout returns column widths for ID/Name/Path and whether to use a stacked fallback. func computeTableLayout(termWidth int) (idW, nameW, pathW int, stacked bool) { if termWidth < 20 { @@ -254,8 +247,8 @@ After creation, you can: cmd.Flags().String("custom-domain", "", "Custom domain for the API") cmd.Flags().String("description", "", "API description") - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("upstream-url") + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("upstream-url") return cmd } @@ -328,7 +321,7 @@ Examples: cmd.Flags().String("version-name", "", "Version name (defaults to info.version or v1)") cmd.Flags().Bool("set-default", true, "Set this version as the default") - cmd.MarkFlagRequired("file") + _ = cmd.MarkFlagRequired("file") return cmd } @@ -544,7 +537,7 @@ func runInteractiveAPIList(c *client.Client, startPage int) error { return fmt.Errorf("failed to enable raw terminal mode: %w", err) } defer func() { - term.Restore(int(os.Stdin.Fd()), oldState) + _ = term.Restore(int(os.Stdin.Fd()), oldState) showCursor(os.Stderr) }() @@ -908,24 +901,6 @@ func stripExistingAPIID(oasData map[string]interface{}) map[string]interface{} { return oasData } -// extractAPIIDFromOAS extracts API ID from x-tyk-api-gateway.info.id -func extractAPIIDFromOAS(oasData map[string]interface{}) (string, bool) { - if xTyk, exists := oasData["x-tyk-api-gateway"]; exists { - if xTykMap, ok := xTyk.(map[string]interface{}); ok { - if info, exists := xTykMap["info"]; exists { - if infoMap, ok := info.(map[string]interface{}); ok { - if id, exists := infoMap["id"]; exists { - if idStr, ok := id.(string); ok && idStr != "" { - return idStr, true - } - } - } - } - } - } - return "", false -} - // runAPIApply implements the 'tyk api apply' command (declarative upsert) func runAPIApply(cmd *cobra.Command, args []string) error { // Get flags @@ -1205,7 +1180,7 @@ func runAPIDelete(cmd *cobra.Command, args []string) error { if !skipConfirmation { fmt.Printf("Are you sure you want to delete API '%s' (%s)? [y/N]: ", apiID, api.Name) var response string - fmt.Scanln(&response) + _, _ = fmt.Scanln(&response) if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { fmt.Println("Delete operation cancelled") return nil diff --git a/internal/cli/api_get_test.go b/internal/cli/api_get_test.go index 372b7f5..933f9c5 100644 --- a/internal/cli/api_get_test.go +++ b/internal/cli/api_get_test.go @@ -58,7 +58,7 @@ func TestAPIGet_WithOASOnly_JSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/apis/oas/test-api-id", r.URL.Path) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(mockOAS) + _ = json.NewEncoder(w).Encode(mockOAS) })) defer server.Close() @@ -110,7 +110,7 @@ func TestAPIGet_WithOASOnly_YAML(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/apis/oas/test-api-id", r.URL.Path) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(mockOAS) + _ = json.NewEncoder(w).Encode(mockOAS) })) defer server.Close() @@ -169,7 +169,7 @@ func TestAPIGet_WithoutOASOnly_ShowsFullOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/apis/oas/test-api-id", r.URL.Path) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(mockOAS) + _ = json.NewEncoder(w).Encode(mockOAS) })) defer server.Close() @@ -222,7 +222,7 @@ func TestAPIGet_WithOASOnly_HumanOutput_ShowsNoSummary(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(mockOAS) + _ = json.NewEncoder(w).Encode(mockOAS) })) defer server.Close() @@ -276,7 +276,7 @@ func TestAPIGet_WithoutOASOnly_HumanOutput_ShowsSummary(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(mockOAS) + _ = json.NewEncoder(w).Encode(mockOAS) })) defer server.Close() @@ -331,7 +331,7 @@ func TestAPIGet_WithoutOASOnly_HumanOutput_ShowsSummary(t *testing.T) { func TestAPIGet_ErrorHandling(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) - w.Write([]byte("API not found")) + _, _ = w.Write([]byte("API not found")) })) defer server.Close() diff --git a/internal/cli/api_import_update_test.go b/internal/cli/api_import_update_test.go index e05d95d..7d5b522 100644 --- a/internal/cli/api_import_update_test.go +++ b/internal/cli/api_import_update_test.go @@ -120,12 +120,12 @@ func TestRunAPIImportOAS_WithFile(t *testing.T) { if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/api/apis/oas") { // Simulate API creation createResp := mockCreateAPIResponse() - json.NewEncoder(w).Encode(createResp) + _ = json.NewEncoder(w).Encode(createResp) } else if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/apis/oas/new-api-456") { // Simulate getting the created API details api := mockCreatedOASAPI() // Return the OAS document directly as the API endpoint does - json.NewEncoder(w).Encode(api.OAS) + _ = json.NewEncoder(w).Encode(api.OAS) } })) defer server.Close() @@ -146,7 +146,7 @@ func TestRunAPIImportOAS_WithFile(t *testing.T) { cmd.SetContext(withOutputFormat(cmd.Context(), types.OutputJSON)) // Set flags - cmd.Flags().Set("file", tmpFile) + _ = cmd.Flags().Set("file", tmpFile) // Execute command err := cmd.Execute() @@ -184,8 +184,8 @@ func TestRunAPIImportOAS_BothInputs(t *testing.T) { cmd.SetContext(withConfig(context.Background(), config)) // Set both file and url flags - cmd.Flags().Set("file", "/tmp/test.yaml") - cmd.Flags().Set("url", "https://example.com/api.yaml") + _ = cmd.Flags().Set("file", "/tmp/test.yaml") + _ = cmd.Flags().Set("url", "https://example.com/api.yaml") err := cmd.Execute() @@ -216,11 +216,11 @@ func TestRunAPIUpdateOAS_Success(t *testing.T) { // Simulate getting existing API existingAPI := mockCreatedOASAPI() existingAPI.ID = testAPIID - json.NewEncoder(w).Encode(existingAPI.OAS) + _ = json.NewEncoder(w).Encode(existingAPI.OAS) } else if r.Method == http.MethodPut && strings.Contains(r.URL.Path, testAPIID) { // Simulate API update updateResp := types.APIResponse{ID: testAPIID, Message: "Updated"} - json.NewEncoder(w).Encode(updateResp) + _ = json.NewEncoder(w).Encode(updateResp) } })) defer server.Close() @@ -242,7 +242,7 @@ func TestRunAPIUpdateOAS_Success(t *testing.T) { // Set args and flags cmd.SetArgs([]string{testAPIID}) - cmd.Flags().Set("file", tmpFile) + _ = cmd.Flags().Set("file", tmpFile) // Execute command err := cmd.Execute() @@ -299,7 +299,7 @@ func TestRunAPIApply_PlainOASRejection(t *testing.T) { cmd.SetContext(withConfig(context.Background(), config)) // Set file flag - cmd.Flags().Set("file", tmpFile) + _ = cmd.Flags().Set("file", tmpFile) // Execute command err := cmd.Execute() @@ -326,12 +326,12 @@ func TestRunAPIApply_MissingIDCreatesAPI(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/api/apis/oas") { createResp := mockCreateAPIResponse() - json.NewEncoder(w).Encode(createResp) + _ = json.NewEncoder(w).Encode(createResp) return } if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/apis/oas/new-api-456") { api := mockCreatedOASAPI() - json.NewEncoder(w).Encode(api.OAS) + _ = json.NewEncoder(w).Encode(api.OAS) return } http.NotFound(w, r) @@ -347,7 +347,7 @@ func TestRunAPIApply_MissingIDCreatesAPI(t *testing.T) { } cmd.SetContext(withConfig(context.Background(), config)) - cmd.Flags().Set("file", tmpFile) + _ = cmd.Flags().Set("file", tmpFile) // Execute command: should succeed and create new API err := cmd.Execute() @@ -382,7 +382,7 @@ func TestLoadOASFromURL_Success(t *testing.T) { testOAS := mockCleanOAS() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(testOAS) + _ = json.NewEncoder(w).Encode(testOAS) })) defer server.Close() @@ -413,7 +413,7 @@ func TestLoadOASFromURL_HTTPError(t *testing.T) { func TestLoadOASFromURL_InvalidJSON(t *testing.T) { // Create a test server that returns invalid JSON server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("invalid json content")) + _, _ = w.Write([]byte("invalid json content")) })) defer server.Close() diff --git a/internal/cli/api_interactive_test.go b/internal/cli/api_interactive_test.go index 0a284c6..ebc801c 100644 --- a/internal/cli/api_interactive_test.go +++ b/internal/cli/api_interactive_test.go @@ -23,7 +23,7 @@ func TestAPIListInteractiveFlag(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/apis/oas", r.URL.Path) - json.NewEncoder(w).Encode(types.OASAPIListResponse{ + _ = json.NewEncoder(w).Encode(types.OASAPIListResponse{ APIResponse: types.APIResponse{Status: "success"}, APIs: mockAPIs, }) @@ -257,7 +257,7 @@ func TestAPIListWithRealEndpoint(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/apis", r.URL.Path) - json.NewEncoder(w).Encode(dashboardResponse) + _ = json.NewEncoder(w).Encode(dashboardResponse) })) defer server.Close() diff --git a/internal/cli/api_list_test.go b/internal/cli/api_list_test.go index 158aeb7..6a158f0 100644 --- a/internal/cli/api_list_test.go +++ b/internal/cli/api_list_test.go @@ -8,35 +8,14 @@ import ( "os" "testing" - "github.com/spf13/cobra" "github.com/stretchr/testify/require" "github.com/tyktech/tyk-cli/pkg/types" ) -// Helper to prepare root with config context -func prepareRootWithEnv(t *testing.T, dashURL string) *cobra.Command { - t.Helper() - root := NewRootCommand("test", "commit", "time") - // Find 'api list' - listCmd, _, err := root.Find([]string{"api", "list"}) - require.NoError(t, err) - - // Inject config into context - cfg := &types.Config{ - DefaultEnvironment: "test", - Environments: map[string]*types.Environment{ - "test": {Name: "test", DashboardURL: dashURL, AuthToken: "token", OrgID: "org"}, - }, - } - listCmd.SetContext(withConfig(context.Background(), cfg)) - listCmd.SetContext(withOutputFormat(listCmd.Context(), types.OutputHuman)) - return root -} - func TestAPIList_JSONOutput(t *testing.T) { mockAPIs := []*types.OASAPI{{ID: "id1", Name: "Name1"}} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(types.OASAPIListResponse{APIs: mockAPIs}) + _ = json.NewEncoder(w).Encode(types.OASAPIListResponse{APIs: mockAPIs}) })) defer server.Close() @@ -58,7 +37,7 @@ func TestAPIList_JSONOutput(t *testing.T) { func TestAPIList_HumanOutput_NoAPIs(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(types.OASAPIListResponse{APIs: []*types.OASAPI{}}) + _ = json.NewEncoder(w).Encode(types.OASAPIListResponse{APIs: []*types.OASAPI{}}) })) defer server.Close() diff --git a/internal/cli/config.go b/internal/cli/config.go index bcd28fe..da2caf1 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -97,9 +97,9 @@ Examples: cmd.Flags().String("org-id", "", "Organization ID") cmd.Flags().Bool("set-default", false, "Set this environment as the default") - cmd.MarkFlagRequired("dashboard-url") - cmd.MarkFlagRequired("auth-token") - cmd.MarkFlagRequired("org-id") + _ = cmd.MarkFlagRequired("dashboard-url") + _ = cmd.MarkFlagRequired("auth-token") + _ = cmd.MarkFlagRequired("org-id") return cmd } diff --git a/internal/cli/init.go b/internal/cli/init.go index 421f599..83ad1fa 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -88,80 +88,6 @@ func runQuickSetup(scanner *bufio.Scanner, skipTest bool) error { return nil } -func runFullWizard(scanner *bufio.Scanner, skipTest bool) error { - fmt.Println("🎯 Full Setup Wizard") - fmt.Println("-------------------") - fmt.Println() - - var environments []*types.Environment - - // Ask how many environments to set up - fmt.Println("How many environments do you want to configure?") - fmt.Println("1. Just one (development)") - fmt.Println("2. Two (development + production)") - fmt.Println("3. Three (development + staging + production)") - fmt.Println("4. Custom") - fmt.Println() - - choice := askChoice(scanner, "Enter your choice (1-4)", []string{"1", "2", "3", "4"}) - - var envNames []string - switch choice { - case "1": - envNames = []string{"development"} - case "2": - envNames = []string{"development", "production"} - case "3": - envNames = []string{"development", "staging", "production"} - case "4": - envNames = askCustomEnvironments(scanner) - } - - fmt.Printf("\n🔧 Setting up %d environment(s)...\n\n", len(envNames)) - - for i, envName := range envNames { - fmt.Printf("--- Environment %d/%d: %s ---\n", i+1, len(envNames), envName) - - env, err := gatherEnvironmentInfo(scanner, envName, i == 0) - if err != nil { - return err - } - - if !skipTest { - fmt.Printf("\n🔍 Testing connection to %s...\n", envName) - if err := testConnection(env); err != nil { - fmt.Printf("⚠️ Connection test failed: %v\n", err) - if !askYesNo(scanner, "Continue with this environment anyway?") { - continue - } - } else { - fmt.Println("✅ Connection successful!") - } - } - - environments = append(environments, env) - fmt.Println() - } - - if len(environments) == 0 { - return fmt.Errorf("no environments configured") - } - - // Ask which environment should be active - activeEnv := selectActiveEnvironment(scanner, environments) - - // Save all environments - for _, env := range environments { - isDefault := (env.Name == activeEnv) - if err := saveEnvironment(env, isDefault); err != nil { - return fmt.Errorf("failed to save %s environment: %w", env.Name, err) - } - } - - printSuccess(activeEnv) - return nil -} - func gatherEnvironmentInfo(scanner *bufio.Scanner, envName string, isFirst bool) (*types.Environment, error) { env := &types.Environment{Name: envName} @@ -210,49 +136,6 @@ func gatherEnvironmentInfo(scanner *bufio.Scanner, envName string, isFirst bool) return env, nil } -func askCustomEnvironments(scanner *bufio.Scanner) []string { - var envNames []string - - fmt.Println("\nEnter environment names (one per line, empty line to finish):") - - for { - name := askString(scanner, "Environment name", "") - if name == "" { - break - } - envNames = append(envNames, name) - } - - if len(envNames) == 0 { - envNames = []string{"development"} // Default fallback - } - - return envNames -} - -func selectActiveEnvironment(scanner *bufio.Scanner, environments []*types.Environment) string { - if len(environments) == 1 { - return environments[0].Name - } - - fmt.Println("🎯 Which environment should be active by default?") - for i, env := range environments { - fmt.Printf("%d. %s\n", i+1, env.Name) - } - fmt.Println() - - choices := make([]string, len(environments)) - for i := range environments { - choices[i] = fmt.Sprintf("%d", i+1) - } - - choice := askChoice(scanner, "Select active environment", choices) - idx := 0 - fmt.Sscanf(choice, "%d", &idx) - - return environments[idx-1].Name -} - func testConnection(env *types.Environment) error { config := &types.Config{ DefaultEnvironment: "test", @@ -349,18 +232,3 @@ func askYesNo(scanner *bufio.Scanner, prompt string) bool { return input == "y" || input == "yes" } -func askChoice(scanner *bufio.Scanner, prompt string, choices []string) string { - for { - fmt.Printf("%s: ", prompt) - scanner.Scan() - input := strings.TrimSpace(scanner.Text()) - - for _, choice := range choices { - if input == choice { - return input - } - } - - fmt.Printf("Invalid choice. Please select from: %s\n", strings.Join(choices, ", ")) - } -} diff --git a/internal/cli/policy.go b/internal/cli/policy.go new file mode 100644 index 0000000..2cce415 --- /dev/null +++ b/internal/cli/policy.go @@ -0,0 +1,567 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/tyktech/tyk-cli/internal/client" + "github.com/tyktech/tyk-cli/internal/policy" + "github.com/tyktech/tyk-cli/pkg/types" + "gopkg.in/yaml.v3" +) + +const httpTimeout = 30 * time.Second + +// NewPolicyCommand creates the 'tyk policy' command and its subcommands +func NewPolicyCommand() *cobra.Command { + policyCmd := &cobra.Command{ + Use: "policy", + Short: "Manage policies", + Long: "Commands for managing security policies in Tyk Dashboard", + } + + policyCmd.AddCommand(NewPolicyListCommand()) + policyCmd.AddCommand(NewPolicyGetCommand()) + policyCmd.AddCommand(NewPolicyApplyCommand()) + policyCmd.AddCommand(NewPolicyDeleteCommand()) + policyCmd.AddCommand(NewPolicyInitCommand()) + + return policyCmd +} + +// NewPolicyListCommand creates the 'tyk policy list' command +func NewPolicyListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List policies", + Long: "List security policies in the Dashboard, paginated", + RunE: runPolicyList, + } + + cmd.Flags().Int("page", 1, "Page number") + + return cmd +} + +// runPolicyList implements the 'tyk policy list' command +func runPolicyList(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + if page <= 0 { + page = 1 + } + + // Get configuration from context + config := GetConfigFromContext(cmd.Context()) + if config == nil { + return fmt.Errorf("configuration not found") + } + + // Create client + c, err := client.NewClient(config) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Get output format from context + outputFormat := GetOutputFormatFromContext(cmd.Context()) + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + // Fetch policies + result, err := c.ListPolicies(ctx, page) + if err != nil { + return &ExitError{Code: 1, Message: fmt.Sprintf("failed to list policies: %v", err)} + } + + policies := result.Data + + if outputFormat == types.OutputJSON { + payload := map[string]interface{}{ + "page": page, + "count": len(policies), + "policies": policies, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(payload) + } + + // Human readable output + displayPolicyPage(policies, page) + return nil +} + +// NewPolicyGetCommand creates the 'tyk policy get' command +func NewPolicyGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a policy by ID", + Long: "Retrieve a security policy by ID, convert to CLI schema, and output as YAML or JSON", + Args: cobra.ExactArgs(1), + RunE: runPolicyGet, + } + + return cmd +} + +// runPolicyGet implements the 'tyk policy get' command +func runPolicyGet(cmd *cobra.Command, args []string) error { + policyID := args[0] + + // Get configuration from context + config := GetConfigFromContext(cmd.Context()) + if config == nil { + return fmt.Errorf("configuration not found") + } + + // Create client + c, err := client.NewClient(config) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Get output format from context + outputFormat := GetOutputFormatFromContext(cmd.Context()) + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + // Resolve friendly ID to fetch the policy (O(1) GET) + dp, err := resolveFriendlyID(ctx, c, policyID) + if err != nil { + return &ExitError{Code: 1, Message: fmt.Sprintf("failed to resolve policy: %v", err)} + } + if dp == nil { + return &ExitError{Code: int(types.ExitNotFound), Message: fmt.Sprintf("policy '%s' not found", policyID)} + } + + // Fetch API list for reverse-resolution of API IDs to names (non-fatal on error) + apis, err := c.ListAPIsDashboard(ctx, 1) + if err != nil { + apis = nil + } + + resolverAPIs := toResolverAPIs(apis) + + // Convert wire format to CLI schema + pf := policy.WireToCLI(*dp, resolverAPIs) + + if outputFormat == types.OutputJSON { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(pf) + } + + // Human mode: summary to stderr, YAML to stdout + fmt.Fprintf(os.Stderr, "Policy: %s\n", pf.Name) + fmt.Fprintf(os.Stderr, " ID: %s\n", pf.ID) + if len(pf.Tags) > 0 { + fmt.Fprintf(os.Stderr, " Tags: %s\n", strings.Join(pf.Tags, ", ")) + } + apiCount := len(pf.Access) + fmt.Fprintf(os.Stderr, " APIs: %d\n", apiCount) + + yamlData, err := yaml.Marshal(pf) + if err != nil { + return fmt.Errorf("failed to marshal policy as YAML: %w", err) + } + fmt.Fprint(os.Stdout, string(yamlData)) + + return nil +} + +// NewPolicyApplyCommand creates the 'tyk policy apply' command +func NewPolicyApplyCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply a policy from a YAML file", + Long: `Apply a policy from a YAML file with idempotent upsert semantics. + +Creates the policy if metadata.id is not found on the server; updates if it exists. + +Selectors in spec.access resolve against the live API list: + - name: exact match on API name + - listenPath: exact match on listen path + - id: exact match on API ID + - tags: expand to all APIs matching all specified tags + +Examples: + tyk policy apply -f policy.yaml # Apply from file + cat policy.yaml | tyk policy apply -f - # Apply from stdin`, + RunE: runPolicyApply, + } + + cmd.Flags().StringP("file", "f", "", "Path to policy YAML file (use '-' for stdin) (required)") + _ = cmd.MarkFlagRequired("file") + + return cmd +} + +// runPolicyApply implements the 'tyk policy apply' command +func runPolicyApply(cmd *cobra.Command, args []string) error { + filePath, _ := cmd.Flags().GetString("file") + + config := GetConfigFromContext(cmd.Context()) + if config == nil { + return fmt.Errorf("configuration not found") + } + + pf, err := readPolicyFile(filePath) + if err != nil { + return err + } + + c, err := client.NewClient(config) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + // Fetch API list and resolve selectors + apis, err := c.ListAPIsDashboard(ctx, 1) + if err != nil { + return &ExitError{Code: 1, Message: fmt.Sprintf("failed to fetch API list: %v", err)} + } + + requests := buildResolveRequests(pf.Access) + resolved, resolveErrs := policy.ResolveAccessEntries(requests, toResolverAPIs(apis)) + if len(resolveErrs) > 0 { + return &ExitError{Code: int(types.ExitBadArgs), Message: joinErrorMessages(resolveErrs)} + } + + // Convert CLI to wire format + activeEnv, err := config.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("no active environment: %w", err) + } + dp, err := policy.CLIToWire(pf, resolved, activeEnv.OrgID) + if err != nil { + return &ExitError{Code: int(types.ExitBadArgs), Message: err.Error()} + } + + // Resolve friendly ID to check if policy already exists (O(1) GET) + existingPolicy, resolveErr := resolveFriendlyID(ctx, c, pf.ID) + if resolveErr != nil { + return &ExitError{Code: 1, Message: fmt.Sprintf("failed to resolve policy: %v", resolveErr)} + } + + // Create or update based on existence check + if existingPolicy != nil { + // Update path — use the friendly ID + if err := c.UpdatePolicy(ctx, pf.ID, &dp); err != nil { + return &ExitError{Code: 1, Message: fmt.Sprintf("failed to update policy: %v", err)} + } + fmt.Fprintf(os.Stderr, "Policy '%s' (%s) updated.\n", pf.Name, pf.ID) + } else { + // Create path — omit _id, let Dashboard generate it + dp.MID = "" + if err := c.CreatePolicy(ctx, &dp); err != nil { + return &ExitError{Code: 1, Message: fmt.Sprintf("failed to create policy: %v", err)} + } + fmt.Fprintf(os.Stderr, "Policy '%s' (%s) created.\n", pf.Name, pf.ID) + } + + return nil +} + +// NewPolicyDeleteCommand creates the 'tyk policy delete' command +func NewPolicyDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a policy by ID", + Long: "Delete a security policy by ID with confirmation prompt", + Args: cobra.ExactArgs(1), + RunE: runPolicyDelete, + } + + cmd.Flags().Bool("yes", false, "Skip confirmation prompt") + + return cmd +} + +// runPolicyDelete implements the 'tyk policy delete' command +func runPolicyDelete(cmd *cobra.Command, args []string) error { + policyID := args[0] + skipConfirmation, _ := cmd.Flags().GetBool("yes") + + // Get configuration from context + config := GetConfigFromContext(cmd.Context()) + if config == nil { + return fmt.Errorf("configuration not found") + } + + // Create client + c, err := client.NewClient(config) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + // Resolve friendly ID to fetch the policy (O(1) GET) + dp, err := resolveFriendlyID(ctx, c, policyID) + if err != nil { + return &ExitError{Code: 1, Message: fmt.Sprintf("failed to resolve policy: %v", err)} + } + if dp == nil { + return &ExitError{Code: int(types.ExitNotFound), Message: fmt.Sprintf("policy '%s' not found", policyID)} + } + + // Confirmation prompt unless --yes flag is provided + if !skipConfirmation { + fmt.Fprintf(os.Stderr, "Are you sure you want to delete policy '%s' (%s)? [y/N]: ", dp.Name, policyID) + var response string + _, _ = fmt.Scanln(&response) + if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { + fmt.Fprintf(os.Stderr, "Delete operation cancelled.\n") + return nil + } + } + + // Delete the policy using the friendly ID + if err := c.DeletePolicy(ctx, policyID); err != nil { + return &ExitError{Code: 1, Message: fmt.Sprintf("failed to delete policy: %v", err)} + } + + // Get output format from context + outputFormat := GetOutputFormatFromContext(cmd.Context()) + + if outputFormat == types.OutputJSON { + result := map[string]interface{}{ + "policy_id": policyID, + "operation": "deleted", + "success": true, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + // Human-readable confirmation to stderr + fmt.Fprintf(os.Stderr, "Policy '%s' (%s) deleted.\n", dp.Name, policyID) + return nil +} + +// NewPolicyInitCommand creates the 'tyk policy init' command +func NewPolicyInitCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Generate a scaffold policy YAML file", + Long: `Generate a scaffold policy YAML file with sensible defaults. + +If --id and --name are provided, prompts are skipped (non-interactive mode). +The file is written to policies/{id}.yaml relative to --dir (default: current directory). +Refuses to overwrite an existing file. + +Examples: + tyk policy init --id my-policy --name "My Policy" + tyk policy init --id gold --name "Gold Plan" --dir ./project`, + RunE: runPolicyInit, + } + + cmd.Flags().String("id", "", "Policy ID (required)") + cmd.Flags().String("name", "", "Policy name (required)") + cmd.Flags().String("dir", ".", "Base directory for output (policies/{id}.yaml is created inside)") + + return cmd +} + +// runPolicyInit implements the 'tyk policy init' command +func runPolicyInit(cmd *cobra.Command, args []string) error { + id, _ := cmd.Flags().GetString("id") + name, _ := cmd.Flags().GetString("name") + dir, _ := cmd.Flags().GetString("dir") + + if id == "" { + return &ExitError{Code: int(types.ExitBadArgs), Message: "policy ID is required (use --id)"} + } + if name == "" { + return &ExitError{Code: int(types.ExitBadArgs), Message: "policy name is required (use --name)"} + } + + // Build output path + policiesDir := filepath.Join(dir, "policies") + outPath := filepath.Join(policiesDir, id+".yaml") + + // Check if file already exists + if _, err := os.Stat(outPath); err == nil { + return &ExitError{Code: int(types.ExitBadArgs), Message: fmt.Sprintf("file already exists: %s", outPath)} + } + + // Generate scaffold + pf := types.PolicyFile{ + ID: id, + Name: name, + RateLimit: &types.RateLimit{ + Requests: 1000, + Per: types.Duration("1m"), + }, + Quota: &types.Quota{ + Limit: 100000, + Period: types.Duration("30d"), + }, + KeyTTL: types.Duration("0"), + Access: []types.AccessEntry{ + { + Name: "your-api-name", + Versions: []string{"Default"}, + }, + }, + } + + data, err := yaml.Marshal(pf) + if err != nil { + return fmt.Errorf("failed to marshal scaffold: %w", err) + } + + // Ensure policies directory exists + if err := os.MkdirAll(policiesDir, 0755); err != nil { + return fmt.Errorf("failed to create policies directory: %w", err) + } + + if err := os.WriteFile(outPath, data, 0644); err != nil { + return fmt.Errorf("failed to write scaffold file: %w", err) + } + + fmt.Fprintf(os.Stderr, "Policy scaffold written to %s\n", outPath) + return nil +} + +// readPolicyFile reads a policy YAML from a file path or stdin ("-"), parses it, +// and validates the schema. Returns the parsed PolicyFile or an error. +func readPolicyFile(filePath string) (types.PolicyFile, error) { + var data []byte + var err error + + if filePath == "-" { + data, err = io.ReadAll(os.Stdin) + if err != nil { + return types.PolicyFile{}, &ExitError{Code: int(types.ExitBadArgs), Message: fmt.Sprintf("failed to read stdin: %v", err)} + } + if len(data) == 0 { + return types.PolicyFile{}, &ExitError{Code: int(types.ExitBadArgs), Message: "no input provided on stdin"} + } + } else { + data, err = os.ReadFile(filePath) + if err != nil { + return types.PolicyFile{}, &ExitError{Code: int(types.ExitBadArgs), Message: fmt.Sprintf("failed to read file: %v", err)} + } + } + + var pf types.PolicyFile + if err := yaml.Unmarshal(data, &pf); err != nil { + return types.PolicyFile{}, &ExitError{Code: int(types.ExitBadArgs), Message: fmt.Sprintf("failed to parse YAML: %v", err)} + } + + if validationErrs := policy.ValidatePolicy(pf); len(validationErrs) > 0 { + msgs := make([]string, len(validationErrs)) + for i := range validationErrs { + msgs[i] = validationErrs[i].Error() + } + return types.PolicyFile{}, &ExitError{Code: int(types.ExitBadArgs), Message: strings.Join(msgs, "; ")} + } + + return pf, nil +} + +// buildResolveRequests converts access entries from a PolicyFile into ResolveRequests. +func buildResolveRequests(entries []types.AccessEntry) []policy.ResolveRequest { + requests := make([]policy.ResolveRequest, 0, len(entries)) + for _, entry := range entries { + req := policy.ResolveRequest{Versions: entry.Versions} + switch { + case entry.ID != "": + req.SelectorType = "id" + req.Value = entry.ID + case entry.Name != "": + req.SelectorType = "name" + req.Value = entry.Name + case entry.ListenPath != "": + req.SelectorType = "listenPath" + req.Value = entry.ListenPath + case len(entry.Tags) > 0: + req.SelectorType = "tags" + req.TagValues = entry.Tags + } + requests = append(requests, req) + } + return requests +} + +// joinErrorMessages concatenates error messages into a semicolon-separated string. +func joinErrorMessages(errs []error) string { + msgs := make([]string, len(errs)) + for i, e := range errs { + msgs[i] = e.Error() + } + return strings.Join(msgs, "; ") +} + +func resolveFriendlyID(ctx context.Context, c *client.Client, friendlyID string) (*types.DashboardPolicy, error) { + dp, err := c.GetPolicy(ctx, friendlyID) + if err != nil { + if isNotFoundError(err) { + return nil, nil // not found — caller decides create or error + } + return nil, fmt.Errorf("failed to resolve policy %q: %w", friendlyID, err) + } + return dp, nil +} + +// isNotFoundError returns true if the error indicates a 404 / not found response. +func isNotFoundError(err error) bool { + if er, ok := err.(*types.ErrorResponse); ok && er.Status == 404 { + return true + } + msg := err.Error() + return strings.Contains(msg, "404") || strings.Contains(strings.ToLower(msg), "not found") +} + +// toResolverAPIs converts OAS API objects to the resolver's input type. +func toResolverAPIs(apis []*types.OASAPI) []policy.ResolverAPI { + result := make([]policy.ResolverAPI, 0, len(apis)) + for _, api := range apis { + result = append(result, policy.ResolverAPI{ + ID: api.ID, + Name: api.Name, + ListenPath: api.ListenPath, + }) + } + return result +} + +// displayPolicyPage displays a page of policies in a formatted table +func displayPolicyPage(policies []types.DashboardPolicy, page int) { + if len(policies) == 0 { + fmt.Fprintf(os.Stderr, "No policies found.\n") + return + } + + fmt.Fprintf(os.Stderr, "Policies (page %d):\n", page) + fmt.Fprintf(os.Stdout, "%-26s %-24s %-10s %s\n", "ID", "Name", "APIs", "Tags") + fmt.Fprintf(os.Stdout, "%s\n", strings.Repeat("-", 26+2+24+2+10+2+20)) + for _, p := range policies { + displayID := p.ID + if displayID == "" { + displayID = p.MID // unmanaged policy — show _id + } + apiCount := len(p.AccessRights) + tags := strings.Join(p.Tags, ", ") + fmt.Fprintf(os.Stdout, "%-26s %-24s %-10d %s\n", displayID, p.Name, apiCount, tags) + } + fmt.Fprintf(os.Stderr, "\nUse '--page %d' for next page.\n", page+1) +} diff --git a/internal/cli/policy_test.go b/internal/cli/policy_test.go new file mode 100644 index 0000000..bb8d916 --- /dev/null +++ b/internal/cli/policy_test.go @@ -0,0 +1,1165 @@ +package cli + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tyktech/tyk-cli/pkg/types" + "gopkg.in/yaml.v3" +) + +// --------------------------------------------------------------------------- +// Test data: shared across all policy tests +// --------------------------------------------------------------------------- + +// mockDashboardPolicy returns a DashboardPolicy JSON-encodable map +// matching the wire format from data-models.md. +// mid is the MongoDB ObjectID (_id), wireID is the managed wire id field (e.g., "gold"). +func mockDashboardPolicy(mid, wireID, name string, rate, per, quotaMax, quotaRenewalRate int64, tags []string, accessRights map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "_id": mid, + "id": wireID, + "name": name, + "org_id": "org", + "rate": rate, + "per": per, + "quota_max": quotaMax, + "quota_renewal_rate": quotaRenewalRate, + "key_expires_in": 0, + "tags": tags, + "access_rights": accessRights, + "active": true, + "is_inactive": false, + } +} + +// mockPolicyListResponse returns the Dashboard policy list response envelope. +func mockPolicyListResponse(policies []map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "Data": policies, + "Pages": 1, + "StatusCode": 200, + } +} + +// mockAPIListResponse returns a Dashboard API list response used for +// selector resolution during apply. +func mockAPIListResponse() map[string]interface{} { + return map[string]interface{}{ + "apis": []interface{}{ + map[string]interface{}{ + "api_definition": map[string]interface{}{ + "api_id": "a1b2c3d4e5f6", + "name": "users-api", + "proxy": map[string]interface{}{ + "listen_path": "/users/", + }, + }, + }, + map[string]interface{}{ + "api_definition": map[string]interface{}{ + "api_id": "g7h8i9j0k1l2", + "name": "orders-api", + "proxy": map[string]interface{}{ + "listen_path": "/orders/", + }, + }, + }, + map[string]interface{}{ + "api_definition": map[string]interface{}{ + "api_id": "m3n4o5p6q7r8", + "name": "payments-api", + "proxy": map[string]interface{}{ + "listen_path": "/payments/", + }, + }, + }, + }, + } +} + +// goldPolicyAccessRights returns the access_rights map for the Gold Plan. +func goldPolicyAccessRights() map[string]interface{} { + return map[string]interface{}{ + "a1b2c3d4e5f6": map[string]interface{}{ + "api_id": "a1b2c3d4e5f6", + "api_name": "users-api", + "versions": []interface{}{"v1"}, + "allowed_urls": []interface{}{}, + "limit": nil, + }, + "g7h8i9j0k1l2": map[string]interface{}{ + "api_id": "g7h8i9j0k1l2", + "api_name": "orders-api", + "versions": []interface{}{"v1", "v2"}, + "allowed_urls": []interface{}{}, + "limit": nil, + }, + "m3n4o5p6q7r8": map[string]interface{}{ + "api_id": "m3n4o5p6q7r8", + "api_name": "payments-api", + "versions": []interface{}{"v1"}, + "allowed_urls": []interface{}{}, + "limit": nil, + }, + } +} + +// createPolicyConfig builds a test config pointing at the mock server. +func createPolicyConfig(serverURL string) *types.Config { + return &types.Config{ + DefaultEnvironment: "test", + Environments: map[string]*types.Environment{ + "test": { + Name: "test", + DashboardURL: serverURL, + AuthToken: "test-token", + OrgID: "org", + }, + }, + } +} + +// writeTempPolicyFile creates a temporary policy YAML file for apply tests. +func writeTempPolicyFile(t *testing.T, content string) string { + t.Helper() + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "policy.yaml") + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + return path +} + +// validPlatinumPolicyYAML is the canonical test policy file content. +const validPlatinumPolicyYAML = `id: platinum +name: Platinum Plan +tags: [platinum, paid] +rateLimit: + requests: 5000 + per: 1m +quota: + limit: 500000 + period: 30d +keyTTL: 0 +access: + - name: users-api + versions: [v1] +` + +// =========================================================================== +// Walking Skeleton Tests (implement FIRST) +// =========================================================================== + +// executePolicyListCmd creates a policy list command with config injected and executes RunE directly. +// This bypasses root PersistentPreRunE (which loads config from disk) and tests the driving port directly. +func executePolicyListCmd(t *testing.T, serverURL string, outputFormat types.OutputFormat, extraArgs ...string) error { + t.Helper() + root := NewRootCommand("test", "commit", "time") + listCmd, _, err := root.Find([]string{"policy", "list"}) + require.NoError(t, err) + + cfg := createPolicyConfig(serverURL) + ctx := withConfig(context.Background(), cfg) + ctx = withOutputFormat(ctx, outputFormat) + listCmd.SetContext(ctx) + + if len(extraArgs) > 0 { + listCmd.SetArgs(extraArgs) + _ = listCmd.ParseFlags(extraArgs) + } + + return listCmd.RunE(listCmd, []string{}) +} + +// TestPolicyList_Empty verifies the list command with an empty Dashboard. +// Walking skeleton scenario 1a. +func TestPolicyList_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/api/portal/policies") { + _ = json.NewEncoder(w).Encode(mockPolicyListResponse(nil)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + // Capture stderr + oldStderr := os.Stderr + rErr, wErr, _ := os.Pipe() + os.Stderr = wErr + + err := executePolicyListCmd(t, server.URL, types.OutputHuman) + + wErr.Close() + os.Stderr = oldStderr + stderr, _ := io.ReadAll(rErr) + + require.NoError(t, err) + assert.Contains(t, string(stderr), "No policies found") +} + +// TestPolicyList_WithPolicies verifies the list command shows policy table. +// Walking skeleton scenario 1b. +func TestPolicyList_WithPolicies(t *testing.T) { + policies := []map[string]interface{}{ + mockDashboardPolicy("507f1f77bcf86cd799439011", "gold", "Gold Plan", 1000, 60, 100000, 2592000, + []string{"gold", "paid"}, goldPolicyAccessRights()), + mockDashboardPolicy("507f1f77bcf86cd799439012", "silver", "Silver Plan", 500, 60, 50000, 2592000, + []string{"silver"}, map[string]interface{}{ + "a1b2c3d4e5f6": map[string]interface{}{ + "api_id": "a1b2c3d4e5f6", "api_name": "users-api", + "versions": []interface{}{"v1"}, + }, + }), + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/api/portal/policies") { + _ = json.NewEncoder(w).Encode(mockPolicyListResponse(policies)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + // Capture stdout (table data goes to stdout) + oldStdout := os.Stdout + rOut, wOut, _ := os.Pipe() + os.Stdout = wOut + + err := executePolicyListCmd(t, server.URL, types.OutputHuman) + + wOut.Close() + os.Stdout = oldStdout + stdout, _ := io.ReadAll(rOut) + + require.NoError(t, err) + output := string(stdout) + // displayPolicyPage extracts friendly IDs from wire id field + assert.Contains(t, output, "gold") + assert.Contains(t, output, "Gold Plan") + assert.Contains(t, output, "silver") + assert.Contains(t, output, "Silver Plan") +} + +// executePolicyApplyCmd creates a policy apply command with config injected and calls RunE directly. +// This bypasses root PersistentPreRunE and tests the driving port directly. +func executePolicyApplyCmd(t *testing.T, serverURL string, filePath string) error { + t.Helper() + applyCmd := NewPolicyApplyCommand() + + cfg := createPolicyConfig(serverURL) + ctx := withConfig(context.Background(), cfg) + ctx = withOutputFormat(ctx, types.OutputHuman) + applyCmd.SetContext(ctx) + + applyCmd.SetArgs([]string{"-f", filePath}) + _ = applyCmd.ParseFlags([]string{"-f", filePath}) + + return applyCmd.RunE(applyCmd, []string{}) +} + +// TestPolicyApply_Create_NameSelector verifies applying a new policy with name selector. +// Walking skeleton scenario 2a. +func TestPolicyApply_Create_NameSelector(t *testing.T) { + + var capturedCreateBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + // Selector resolution: list APIs + case r.Method == http.MethodGet && r.URL.Path == "/api/apis": + _ = json.NewEncoder(w).Encode(mockAPIListResponse()) + + // Resolve policy by ID — not found -> create path + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/platinum": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"status": 404, "message": "not found"}) + + // Create policy + case r.Method == http.MethodPost && r.URL.Path == "/api/portal/policies": + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &capturedCreateBody) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "Status": "success", "Message": "created", "Meta": "507f1f77bcf86cd799439099", + }) + + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + policyFile := writeTempPolicyFile(t, validPlatinumPolicyYAML) + err := executePolicyApplyCmd(t, server.URL, policyFile) + require.NoError(t, err) + + // Verify the wire format sent to Dashboard + require.NotNil(t, capturedCreateBody) + // _id should be empty/absent on create — Dashboard generates it + mid, hasMID := capturedCreateBody["_id"] + assert.True(t, !hasMID || mid == "", "_id should be empty or absent on create, got: %v", mid) + // wire id should be the managed ID + assert.Equal(t, "platinum", capturedCreateBody["id"]) + assert.Equal(t, "Platinum Plan", capturedCreateBody["name"]) + // Duration conversion: "1m" -> 60 seconds + assert.EqualValues(t, 5000, capturedCreateBody["rate"]) + assert.EqualValues(t, 60, capturedCreateBody["per"]) + // Duration conversion: "30d" -> 2592000 seconds + assert.EqualValues(t, 500000, capturedCreateBody["quota_max"]) + assert.EqualValues(t, 2592000, capturedCreateBody["quota_renewal_rate"]) + + // Verify selector resolved: name "users-api" -> API ID in access_rights + accessRights, ok := capturedCreateBody["access_rights"].(map[string]interface{}) + require.True(t, ok, "access_rights should be a map") + _, hasUsersAPI := accessRights["a1b2c3d4e5f6"] + assert.True(t, hasUsersAPI, "access_rights should contain resolved API ID a1b2c3d4e5f6") +} + +// TestPolicyApply_Update_Idempotent verifies updating an existing policy. +// Walking skeleton scenario 2b. +func TestPolicyApply_Update_Idempotent(t *testing.T) { + + var capturedUpdateBody map[string]interface{} + updateCalled := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + // Selector resolution: list APIs + case r.Method == http.MethodGet && r.URL.Path == "/api/apis": + _ = json.NewEncoder(w).Encode(mockAPIListResponse()) + + // Resolve policy by ID — found -> update path + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/platinum": + existing := mockDashboardPolicy("507f1f77bcf86cd799439013", "platinum", "Platinum Plan", 5000, 60, 500000, 2592000, + []string{"platinum", "paid"}, map[string]interface{}{}) + _ = json.NewEncoder(w).Encode(existing) + + // Update policy — PUT by id + case r.Method == http.MethodPut && r.URL.Path == "/api/portal/policies/platinum": + updateCalled = true + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &capturedUpdateBody) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "Status": "success", "Message": "updated", "Meta": "507f1f77bcf86cd799439013", + }) + + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + // Policy with updated rate limit (10000 instead of 5000) + updatedYAML := strings.Replace(validPlatinumPolicyYAML, "requests: 5000", "requests: 10000", 1) + policyFile := writeTempPolicyFile(t, updatedYAML) + + err := executePolicyApplyCmd(t, server.URL, policyFile) + require.NoError(t, err) + + assert.True(t, updateCalled, "should have called PUT for existing policy") + require.NotNil(t, capturedUpdateBody) + assert.EqualValues(t, 10000, capturedUpdateBody["rate"]) +} + +// =========================================================================== +// Milestone 1: List + Get (focused scenarios) +// =========================================================================== + +func TestPolicyList_JSONOutput(t *testing.T) { + policies := []map[string]interface{}{ + mockDashboardPolicy("507f1f77bcf86cd799439011", "gold", "Gold Plan", 1000, 60, 100000, 2592000, + []string{"gold", "paid"}, goldPolicyAccessRights()), + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(mockPolicyListResponse(policies)) + })) + defer server.Close() + + // Capture stdout (JSON goes to stdout) + oldStdout := os.Stdout + rOut, wOut, _ := os.Pipe() + os.Stdout = wOut + + err := executePolicyListCmd(t, server.URL, types.OutputJSON) + + wOut.Close() + os.Stdout = oldStdout + stdout, _ := io.ReadAll(rOut) + + require.NoError(t, err) + + // Verify valid JSON with expected structure + var result map[string]interface{} + err = json.Unmarshal(stdout, &result) + require.NoError(t, err, "output should be valid JSON") + assert.NotNil(t, result["policies"]) + assert.Equal(t, float64(1), result["page"]) + assert.Equal(t, float64(1), result["count"]) +} + +func TestPolicyList_Pagination_EmptyPage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2", r.URL.Query().Get("p")) + _ = json.NewEncoder(w).Encode(mockPolicyListResponse(nil)) + })) + defer server.Close() + + // Capture stderr + oldStderr := os.Stderr + rErr, wErr, _ := os.Pipe() + os.Stderr = wErr + + err := executePolicyListCmd(t, server.URL, types.OutputHuman, "--page", "2") + + wErr.Close() + os.Stderr = oldStderr + stderr, _ := io.ReadAll(rErr) + + require.NoError(t, err) + assert.Contains(t, string(stderr), "No policies found") +} + +func TestPolicyList_NetworkError(t *testing.T) { + // Use a server that is immediately closed to trigger a network error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + server.Close() + + err := executePolicyListCmd(t, server.URL, types.OutputHuman) + + require.Error(t, err) + exitErr, ok := err.(*ExitError) + require.True(t, ok, "should return ExitError") + assert.Equal(t, 1, exitErr.Code) +} + +func TestPolicyCommand_Registration(t *testing.T) { + root := NewRootCommand("test", "commit", "time") + + // Verify 'policy' appears in root subcommands + found := false + for _, cmd := range root.Commands() { + if cmd.Name() == "policy" { + found = true + // Verify subcommands of 'policy' + subNames := make(map[string]bool) + for _, sub := range cmd.Commands() { + subNames[sub.Name()] = true + } + assert.True(t, subNames["list"], "'list' should be a subcommand of 'policy'") + assert.True(t, subNames["get"], "'get' should be a subcommand of 'policy'") + assert.True(t, subNames["apply"], "'apply' should be a subcommand of 'policy'") + assert.True(t, subNames["delete"], "'delete' should be a subcommand of 'policy'") + } + } + assert.True(t, found, "'policy' should be a subcommand of root") +} + +// executePolicyGetCmd creates a policy get command with config injected and executes RunE directly. +// This bypasses root PersistentPreRunE (which loads config from disk) and tests the driving port directly. +func executePolicyGetCmd(t *testing.T, serverURL string, outputFormat types.OutputFormat, policyID string) error { + t.Helper() + root := NewRootCommand("test", "commit", "time") + getCmd, _, err := root.Find([]string{"policy", "get"}) + require.NoError(t, err) + + cfg := createPolicyConfig(serverURL) + ctx := withConfig(context.Background(), cfg) + ctx = withOutputFormat(ctx, outputFormat) + getCmd.SetContext(ctx) + + return getCmd.RunE(getCmd, []string{policyID}) +} + +func TestPolicyGet_Human(t *testing.T) { + goldPolicy := mockDashboardPolicy("507f1f77bcf86cd799439011", "gold", "Gold Plan", 1000, 60, 100000, 2592000, + []string{"gold", "paid"}, goldPolicyAccessRights()) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/gold": + _ = json.NewEncoder(w).Encode(goldPolicy) + case r.Method == http.MethodGet && r.URL.Path == "/api/apis": + _ = json.NewEncoder(w).Encode(mockAPIListResponse()) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + // Capture both stdout (YAML) and stderr (summary) + oldStdout := os.Stdout + oldStderr := os.Stderr + rOut, wOut, _ := os.Pipe() + rErr, wErr, _ := os.Pipe() + os.Stdout = wOut + os.Stderr = wErr + + err := executePolicyGetCmd(t, server.URL, types.OutputHuman, "gold") + + wOut.Close() + wErr.Close() + os.Stdout = oldStdout + os.Stderr = oldStderr + + stdout, _ := io.ReadAll(rOut) + stderr, _ := io.ReadAll(rErr) + + require.NoError(t, err) + + // stderr should contain human-readable summary + stderrStr := string(stderr) + assert.Contains(t, stderrStr, "Gold Plan") + + // Verify YAML is parseable + var yamlResult map[string]interface{} + err = yaml.Unmarshal(stdout, &yamlResult) + require.NoError(t, err, "stdout should be valid YAML") +} + +func TestPolicyGet_JSON(t *testing.T) { + goldPolicy := mockDashboardPolicy("507f1f77bcf86cd799439011", "gold", "Gold Plan", 1000, 60, 100000, 2592000, + []string{"gold", "paid"}, goldPolicyAccessRights()) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/gold": + _ = json.NewEncoder(w).Encode(goldPolicy) + case r.Method == http.MethodGet && r.URL.Path == "/api/apis": + _ = json.NewEncoder(w).Encode(mockAPIListResponse()) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + oldStdout := os.Stdout + rOut, wOut, _ := os.Pipe() + os.Stdout = wOut + + err := executePolicyGetCmd(t, server.URL, types.OutputJSON, "gold") + + wOut.Close() + os.Stdout = oldStdout + stdout, _ := io.ReadAll(rOut) + + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(stdout, &result) + require.NoError(t, err, "output should be valid JSON") + + assert.Equal(t, "gold", result["id"]) + assert.Equal(t, "Gold Plan", result["name"]) +} + +func TestPolicyGet_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/nonexistent": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"status": 404, "message": "not found"}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + err := executePolicyGetCmd(t, server.URL, types.OutputHuman, "nonexistent") + + require.Error(t, err) + exitErr, ok := err.(*ExitError) + require.True(t, ok, "should return ExitError") + assert.Equal(t, 3, exitErr.Code) + assert.Contains(t, exitErr.Message, "not found") +} + +// =========================================================================== +// Milestone 2: Apply (focused scenarios) +// =========================================================================== + +func TestPolicyApply_ListenPathSelector(t *testing.T) { + + var capturedBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/apis": + _ = json.NewEncoder(w).Encode(mockAPIListResponse()) + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/path-test": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"status": 404, "message": "not found"}) + case r.Method == http.MethodPost && r.URL.Path == "/api/portal/policies": + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &capturedBody) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"Status": "success", "Meta": "path-test"}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + policyYAML := `id: path-test +name: Path Test Policy +rateLimit: + requests: 100 + per: 60 +quota: + limit: 10000 + period: 86400 +keyTTL: 0 +access: + - listenPath: /orders/ + versions: [v1] +` + policyFile := writeTempPolicyFile(t, policyYAML) + + err := executePolicyApplyCmd(t, server.URL, policyFile) + require.NoError(t, err) + + // Verify listenPath "/orders/" resolved to "g7h8i9j0k1l2" + accessRights, ok := capturedBody["access_rights"].(map[string]interface{}) + require.True(t, ok) + _, hasOrdersAPI := accessRights["g7h8i9j0k1l2"] + assert.True(t, hasOrdersAPI, "listenPath /orders/ should resolve to g7h8i9j0k1l2") +} + +func TestPolicyApply_DurationConversion(t *testing.T) { + + var capturedBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/apis": + _ = json.NewEncoder(w).Encode(mockAPIListResponse()) + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/dur-test": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"status": 404, "message": "not found"}) + case r.Method == http.MethodPost && r.URL.Path == "/api/portal/policies": + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &capturedBody) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"Status": "success", "Meta": "dur-test"}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + policyYAML := `id: dur-test +name: Duration Test +rateLimit: + requests: 1000 + per: 1m +quota: + limit: 100000 + period: 30d +keyTTL: 24h +access: + - name: users-api + versions: [v1] +` + policyFile := writeTempPolicyFile(t, policyYAML) + + err := executePolicyApplyCmd(t, server.URL, policyFile) + require.NoError(t, err) + + assert.EqualValues(t, 60, capturedBody["per"], "1m should convert to 60 seconds") + assert.EqualValues(t, 2592000, capturedBody["quota_renewal_rate"], "30d should convert to 2592000 seconds") + assert.EqualValues(t, 86400, capturedBody["key_expires_in"], "24h should convert to 86400 seconds") +} + +func TestPolicyApply_NameNotFound(t *testing.T) { + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/apis" { + _ = json.NewEncoder(w).Encode(mockAPIListResponse()) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + policyYAML := `id: typo-test +name: Typo Test +rateLimit: + requests: 100 + per: 60 +quota: + limit: 10000 + period: 86400 +keyTTL: 0 +access: + - name: inventori-api + versions: [v1] +` + policyFile := writeTempPolicyFile(t, policyYAML) + + err := executePolicyApplyCmd(t, server.URL, policyFile) + + require.Error(t, err) + exitErr, ok := err.(*ExitError) + require.True(t, ok, "should return ExitError") + assert.Equal(t, 2, exitErr.Code) + assert.Contains(t, exitErr.Message, "no API found") +} + +func TestPolicyApply_NameAmbiguous(t *testing.T) { + + // Mock server with two APIs named "api-service" + ambiguousAPIList := map[string]interface{}{ + "apis": []interface{}{ + map[string]interface{}{ + "api_definition": map[string]interface{}{ + "api_id": "dup-1", "name": "api-service", + "proxy": map[string]interface{}{"listen_path": "/svc-1/"}, + }, + }, + map[string]interface{}{ + "api_definition": map[string]interface{}{ + "api_id": "dup-2", "name": "api-service", + "proxy": map[string]interface{}{"listen_path": "/svc-2/"}, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/apis" { + _ = json.NewEncoder(w).Encode(ambiguousAPIList) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + policyYAML := `id: ambig-test +name: Ambiguous Test +rateLimit: + requests: 100 + per: 60 +quota: + limit: 10000 + period: 86400 +keyTTL: 0 +access: + - name: api-service + versions: [v1] +` + policyFile := writeTempPolicyFile(t, policyYAML) + + err := executePolicyApplyCmd(t, server.URL, policyFile) + + require.Error(t, err) + exitErr, ok := err.(*ExitError) + require.True(t, ok, "should return ExitError") + assert.Equal(t, 2, exitErr.Code) + assert.Contains(t, exitErr.Message, "ambiguous") +} + +func TestPolicyApply_MissingID(t *testing.T) { + + policyYAML := `name: No ID Policy +rateLimit: + requests: 100 + per: 60 +quota: + limit: 10000 + period: 86400 +keyTTL: 0 +access: + - name: users-api + versions: [v1] +` + policyFile := writeTempPolicyFile(t, policyYAML) + + err := executePolicyApplyCmd(t, "http://unused", policyFile) + + require.Error(t, err) + exitErr, ok := err.(*ExitError) + require.True(t, ok, "should return ExitError") + assert.Equal(t, 2, exitErr.Code) + assert.Contains(t, exitErr.Message, "id") +} + +func TestPolicyApply_InvalidDuration(t *testing.T) { + + policyYAML := `id: bad-dur +name: Bad Duration +rateLimit: + requests: 100 + per: abc +quota: + limit: 10000 + period: 86400 +keyTTL: 0 +access: + - name: users-api + versions: [v1] +` + policyFile := writeTempPolicyFile(t, policyYAML) + + err := executePolicyApplyCmd(t, "http://unused", policyFile) + + require.Error(t, err) + exitErr, ok := err.(*ExitError) + require.True(t, ok, "should return ExitError") + assert.Equal(t, 2, exitErr.Code) + assert.Contains(t, exitErr.Message, "invalid duration") +} + +func TestPolicyApply_FileNotFound(t *testing.T) { + err := executePolicyApplyCmd(t, "http://unused", "/nonexistent/policy.yaml") + + require.Error(t, err) +} + +// =========================================================================== +// Milestone 3: Delete + Init +// =========================================================================== + +// executePolicyDeleteCmd creates a policy delete command with config injected and executes RunE directly. +// This bypasses root PersistentPreRunE (which loads config from disk) and tests the driving port directly. +func executePolicyDeleteCmd(t *testing.T, serverURL string, outputFormat types.OutputFormat, policyID string, yes bool) error { + t.Helper() + deleteCmd := NewPolicyDeleteCommand() + + cfg := createPolicyConfig(serverURL) + ctx := withConfig(context.Background(), cfg) + ctx = withOutputFormat(ctx, outputFormat) + deleteCmd.SetContext(ctx) + + cmdArgs := []string{policyID} + if yes { + cmdArgs = append(cmdArgs, "--yes") + } + deleteCmd.SetArgs(cmdArgs) + _ = deleteCmd.ParseFlags(cmdArgs) + + return deleteCmd.RunE(deleteCmd, []string{policyID}) +} + +func TestPolicyDelete_WithYes(t *testing.T) { + deleteCalled := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/free-tier": + p := mockDashboardPolicy("507f1f77bcf86cd799439020", "free-tier", "Free Plan", 100, 60, 10000, 86400, + []string{"free"}, map[string]interface{}{ + "a1b2c3d4e5f6": map[string]interface{}{"api_id": "a1b2c3d4e5f6"}, + }) + _ = json.NewEncoder(w).Encode(p) + case r.Method == http.MethodDelete && r.URL.Path == "/api/portal/policies/free-tier": + deleteCalled = true + _ = json.NewEncoder(w).Encode(map[string]interface{}{"Status": "success", "Message": "deleted"}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + // Capture stderr for confirmation message + oldStderr := os.Stderr + rErr, wErr, _ := os.Pipe() + os.Stderr = wErr + + err := executePolicyDeleteCmd(t, server.URL, types.OutputHuman, "free-tier", true) + + wErr.Close() + os.Stderr = oldStderr + stderr, _ := io.ReadAll(rErr) + + require.NoError(t, err) + assert.True(t, deleteCalled, "DELETE should have been called") + assert.Contains(t, string(stderr), "Free Plan", "stderr should mention policy name") + assert.Contains(t, string(stderr), "deleted", "stderr should confirm deletion") +} + +func TestPolicyDelete_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/nonexistent": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"status": 404, "message": "not found"}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + err := executePolicyDeleteCmd(t, server.URL, types.OutputHuman, "nonexistent", true) + + require.Error(t, err) + exitErr, ok := err.(*ExitError) + require.True(t, ok, "should return ExitError") + assert.Equal(t, 3, exitErr.Code) + assert.Contains(t, exitErr.Message, "not found") +} + +func TestPolicyDelete_WithYes_JSON(t *testing.T) { + deleteCalled := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies/free-tier": + p := mockDashboardPolicy("507f1f77bcf86cd799439020", "free-tier", "Free Plan", 100, 60, 10000, 86400, + []string{"free"}, map[string]interface{}{}) + _ = json.NewEncoder(w).Encode(p) + case r.Method == http.MethodDelete && r.URL.Path == "/api/portal/policies/free-tier": + deleteCalled = true + _ = json.NewEncoder(w).Encode(map[string]interface{}{"Status": "success", "Message": "deleted"}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + // Capture stdout for JSON output + oldStdout := os.Stdout + rOut, wOut, _ := os.Pipe() + os.Stdout = wOut + + err := executePolicyDeleteCmd(t, server.URL, types.OutputJSON, "free-tier", true) + + wOut.Close() + os.Stdout = oldStdout + stdout, _ := io.ReadAll(rOut) + + require.NoError(t, err) + assert.True(t, deleteCalled, "DELETE should have been called") + + // Verify structured JSON output + var result map[string]interface{} + err = json.Unmarshal(stdout, &result) + require.NoError(t, err, "output should be valid JSON") + assert.Equal(t, "free-tier", result["policy_id"]) + assert.Equal(t, "deleted", result["operation"]) + assert.Equal(t, true, result["success"]) +} + +// executePolicyInitCmd creates a policy init command and executes RunE directly. +// Uses --id and --name flags to bypass interactive prompts. +func executePolicyInitCmd(t *testing.T, dir string, id string, name string) error { + t.Helper() + initCmd := NewPolicyInitCommand() + + // No config needed -- init is offline + ctx := context.Background() + initCmd.SetContext(ctx) + + args := []string{"--id", id, "--name", name, "--dir", dir} + initCmd.SetArgs(args) + if err := initCmd.ParseFlags(args); err != nil { + return err + } + + return initCmd.RunE(initCmd, []string{}) +} + +func TestPolicyInit_NewFile(t *testing.T) { + tmpDir := t.TempDir() + + err := executePolicyInitCmd(t, tmpDir, "my-policy", "My Policy") + require.NoError(t, err) + + // Verify file was created at policies/{id}.yaml inside the dir + outPath := filepath.Join(tmpDir, "policies", "my-policy.yaml") + data, err := os.ReadFile(outPath) + require.NoError(t, err, "scaffold file should exist at policies/{id}.yaml") + + // Parse and validate the scaffold YAML + var pf types.PolicyFile + err = yaml.Unmarshal(data, &pf) + require.NoError(t, err, "scaffold should be valid YAML") + + // Verify schema fields + assert.Equal(t, "my-policy", pf.ID) + assert.Equal(t, "My Policy", pf.Name) + + // Verify sensible defaults + require.NotNil(t, pf.RateLimit, "scaffold should have default rateLimit") + assert.Equal(t, int64(1000), pf.RateLimit.Requests) + assert.Equal(t, types.Duration("1m"), pf.RateLimit.Per) + require.NotNil(t, pf.Quota, "scaffold should have default quota") + assert.Equal(t, int64(100000), pf.Quota.Limit) + assert.Equal(t, types.Duration("30d"), pf.Quota.Period) + assert.Equal(t, types.Duration("0"), pf.KeyTTL) + require.Len(t, pf.Access, 1, "scaffold should have one placeholder access entry") + assert.Equal(t, "your-api-name", pf.Access[0].Name) + assert.Equal(t, []string{"Default"}, pf.Access[0].Versions) +} + +func TestPolicyInit_FileExistsNoOverwrite(t *testing.T) { + tmpDir := t.TempDir() + + // Create the file that init would write to + policiesDir := filepath.Join(tmpDir, "policies") + require.NoError(t, os.MkdirAll(policiesDir, 0755)) + existingPath := filepath.Join(policiesDir, "existing.yaml") + require.NoError(t, os.WriteFile(existingPath, []byte("original content"), 0644)) + + err := executePolicyInitCmd(t, tmpDir, "existing", "Existing Policy") + + require.Error(t, err, "init should error when file already exists") + exitErr, ok := err.(*ExitError) + require.True(t, ok, "should return ExitError") + assert.Contains(t, exitErr.Message, "already exists") + + // Verify original content untouched + data, _ := os.ReadFile(existingPath) + assert.Equal(t, "original content", string(data)) +} + +func TestPolicyInit_Registration(t *testing.T) { + root := NewRootCommand("test", "commit", "time") + + // Find init under policy + found := false + for _, cmd := range root.Commands() { + if cmd.Name() == "policy" { + for _, sub := range cmd.Commands() { + if sub.Name() == "init" { + found = true + } + } + } + } + assert.True(t, found, "'init' should be a subcommand of 'policy'") +} + +// =========================================================================== +// Full Integration Walking Skeleton +// =========================================================================== + +func TestPolicyIntegration_FullLifecycle(t *testing.T) { + // This test exercises: list empty -> apply new -> list shows policy -> get returns CLI schema -> delete removes + // The mock Dashboard stores policies keyed by id (e.g., "platinum"). + policyStore := map[string]map[string]interface{}{} // keyed by id (e.g., "platinum") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/apis": + _ = json.NewEncoder(w).Encode(mockAPIListResponse()) + + case r.Method == http.MethodGet && r.URL.Path == "/api/portal/policies": + policies := make([]map[string]interface{}, 0) + for _, p := range policyStore { + policies = append(policies, p) + } + _ = json.NewEncoder(w).Encode(mockPolicyListResponse(policies)) + + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/portal/policies/"): + lookupID := strings.TrimPrefix(r.URL.Path, "/api/portal/policies/") + if p, ok := policyStore[lookupID]; ok { + _ = json.NewEncoder(w).Encode(p) + } else { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"status": 404, "message": "not found"}) + } + + case r.Method == http.MethodPost && r.URL.Path == "/api/portal/policies": + body, _ := io.ReadAll(r.Body) + var created map[string]interface{} + _ = json.Unmarshal(body, &created) + created["_id"] = "507f1f77bcf86cd799439099" + id, _ := created["id"].(string) + policyStore[id] = created + _ = json.NewEncoder(w).Encode(map[string]interface{}{"Status": "success", "Message": "created", "Meta": "507f1f77bcf86cd799439099"}) + + case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/portal/policies/"): + id := strings.TrimPrefix(r.URL.Path, "/api/portal/policies/") + body, _ := io.ReadAll(r.Body) + var updated map[string]interface{} + _ = json.Unmarshal(body, &updated) + policyStore[id] = updated + _ = json.NewEncoder(w).Encode(map[string]interface{}{"Status": "success", "Message": "updated"}) + + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/api/portal/policies/"): + id := strings.TrimPrefix(r.URL.Path, "/api/portal/policies/") + delete(policyStore, id) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"Status": "success", "Message": "deleted"}) + + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + // Step 1: List should be empty + oldStderr := os.Stderr + rErr, wErr, _ := os.Pipe() + os.Stderr = wErr + + err := executePolicyListCmd(t, server.URL, types.OutputHuman) + + wErr.Close() + os.Stderr = oldStderr + stderr, _ := io.ReadAll(rErr) + require.NoError(t, err) + assert.Contains(t, string(stderr), "No policies found") + + // Step 2: Apply a new policy + policyFile := writeTempPolicyFile(t, validPlatinumPolicyYAML) + err = executePolicyApplyCmd(t, server.URL, policyFile) + require.NoError(t, err, "apply should succeed") + + // Step 3: List should now show the policy + oldStdout := os.Stdout + rOut, wOut, _ := os.Pipe() + os.Stdout = wOut + + err = executePolicyListCmd(t, server.URL, types.OutputJSON) + + wOut.Close() + os.Stdout = oldStdout + stdout, _ := io.ReadAll(rOut) + require.NoError(t, err) + + var listResult map[string]interface{} + require.NoError(t, json.Unmarshal(stdout, &listResult)) + assert.Equal(t, float64(1), listResult["count"], "list should show 1 policy after apply") + + // Step 4: Get should return CLI schema + oldStdout = os.Stdout + rOut, wOut, _ = os.Pipe() + os.Stdout = wOut + + err = executePolicyGetCmd(t, server.URL, types.OutputJSON, "platinum") + + wOut.Close() + os.Stdout = oldStdout + stdout, _ = io.ReadAll(rOut) + require.NoError(t, err) + + var getResult map[string]interface{} + require.NoError(t, json.Unmarshal(stdout, &getResult)) + assert.Equal(t, "platinum", getResult["id"]) + assert.Equal(t, "Platinum Plan", getResult["name"]) + + // Step 5: Delete the policy + err = executePolicyDeleteCmd(t, server.URL, types.OutputHuman, "platinum", true) + require.NoError(t, err, "delete should succeed") + + // Step 6: List should be empty again + oldStderr = os.Stderr + rErr, wErr, _ = os.Pipe() + os.Stderr = wErr + + err = executePolicyListCmd(t, server.URL, types.OutputHuman) + + wErr.Close() + os.Stderr = oldStderr + stderr, _ = io.ReadAll(rErr) + require.NoError(t, err) + assert.Contains(t, string(stderr), "No policies found") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 47b7c6d..bd527c8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -22,8 +22,10 @@ func NewRootCommand(version, commit, buildTime string) *cobra.Command { var globalFlags GlobalFlags rootCmd := &cobra.Command{ - Use: "tyk", - Short: "Tyk CLI - Manage Tyk OAS-native APIs", + Use: "tyk", + Short: "Tyk CLI - Manage Tyk OAS-native APIs", + SilenceUsage: true, + SilenceErrors: true, Long: `Tyk CLI is a command-line interface for managing Tyk OAS-native APIs. It provides commands to create, update, delete, and manage API versions with support for OpenAPI 3.0 specifications.`, @@ -62,6 +64,7 @@ with support for OpenAPI 3.0 specifications.`, // Add subcommands rootCmd.AddCommand(NewInitCommand()) rootCmd.AddCommand(NewAPICommand()) + rootCmd.AddCommand(NewPolicyCommand()) rootCmd.AddCommand(NewConfigCommand()) return rootCmd diff --git a/internal/client/client.go b/internal/client/client.go index ad42006..b2bedfe 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -141,8 +141,19 @@ func (c *Client) handleResponse(resp *http.Response, result interface{}) error { // Try to parse as JSON error response if err := json.Unmarshal(body, &errorResp); err != nil { - // If not JSON, use status text and body as message - errorResp.Message = fmt.Sprintf("%s: %s", resp.Status, string(body)) + // Dashboard errors use {"Status":"Error","Message":"...","Meta":null} + // where "Status" is a string (not int), causing unmarshal to fail. + // Try to extract just the message field. + type msgOnly struct { + Message string `json:"Message"` + } + var m msgOnly + if json.Unmarshal(body, &m) == nil && m.Message != "" { + errorResp.Message = fmt.Sprintf("%d %s: %s", resp.StatusCode, http.StatusText(resp.StatusCode), m.Message) + } else { + // If not JSON at all, use status text and body as message + errorResp.Message = fmt.Sprintf("%s: %s", resp.Status, string(body)) + } } return &errorResp diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 850f025..54f7cbb 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -93,7 +93,7 @@ func TestClient_doRequest(t *testing.T) { "path": r.URL.Path, "status": "success", } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer server.Close() @@ -119,7 +119,7 @@ func TestClient_handleResponse(t *testing.T) { t.Run("successful response", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := map[string]string{"status": "success", "message": "OK"} - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer server.Close() @@ -140,7 +140,7 @@ func TestClient_handleResponse(t *testing.T) { "status": 404, "message": "API not found", } - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer server.Close() @@ -187,7 +187,7 @@ func TestClient_GetOASAPI(t *testing.T) { // Return raw OAS document (as the Tyk Dashboard does) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(mockOASDoc) + _ = json.NewEncoder(w).Encode(mockOASDoc) })) defer server.Close() @@ -210,7 +210,7 @@ func TestClient_CreateOASAPI(t *testing.T) { if r.Method == http.MethodPost && r.URL.Path == "/api/apis/oas" { // Handle create request - return basic response with ID response := types.APIResponse{Status: "success", ID: "new-api-id"} - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } else if r.Method == http.MethodGet && r.URL.Path == "/api/apis/oas/new-api-id" { // Return raw OAS document similar to Dashboard oasDoc := map[string]interface{}{ @@ -231,7 +231,7 @@ func TestClient_CreateOASAPI(t *testing.T) { }, }, } - json.NewEncoder(w).Encode(oasDoc) + _ = json.NewEncoder(w).Encode(oasDoc) } else { http.NotFound(w, r) } @@ -270,7 +270,7 @@ func TestClient_ListOASAPIs(t *testing.T) { assert.Equal(t, "/api/apis/oas", r.URL.Path) // Ensure pagination param is passed when provided assert.Equal(t, "2", r.URL.Query().Get("p")) - json.NewEncoder(w).Encode(types.OASAPIListResponse{ + _ = json.NewEncoder(w).Encode(types.OASAPIListResponse{ APIResponse: types.APIResponse{Status: "success"}, APIs: mockAPIs, }) @@ -295,7 +295,7 @@ func TestClient_Health(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/health", r.URL.Path) w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + _, _ = w.Write([]byte("OK")) })) defer server.Close() @@ -312,7 +312,7 @@ func TestClient_Health(t *testing.T) { t.Run("unhealthy dashboard", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) - w.Write([]byte("Service Unavailable")) + _, _ = w.Write([]byte("Service Unavailable")) })) defer server.Close() diff --git a/internal/client/policy.go b/internal/client/policy.go new file mode 100644 index 0000000..b73f501 --- /dev/null +++ b/internal/client/policy.go @@ -0,0 +1,92 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/tyktech/tyk-cli/pkg/types" +) + +const ( + // Policy endpoints + PoliciesPath = "/api/portal/policies" + PolicyPath = "/api/portal/policies/%s" // {policyId} +) + +// ListPolicies retrieves a paginated list of policies from the Dashboard. +// Page numbers are 1-based. +func (c *Client) ListPolicies(ctx context.Context, page int) (*types.DashboardPolicyListResponse, error) { + listPath := PoliciesPath + if page > 0 { + values := url.Values{} + values.Set("p", fmt.Sprintf("%d", page)) + listPath += "?" + values.Encode() + } + + resp, err := c.doRequest(ctx, http.MethodGet, listPath, nil) + if err != nil { + return nil, err + } + + var result types.DashboardPolicyListResponse + if err := c.handleResponse(resp, &result); err != nil { + return nil, err + } + + return &result, nil +} + +// GetPolicy retrieves a single policy by its MongoDB _id. +// Returns *types.ErrorResponse on 404. +func (c *Client) GetPolicy(ctx context.Context, policyID string) (*types.DashboardPolicy, error) { + policyPath := fmt.Sprintf(PolicyPath, url.PathEscape(policyID)) + + resp, err := c.doRequest(ctx, http.MethodGet, policyPath, nil) + if err != nil { + return nil, err + } + + var result types.DashboardPolicy + if err := c.handleResponse(resp, &result); err != nil { + return nil, err + } + + return &result, nil +} + +// CreatePolicy sends a POST request with a DashboardPolicy JSON body. +func (c *Client) CreatePolicy(ctx context.Context, policy *types.DashboardPolicy) error { + resp, err := c.doRequest(ctx, http.MethodPost, PoliciesPath, policy) + if err != nil { + return err + } + + return c.handleResponse(resp, nil) +} + +// UpdatePolicy sends a PUT request to update an existing policy by ID. +func (c *Client) UpdatePolicy(ctx context.Context, policyID string, policy *types.DashboardPolicy) error { + policyPath := fmt.Sprintf(PolicyPath, url.PathEscape(policyID)) + + resp, err := c.doRequest(ctx, http.MethodPut, policyPath, policy) + if err != nil { + return err + } + + return c.handleResponse(resp, nil) +} + +// DeletePolicy sends a DELETE request to remove a policy by ID. +// Returns *types.ErrorResponse on 404. +func (c *Client) DeletePolicy(ctx context.Context, policyID string) error { + policyPath := fmt.Sprintf(PolicyPath, url.PathEscape(policyID)) + + resp, err := c.doRequest(ctx, http.MethodDelete, policyPath, nil) + if err != nil { + return err + } + + return c.handleResponse(resp, nil) +} diff --git a/internal/client/policy_test.go b/internal/client/policy_test.go new file mode 100644 index 0000000..4ff8e6e --- /dev/null +++ b/internal/client/policy_test.go @@ -0,0 +1,244 @@ +package client + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tyktech/tyk-cli/pkg/types" +) + +// --------------------------------------------------------------------------- +// Test data helpers +// --------------------------------------------------------------------------- + +func sampleDashboardPolicy(id, name string, rate int64) types.DashboardPolicy { + return types.DashboardPolicy{ + MID: id, + ID: "", + Name: name, + OrgID: "test-org", + Rate: rate, + Per: 60, + QuotaMax: 100000, + QuotaRenewalRate: 2592000, + Tags: []string{"test"}, + AccessRights: map[string]*types.AccessRight{}, + Active: true, + IsInactive: false, + } +} + +// --------------------------------------------------------------------------- +// ListPolicies +// --------------------------------------------------------------------------- + +func TestClient_ListPolicies(t *testing.T) { + gold := sampleDashboardPolicy("gold", "Gold Plan", 1000) + silver := sampleDashboardPolicy("silver", "Silver Plan", 500) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/portal/policies", r.URL.Path) + assert.Equal(t, "1", r.URL.Query().Get("p")) + assert.Equal(t, "test-token", r.Header.Get("authorization")) + + resp := types.DashboardPolicyListResponse{ + Data: []types.DashboardPolicy{gold, silver}, + Pages: 1, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + config := createTestConfig(server.URL, "test-token", "test-org") + client, err := NewClient(config) + require.NoError(t, err) + + result, err := client.ListPolicies(context.Background(), 1) + require.NoError(t, err) + require.Len(t, result.Data, 2) + assert.Equal(t, "gold", result.Data[0].MID) + assert.Equal(t, "Gold Plan", result.Data[0].Name) + assert.Equal(t, "silver", result.Data[1].MID) +} + +func TestClient_ListPolicies_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := types.DashboardPolicyListResponse{ + Data: nil, + Pages: 0, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + config := createTestConfig(server.URL, "test-token", "test-org") + client, err := NewClient(config) + require.NoError(t, err) + + result, err := client.ListPolicies(context.Background(), 1) + require.NoError(t, err) + assert.Empty(t, result.Data) +} + +// --------------------------------------------------------------------------- +// GetPolicy +// --------------------------------------------------------------------------- + +func TestClient_GetPolicy(t *testing.T) { + gold := sampleDashboardPolicy("gold", "Gold Plan", 1000) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/portal/policies/gold", r.URL.Path) + _ = json.NewEncoder(w).Encode(gold) + })) + defer server.Close() + + config := createTestConfig(server.URL, "test-token", "test-org") + client, err := NewClient(config) + require.NoError(t, err) + + result, err := client.GetPolicy(context.Background(), "gold") + require.NoError(t, err) + assert.Equal(t, "gold", result.MID) + assert.Equal(t, "Gold Plan", result.Name) + assert.Equal(t, int64(1000), result.Rate) +} + +func TestClient_GetPolicy_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": 404, "message": "policy not found", + }) + })) + defer server.Close() + + config := createTestConfig(server.URL, "test-token", "test-org") + client, err := NewClient(config) + require.NoError(t, err) + + _, err = client.GetPolicy(context.Background(), "nonexistent") + require.Error(t, err) + errorResp, ok := err.(*types.ErrorResponse) + require.True(t, ok, "expected *types.ErrorResponse, got %T", err) + assert.Equal(t, 404, errorResp.Status) + assert.Contains(t, errorResp.Message, "policy not found") +} + +// --------------------------------------------------------------------------- +// CreatePolicy +// --------------------------------------------------------------------------- + +func TestClient_CreatePolicy(t *testing.T) { + policy := sampleDashboardPolicy("new-policy", "New Policy", 500) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/portal/policies", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("content-type")) + + body, _ := io.ReadAll(r.Body) + var payload types.DashboardPolicy + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "new-policy", payload.MID) + + // Dashboard returns a Message response with the created policy ID in Meta + _ = json.NewEncoder(w).Encode(types.APIResponse{ + Status: "success", + Message: "created", + Meta: "new-policy", + }) + })) + defer server.Close() + + config := createTestConfig(server.URL, "test-token", "test-org") + client, err := NewClient(config) + require.NoError(t, err) + + err = client.CreatePolicy(context.Background(), &policy) + require.NoError(t, err) +} + +// --------------------------------------------------------------------------- +// UpdatePolicy +// --------------------------------------------------------------------------- + +func TestClient_UpdatePolicy(t *testing.T) { + policy := sampleDashboardPolicy("gold", "Gold Plan Updated", 2000) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/api/portal/policies/gold", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("content-type")) + + body, _ := io.ReadAll(r.Body) + var payload types.DashboardPolicy + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "Gold Plan Updated", payload.Name) + + _ = json.NewEncoder(w).Encode(types.APIResponse{ + Status: "success", + Message: "updated", + }) + })) + defer server.Close() + + config := createTestConfig(server.URL, "test-token", "test-org") + client, err := NewClient(config) + require.NoError(t, err) + + err = client.UpdatePolicy(context.Background(), "gold", &policy) + require.NoError(t, err) +} + +// --------------------------------------------------------------------------- +// DeletePolicy +// --------------------------------------------------------------------------- + +func TestClient_DeletePolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/portal/policies/free-tier", r.URL.Path) + + _ = json.NewEncoder(w).Encode(types.APIResponse{ + Status: "success", + Message: "deleted", + }) + })) + defer server.Close() + + config := createTestConfig(server.URL, "test-token", "test-org") + client, err := NewClient(config) + require.NoError(t, err) + + err = client.DeletePolicy(context.Background(), "free-tier") + require.NoError(t, err) +} + +func TestClient_DeletePolicy_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": 404, "message": "policy not found", + }) + })) + defer server.Close() + + config := createTestConfig(server.URL, "test-token", "test-org") + client, err := NewClient(config) + require.NoError(t, err) + + err = client.DeletePolicy(context.Background(), "nonexistent") + require.Error(t, err) + errorResp, ok := err.(*types.ErrorResponse) + require.True(t, ok, "expected *types.ErrorResponse, got %T", err) + assert.Equal(t, 404, errorResp.Status) +} diff --git a/internal/config/config.go b/internal/config/config.go index d670246..e1a9617 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,7 +82,7 @@ func (m *Manager) LoadConfig() error { AuthToken: authToken, OrgID: orgID, } - m.SaveEnvironment(env, true) + _ = m.SaveEnvironment(env, true) } } @@ -125,7 +125,7 @@ func (m *Manager) SetFromFlags(dashURL, authToken, orgID string) { // If we had to create a temp environment, save it if activeEnv.Name == "temp" { - m.SaveEnvironment(activeEnv, true) + _ = m.SaveEnvironment(activeEnv, true) } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a71c3a6..f24d335 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -199,7 +199,7 @@ func TestManagerPartialFlagOverride(t *testing.T) { OrgID: "base-org-id", } - manager.SaveEnvironment(baseEnv, true) + _ = manager.SaveEnvironment(baseEnv, true) // Override only dashboard URL with flag flagDashURL := "http://flag-dashboard:3000" diff --git a/internal/filehandler/filehandler_test.go b/internal/filehandler/filehandler_test.go index ac23b1d..f9e20ad 100644 --- a/internal/filehandler/filehandler_test.go +++ b/internal/filehandler/filehandler_test.go @@ -306,10 +306,7 @@ func TestConvertToYAML(t *testing.T) { yamlBytes, err := ConvertToYAML(sampleOAS) require.NoError(t, err) - // Parse YAML back to verify - var parsed map[string]interface{} - err = json.Unmarshal(yamlBytes, &parsed) - // YAML parsing would require yaml.Unmarshal, but we can at least check it's not empty + // Verify YAML output is not empty and contains expected content assert.NotEmpty(t, yamlBytes) assert.Contains(t, string(yamlBytes), "openapi: 3.0.0") assert.Contains(t, string(yamlBytes), "title: Test API") diff --git a/internal/policy/convert.go b/internal/policy/convert.go new file mode 100644 index 0000000..dba0f32 --- /dev/null +++ b/internal/policy/convert.go @@ -0,0 +1,134 @@ +package policy + +import ( + "fmt" + "sort" + + "github.com/tyktech/tyk-cli/pkg/types" +) + +// CLIToWire converts a PolicyFile and pre-resolved access entries into the Dashboard wire format. +// The caller is responsible for resolving selectors before calling this function. +func CLIToWire(pf types.PolicyFile, resolved []ResolvedAccess, orgID string) (types.DashboardPolicy, error) { + dp := types.DashboardPolicy{ + // MID intentionally left empty — caller sets it after resolution + ID: pf.ID, + Name: pf.Name, + OrgID: orgID, + Tags: pf.Tags, + Active: true, + IsInactive: false, + } + + // Rate limit + if pf.RateLimit != nil { + dp.Rate = pf.RateLimit.Requests + if pf.RateLimit.Per != "" { + per, err := ParseDuration(string(pf.RateLimit.Per)) + if err != nil { + return types.DashboardPolicy{}, fmt.Errorf("rateLimit.per: %w", err) + } + dp.Per = per + } + } + + // Quota + if pf.Quota != nil { + dp.QuotaMax = pf.Quota.Limit + if pf.Quota.Period != "" { + period, err := ParseDuration(string(pf.Quota.Period)) + if err != nil { + return types.DashboardPolicy{}, fmt.Errorf("quota.period: %w", err) + } + dp.QuotaRenewalRate = period + } + } + + // Key TTL + if pf.KeyTTL != "" { + ttl, err := ParseDuration(string(pf.KeyTTL)) + if err != nil { + return types.DashboardPolicy{}, fmt.Errorf("keyTTL: %w", err) + } + dp.KeyExpiresIn = ttl + } + + // Access rights from resolved entries + dp.AccessRights = make(map[string]*types.AccessRight, len(resolved)) + for _, r := range resolved { + dp.AccessRights[r.APIID] = &types.AccessRight{ + APIID: r.APIID, + APIName: r.APIName, + Versions: r.Versions, + AllowedURLs: nil, + Limit: nil, + } + } + + return dp, nil +} + +// WireToCLI converts a DashboardPolicy back to the CLI PolicyFile format. +// It uses the provided API list for best-effort reverse resolution of API IDs to names. +func WireToCLI(dp types.DashboardPolicy, apis []ResolverAPI) types.PolicyFile { + friendlyID := dp.ID + if friendlyID == "" { + friendlyID = dp.MID // fallback for unmanaged policies + } + + pf := types.PolicyFile{ + ID: friendlyID, + Name: dp.Name, + Tags: dp.Tags, + } + + // Rate limit + if dp.Rate > 0 || dp.Per > 0 { + pf.RateLimit = &types.RateLimit{ + Requests: dp.Rate, + Per: types.Duration(FormatDuration(dp.Per)), + } + } + + // Quota + if dp.QuotaMax > 0 || dp.QuotaRenewalRate > 0 { + pf.Quota = &types.Quota{ + Limit: dp.QuotaMax, + Period: types.Duration(FormatDuration(dp.QuotaRenewalRate)), + } + } + + // Key TTL + pf.KeyTTL = types.Duration(FormatDuration(dp.KeyExpiresIn)) + + // Build API name lookup + apiByID := make(map[string]ResolverAPI, len(apis)) + for _, api := range apis { + apiByID[api.ID] = api + } + + // Access rights -> access entries, sorted by API ID for deterministic output + apiIDs := make([]string, 0, len(dp.AccessRights)) + for id := range dp.AccessRights { + apiIDs = append(apiIDs, id) + } + sort.Strings(apiIDs) + + for _, apiID := range apiIDs { + ar := dp.AccessRights[apiID] + entry := types.AccessEntry{ + Versions: ar.Versions, + } + + // Best-effort reverse resolution: use name if API is known, otherwise fall back to ID + if api, ok := apiByID[apiID]; ok { + entry.Name = api.Name + } else { + entry.ID = apiID + } + + pf.Access = append(pf.Access, entry) + } + + return pf +} diff --git a/internal/policy/convert_test.go b/internal/policy/convert_test.go new file mode 100644 index 0000000..752f79c --- /dev/null +++ b/internal/policy/convert_test.go @@ -0,0 +1,185 @@ +package policy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tyktech/tyk-cli/pkg/types" +) + +func TestCLIToWire(t *testing.T) { + resolved := []ResolvedAccess{ + {APIID: "a1b2c3d4e5f6", APIName: "users-api", Versions: []string{"v1"}}, + {APIID: "g7h8i9j0k1l2", APIName: "orders-api", Versions: []string{"v1", "v2"}}, + } + + pf := types.PolicyFile{ + ID: "gold", + Name: "Gold Plan", + Tags: []string{"gold", "paid"}, + RateLimit: &types.RateLimit{Requests: 1000, Per: "60"}, + Quota: &types.Quota{Limit: 100000, Period: "30d"}, + KeyTTL: "0", + Access: []types.AccessEntry{ + {Name: "users-api", Versions: []string{"v1"}}, + {ListenPath: "/orders/", Versions: []string{"v1", "v2"}}, + }, + } + + dp, err := CLIToWire(pf, resolved, "org-123") + require.NoError(t, err) + + assert.Empty(t, dp.MID, "MID should be empty — caller sets it after resolution") + assert.Equal(t, "gold", dp.ID, "wire id should match friendly id directly") + assert.Equal(t, "Gold Plan", dp.Name) + assert.Equal(t, "org-123", dp.OrgID) + assert.Equal(t, []string{"gold", "paid"}, dp.Tags) + assert.Equal(t, int64(1000), dp.Rate) + assert.Equal(t, int64(60), dp.Per) + assert.Equal(t, int64(100000), dp.QuotaMax) + assert.Equal(t, int64(2592000), dp.QuotaRenewalRate) + assert.Equal(t, int64(0), dp.KeyExpiresIn) + assert.True(t, dp.Active) + assert.False(t, dp.IsInactive) + + require.Len(t, dp.AccessRights, 2) + usersAR := dp.AccessRights["a1b2c3d4e5f6"] + require.NotNil(t, usersAR) + assert.Equal(t, "a1b2c3d4e5f6", usersAR.APIID) + assert.Equal(t, "users-api", usersAR.APIName) + assert.Equal(t, []string{"v1"}, usersAR.Versions) + + ordersAR := dp.AccessRights["g7h8i9j0k1l2"] + require.NotNil(t, ordersAR) + assert.Equal(t, []string{"v1", "v2"}, ordersAR.Versions) +} + +func TestWireToCLI(t *testing.T) { + dp := types.DashboardPolicy{ + MID: "507f1f77bcf86cd799439011", + ID: "gold", + Name: "Gold Plan", + Tags: []string{"gold", "paid"}, + Rate: 1000, + Per: 60, + QuotaMax: 100000, + QuotaRenewalRate: 2592000, + KeyExpiresIn: 0, + Active: true, + AccessRights: map[string]*types.AccessRight{ + "a1b2c3d4e5f6": { + APIID: "a1b2c3d4e5f6", + APIName: "users-api", + Versions: []string{"v1"}, + }, + "g7h8i9j0k1l2": { + APIID: "g7h8i9j0k1l2", + APIName: "orders-api", + Versions: []string{"v1", "v2"}, + }, + }, + } + + apis := []ResolverAPI{ + {ID: "a1b2c3d4e5f6", Name: "users-api"}, + {ID: "g7h8i9j0k1l2", Name: "orders-api"}, + } + + pf := WireToCLI(dp, apis) + + assert.Equal(t, "gold", pf.ID, "should use wire id directly") + assert.Equal(t, "Gold Plan", pf.Name) + assert.Equal(t, []string{"gold", "paid"}, pf.Tags) + assert.Equal(t, int64(1000), pf.RateLimit.Requests) + assert.Equal(t, types.Duration("1m"), pf.RateLimit.Per) + assert.Equal(t, int64(100000), pf.Quota.Limit) + assert.Equal(t, types.Duration("30d"), pf.Quota.Period) + assert.Equal(t, types.Duration("0"), pf.KeyTTL) + + require.Len(t, pf.Access, 2) + // Access entries come from map iteration, so sort by name for stable assertion + accessByName := make(map[string]types.AccessEntry) + for _, a := range pf.Access { + key := a.Name + if key == "" { + key = a.ID + } + accessByName[key] = a + } + usersEntry := accessByName["users-api"] + assert.Equal(t, "users-api", usersEntry.Name) + assert.Equal(t, []string{"v1"}, usersEntry.Versions) + + ordersEntry := accessByName["orders-api"] + assert.Equal(t, "orders-api", ordersEntry.Name) + assert.Equal(t, []string{"v1", "v2"}, ordersEntry.Versions) +} + +func TestWireToCLI_FallbackToMID(t *testing.T) { + dp := types.DashboardPolicy{ + MID: "507f1f77bcf86cd799439011", + ID: "", // unmanaged policy — no tyk-cli: prefix + Name: "Legacy Policy", + AccessRights: map[string]*types.AccessRight{ + "a1b2c3d4e5f6": { + APIID: "a1b2c3d4e5f6", + APIName: "users-api", + Versions: []string{"v1"}, + }, + }, + } + + apis := []ResolverAPI{ + {ID: "a1b2c3d4e5f6", Name: "users-api"}, + } + + pf := WireToCLI(dp, apis) + + assert.Equal(t, "507f1f77bcf86cd799439011", pf.ID, "should fall back to MID when wire id is empty") + assert.Equal(t, "Legacy Policy", pf.Name) +} + +func TestRoundTrip_CLIToWireToCLI(t *testing.T) { + // Original CLI policy + original := types.PolicyFile{ + ID: "silver", + Name: "Silver Plan", + Tags: []string{"silver"}, + RateLimit: &types.RateLimit{Requests: 500, Per: "1h"}, + Quota: &types.Quota{Limit: 50000, Period: "1d"}, + KeyTTL: "24h", + Access: []types.AccessEntry{ + {Name: "users-api", Versions: []string{"v1"}}, + }, + } + + resolved := []ResolvedAccess{ + {APIID: "a1b2c3d4e5f6", APIName: "users-api", Versions: []string{"v1"}}, + } + + apis := []ResolverAPI{ + {ID: "a1b2c3d4e5f6", Name: "users-api"}, + } + + // CLI -> Wire + wire, err := CLIToWire(original, resolved, "org-123") + require.NoError(t, err) + + // Wire -> CLI + roundTrip := WireToCLI(wire, apis) + + // Semantic equivalence (duration strings may normalize) + assert.Equal(t, original.ID, roundTrip.ID) + assert.Equal(t, original.Name, roundTrip.Name) + assert.Equal(t, original.RateLimit.Requests, roundTrip.RateLimit.Requests) + + // Duration round-trip: "1h" -> 3600 -> "1h" + assert.Equal(t, types.Duration("1h"), roundTrip.RateLimit.Per) + assert.Equal(t, types.Duration("1d"), roundTrip.Quota.Period) + assert.Equal(t, types.Duration("1d"), roundTrip.KeyTTL) + + require.Len(t, roundTrip.Access, 1) + assert.Equal(t, "users-api", roundTrip.Access[0].Name) + assert.Equal(t, []string{"v1"}, roundTrip.Access[0].Versions) +} diff --git a/internal/policy/duration.go b/internal/policy/duration.go new file mode 100644 index 0000000..f084a4b --- /dev/null +++ b/internal/policy/duration.go @@ -0,0 +1,86 @@ +package policy + +import ( + "fmt" + "strconv" + "strings" +) + +// Duration unit constants in seconds. +const ( + secondsPerMinute = 60 + secondsPerHour = 3600 + secondsPerDay = 86400 +) + +// suffixMultipliers maps duration suffixes to their multiplier in seconds. +var suffixMultipliers = map[byte]int64{ + 's': 1, + 'm': secondsPerMinute, + 'h': secondsPerHour, + 'd': secondsPerDay, +} + +// ParseDuration parses a duration string into seconds. +// Accepted formats: plain integer ("60"), or integer with suffix s/m/h/d ("30d", "1h"). +// Rejects negative values, fractional values, mixed units, and unsupported suffixes. +func ParseDuration(s string) (int64, error) { + if s == "" { + return 0, fmt.Errorf("invalid duration %q: empty string", s) + } + + // Check for spaces + if strings.ContainsAny(s, " \t") { + return 0, fmt.Errorf("invalid duration %q: must not contain spaces", s) + } + + // Check for negative + if s[0] == '-' { + return 0, fmt.Errorf("invalid duration %q: negative values not allowed", s) + } + + // Check for fractional (contains '.') + if strings.Contains(s, ".") { + return 0, fmt.Errorf("invalid duration %q: fractional values not allowed", s) + } + + last := s[len(s)-1] + multiplier, hasSuffix := suffixMultipliers[last] + + if hasSuffix { + numPart := s[:len(s)-1] + if numPart == "" { + return 0, fmt.Errorf("invalid duration %q: missing numeric value", s) + } + n, err := strconv.ParseInt(numPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + return n * multiplier, nil + } + + // No recognized suffix -- try plain integer + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: expected integer or NNs/NNm/NNh/NNd", s) + } + return n, nil +} + +// FormatDuration converts seconds to the largest clean human-readable unit. +// 0 -> "0", 86400 -> "1d", 3600 -> "1h", 60 -> "1m", 45 -> "45s". +func FormatDuration(seconds int64) string { + if seconds == 0 { + return "0" + } + if seconds%secondsPerDay == 0 { + return fmt.Sprintf("%dd", seconds/secondsPerDay) + } + if seconds%secondsPerHour == 0 { + return fmt.Sprintf("%dh", seconds/secondsPerHour) + } + if seconds%secondsPerMinute == 0 { + return fmt.Sprintf("%dm", seconds/secondsPerMinute) + } + return fmt.Sprintf("%ds", seconds) +} diff --git a/internal/policy/duration_test.go b/internal/policy/duration_test.go new file mode 100644 index 0000000..0b6e249 --- /dev/null +++ b/internal/policy/duration_test.go @@ -0,0 +1,86 @@ +package policy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDuration(t *testing.T) { + tests := []struct { + name string + input string + expected int64 + }{ + {"plain seconds", "60", 60}, + {"seconds suffix", "60s", 60}, + {"minutes", "1m", 60}, + {"five minutes", "5m", 300}, + {"one hour", "1h", 3600}, + {"twenty-four hours", "24h", 86400}, + {"one day", "1d", 86400}, + {"thirty days", "30d", 2592000}, + {"zero", "0", 0}, + {"zero suffix", "0s", 0}, + {"large seconds", "2592000", 2592000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseDuration(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseDuration_Errors(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"alphabetic only", "abc"}, + {"negative", "-1"}, + {"negative with suffix", "-5m"}, + {"fractional", "1.5h"}, + {"mixed units", "1h30m"}, + {"empty string", ""}, + {"unsupported suffix", "30w"}, + {"unsupported suffix y", "1y"}, + {"spaces", "30 d"}, + {"suffix only", "m"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseDuration(tt.input) + assert.Error(t, err, "ParseDuration(%q) should return error", tt.input) + }) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + seconds int64 + expected string + }{ + {"zero", 0, "0"}, + {"exact days", 2592000, "30d"}, + {"one day", 86400, "1d"}, + {"exact hours", 3600, "1h"}, + {"two hours", 7200, "2h"}, + {"exact minutes", 60, "1m"}, + {"five minutes", 300, "5m"}, + {"odd seconds", 45, "45s"}, + {"ninety seconds", 90, "90s"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatDuration(tt.seconds) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/policy/resolve.go b/internal/policy/resolve.go new file mode 100644 index 0000000..8cbf7ee --- /dev/null +++ b/internal/policy/resolve.go @@ -0,0 +1 @@ +package policy diff --git a/internal/policy/resolve_test.go b/internal/policy/resolve_test.go new file mode 100644 index 0000000..8cbf7ee --- /dev/null +++ b/internal/policy/resolve_test.go @@ -0,0 +1 @@ +package policy diff --git a/internal/policy/selector.go b/internal/policy/selector.go new file mode 100644 index 0000000..320fd73 --- /dev/null +++ b/internal/policy/selector.go @@ -0,0 +1,256 @@ +package policy + +import ( + "fmt" + "sort" + "strings" +) + +// ResolverAPI is the API information needed for selector resolution. +// It decouples the resolver from the wire type (types.OASAPI) and adds Tags support. +type ResolverAPI struct { + ID string + Name string + ListenPath string + Tags []string +} + +// ResolvedAccess represents a successfully resolved access entry. +type ResolvedAccess struct { + APIID string + APIName string + Versions []string +} + +// ResolveRequest describes a single access entry to resolve. +type ResolveRequest struct { + SelectorType string // "id", "name", "listenPath", "tags" + Value string // for id, name, listenPath + TagValues []string // for tags selector + Versions []string +} + +// FuzzySuggestion represents a fuzzy match suggestion. +type FuzzySuggestion struct { + Name string + ID string + Distance int +} + +// ResolveByName finds exactly one API by name. Returns error if zero or multiple matches. +func ResolveByName(name string, apis []ResolverAPI) (ResolverAPI, error) { + var matches []ResolverAPI + for _, api := range apis { + if api.Name == name { + matches = append(matches, api) + } + } + + if len(matches) == 0 { + suggestions := FuzzySuggestions(name, apis, 3) + msg := fmt.Sprintf("no API found for name %q", name) + if len(suggestions) > 0 { + parts := make([]string, len(suggestions)) + for i, s := range suggestions { + parts[i] = fmt.Sprintf("%s (%s)", s.Name, s.ID) + } + msg += ". Did you mean: " + strings.Join(parts, ", ") + } + return ResolverAPI{}, fmt.Errorf("%s", msg) + } + + if len(matches) > 1 { + ids := make([]string, len(matches)) + for i, m := range matches { + ids[i] = m.ID + } + return ResolverAPI{}, fmt.Errorf("ambiguous: name %q matches %d APIs: %s", + name, len(matches), strings.Join(ids, ", ")) + } + + return matches[0], nil +} + +// ResolveByListenPath finds exactly one API by listen path. Returns error if zero or multiple matches. +func ResolveByListenPath(path string, apis []ResolverAPI) (ResolverAPI, error) { + var matches []ResolverAPI + for _, api := range apis { + if api.ListenPath == path { + matches = append(matches, api) + } + } + + if len(matches) == 0 { + return ResolverAPI{}, fmt.Errorf("no API found for listenPath %q", path) + } + + if len(matches) > 1 { + ids := make([]string, len(matches)) + for i, m := range matches { + ids[i] = m.ID + } + return ResolverAPI{}, fmt.Errorf("ambiguous: listenPath %q matches %d APIs: %s", + path, len(matches), strings.Join(ids, ", ")) + } + + return matches[0], nil +} + +// ResolveByID finds exactly one API by ID. Returns error if not found. +func ResolveByID(id string, apis []ResolverAPI) (ResolverAPI, error) { + for _, api := range apis { + if api.ID == id { + return api, nil + } + } + return ResolverAPI{}, fmt.Errorf("no API found for id %q", id) +} + +// ResolveByTags finds all APIs that have ALL the specified tags. Returns error if none match. +func ResolveByTags(tags []string, apis []ResolverAPI) ([]ResolverAPI, error) { + var matches []ResolverAPI + for _, api := range apis { + if hasAllTags(api.Tags, tags) { + matches = append(matches, api) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("no APIs matched tags %v", tags) + } + + return matches, nil +} + +// hasAllTags returns true if apiTags contains all of requiredTags. +func hasAllTags(apiTags, requiredTags []string) bool { + tagSet := make(map[string]struct{}, len(apiTags)) + for _, t := range apiTags { + tagSet[t] = struct{}{} + } + for _, t := range requiredTags { + if _, ok := tagSet[t]; !ok { + return false + } + } + return true +} + +// FuzzySuggestions returns the top N closest API names by Levenshtein distance. +func FuzzySuggestions(query string, apis []ResolverAPI, n int) []FuzzySuggestion { + type scored struct { + api ResolverAPI + distance int + } + + var candidates []scored + for _, api := range apis { + d := levenshtein(query, api.Name) + candidates = append(candidates, scored{api: api, distance: d}) + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].distance < candidates[j].distance + }) + + n = min(n, len(candidates)) + + result := make([]FuzzySuggestion, n) + for i := 0; i < n; i++ { + result[i] = FuzzySuggestion{ + Name: candidates[i].api.Name, + ID: candidates[i].api.ID, + Distance: candidates[i].distance, + } + } + return result +} + +// levenshtein computes the Levenshtein edit distance between two strings. +func levenshtein(a, b string) int { + lenA, lenB := len(a), len(b) + if lenA == 0 { + return lenB + } + if lenB == 0 { + return lenA + } + + // Single-row optimization: only keep the previous row in memory. + prev := make([]int, lenB+1) + for j := 0; j <= lenB; j++ { + prev[j] = j + } + + for i := 1; i <= lenA; i++ { + curr := make([]int, lenB+1) + curr[0] = i + for j := 1; j <= lenB; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost) + } + prev = curr + } + return prev[lenB] +} + +// ResolveAccessEntries resolves a batch of access entry requests against an API list. +// It collects all errors before returning. Successful resolutions and errors are returned separately. +func ResolveAccessEntries(requests []ResolveRequest, apis []ResolverAPI) ([]ResolvedAccess, []error) { + var resolved []ResolvedAccess + var errs []error + + for _, req := range requests { + switch req.SelectorType { + case "id": + api, err := ResolveByID(req.Value, apis) + if err != nil { + errs = append(errs, err) + continue + } + resolved = append(resolved, ResolvedAccess{ + APIID: api.ID, APIName: api.Name, Versions: req.Versions, + }) + + case "name": + api, err := ResolveByName(req.Value, apis) + if err != nil { + errs = append(errs, err) + continue + } + resolved = append(resolved, ResolvedAccess{ + APIID: api.ID, APIName: api.Name, Versions: req.Versions, + }) + + case "listenPath": + api, err := ResolveByListenPath(req.Value, apis) + if err != nil { + errs = append(errs, err) + continue + } + resolved = append(resolved, ResolvedAccess{ + APIID: api.ID, APIName: api.Name, Versions: req.Versions, + }) + + case "tags": + matches, err := ResolveByTags(req.TagValues, apis) + if err != nil { + errs = append(errs, err) + continue + } + for _, api := range matches { + resolved = append(resolved, ResolvedAccess{ + APIID: api.ID, APIName: api.Name, Versions: req.Versions, + }) + } + + default: + errs = append(errs, fmt.Errorf("unknown selector type %q", req.SelectorType)) + } + } + + return resolved, errs +} diff --git a/internal/policy/selector_test.go b/internal/policy/selector_test.go new file mode 100644 index 0000000..41c6a53 --- /dev/null +++ b/internal/policy/selector_test.go @@ -0,0 +1,146 @@ +package policy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testAPIList() []ResolverAPI { + return []ResolverAPI{ + {ID: "a1b2c3d4e5f6", Name: "users-api", ListenPath: "/users/", Tags: []string{"public", "v1"}}, + {ID: "g7h8i9j0k1l2", Name: "orders-api", ListenPath: "/orders/", Tags: []string{"internal", "v1"}}, + {ID: "m3n4o5p6q7r8", Name: "payments-api", ListenPath: "/payments/", Tags: []string{"internal", "v2"}}, + } +} + +func TestResolveByName(t *testing.T) { + apis := testAPIList() + + t.Run("exact match", func(t *testing.T) { + resolved, err := ResolveByName("users-api", apis) + require.NoError(t, err) + assert.Equal(t, "a1b2c3d4e5f6", resolved.ID) + assert.Equal(t, "users-api", resolved.Name) + }) + + t.Run("not found with fuzzy suggestions", func(t *testing.T) { + _, err := ResolveByName("inventori-api", apis) + require.Error(t, err) + assert.Contains(t, err.Error(), "no API found") + }) + + t.Run("ambiguous match", func(t *testing.T) { + dupes := []ResolverAPI{ + {ID: "dup-1", Name: "api-service", ListenPath: "/svc-1/"}, + {ID: "dup-2", Name: "api-service", ListenPath: "/svc-2/"}, + } + _, err := ResolveByName("api-service", dupes) + require.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous") + }) +} + +func TestResolveByListenPath(t *testing.T) { + apis := testAPIList() + + t.Run("exact match", func(t *testing.T) { + resolved, err := ResolveByListenPath("/orders/", apis) + require.NoError(t, err) + assert.Equal(t, "g7h8i9j0k1l2", resolved.ID) + }) + + t.Run("not found", func(t *testing.T) { + _, err := ResolveByListenPath("/unknown/", apis) + require.Error(t, err) + assert.Contains(t, err.Error(), "no API found") + }) +} + +func TestResolveByID(t *testing.T) { + apis := testAPIList() + + t.Run("found", func(t *testing.T) { + resolved, err := ResolveByID("a1b2c3d4e5f6", apis) + require.NoError(t, err) + assert.Equal(t, "users-api", resolved.Name) + }) + + t.Run("not found", func(t *testing.T) { + _, err := ResolveByID("nonexistent-id", apis) + require.Error(t, err) + assert.Contains(t, err.Error(), "no API found") + }) +} + +func TestResolveByTags(t *testing.T) { + apis := testAPIList() + + t.Run("single tag matches multiple", func(t *testing.T) { + resolved, err := ResolveByTags([]string{"internal"}, apis) + require.NoError(t, err) + assert.Len(t, resolved, 2) + }) + + t.Run("multiple tags intersection", func(t *testing.T) { + resolved, err := ResolveByTags([]string{"internal", "v1"}, apis) + require.NoError(t, err) + require.Len(t, resolved, 1) + assert.Equal(t, "orders-api", resolved[0].Name) + }) + + t.Run("no match", func(t *testing.T) { + _, err := ResolveByTags([]string{"legacy"}, apis) + require.Error(t, err) + assert.Contains(t, err.Error(), "no APIs matched tags") + }) +} + +func TestFuzzySuggestions(t *testing.T) { + apis := testAPIList() + + suggestions := FuzzySuggestions("inventori-api", apis, 3) + require.NotEmpty(t, suggestions) + // Closest should come first, and all APIs have reasonable edit distance + assert.LessOrEqual(t, len(suggestions), 3) + // The closest should be one of the real APIs + assert.NotEmpty(t, suggestions[0].Name) + assert.NotEmpty(t, suggestions[0].ID) + assert.Greater(t, suggestions[0].Distance, 0) +} + +func TestResolveAccessEntries(t *testing.T) { + apis := testAPIList() + entries := []ResolveRequest{ + {SelectorType: "name", Value: "users-api", Versions: []string{"v1"}}, + {SelectorType: "listenPath", Value: "/orders/", Versions: []string{"v1", "v2"}}, + {SelectorType: "id", Value: "m3n4o5p6q7r8", Versions: []string{"v1"}}, + {SelectorType: "tags", TagValues: []string{"public", "v1"}, Versions: []string{"v1"}}, + } + + resolved, errs := ResolveAccessEntries(entries, apis) + require.Empty(t, errs, "expected no resolution errors, got: %v", errs) + require.Len(t, resolved, 4) + + assert.Equal(t, "a1b2c3d4e5f6", resolved[0].APIID) + assert.Equal(t, "g7h8i9j0k1l2", resolved[1].APIID) + assert.Equal(t, "m3n4o5p6q7r8", resolved[2].APIID) + // tags resolved to users-api (has both "public" and "v1") + assert.Equal(t, "a1b2c3d4e5f6", resolved[3].APIID) +} + +func TestResolveAccessEntries_CollectsErrors(t *testing.T) { + apis := testAPIList() + entries := []ResolveRequest{ + {SelectorType: "name", Value: "nonexistent-api"}, + {SelectorType: "name", Value: "users-api", Versions: []string{"v1"}}, // valid + {SelectorType: "listenPath", Value: "/nowhere/"}, + } + + resolved, errs := ResolveAccessEntries(entries, apis) + // The valid entry should still resolve + assert.Len(t, resolved, 1) + // Two errors should be collected + assert.Len(t, errs, 2) +} diff --git a/internal/policy/validate.go b/internal/policy/validate.go new file mode 100644 index 0000000..9314bfc --- /dev/null +++ b/internal/policy/validate.go @@ -0,0 +1,133 @@ +package policy + +import ( + "fmt" + "regexp" + + "github.com/tyktech/tyk-cli/pkg/types" +) + +var friendlyIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// ValidatePolicy validates a PolicyFile and collects all errors before returning. +// It checks schema (required fields, types), duration formats, and selector constraints. +func ValidatePolicy(pf types.PolicyFile) types.ValidationErrors { + var errs types.ValidationErrors + + // Schema: required fields + if pf.ID == "" { + errs = append(errs, types.ValidationError{ + Field: "id", Message: "required field missing", Kind: "schema", + }) + } else if verr := validateFriendlyID(pf.ID); verr != nil { + errs = append(errs, *verr) + } + + if pf.Name == "" { + errs = append(errs, types.ValidationError{ + Field: "name", Message: "required field missing", Kind: "schema", + }) + } + + if len(pf.Access) == 0 { + errs = append(errs, types.ValidationError{ + Field: "access", Message: "at least one access entry required", Kind: "schema", + }) + } + + // Duration validation + if pf.RateLimit != nil { + if pf.RateLimit.Per != "" { + if _, err := ParseDuration(string(pf.RateLimit.Per)); err != nil { + errs = append(errs, types.ValidationError{ + Field: "rateLimit.per", Message: err.Error(), Kind: "duration", + }) + } + } + } + + if pf.Quota != nil { + if pf.Quota.Period != "" { + if _, err := ParseDuration(string(pf.Quota.Period)); err != nil { + errs = append(errs, types.ValidationError{ + Field: "quota.period", Message: err.Error(), Kind: "duration", + }) + } + } + } + + if pf.KeyTTL != "" { + if _, err := ParseDuration(string(pf.KeyTTL)); err != nil { + errs = append(errs, types.ValidationError{ + Field: "keyTTL", Message: err.Error(), Kind: "duration", + }) + } + } + + // Selector constraints per access entry + for i, entry := range pf.Access { + count := selectorCount(entry) + if count != 1 { + errs = append(errs, types.ValidationError{ + Field: fmt.Sprintf("access[%d]", i), + Message: "exactly one of id, name, listenPath, or tags must be set", + Kind: "selector", + }) + } + } + + return errs +} + +// validateFriendlyID validates the format of a friendly policy ID. +func validateFriendlyID(id string) *types.ValidationError { + if len(id) > 64 { + return &types.ValidationError{Field: "id", Message: "must be 64 characters or fewer", Kind: "schema"} + } + if !friendlyIDPattern.MatchString(id) { + return &types.ValidationError{ + Field: "id", + Message: "must contain only lowercase letters, numbers, dots, hyphens, underscores, and start with a letter or number", + Kind: "schema", + } + } + if isObjectIDFormat(id) { + return &types.ValidationError{ + Field: "id", + Message: "looks like a MongoDB ObjectID — use a human-readable name instead (e.g., 'gold', 'free-tier')", + Kind: "schema", + } + } + return nil +} + +// isObjectIDFormat returns true if s looks like a 24-character hex MongoDB ObjectID. +func isObjectIDFormat(s string) bool { + if len(s) != 24 { + return false + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +// selectorCount returns how many selector fields are set on an AccessEntry. +func selectorCount(e types.AccessEntry) int { + count := 0 + if e.ID != "" { + count++ + } + if e.Name != "" { + count++ + } + if e.ListenPath != "" { + count++ + } + if len(e.Tags) > 0 { + count++ + } + return count +} diff --git a/internal/policy/validate_test.go b/internal/policy/validate_test.go new file mode 100644 index 0000000..b76248a --- /dev/null +++ b/internal/policy/validate_test.go @@ -0,0 +1,225 @@ +package policy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tyktech/tyk-cli/pkg/types" +) + +func validPolicyFile() types.PolicyFile { + return types.PolicyFile{ + ID: "gold", + Name: "Gold Plan", + RateLimit: &types.RateLimit{Requests: 1000, Per: "60"}, + Quota: &types.Quota{Limit: 100000, Period: "30d"}, + KeyTTL: "0", + Access: []types.AccessEntry{ + {Name: "users-api", Versions: []string{"v1"}}, + }, + } +} + +func TestValidatePolicy_Valid(t *testing.T) { + errs := ValidatePolicy(validPolicyFile()) + assert.Empty(t, errs) +} + +func TestValidatePolicy_MissingRequiredFields(t *testing.T) { + tests := []struct { + name string + modify func(*types.PolicyFile) + expectedField string + expectedKind string + }{ + { + "missing id", + func(pf *types.PolicyFile) { pf.ID = "" }, + "id", "schema", + }, + { + "missing name", + func(pf *types.PolicyFile) { pf.Name = "" }, + "name", "schema", + }, + { + "empty access list", + func(pf *types.PolicyFile) { pf.Access = nil }, + "access", "schema", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pf := validPolicyFile() + tt.modify(&pf) + errs := ValidatePolicy(pf) + require.NotEmpty(t, errs, "expected validation error for %s", tt.name) + + found := false + for _, e := range errs { + if e.Field == tt.expectedField && e.Kind == tt.expectedKind { + found = true + break + } + } + assert.True(t, found, "expected error for field %s with kind %s, got: %v", + tt.expectedField, tt.expectedKind, errs) + }) + } +} + +func TestValidatePolicy_InvalidDurations(t *testing.T) { + tests := []struct { + name string + modify func(*types.PolicyFile) + expectedField string + }{ + { + "invalid rateLimit.per", + func(pf *types.PolicyFile) { pf.RateLimit.Per = "abc" }, + "rateLimit.per", + }, + { + "invalid quota.period", + func(pf *types.PolicyFile) { pf.Quota.Period = "1.5h" }, + "quota.period", + }, + { + "invalid keyTTL", + func(pf *types.PolicyFile) { pf.KeyTTL = "-1" }, + "keyTTL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pf := validPolicyFile() + tt.modify(&pf) + errs := ValidatePolicy(pf) + require.NotEmpty(t, errs) + + found := false + for _, e := range errs { + if e.Field == tt.expectedField && e.Kind == "duration" { + found = true + break + } + } + assert.True(t, found, "expected duration error for field %s, got: %v", + tt.expectedField, errs) + }) + } +} + +func TestValidatePolicy_SelectorConstraints(t *testing.T) { + tests := []struct { + name string + entry types.AccessEntry + errMsg string + }{ + { + "zero selectors", + types.AccessEntry{Versions: []string{"v1"}}, + "exactly one of", + }, + { + "multiple selectors: name and id", + types.AccessEntry{Name: "foo", ID: "bar"}, + "exactly one of", + }, + { + "multiple selectors: name and listenPath", + types.AccessEntry{Name: "foo", ListenPath: "/bar/"}, + "exactly one of", + }, + { + "empty tags slice", + types.AccessEntry{Tags: []string{}}, + "exactly one of", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pf := validPolicyFile() + pf.Access = []types.AccessEntry{tt.entry} + errs := ValidatePolicy(pf) + require.NotEmpty(t, errs) + + found := false + for _, e := range errs { + if e.Kind == "selector" { + found = true + break + } + } + assert.True(t, found, "expected selector error, got: %v", errs) + }) + } +} + +func TestValidatePolicy_CollectsAllErrors(t *testing.T) { + pf := types.PolicyFile{ + // Missing id, name + RateLimit: &types.RateLimit{Requests: 1000, Per: "abc"}, + Access: []types.AccessEntry{ + {Name: "foo", ID: "bar"}, // multiple selectors + }, + } + + errs := ValidatePolicy(pf) + // Should have at least: id, name, duration, selector = 4 + assert.GreaterOrEqual(t, len(errs), 4, + "expected at least 4 errors for multiply-broken policy, got %d: %v", len(errs), errs) +} + +func TestValidatePolicy_FriendlyID_Valid(t *testing.T) { + validIDs := []string{"gold", "free-tier", "rate-limit-basic", "v2.0", "a", "abc_def"} + + for _, id := range validIDs { + t.Run(id, func(t *testing.T) { + pf := validPolicyFile() + pf.ID = id + errs := ValidatePolicy(pf) + for _, e := range errs { + assert.NotEqual(t, "id", e.Field, "expected no id error for valid ID %q, got: %v", id, e) + } + }) + } +} + +func TestValidatePolicy_FriendlyID_Invalid(t *testing.T) { + tests := []struct { + name string + id string + wantMsg string + }{ + {"uppercase", "Gold", "lowercase"}, + {"starts with hyphen", "-bad", "start with"}, + {"special chars", "gold!", "lowercase"}, + {"too long", "aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffggggg", "64 characters"}, + {"ObjectID-shaped", "507f1f77bcf86cd799439011", "MongoDB ObjectID"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pf := validPolicyFile() + pf.ID = tt.id + errs := ValidatePolicy(pf) + require.NotEmpty(t, errs, "expected validation error for ID %q", tt.id) + + found := false + for _, e := range errs { + if e.Field == "id" && e.Kind == "schema" { + found = true + assert.Contains(t, e.Message, tt.wantMsg, + "error message for ID %q should contain %q", tt.id, tt.wantMsg) + break + } + } + assert.True(t, found, "expected schema error for field 'id', got: %v", errs) + }) + } +} diff --git a/pkg/types/policy.go b/pkg/types/policy.go new file mode 100644 index 0000000..155d715 --- /dev/null +++ b/pkg/types/policy.go @@ -0,0 +1,155 @@ +package types + +import ( + "encoding/json" + "fmt" + + "gopkg.in/yaml.v3" +) + +// PolicyFile is the top-level structure users write in YAML policy files. +type PolicyFile struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name" json:"name"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + RateLimit *RateLimit `yaml:"rateLimit,omitempty" json:"rateLimit,omitempty"` + Quota *Quota `yaml:"quota,omitempty" json:"quota,omitempty"` + KeyTTL Duration `yaml:"keyTTL,omitempty" json:"keyTTL,omitempty"` + Access []AccessEntry `yaml:"access" json:"access"` +} + +// RateLimit defines requests-per-window rate limiting. +type RateLimit struct { + Requests int64 `yaml:"requests" json:"requests"` + Per Duration `yaml:"per" json:"per"` +} + +// Quota defines quota limits over a time period. +type Quota struct { + Limit int64 `yaml:"limit" json:"limit"` + Period Duration `yaml:"period" json:"period"` +} + +// AccessEntry represents a single API access rule with exactly one selector. +// Exactly one of ID, Name, ListenPath, or Tags must be set. +type AccessEntry struct { + ID string `yaml:"id,omitempty" json:"id,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + ListenPath string `yaml:"listenPath,omitempty" json:"listenPath,omitempty"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + Versions []string `yaml:"versions,omitempty" json:"versions,omitempty"` +} + +// Duration is a string that accepts both string durations ("30d", "1m") and +// plain integers (interpreted as seconds) from YAML input. Internally stored +// as the string representation. +type Duration string + +// UnmarshalYAML implements yaml.Unmarshaler for Duration. +// It handles both string nodes (e.g. "30d") and integer nodes (e.g. 60). +// All YAML tag types are stored as their raw string value. +func (d *Duration) UnmarshalYAML(value *yaml.Node) error { + *d = Duration(value.Value) + return nil +} + +// DashboardPolicy represents the wire format returned by the Tyk Dashboard API. +type DashboardPolicy struct { + MID string `json:"_id,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + OrgID string `json:"org_id,omitempty"` + Rate int64 `json:"rate"` + Per int64 `json:"per"` + QuotaMax int64 `json:"quota_max"` + QuotaRenewalRate int64 `json:"quota_renewal_rate"` + KeyExpiresIn int64 `json:"key_expires_in"` + Tags []string `json:"tags,omitempty"` + AccessRights map[string]*AccessRight `json:"access_rights,omitempty"` + Active bool `json:"active"` + IsInactive bool `json:"is_inactive"` +} + +// AccessRight represents per-API access configuration in the Dashboard wire format. +type AccessRight struct { + APIID string `json:"api_id"` + APIName string `json:"api_name"` + Versions []string `json:"versions"` + AllowedURLs []AllowedURL `json:"allowed_urls"` + Limit *RateQuotaLimit `json:"limit"` +} + +// AllowedURL represents a URL-level access restriction (Phase 1: unused). +type AllowedURL struct { + URL string `json:"url"` + Methods []string `json:"methods"` +} + +// RateQuotaLimit represents per-API rate and quota limits (Phase 1: unused). +type RateQuotaLimit struct { + Rate int64 `json:"rate"` + Per int64 `json:"per"` + QuotaMax int64 `json:"quota_max"` + QuotaRenewalRate int64 `json:"quota_renewal_rate"` +} + +// DashboardPolicyListResponse represents the paginated list response from +// the Dashboard policy API. +type DashboardPolicyListResponse struct { + Data []DashboardPolicy `json:"Data"` + Pages int `json:"Pages"` + StatusCode int `json:"StatusCode"` +} + +// ValidationError describes a single validation failure on a policy file. +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` + Kind string `json:"kind"` // "schema", "duration", or "selector" +} + +// Error implements the error interface. +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s: %s (%s)", e.Field, e.Message, e.Kind) +} + +// ValidationErrors collects multiple validation failures. +type ValidationErrors []ValidationError + +// Error implements the error interface. +func (ve ValidationErrors) Error() string { + if len(ve) == 0 { + return "no validation errors" + } + if len(ve) == 1 { + return ve[0].Error() + } + return fmt.Sprintf("%d validation errors: %s (and %d more)", len(ve), ve[0].Error(), len(ve)-1) +} + +// MarshalJSON implements json.Marshaler for AccessRight to handle nil AllowedURLs as empty array. +func (ar *AccessRight) MarshalJSON() ([]byte, error) { + type Alias AccessRight + a := &struct { + *Alias + AllowedURLs []AllowedURL `json:"allowed_urls"` + Limit json.RawMessage `json:"limit"` + }{ + Alias: (*Alias)(ar), + } + if ar.AllowedURLs == nil { + a.AllowedURLs = []AllowedURL{} + } else { + a.AllowedURLs = ar.AllowedURLs + } + if ar.Limit == nil { + a.Limit = json.RawMessage("null") + } else { + b, err := json.Marshal(ar.Limit) + if err != nil { + return nil, err + } + a.Limit = b + } + return json.Marshal(a) +} diff --git a/pkg/types/policy_test.go b/pkg/types/policy_test.go new file mode 100644 index 0000000..135cd85 --- /dev/null +++ b/pkg/types/policy_test.go @@ -0,0 +1,277 @@ +package types + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestPolicyFile_YAMLRoundTrip(t *testing.T) { + original := PolicyFile{ + ID: "gold", + Name: "Gold Plan", + Tags: []string{"gold", "paid"}, + RateLimit: &RateLimit{Requests: 1000, Per: Duration("1m")}, + Quota: &Quota{Limit: 100000, Period: Duration("30d")}, + KeyTTL: Duration("0"), + Access: []AccessEntry{ + {Name: "users-api", Versions: []string{"v1"}}, + {ListenPath: "/orders/", Versions: []string{"v1", "v2"}}, + {Tags: []string{"public", "v1"}, Versions: []string{"v1"}}, + {ID: "foobar123"}, + }, + } + + yamlBytes, err := yaml.Marshal(&original) + require.NoError(t, err) + + var restored PolicyFile + err = yaml.Unmarshal(yamlBytes, &restored) + require.NoError(t, err) + + assert.Equal(t, original.ID, restored.ID) + assert.Equal(t, original.Name, restored.Name) + assert.Equal(t, original.Tags, restored.Tags) + assert.Equal(t, original.RateLimit.Requests, restored.RateLimit.Requests) + assert.Equal(t, original.RateLimit.Per, restored.RateLimit.Per) + assert.Equal(t, original.Quota.Limit, restored.Quota.Limit) + assert.Equal(t, original.Quota.Period, restored.Quota.Period) + assert.Equal(t, original.KeyTTL, restored.KeyTTL) + require.Len(t, restored.Access, 4) + assert.Equal(t, "users-api", restored.Access[0].Name) + assert.Equal(t, []string{"v1"}, restored.Access[0].Versions) + assert.Equal(t, "/orders/", restored.Access[1].ListenPath) + assert.Equal(t, []string{"v1", "v2"}, restored.Access[1].Versions) + assert.Equal(t, []string{"public", "v1"}, restored.Access[2].Tags) + assert.Equal(t, "foobar123", restored.Access[3].ID) + assert.Empty(t, restored.Access[3].Versions, "omitted versions should remain nil/empty") +} + +func TestDashboardPolicy_JSONRoundTrip(t *testing.T) { + wireJSON := `{ + "_id": "gold", + "id": "", + "name": "Gold Plan", + "org_id": "5e9d9544a1dcd60001d0ed20", + "rate": 1000, + "per": 60, + "quota_max": 100000, + "quota_renewal_rate": 2592000, + "key_expires_in": 0, + "tags": ["gold", "paid"], + "access_rights": { + "a1b2c3d4e5f6": { + "api_id": "a1b2c3d4e5f6", + "api_name": "users-api", + "versions": ["v1"], + "allowed_urls": [], + "limit": null + }, + "g7h8i9j0k1l2": { + "api_id": "g7h8i9j0k1l2", + "api_name": "orders-api", + "versions": ["v1", "v2"], + "allowed_urls": [], + "limit": null + } + }, + "active": true, + "is_inactive": false + }` + + var policy DashboardPolicy + err := json.Unmarshal([]byte(wireJSON), &policy) + require.NoError(t, err) + + assert.Equal(t, "gold", policy.MID) + assert.Equal(t, "", policy.ID) + assert.Equal(t, "Gold Plan", policy.Name) + assert.Equal(t, "5e9d9544a1dcd60001d0ed20", policy.OrgID) + assert.Equal(t, int64(1000), policy.Rate) + assert.Equal(t, int64(60), policy.Per) + assert.Equal(t, int64(100000), policy.QuotaMax) + assert.Equal(t, int64(2592000), policy.QuotaRenewalRate) + assert.Equal(t, int64(0), policy.KeyExpiresIn) + assert.Equal(t, []string{"gold", "paid"}, policy.Tags) + assert.True(t, policy.Active) + assert.False(t, policy.IsInactive) + + require.Len(t, policy.AccessRights, 2) + usersRight := policy.AccessRights["a1b2c3d4e5f6"] + assert.Equal(t, "a1b2c3d4e5f6", usersRight.APIID) + assert.Equal(t, "users-api", usersRight.APIName) + assert.Equal(t, []string{"v1"}, usersRight.Versions) + + ordersRight := policy.AccessRights["g7h8i9j0k1l2"] + assert.Equal(t, "g7h8i9j0k1l2", ordersRight.APIID) + assert.Equal(t, "orders-api", ordersRight.APIName) + assert.Equal(t, []string{"v1", "v2"}, ordersRight.Versions) + + // Re-marshal and unmarshal to verify round-trip + remarshaled, err := json.Marshal(&policy) + require.NoError(t, err) + + var roundTripped DashboardPolicy + err = json.Unmarshal(remarshaled, &roundTripped) + require.NoError(t, err) + + assert.Equal(t, policy.MID, roundTripped.MID) + assert.Equal(t, policy.Name, roundTripped.Name) + assert.Equal(t, policy.Rate, roundTripped.Rate) + assert.Equal(t, policy.Per, roundTripped.Per) + assert.Equal(t, policy.QuotaMax, roundTripped.QuotaMax) + assert.Equal(t, policy.QuotaRenewalRate, roundTripped.QuotaRenewalRate) + assert.Equal(t, policy.AccessRights, roundTripped.AccessRights) +} + +func TestDuration_UnmarshalYAML(t *testing.T) { + t.Run("string durations", func(t *testing.T) { + tests := []struct { + input string + expected Duration + }{ + {`per: "30d"`, Duration("30d")}, + {`per: "1m"`, Duration("1m")}, + {`per: "24h"`, Duration("24h")}, + {`per: "60s"`, Duration("60s")}, + {`per: "0"`, Duration("0")}, + } + for _, tt := range tests { + var dest struct { + Per Duration `yaml:"per"` + } + err := yaml.Unmarshal([]byte(tt.input), &dest) + require.NoError(t, err, "input: %s", tt.input) + assert.Equal(t, tt.expected, dest.Per, "input: %s", tt.input) + } + }) + + t.Run("integer durations", func(t *testing.T) { + tests := []struct { + input string + expected Duration + }{ + {`per: 60`, Duration("60")}, + {`per: 0`, Duration("0")}, + {`per: 2592000`, Duration("2592000")}, + } + for _, tt := range tests { + var dest struct { + Per Duration `yaml:"per"` + } + err := yaml.Unmarshal([]byte(tt.input), &dest) + require.NoError(t, err, "input: %s", tt.input) + assert.Equal(t, tt.expected, dest.Per, "input: %s", tt.input) + } + }) +} + +func TestAccessEntry_SelectorFields(t *testing.T) { + // Verify that each selector field is independently settable and + // survives YAML round-trip in isolation. + tests := []struct { + name string + yaml string + check func(t *testing.T, e AccessEntry) + }{ + { + name: "id selector only", + yaml: "id: abc123\nversions: [v1]", + check: func(t *testing.T, e AccessEntry) { + assert.Equal(t, "abc123", e.ID) + assert.Empty(t, e.Name) + assert.Empty(t, e.ListenPath) + assert.Empty(t, e.Tags) + assert.Equal(t, []string{"v1"}, e.Versions) + }, + }, + { + name: "name selector only", + yaml: "name: users-api", + check: func(t *testing.T, e AccessEntry) { + assert.Empty(t, e.ID) + assert.Equal(t, "users-api", e.Name) + assert.Empty(t, e.ListenPath) + assert.Empty(t, e.Tags) + }, + }, + { + name: "listenPath selector only", + yaml: "listenPath: /orders/", + check: func(t *testing.T, e AccessEntry) { + assert.Empty(t, e.ID) + assert.Empty(t, e.Name) + assert.Equal(t, "/orders/", e.ListenPath) + assert.Empty(t, e.Tags) + }, + }, + { + name: "tags selector only", + yaml: "tags: [public, v1]", + check: func(t *testing.T, e AccessEntry) { + assert.Empty(t, e.ID) + assert.Empty(t, e.Name) + assert.Empty(t, e.ListenPath) + assert.Equal(t, []string{"public", "v1"}, e.Tags) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var entry AccessEntry + err := yaml.Unmarshal([]byte(tt.yaml), &entry) + require.NoError(t, err) + tt.check(t, entry) + }) + } +} + +func TestAccessRight_MarshalJSON_NilHandling(t *testing.T) { + t.Run("nil AllowedURLs serializes as empty array", func(t *testing.T) { + ar := AccessRight{APIID: "a1", APIName: "test", AllowedURLs: nil, Limit: nil} + data, err := json.Marshal(&ar) + require.NoError(t, err) + assert.Contains(t, string(data), `"allowed_urls":[]`) + assert.Contains(t, string(data), `"limit":null`) + }) + + t.Run("non-nil AllowedURLs preserved", func(t *testing.T) { + ar := AccessRight{ + APIID: "a1", + APIName: "test", + AllowedURLs: []AllowedURL{{URL: "/foo", Methods: []string{"GET"}}}, + Limit: &RateQuotaLimit{Rate: 10, Per: 60}, + } + data, err := json.Marshal(&ar) + require.NoError(t, err) + assert.Contains(t, string(data), `"/foo"`) + assert.NotContains(t, string(data), `"allowed_urls":[]`) + assert.NotContains(t, string(data), `"limit":null`) + }) +} + +func TestDashboardPolicyListResponse_JSONUnmarshal(t *testing.T) { + listJSON := `{ + "Data": [ + {"_id": "gold", "name": "Gold Plan", "rate": 1000, "per": 60, "active": true}, + {"_id": "silver", "name": "Silver Plan", "rate": 500, "per": 60, "active": true} + ], + "Pages": 1, + "StatusCode": 200 + }` + + var resp DashboardPolicyListResponse + err := json.Unmarshal([]byte(listJSON), &resp) + require.NoError(t, err) + + assert.Equal(t, 1, resp.Pages) + assert.Equal(t, 200, resp.StatusCode) + require.Len(t, resp.Data, 2) + assert.Equal(t, "gold", resp.Data[0].MID) + assert.Equal(t, "Gold Plan", resp.Data[0].Name) + assert.Equal(t, "silver", resp.Data[1].MID) + assert.Equal(t, "Silver Plan", resp.Data[1].Name) +}