diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 00000000..d8f35453 --- /dev/null +++ b/.cursor/rules/README.md @@ -0,0 +1,5 @@ +# Cursor (optional) + +*Cursor* users: start at *[AGENTS.md](../../AGENTS.md)*. All conventions live in **skills/*/SKILL.md**. + +This folder only points contributors to *AGENTS.md* so editor-specific config does not duplicate the canonical docs. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 785adb40..d9f2fb34 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,5 @@ -* @contentstack/devex-pr-reviewers - -.github/workflows/sca-scan.yml @contentstack/security-admin - +* @contentstack/developer-ecosystem-pr-reviewers +.github/workflows/ @contentstack/security-admin **/.snyk @contentstack/security-admin - -.github/workflows/policy-scan.yml @contentstack/security-admin - -.github/workflows/issues-jira.yml @contentstack/security-admin +**/CODEOWNERS @contentstack/security-admin +**/.gitguardian.yaml @contentstack/security-admin \ No newline at end of file diff --git a/.github/workflows/back-merge-pr.yml b/.github/workflows/back-merge-pr.yml new file mode 100644 index 00000000..cec0f269 --- /dev/null +++ b/.github/workflows/back-merge-pr.yml @@ -0,0 +1,59 @@ +# Opens a PR from master → development after changes land on master (back-merge). +# +# Org/repo Settings → Actions → General → Workflow permissions: read and write +# (so GITHUB_TOKEN can create pull requests). Or use a PAT in secret GH_TOKEN. + +name: Back-merge master to development + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + open-back-merge-pr: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Open back-merge PR if needed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git fetch origin development master + + MASTER_SHA=$(git rev-parse origin/master) + DEV_SHA=$(git rev-parse origin/development) + + if [ "$MASTER_SHA" = "$DEV_SHA" ]; then + echo "master and development are at the same commit; nothing to back-merge." + exit 0 + fi + + EXISTING=$(gh pr list --repo "${{ github.repository }}" \ + --base development \ + --head master \ + --state open \ + --json number \ + --jq 'length') + + if [ "$EXISTING" -gt 0 ]; then + echo "An open PR from master to development already exists; skipping." + exit 0 + fi + + gh pr create --repo "${{ github.repository }}" \ + --base development \ + --head master \ + --title "chore: back-merge master into development" \ + --body "Automated back-merge after changes landed on \`master\`. Review and merge to keep \`development\` in sync." + + echo "Created back-merge PR master → development." diff --git a/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml deleted file mode 100644 index 8a3a32ab..00000000 --- a/.github/workflows/check-branch.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: 'Check Branch' - -on: - pull_request: - -jobs: - check_branch: - runs-on: ubuntu-latest - steps: - - name: Comment PR - if: github.base_ref == 'master' && github.head_ref != 'staging' - uses: thollander/actions-comment-pull-request@v2 - with: - message: | - We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. - - name: Check branch - if: github.base_ref == 'master' && github.head_ref != 'staging' - run: | - echo "ERROR: We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." - exit 1 \ No newline at end of file diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml new file mode 100644 index 00000000..9c7d3136 --- /dev/null +++ b/.github/workflows/check-version-bump.yml @@ -0,0 +1,111 @@ +# Release-affecting changes under src/main/ or pom.xml require pom.xml + changelog.md bumps +# aligned with the latest tag. Skips when only tests, .github, skills, or docs change. + +name: Check Version Bump + +on: + pull_request: + +jobs: + version-bump: + name: Version & changelog bump + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed files + id: detect + run: | + FILES=$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}") + echo "Changed files:" + echo "$FILES" + + CODE_CHANGED=false + while IFS= read -r f; do + [ -z "$f" ] && continue + if [[ "$f" == src/main/* ]] || [[ "$f" == "pom.xml" ]]; then + CODE_CHANGED=true + break + fi + done <<< "$FILES" + + POM_CHANGED=false + CHANGELOG_CHANGED=false + echo "$FILES" | grep -qx 'pom.xml' && POM_CHANGED=true + echo "$FILES" | grep -qx 'changelog.md' && CHANGELOG_CHANGED=true + + VERSION_FILES_OK=false + if [ "$POM_CHANGED" = true ] && [ "$CHANGELOG_CHANGED" = true ]; then + VERSION_FILES_OK=true + fi + + echo "code_changed=$CODE_CHANGED" >> "$GITHUB_OUTPUT" + echo "version_files_ok=$VERSION_FILES_OK" >> "$GITHUB_OUTPUT" + + - name: Skip when no release-affecting code changed + if: steps.detect.outputs.code_changed != 'true' + run: | + echo "No src/main or pom-only release path triggered (e.g. tests/docs/.github only). Skipping version-bump check." + exit 0 + + - name: Fail when version bump files were not both updated + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_ok != 'true' + run: | + echo "::error::This PR changes release-affecting code but pom.xml and/or changelog.md were not both updated. Bump in pom.xml and add a ## vX.Y.Z section in changelog.md." + exit 1 + + - name: Validate version vs latest tag and changelog header + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_ok == 'true' + run: | + set -euo pipefail + POM_VERSION=$(python3 <<'PY' + import xml.etree.ElementTree as ET + root = ET.parse("pom.xml").getroot() + ns = {"m": "http://maven.apache.org/POM/4.0.0"} + el = root.find("m:version", ns) + if el is None or not (el.text or "").strip(): + raise SystemExit("Could not read project version from pom.xml") + print(el.text.strip()) + PY + ) + + git fetch --tags --force 2>/dev/null || true + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) + if [ -z "$LATEST_TAG" ]; then + echo "No existing tags found. Skipping semver vs tag check (first release)." + CHANGELOG_HEAD=$(sed -nE 's/^## v?([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' changelog.md | head -1) + if [ -z "$CHANGELOG_HEAD" ]; then + echo "::error::Could not find a ## vX.Y.Z entry at the top of changelog.md." + exit 1 + fi + if [ "$CHANGELOG_HEAD" != "$POM_VERSION" ]; then + echo "::error::changelog.md top version ($CHANGELOG_HEAD) does not match pom.xml version ($POM_VERSION)." + exit 1 + fi + exit 0 + fi + + LATEST_VERSION="${LATEST_TAG#v}" + LATEST_VERSION="${LATEST_VERSION%%-*}" + if [ "$(printf '%s\n' "$LATEST_VERSION" "$POM_VERSION" | sort -V | tail -1)" != "$POM_VERSION" ]; then + echo "::error::pom.xml version ($POM_VERSION) must be greater than latest tag ($LATEST_TAG)." + exit 1 + fi + if [ "$POM_VERSION" = "$LATEST_VERSION" ]; then + echo "::error::pom.xml version ($POM_VERSION) must be strictly greater than latest tag version ($LATEST_VERSION)." + exit 1 + fi + + CHANGELOG_HEAD=$(sed -nE 's/^## v?([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' changelog.md | head -1) + if [ -z "$CHANGELOG_HEAD" ]; then + echo "::error::Could not find a ## vX.Y.Z entry at the top of changelog.md." + exit 1 + fi + if [ "$CHANGELOG_HEAD" != "$POM_VERSION" ]; then + echo "::error::changelog.md top version ($CHANGELOG_HEAD) does not match pom.xml version ($POM_VERSION)." + exit 1 + fi + echo "Version bump check passed: pom.xml and changelog.md at $POM_VERSION (latest tag: $LATEST_TAG)." diff --git a/.github/workflows/issues-jira.yml b/.github/workflows/issues-jira.yml deleted file mode 100644 index 7bf04694..00000000 --- a/.github/workflows/issues-jira.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Create Jira Ticket for Github Issue - -on: - issues: - types: [opened] - -jobs: - issue-jira: - runs-on: ubuntu-latest - steps: - - - name: Login to Jira - uses: atlassian/gajira-login@master - env: - JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} - - - name: Create Jira Issue - id: create_jira - uses: atlassian/gajira-create@master - with: - project: ${{ secrets.JIRA_PROJECT }} - issuetype: ${{ secrets.JIRA_ISSUE_TYPE }} - summary: Github | Issue | ${{ github.event.repository.name }} | ${{ github.event.issue.title }} - description: | - *GitHub Issue:* ${{ github.event.issue.html_url }} - - *Description:* - ${{ github.event.issue.body }} - fields: "${{ secrets.ISSUES_JIRA_FIELDS }}" \ No newline at end of file diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml index 4c5577e1..e0bc9053 100644 --- a/.github/workflows/maven-publish.yml +++ b/.github/workflows/maven-publish.yml @@ -1,9 +1,11 @@ name: Publish package to the Maven Central Repository +# Publishes when a GitHub Release is created (same pattern as before tag-based experiments). on: release: types: - created + jobs: publish-maven: runs-on: ubuntu-latest diff --git a/.github/workflows/sca-scan.yml b/.github/workflows/sca-scan.yml index fc88856b..3e6f6dd8 100644 --- a/.github/workflows/sca-scan.yml +++ b/.github/workflows/sca-scan.yml @@ -5,6 +5,9 @@ on: jobs: security-sca: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@master - name: Run Snyk to check for vulnerabilities diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d4a23aee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# Contentstack Management Java SDK – Agent guide + +*Universal entry point* for contributors and AI agents. Detailed conventions live in **skills/*/SKILL.md**. + +## What this repo is + +| Field | Detail | +| --- | --- | +| *Name:* | [contentstack-management-java](https://github.com/contentstack/contentstack-management-java) | +| *Purpose:* | Java client library for the Contentstack Content Management API (CMA): create, update, delete, and fetch account content and configuration from JVM applications. | +| *Out of scope (if any):* | Content Delivery API (CDA) and delivery-only use cases belong in the Delivery SDK, not this package. This SDK is read/write against CMA; do not treat it as the primary way to serve content to end users. | + +## Tech stack (at a glance) + +| Area | Details | +| --- | --- | +| Language | Java 8 (source/target in `pom.xml`); README suggests Java 8+ for consumers. | +| Build | Apache Maven; `pom.xml` at repo root. | +| Tests | JUnit 5 / Vintage, Mockito, OkHttp MockWebServer; tests under `src/test/java/com/contentstack/cms/`. | +| Lint / coverage | Compiler: `-Xlint:all` (see `maven-compiler-plugin`). Coverage: JaCoCo (`jacoco-maven-plugin`); HTML report under `target/site/jacoco` after tests + `jacoco:report`. No separate Checkstyle/SpotBugs in this `pom.xml`. | +| Other | HTTP: Retrofit 3, OkHttp 5, Gson; RxJava 3; Lombok for generated code. | + +## Commands (quick reference) + +| Command type | Command | +| --- | --- | +| Build | `mvn clean compile` | +| Test | `mvn clean test -DskipTests=false` | +| Lint | `mvn compile` (compiler warnings; see Tech stack) | +| Coverage | `mvn clean test -DskipTests=false jacoco:report` (open `target/site/jacoco/index.html`) | + +**Note:** `maven-surefire-plugin` sets `skipTests` to `true` by default in this repo, so you must pass `-DskipTests=false` (or override in your IDE) to run unit tests locally. + +**CI:** [.github/workflows/coverage.yml](.github/workflows/coverage.yml) runs `mvn clean package` and JaCoCo steps on push. Other workflows under [.github/workflows/](.github/workflows/) handle publish, scans, and branch checks. + +## Where the documentation lives: skills + +| Skill | Path | What it covers | +| --- | --- | --- | +| Dev workflow | [skills/dev-workflow/SKILL.md](skills/dev-workflow/SKILL.md) | Branches, Maven lifecycle, CI expectations, PR flow. | +| Contentstack Java CMA SDK | [skills/contentstack-java-cma-sdk/SKILL.md](skills/contentstack-java-cma-sdk/SKILL.md) | Public API, `Contentstack` entry point, auth, versioning boundaries. | +| Java (repo conventions) | [skills/java/SKILL.md](skills/java/SKILL.md) | Package layout, language conventions, Lombok usage. | +| Testing | [skills/testing/SKILL.md](skills/testing/SKILL.md) | Test layout, naming, skipping policy, credentials. | +| Code review | [skills/code-review/SKILL.md](skills/code-review/SKILL.md) | PR checklist and severity guidance. | +| HTTP client stack | [skills/http-client-stack/SKILL.md](skills/http-client-stack/SKILL.md) | Retrofit, OkHttp, retries, logging—what this repo actually wires. | + +An index with “when to use” hints is in [skills/README.md](skills/README.md). + +## Using Cursor (optional) + +If you use *Cursor*, [.cursor/rules/README.md](.cursor/rules/README.md) only points to *AGENTS.md*—same docs as everyone else. diff --git a/changelog.md b/changelog.md index 80f1373b..b101af0d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,61 @@ # Changelog +## v1.15.0 + +### Nov 02, 2026 + +- Feat: Added the opt-in `Contentstack.Builder.setSerializeNulls(boolean)` option. When enabled, `null` values in request bodies are sent to the API instead of being dropped, so a field's value (for example a Number field on a localized entry) can be cleared. Off by default; existing behavior is unchanged. +- Tests: Replaced hardcoded usernames and API keys in `LoginDetailTest` and `TestClient` with generated values to resolve Snyk Code findings (DX-22133, DX-22134). No change to the published artifact. + +## v1.14.1 + +### Aug 26, 2026 + +- Fix: Upgraded `org.jsoup:jsoup` to 1.23.2 to address a Snyk-reported Allocation of Resources Without Limits or Throttling vulnerability (CVE-2026-75140) + +## v1.14.0 + +### Aug 24, 2026 + +- Feat: Added taxonomy and term publish, unpublish, localize, and unlocalize support + +## v1.13.1 + +### Aug 17, 2026 + +- Snyk fix + +## v1.13.0 + +### Jul 27, 2026 + +- Feature: Branch support in entry variants + +## v1.12.2 + +### Jul 06, 2026 + +- Snyk fix + +## v1.12.1 + +### Jun 29, 2026 + +- Snyk fix + +## v1.12.0 + +### Jun 15, 2026 + +- Feature: Dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` and `Builder.setRegion()` backed by the Contentstack Regions Registry. + +## v1.11.2 + +### Jun 01, 2026 + +- Fix: `SocketTimeoutException` now correctly triggers the retry mechanism in `AuthInterceptor` and `OAuthInterceptor`. Previously, network-level timeouts bypassed retry logic entirely, causing `.setRetry(true)` to have no effect on timeout errors. +- Enhancement: Added `setProtocols(List)` to the Builder, allowing callers to restrict the HTTP protocol (e.g. force HTTP/1.1 via `Collections.singletonList(Protocol.HTTP_1_1)`) for environments where proxies or intermediaries have issues with HTTP/2. + ## v1.11.1 ### Apr 06, 2026 diff --git a/pom.xml b/pom.xml index 2feb459a..da74f14a 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.11.1 + 1.15.0 Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an API-first approach @@ -93,7 +93,7 @@ 3.0.0 5.3.2 0.8.13 - 1.18.42 + 1.18.44 5.11.4 5.10.1 2.13.2 @@ -148,7 +148,7 @@ org.jetbrains annotations - 26.0.2 + 26.1.0 provided @@ -214,12 +214,12 @@ org.jsoup jsoup - 1.21.2 + 1.23.2 org.jetbrains.kotlin kotlin-stdlib - 2.2.21 + 2.3.20 com.warrenstrange @@ -278,6 +278,26 @@ + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + download-regions + generate-resources + + exec + + + bash + + ${project.basedir}/scripts/download-regions.sh + + + + + org.apache.maven.plugins maven-source-plugin diff --git a/scripts/download-regions.sh b/scripts/download-regions.sh new file mode 100755 index 00000000..60a76418 --- /dev/null +++ b/scripts/download-regions.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Downloads the Contentstack regions registry from the official source and +# saves it to src/main/resources/regions.json. +# +# Invoked automatically by Maven on the generate-resources phase, and +# manually via: bash scripts/download-regions.sh +# +# Requires: curl (preferred) or wget as fallback + +set -euo pipefail + +URL="https://artifacts.contentstack.com/regions.json" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEST="${SCRIPT_DIR}/../src/main/resources/regions.json" +DIR="$(dirname "$DEST")" + +mkdir -p "$DIR" + +data="" + +# --- Attempt 1: curl (preferred) -------------------------------------------- +if command -v curl &>/dev/null; then + data=$(curl --silent --fail --location --max-time 30 "$URL") || data="" +fi + +# --- Attempt 2: wget fallback ----------------------------------------------- +if [[ -z "$data" ]] && command -v wget &>/dev/null; then + data=$(wget --quiet --timeout=30 -O - "$URL") || data="" +fi + +# --- Validate and write ------------------------------------------------------ +if [[ -z "$data" ]]; then + echo "contentstack/cms: Warning — could not download regions.json." >&2 + echo " The SDK will attempt to download it at runtime on first use." >&2 + exit 0 # non-fatal: runtime fallback in Endpoint.java handles it +fi + +# Basic validation: must contain a "regions" key +if ! echo "$data" | grep -q '"regions"'; then + echo "contentstack/cms: Warning — downloaded data is not valid regions.json." >&2 + exit 0 +fi + +echo "$data" > "$DEST" + +region_count=$(echo "$data" | grep -o '"id"' | wc -l | tr -d ' ') +echo "contentstack/cms: regions.json downloaded (${region_count} regions)." diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 00000000..0a5b2cbc --- /dev/null +++ b/skills/README.md @@ -0,0 +1,16 @@ +# Skills – Contentstack Management Java SDK + +Source of truth for detailed guidance. Read [AGENTS.md](../AGENTS.md) first, then open the skill that matches your task. + +## When to use which skill + +| Skill folder | Use when | +| --- | --- | +| [dev-workflow](dev-workflow/SKILL.md) | Branching (`development` → `master`, back-merge, GitHub Release publish), Maven/CI, publish touchpoints. | +| [contentstack-java-cma-sdk](contentstack-java-cma-sdk/SKILL.md) | Changing public API, `Contentstack` / `Stack` flows, auth tokens, or SDK surface exposed to integrators. | +| [java](java/SKILL.md) | Package structure under `com.contentstack.cms`, Java 8 compatibility, Lombok, imports, and code style in this repo. | +| [testing](testing/SKILL.md) | Adding or fixing tests, Surefire `skipTests` behavior, MockWebServer or live API tests, env/credentials. | +| [code-review](code-review/SKILL.md) | Preparing or reviewing a PR: scope, tests, API compatibility, and security around tokens. | +| [http-client-stack](http-client-stack/SKILL.md) | Retrofit services, OkHttp client/interceptors, retries, proxies, or HTTP logging. | + +Each folder contains `SKILL.md` with YAML frontmatter (`name`, `description`). diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md new file mode 100644 index 00000000..906a50f3 --- /dev/null +++ b/skills/code-review/SKILL.md @@ -0,0 +1,42 @@ +--- +name: code-review +description: Use when reviewing or preparing a PR—API safety, tests, auth handling, and compatibility checklist. +--- + +# Code review – Contentstack Management Java SDK + +## When to use + +- You are reviewing a pull request or self-reviewing before request. +- You need a quick severity rubric (blocker / major / minor) aligned with this SDK. + +## Instructions + +### Blocker + +- Real credentials, tokens, or stack keys committed in source or fixtures. +- Breaking public API changes without version strategy or without team agreement. +- New network behavior that bypasses existing interceptors, retry policy, or auth without explicit design. + +### Major + +- Missing or insufficient tests for new CMA behavior or error paths. +- Behavior change for `Contentstack` / `Stack` builders or defaults without README or Javadoc updates where integrators would notice. +- Regressions for Java 8 compatibility or new compiler warnings left unaddressed. + +### Minor + +- Style inconsistencies fixable in follow-up, or internal refactors with no API impact. +- Javadoc typos or non-user-facing comment cleanup. + +### PR checklist (short) + +- [ ] `mvn clean test -DskipTests=false` passes locally. +- [ ] Public changes documented (Javadoc / README / changelog per team process). +- [ ] No secrets in repo; test config uses env or mocks. +- [ ] HTTP changes reviewed against [http-client-stack/SKILL.md](../http-client-stack/SKILL.md). + +## References + +- [dev-workflow/SKILL.md](../dev-workflow/SKILL.md) +- [contentstack-java-cma-sdk/SKILL.md](../contentstack-java-cma-sdk/SKILL.md) diff --git a/skills/contentstack-java-cma-sdk/SKILL.md b/skills/contentstack-java-cma-sdk/SKILL.md new file mode 100644 index 00000000..15501d47 --- /dev/null +++ b/skills/contentstack-java-cma-sdk/SKILL.md @@ -0,0 +1,35 @@ +--- +name: contentstack-java-cma-sdk +description: Use when changing the Management SDK surface—Contentstack entry, Stack, auth, or CMA integration boundaries. +--- + +# Contentstack Java CMA SDK – Contentstack Management Java SDK + +## When to use + +- You add or change public methods on `Contentstack`, `Stack`, organization, user, or other API-facing types under `com.contentstack.cms`. +- You need to document how integrators authenticate (authtoken, management token, OAuth, login). +- You are deciding what belongs in this SDK vs the Delivery API or other Contentstack clients. + +## Instructions + +### Purpose and boundaries + +- This library targets the [Content Management API](https://www.contentstack.com/docs/developers/apis/content-management-api/) (CMA). It is **not** the recommended way to deliver content to websites or apps at scale; the README directs readers to the [Content Delivery API](https://www.contentstack.com/docs/developers/apis/content-delivery-api/) for that. +- The main entry point is [`Contentstack`](../../src/main/java/com/contentstack/cms/Contentstack.java): builders set host, tokens, timeouts, proxy, OAuth, and retry behavior before obtaining `Stack`, `User`, `Organization`, etc. + +### Public API and compatibility + +- Treat additions to public classes as **semver-sensitive**: new optional builder methods are easier to ship than breaking signature changes. +- Errors and responses should stay consistent with existing patterns (`retrofit2.Response`, model types under `com.contentstack.cms.models`, etc.). +- Document behavior in Javadoc where integrators rely on contracts (auth order, token precedence, branch/early-access headers). + +### Versioning + +- Artifact coordinates: `com.contentstack.sdk:cms` — version in root [`pom.xml`](../../pom.xml). Follow org release process for bumping version and tagging. + +## References + +- [java/SKILL.md](../java/SKILL.md) +- [http-client-stack/SKILL.md](../http-client-stack/SKILL.md) +- [Content Management API – Authentication](https://www.contentstack.com/docs/developers/apis/content-management-api/#authentication) (official) diff --git a/skills/dev-workflow/SKILL.md b/skills/dev-workflow/SKILL.md new file mode 100644 index 00000000..66a5194c --- /dev/null +++ b/skills/dev-workflow/SKILL.md @@ -0,0 +1,36 @@ +--- +name: dev-workflow +description: Use when branching, building with Maven, CI, or release/publish workflows for this repo. +--- + +# Dev workflow – Contentstack Management Java SDK + +## When to use + +- You need the canonical build/test commands or CI expectations. +- You are opening a PR and need branch rules (`development` → `master`, GitHub Release publishing). +- You are changing `pom.xml`, plugins, or publishing configuration. + +## Instructions + +### Repository and branches + +- **Flow:** work merges to **`development`**; **release PRs** go **`development` → `master`** (no `staging`). After `master` moves, [.github/workflows/back-merge-pr.yml](../../.github/workflows/back-merge-pr.yml) can open a PR **`master` → `development`** to stay aligned. +- **Releases:** create a **GitHub Release** (triggers [.github/workflows/maven-publish.yml](../../.github/workflows/maven-publish.yml) on **`release: created`**). PRs that change `src/main` or `pom.xml` are checked by [.github/workflows/check-version-bump.yml](../../.github/workflows/check-version-bump.yml) (version + `changelog.md`). + +### Maven + +- **Compile:** `mvn clean compile` +- **Tests:** `mvn clean test -DskipTests=false` (Surefire is configured with `skipTests` true by default in [`pom.xml`](../../pom.xml); always pass `-DskipTests=false` for real test runs unless you intentionally skip.) +- **Package:** `mvn clean package` — note tests may be skipped unless you add `-DskipTests=false`. +- **Coverage report:** after tests with skip disabled, `mvn jacoco:report` and open `target/site/jacoco/index.html`. + +### CI + +- On push, [.github/workflows/coverage.yml](../../.github/workflows/coverage.yml) uses Java 11 on Ubuntu and runs Maven + JaCoCo. Align local verification with those steps when debugging CI-only failures. +- Publishing and security scans live under [.github/workflows/](../../.github/workflows/); read the relevant workflow before changing release or scan behavior. + +### PR expectations + +- Run tests locally with `-DskipTests=false` before pushing. +- Update [changelog.md](../../changelog.md) or version metadata when your team’s release process requires it. diff --git a/skills/http-client-stack/SKILL.md b/skills/http-client-stack/SKILL.md new file mode 100644 index 00000000..db8717ba --- /dev/null +++ b/skills/http-client-stack/SKILL.md @@ -0,0 +1,42 @@ +--- +name: http-client-stack +description: Use when changing Retrofit, OkHttp, interceptors, retries, proxies, or HTTP logging in this SDK. +--- + +# HTTP client stack – Contentstack Management Java SDK + +## When to use + +- You modify [`Contentstack`](../../src/main/java/com/contentstack/cms/Contentstack.java) or related code that builds `Retrofit`, `OkHttpClient`, or interceptors. +- You touch `AuthInterceptor`, OAuth interceptors, logging, connection pool, timeouts, or `RetryConfig` / retry utilities under `com.contentstack.cms.core`. + +## Instructions + +### Stack in this repo + +- **Retrofit 3** + **Gson** converter for API interfaces and JSON binding. +- **OkHttp 5** for transport; **logging-interceptor** for debug logging when enabled. +- **RxJava 3** is a dependency for async patterns where used; keep consistency with existing call patterns. + +### Auth and headers + +- `AuthInterceptor` and OAuth-related types attach tokens and headers expected by CMA. Changes here affect every call—coordinate with tests in `core/` (e.g. `AuthInterceptorTest`). + +### Retries and resilience + +- Retry behavior is configurable via `RetryConfig` and related utilities (`core/Retry*.java`). Preserve backward-compatible defaults when adjusting retry conditions or backoff. + +### Proxies and TLS + +- `Contentstack` supports `Proxy` configuration; verify behavior if you change client building or connection pool settings. + +### Testing + +- Prefer **MockWebServer** for deterministic HTTP tests; see existing stack and core tests for patterns. + +## References + +- [contentstack-java-cma-sdk/SKILL.md](../contentstack-java-cma-sdk/SKILL.md) +- [testing/SKILL.md](../testing/SKILL.md) +- [Retrofit](https://square.github.io/retrofit/) (official) +- [OkHttp](https://square.github.io/okhttp/) (official) diff --git a/skills/java/SKILL.md b/skills/java/SKILL.md new file mode 100644 index 00000000..59dfab06 --- /dev/null +++ b/skills/java/SKILL.md @@ -0,0 +1,37 @@ +--- +name: java +description: Use for Java package layout, Java 8 constraints, Lombok, and coding conventions in this repository. +--- + +# Java – Contentstack Management Java SDK + +## When to use + +- You add new types under `com.contentstack.cms` or reorganize packages. +- You need to stay compatible with Java 8 (`maven.compiler.source` / `target` = 1.8). +- You use or configure Lombok and need to match existing patterns. + +## Instructions + +### Layout + +- **Main code:** `src/main/java/com/contentstack/cms/` — core client in `Contentstack`, stack features under `stack/`, HTTP helpers in `core/`, OAuth under `oauth/`, models under `models/`. +- **Tests:** mirror packages under `src/test/java/com/contentstack/cms/`; Surefire includes `**/Test*.java`, `**/*Test.java`, `**/*Tests.java`, and related patterns per [`pom.xml`](../../pom.xml). + +### Language level + +- Do not use language or library features that require a JVM newer than 8 for **source** compatibility unless the project explicitly upgrades (check `maven-compiler-plugin` and `` in [`pom.xml`](../../pom.xml)). + +### Lombok and tooling + +- Lombok is on the annotation processor path; prefer the same style as neighboring classes (`@Getter`, `@Builder`, etc.) instead of mixing hand-written boilerplate inconsistently. +- JetBrains `@NotNull` / nullable annotations appear in places like [`Contentstack`](../../src/main/java/com/contentstack/cms/Contentstack.java); follow existing nullability hints for new APIs. + +### Compiler + +- The compiler enables broad `-Xlint` checks; fix new warnings rather than suppressing without cause. + +## References + +- [contentstack-java-cma-sdk/SKILL.md](../contentstack-java-cma-sdk/SKILL.md) +- [testing/SKILL.md](../testing/SKILL.md) diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md new file mode 100644 index 00000000..d345a1f3 --- /dev/null +++ b/skills/testing/SKILL.md @@ -0,0 +1,37 @@ +--- +name: testing +description: Use when writing or running tests—Surefire skip flag, test naming, mocks, MockWebServer, and secrets policy. +--- + +# Testing – Contentstack Management Java SDK + +## When to use + +- You add or change unit or integration tests under `src/test/java`. +- Tests “do not run” locally or in CI — usually `skipTests`. +- You need to mock HTTP or use OkHttp `MockWebServer` like existing tests. + +## Instructions + +### Running tests + +- **Always** use `mvn test -DskipTests=false` (or `mvn clean test -DskipTests=false`) unless you are only compiling. The Surefire plugin in [`pom.xml`](../../pom.xml) sets `true` by default. + +### Layout and naming + +- Tests live under `src/test/java/com/contentstack/cms/` with class names matching Surefire includes (`*Test`, `*Tests`, `Test*`, suites like `UnitTestSuite`). +- Reuse helpers such as [`TestClient`](../../src/test/java/com/contentstack/cms/TestClient.java), [`TestUtils`](../../src/test/java/com/contentstack/cms/core/TestUtils.java), or patterns in existing `*APITest` classes when appropriate. + +### HTTP and API tests + +- **MockWebServer** is available for stubbing HTTP; align with tests in `core/` and `stack/` packages. +- Tests that hit real APIs may require environment variables or `.env` (see `java-dotenv` test dependency in [`pom.xml`](../../pom.xml)). **Never commit** real API keys, management tokens, or passwords; use CI secrets or local env only. + +### Coverage + +- JaCoCo attaches via `prepare-agent`; generate HTML with `mvn jacoco:report` after a test run with skips disabled. + +## References + +- [dev-workflow/SKILL.md](../dev-workflow/SKILL.md) +- [http-client-stack/SKILL.md](../http-client-stack/SKILL.md) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index ebe847b3..9773a088 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -3,7 +3,9 @@ import java.io.IOException; import java.net.Proxy; import java.time.Duration; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -12,6 +14,7 @@ import org.jetbrains.annotations.NotNull; import com.contentstack.cms.core.AuthInterceptor; +import com.contentstack.cms.core.Endpoint; import com.contentstack.cms.core.Util; import static com.contentstack.cms.core.Util.API_KEY; import static com.contentstack.cms.core.Util.AUTHORIZATION; @@ -27,9 +30,11 @@ import com.contentstack.cms.stack.Stack; import com.contentstack.cms.user.User; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.contentstack.cms.core.RetryConfig; import okhttp3.ConnectionPool; import okhttp3.OkHttpClient; +import okhttp3.Protocol; import okhttp3.ResponseBody; import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Response; @@ -439,16 +444,20 @@ public Stack stack(@NotNull String key) { * property). Within a stack, you can create content structures, content * entries, users, etc. related to the project *

+ * Passing {@code branch} sets the {@value com.contentstack.cms.core.Util#BRANCH} request header for this stack. + * That header applies to entries and entry-variant operations ({@code …/variants/…}) unless overridden per instance, + * e.g. {@link com.contentstack.cms.stack.Entry#addBranch(String)} replaces {@code branch} for that entry only. + *

* Example * *

      * Contentstack client = new Contentstack.Builder().build();
-     * Stack org = client.stack();
+     * Stack stack = client.stack("API_KEY", "MANAGEMENT_TOKEN", "feature-branch");
      * 
* * @param managementToken the authorization for the stack * @param apiKey the apiKey for the stack - * @param branch the branch that include branching in the response + * @param branch branch UID or alias for the {@value com.contentstack.cms.core.Util#BRANCH} header * @return the stack instance */ public Stack stack(@NotNull String apiKey, @NotNull String managementToken, @NotNull String branch) { @@ -558,6 +567,81 @@ public CompletableFuture oauthLogout() { return oauthLogout(false); } + /** + * Forces a live download of the Contentstack regions registry and replaces + * the in-memory cache. Useful when new regions or service URLs have been + * published since the SDK JAR was built. + * + *
{@code
+     * int count = Contentstack.refreshRegions();
+     * // now getContentstackEndpoint() resolves from the freshly downloaded registry
+     * }
+ * + * @return the number of regions loaded from the live registry + * @throws RuntimeException if the download fails + */ + public static int refreshRegions() { + return Endpoint.refresh(); + } + + /** + * Resolves a Contentstack service endpoint URL for the given region. + * + *
{@code
+     * // Full URL
+     * String url = Contentstack.getContentstackEndpoint("eu", "contentManagement");
+     * // → "https://eu-api.contentstack.com"
+     *
+     * // Host only (for Builder.setHost)
+     * String host = Contentstack.getContentstackEndpoint("eu", "contentManagement", true);
+     * // → "eu-api.contentstack.com"
+     * }
+ * + * @param region region ID or alias (e.g. {@code na}, {@code eu}, {@code azure-na}) + * @param service service key (e.g. {@code contentManagement}, {@code contentDelivery}) + * @param omitHttps when {@code true}, strips {@code https://} from the returned URL + * @return the resolved URL string + * @throws IllegalArgumentException if region or service is unknown + */ + public static String getContentstackEndpoint(String region, String service, boolean omitHttps) { + return Endpoint.getContentstackEndpoint(region, service, omitHttps); + } + + /** + * Resolves a Contentstack service endpoint URL for the given region (with scheme). + * + * @param region region ID or alias + * @param service service key + * @return the resolved URL including {@code https://} + * @throws IllegalArgumentException if region or service is unknown + */ + public static String getContentstackEndpoint(String region, String service) { + return Endpoint.getContentstackEndpoint(region, service); + } + + /** + * Returns all endpoint URLs for the given region as an ordered map. + * + * @param region region ID or alias + * @return map of service name → URL (includes {@code https://}) + * @throws IllegalArgumentException if region is unknown or empty + */ + public static java.util.Map getContentstackEndpoints(String region) { + return Endpoint.getContentstackEndpoints(region); + } + + /** + * Returns all endpoint URLs for the given region as an ordered map. + * + * @param region region ID or alias + * @param omitHttps when {@code true}, strips {@code https://} from every URL + * @return map of service name → URL + * @throws IllegalArgumentException if region is unknown or empty + */ + public static java.util.Map getContentstackEndpoints(String region, boolean omitHttps) { + return Endpoint.getContentstackEndpoints(region, omitHttps); + } + public Contentstack(Builder builder) { this.host = builder.hostname; this.port = builder.port; @@ -574,6 +658,16 @@ public Contentstack(Builder builder) { this.retryConfig = builder.retryConfig; } + /** Returns the API hostname this client is configured to target. */ + public String getHost() { + return host; + } + + /** Returns the full Retrofit base URL (e.g. {@code https://eu-api.contentstack.com/v3/}). */ + public String getBaseUrl() { + return instance.baseUrl().toString(); + } + public RetryConfig getRetryConfig() { return retryConfig; } @@ -603,12 +697,14 @@ public static class Builder { private Integer readTimeoutSeconds; private Integer writeTimeoutSeconds; private Boolean retry = Util.RETRY_ON_FAILURE;// Default base url for contentstack + private boolean serializeNulls = false; private RetryConfig retryConfig = RetryConfig.defaultConfig(); /** * Default ConnectionPool holds up to 5 idle connections which will be * evicted after 5 minutes of inactivity. */ private ConnectionPool connectionPool = new ConnectionPool(); // Connection + private List protocols = null; /** * Instantiates a new Builder. @@ -650,6 +746,22 @@ public Builder setRetry(@NotNull Boolean retry) { return this; } + /** + * Controls whether {@code null} values in request bodies are sent to the API. + *

+ * By default {@code null} values are dropped before the request is sent, so + * {@code "field": null} behaves exactly like leaving the field out. Enable this + * to send {@code null} explicitly, for example to clear a Number field's value + * on an entry. It applies to every request made through this client. + * + * @param serializeNulls {@code true} to send {@code null} values, {@code false} (default) to drop them + * @return the Builder + */ + public Builder setSerializeNulls(boolean serializeNulls) { + this.serializeNulls = serializeNulls; + return this; + } + /** * Set host for client instance * @@ -657,7 +769,56 @@ public Builder setRetry(@NotNull Boolean retry) { * @return Client host */ public Builder setHost(@NotNull String hostname) { - this.hostname = hostname; + this.hostname = validateHostname(hostname); + return this; + } + + /** + * Validates that the supplied hostname is a bare host (optionally with a + * port) and does not smuggle a scheme, credentials, path, query, or other + * characters that could redirect outbound requests to an unintended + * destination. This guards against Server-Side Request Forgery (SSRF) + * when the host is sourced from untrusted input. + * + * @param hostname the candidate host + * @return the validated host, unchanged + * @throws IllegalArgumentException if the host is null, blank, or malformed + */ + private static String validateHostname(String hostname) { + if (hostname == null || hostname.trim().isEmpty()) { + throw new IllegalArgumentException("Hostname must not be null or empty"); + } + String host = hostname.trim(); + // Reject embedded scheme, credentials, path/query/fragment, whitespace, + // and other characters that would change the request target. + if (!host.matches("^[A-Za-z0-9.-]+(:\\d{1,5})?$")) { + throw new IllegalArgumentException( + "Invalid hostname: '" + hostname + "'. Expected a bare host name with an optional port."); + } + return host; + } + + /** + * Configures the client to target a specific Contentstack region by resolving + * the correct Content Management API host from the bundled regions registry. + * + *

This is a convenience alternative to calling {@link #setHost(String)} with + * a manually constructed hostname. + * + *

{@code
+         * Contentstack client = new Contentstack.Builder()
+         *     .setAuthtoken("authtoken")
+         *     .setRegion("eu")
+         *     .build();
+         * }
+ * + * @param region region ID or alias (e.g. {@code "na"}, {@code "eu"}, {@code "azure-na"}, + * {@code "azure-eu"}, {@code "gcp-na"}, {@code "gcp-eu"}, {@code "au"}). + * @return this Builder + * @throws IllegalArgumentException if the region is unknown or empty + */ + public Builder setRegion(@NotNull String region) { + this.hostname = Endpoint.getContentstackEndpoint(region, "contentManagement", true); return this; } @@ -713,6 +874,11 @@ public Builder setConnectTimeout(int connectTimeoutSeconds) { return this; } + public Builder setProtocols(@NotNull List protocols) { + this.protocols = protocols; + return this; + } + private static void validateTimeoutSeconds(int seconds, String name) { if (seconds <= 0) { throw new IllegalArgumentException(name + " must be positive."); @@ -821,8 +987,7 @@ public Builder setOAuth(String appId, String clientId, String redirectUri, Strin OAuthConfig.OAuthConfigBuilder builder = OAuthConfig.builder() .appId(appId) .clientId(clientId) - .redirectUri(redirectUri) - .host(host); + .redirectUri(redirectUri); // Only set clientSecret if provided (otherwise PKCE flow will be used) if (clientSecret != null && !clientSecret.trim().isEmpty()) { @@ -834,7 +999,16 @@ public Builder setOAuth(String appId, String clientId, String redirectUri, Strin builder.tokenCallback(this.tokenCallback); } - this.oauthConfig = builder.build(); + // Validate the required fields first so their errors surface ahead of + // host validation, matching the previous configuration-error behaviour. + builder.build().validate(); + + // Validate the host before it is stored on the OAuth config, so the same + // SSRF protection that guards the main API host also applies here + // regardless of how the host was sourced (setHost, region resolution, + // or a value passed directly by the caller). + String validatedHost = host != null ? validateHostname(host) : null; + this.oauthConfig = builder.host(validatedHost).build(); return this; } @@ -855,9 +1029,16 @@ public Contentstack build() { } private void validateClient(Contentstack contentstack) { - String baseUrl = Util.PROTOCOL + "://" + this.hostname + "/" + version + "/"; + // Re-validate the host at build time so SSRF protection applies regardless + // of how the hostname was set (setHost, region resolution, or default). + String validatedHost = validateHostname(this.hostname); + String baseUrl = Util.PROTOCOL + "://" + validatedHost + "/" + version + "/"; + GsonBuilder gson = new GsonBuilder(); + if (serializeNulls) { + gson.serializeNulls(); + } this.instance = new Retrofit.Builder().baseUrl(baseUrl) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson.create())) .client(httpClient(contentstack, this.retry)).build(); contentstack.instance = this.instance; @@ -881,6 +1062,9 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur .readTimeout(Duration.ofSeconds(readSec)) .writeTimeout(Duration.ofSeconds(writeSec)) .retryOnConnectionFailure(retryOnFailure); + if (this.protocols != null && !this.protocols.isEmpty()) { + builder.protocols(this.protocols); + } // Add either OAuth or traditional auth interceptor if (this.oauthConfig != null) { diff --git a/src/main/java/com/contentstack/cms/core/AuthInterceptor.java b/src/main/java/com/contentstack/cms/core/AuthInterceptor.java index 80ee0f97..873f5912 100644 --- a/src/main/java/com/contentstack/cms/core/AuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/core/AuthInterceptor.java @@ -1,6 +1,7 @@ package com.contentstack.cms.core; import java.io.IOException; +import java.net.SocketTimeoutException; import org.jetbrains.annotations.NotNull; @@ -116,21 +117,35 @@ public void setRetryConfig(RetryConfig retryConfig) { this.retryConfig = retryConfig != null ? retryConfig : RetryConfig.defaultConfig(); } - private Response executeRequest(Chain chain, Request request, int retryCount) throws IOException{ - Response response = chain.proceed(request); - int code = response.code(); - if(retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(code, null)){ - response.close(); - long delay = RetryUtil.calculateDelay(retryConfig, retryCount+1, code); - try { - Thread.sleep(delay); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - throw new IOException("Retry interrupted", ex); + private Response executeRequest(Chain chain, Request request, int retryCount) throws IOException { + try { + Response response = chain.proceed(request); + int code = response.code(); + if (retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(code, null)) { + response.close(); + long delay = RetryUtil.calculateDelay(retryConfig, retryCount + 1, code); + try { + Thread.sleep(delay); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Retry interrupted", ex); + } + return executeRequest(chain, request, retryCount + 1); } - return executeRequest(chain, request, retryCount + 1); + return response; + } catch (SocketTimeoutException e) { + if (retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(0, e)) { + long delay = RetryUtil.calculateDelay(retryConfig, retryCount + 1, 0); + try { + Thread.sleep(delay); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Retry interrupted", ex); + } + return executeRequest(chain, request, retryCount + 1); + } + throw e; } - return response; } } diff --git a/src/main/java/com/contentstack/cms/core/Endpoint.java b/src/main/java/com/contentstack/cms/core/Endpoint.java new file mode 100644 index 00000000..74d67242 --- /dev/null +++ b/src/main/java/com/contentstack/cms/core/Endpoint.java @@ -0,0 +1,284 @@ +package com.contentstack.cms.core; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Resolves Contentstack API endpoints for any region and service. + * + *

Endpoint data is loaded from the bundled {@code regions.json} classpath resource. + * The parsed result is cached for the lifetime of the JVM process. + * If the bundled file is absent, a live download from + * {@code https://artifacts.contentstack.com/regions.json} is attempted as a fallback. + * + *

{@code
+ * // Get a specific service URL
+ * String cdnUrl = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
+ * // → "https://eu-cdn.contentstack.com"
+ *
+ * // Get the host without the https:// scheme (for use with Builder.setHost)
+ * String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
+ * // → "eu-cdn.contentstack.com"
+ *
+ * // Get all endpoints for a region
+ * Map all = Endpoint.getContentstackEndpoints("eu");
+ * }
+ */ +public class Endpoint { + + private static final String REGIONS_URL = "https://artifacts.contentstack.com/regions.json"; + private static final String REGIONS_RESOURCE = "regions.json"; + + /** + * Domain suffixes that endpoint URLs are permitted to target. The regions + * registry is fetched from a remote resource, so every URL derived from it + * is validated against this allowlist before it can be used as a request + * target. This prevents Server-Side Request Forgery (SSRF) should the + * registry ever be tampered with or point at an unexpected host. + */ + private static final String[] TRUSTED_DOMAIN_SUFFIXES = { + ".contentstack.com", + ".contentstack.io" + }; + + private static volatile JsonArray regionsData = null; + + private Endpoint() {} + + /** + * Returns the URL for a specific service in the given region. + * + * @param region canonical region ID ({@code na}, {@code eu}, {@code au}, {@code azure-na}, + * {@code azure-eu}, {@code gcp-na}, {@code gcp-eu}) or any accepted alias. + * Case-insensitive; {@code -} and {@code _} separators are equivalent. + * @param service service name (e.g. {@code contentManagement}, {@code contentDelivery}) + * @return full URL including {@code https://} + * @throws IllegalArgumentException if region or service is unknown, or region is empty + * @throws RuntimeException if {@code regions.json} cannot be loaded + */ + public static String getContentstackEndpoint(String region, String service) { + return getContentstackEndpoint(region, service, false); + } + + /** + * Returns the URL for a specific service in the given region. + * + * @param region canonical region ID or alias + * @param service service name + * @param omitHttps when {@code true}, strips {@code https://} from the result + * @return URL string, with or without scheme depending on {@code omitHttps} + * @throws IllegalArgumentException if region or service is unknown, or region is empty + * @throws RuntimeException if {@code regions.json} cannot be loaded + */ + public static String getContentstackEndpoint(String region, String service, boolean omitHttps) { + if (service == null || service.trim().isEmpty()) { + throw new IllegalArgumentException( + "Service must not be empty. Use getContentstackEndpoints(region) to retrieve all endpoints."); + } + JsonObject regionRow = resolveRegion(region); + JsonObject endpoints = regionRow.getAsJsonObject("endpoints"); + if (endpoints == null || !endpoints.has(service)) { + throw new IllegalArgumentException( + "Service \"" + service + "\" not found for region \"" + regionRow.get("id").getAsString() + "\""); + } + String url = validateTrustedEndpoint(endpoints.get(service).getAsString()); + return omitHttps ? stripHttps(url) : url; + } + + /** + * Returns all endpoint URLs for the given region as an ordered map. + * + * @param region canonical region ID or alias + * @return map of service name → URL (includes {@code https://}) + * @throws IllegalArgumentException if region is unknown or empty + * @throws RuntimeException if {@code regions.json} cannot be loaded + */ + public static Map getContentstackEndpoints(String region) { + return getContentstackEndpoints(region, false); + } + + /** + * Returns all endpoint URLs for the given region as an ordered map. + * + * @param region canonical region ID or alias + * @param omitHttps when {@code true}, strips {@code https://} from every URL + * @return map of service name → URL + * @throws IllegalArgumentException if region is unknown or empty + * @throws RuntimeException if {@code regions.json} cannot be loaded + */ + public static Map getContentstackEndpoints(String region, boolean omitHttps) { + JsonObject regionRow = resolveRegion(region); + JsonObject endpoints = regionRow.getAsJsonObject("endpoints"); + Map result = new LinkedHashMap<>(); + if (endpoints != null) { + for (Map.Entry entry : endpoints.entrySet()) { + String url = validateTrustedEndpoint(entry.getValue().getAsString()); + result.put(entry.getKey(), omitHttps ? stripHttps(url) : url); + } + } + return result; + } + + // ── internal ────────────────────────────────────────────────────────────── + + private static JsonObject resolveRegion(String region) { + if (region == null || region.trim().isEmpty()) { + throw new IllegalArgumentException("Empty region provided. Please provide a valid region."); + } + JsonArray regions = loadRegions(); + String normalized = region.trim().toLowerCase().replace('_', '-'); + + // First pass: exact match on canonical id + for (int i = 0; i < regions.size(); i++) { + JsonObject row = regions.get(i).getAsJsonObject(); + if (row.get("id").getAsString().equals(normalized)) { + return row; + } + } + + // Second pass: match on alias list (case-insensitive, normalised separators) + for (int i = 0; i < regions.size(); i++) { + JsonObject row = regions.get(i).getAsJsonObject(); + JsonArray aliases = row.getAsJsonArray("alias"); + if (aliases != null) { + for (int j = 0; j < aliases.size(); j++) { + String alias = aliases.get(j).getAsString().toLowerCase().replace('_', '-'); + if (alias.equals(normalized)) { + return row; + } + } + } + } + + throw new IllegalArgumentException("Invalid region: " + region); + } + + private static synchronized JsonArray loadRegions() { + if (regionsData != null) { + return regionsData; + } + + // Try bundled classpath resource first + InputStream is = Endpoint.class.getClassLoader().getResourceAsStream(REGIONS_RESOURCE); + if (is != null) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + String json = reader.lines().collect(Collectors.joining("\n")); + regionsData = JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("regions"); + return regionsData; + } catch (Exception e) { + // fall through to live download + } + } + + // Fallback: download from Contentstack + try { + String json = downloadRegions(); + regionsData = JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("regions"); + return regionsData; + } catch (Exception e) { + throw new RuntimeException( + "contentstack/cms: regions.json not found and could not be downloaded. " + + "Ensure the JAR was built correctly or network access is available.", e); + } + } + + private static String downloadRegions() throws IOException { + URL url = new URL(REGIONS_URL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + try (InputStream is = conn.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")); + } finally { + conn.disconnect(); + } + } + + /** + * Forces a live download of the regions registry from + * {@code https://artifacts.contentstack.com/regions.json} and replaces + * the in-memory cache. + * + *

Call this if you suspect new Contentstack regions or service URLs have + * been published since the SDK JAR was built, without needing to upgrade + * the SDK version. + * + *

{@code
+     * int count = Endpoint.refresh();
+     * System.out.println("Loaded " + count + " regions from live registry.");
+     * }
+ * + * @return the number of regions loaded from the live registry + * @throws RuntimeException if the download fails or the response is not valid JSON + */ + public static synchronized int refresh() { + try { + String json = downloadRegions(); + JsonArray fresh = JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("regions"); + regionsData = fresh; + return fresh.size(); + } catch (Exception e) { + throw new RuntimeException( + "contentstack/cms: Failed to refresh regions.json from " + REGIONS_URL + ". " + + "Check network connectivity and try again.", e); + } + } + + /** + * Validates that an endpoint URL sourced from the regions registry is a + * well-formed HTTPS URL whose host belongs to a trusted Contentstack domain. + * + *

The regions registry is loaded from a remote resource; constraining the + * host to an allowlist here neutralizes any Server-Side Request Forgery (SSRF) + * risk before the value can flow into an outbound request. + * + * @param url the endpoint URL read from the registry + * @return the URL, unchanged, when it targets a trusted host + * @throws IllegalArgumentException if the URL is malformed, not HTTPS, or + * targets an untrusted host + */ + private static String validateTrustedEndpoint(String url) { + URL parsed; + try { + parsed = new URL(url); + } catch (java.net.MalformedURLException e) { + throw new IllegalArgumentException("Malformed endpoint URL in regions registry: " + url, e); + } + if (!"https".equalsIgnoreCase(parsed.getProtocol())) { + throw new IllegalArgumentException("Endpoint URL must use HTTPS: " + url); + } + String host = parsed.getHost() == null ? "" : parsed.getHost().toLowerCase(); + for (String suffix : TRUSTED_DOMAIN_SUFFIXES) { + // suffix begins with '.', so "contentstack.com" itself is matched via + // the leading-dot form only for subdomains; also allow the bare apex. + if (host.endsWith(suffix) || host.equals(suffix.substring(1))) { + return url; + } + } + throw new IllegalArgumentException("Endpoint URL targets an untrusted host: " + url); + } + + private static String stripHttps(String url) { + return url.replaceAll("^https?://", ""); + } + + /** Clears the in-memory cache. For use in tests only. */ + static void resetCache() { + regionsData = null; + } +} diff --git a/src/main/java/com/contentstack/cms/core/ErrorMessages.java b/src/main/java/com/contentstack/cms/core/ErrorMessages.java index 477809fa..ac3175e5 100644 --- a/src/main/java/com/contentstack/cms/core/ErrorMessages.java +++ b/src/main/java/com/contentstack/cms/core/ErrorMessages.java @@ -36,7 +36,9 @@ private ErrorMessages() { public static final String PUBLISH_QUEUE_UID_REQUIRED = "Publish Queue UID is required. Provide a valid Publish Queue UID and try again."; public static final String RELEASE_UID_REQUIRED = "Release UID is required. Provide a valid Release UID and try again."; public static final String ROLE_UID_REQUIRED = "Role UID is required. Provide a valid Role UID and try again."; + public static final String TAXONOMY_UID_REQUIRED = "Taxonomy UID is required. Provide a valid Taxonomy UID and try again."; public static final String VARIANT_GROUP_UID_REQUIRED = "Variant Group UID is required. Provide a valid Variant Group UID and try again."; + public static final String VARIANT_UID_REQUIRED = "Variant UID is required. Provide a valid Variant UID and try again."; public static final String WEBHOOK_UID_REQUIRED = "Webhook UID is required. Provide a valid Webhook UID and try again."; public static final String WORKFLOW_UID_REQUIRED = "Workflow UID is required. Provide a valid Workflow UID and try again."; diff --git a/src/main/java/com/contentstack/cms/core/Util.java b/src/main/java/com/contentstack/cms/core/Util.java index 9e2d5130..7a0ab462 100644 --- a/src/main/java/com/contentstack/cms/core/Util.java +++ b/src/main/java/com/contentstack/cms/core/Util.java @@ -39,6 +39,20 @@ public class Util { public static final String AUTHTOKEN = "authtoken"; public static final String EARLY_ACCESS_HEADER = "x-header-ea"; public static final String BRANCH = "branch"; + + /** + * Request header to fetch a base entry with a specific entry variant applied (personalization). + */ + public static final String X_CS_VARIANT_UID = "x-cs-variant-uid"; + + /** + * Required on publish/unpublish when the request body includes {@code entry.variants} (entry variant flows). + */ + public static final String API_VERSION = "api_version"; + + /** Value for {@link #API_VERSION} when publishing or unpublishing entry variants per Content Management API. */ + public static final String API_VERSION_ENTRY_VARIANTS_PUBLISH = "3.2"; + public static final String X_USER_AGENT = "X-User-Agent"; public static final String USER_AGENT = "User-Agent"; public static final String CONTENT_TYPE = "Content-Type"; diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index 0b29f02a..f998b345 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -73,20 +73,18 @@ public boolean isPkceEnabled() { */ public String getFormattedAuthorizationEndpoint() { if (authEndpoint != null) { - return authEndpoint; + return validateHttpsEndpoint(authEndpoint); } - String hostname = host != null ? host : Util.OAUTH_APP_HOST; + // Only use the configured host when it is a genuine Contentstack host; + // otherwise fall back to the default. This prevents SSRF via a host such + // as "evil-contentstack.attacker.com" that merely contains the substring. + String hostname = isTrustedContentstackHost(host) ? host : Util.OAUTH_APP_HOST; - // Transform hostname if needed - if (hostname.contains("contentstack")) { - hostname = hostname - .replaceAll("-api\\.", "-app.") // eu-api.contentstack.com -> eu-app.contentstack.com - .replaceAll("^api\\.", "app.") // api.contentstack.io -> app.contentstack.io - .replaceAll("\\.io$", ".com"); // *.io -> *.com - } else { - hostname = Util.OAUTH_APP_HOST; - } + hostname = hostname + .replaceAll("-api\\.", "-app.") // eu-api.contentstack.com -> eu-app.contentstack.com + .replaceAll("^api\\.", "app.") // api.contentstack.io -> app.contentstack.io + .replaceAll("\\.io$", ".com"); // *.io -> *.com return "https://" + hostname + String.format(Util.OAUTH_AUTHORIZE_ENDPOINT, appId); } @@ -98,24 +96,74 @@ public String getFormattedAuthorizationEndpoint() { */ public String getTokenEndpoint() { if (tokenEndpoint != null) { - return tokenEndpoint; + return validateHttpsEndpoint(tokenEndpoint); } - String hostname = host != null ? host : Util.OAUTH_API_HOST; + // Only use the configured host when it is a genuine Contentstack host; + // otherwise fall back to the default. This prevents SSRF via a host such + // as "evil-contentstack.attacker.com" that merely contains the substring. + String hostname = isTrustedContentstackHost(host) ? host : Util.OAUTH_API_HOST; - // Transform hostname if needed - if (hostname.contains("contentstack")) { - hostname = hostname - .replaceAll("-api\\.", "-developerhub-api.") // eu-api.contentstack.com -> eu-developerhub-api.contentstack.com - .replaceAll("^api\\.", "developerhub-api.") // api.contentstack.io -> developerhub-api.contentstack.io - .replaceAll("\\.io$", ".com"); // *.io -> *.com - } else { - hostname = Util.OAUTH_API_HOST; - } + hostname = hostname + .replaceAll("-api\\.", "-developerhub-api.") // eu-api.contentstack.com -> eu-developerhub-api.contentstack.com + .replaceAll("^api\\.", "developerhub-api.") // api.contentstack.io -> developerhub-api.contentstack.io + .replaceAll("\\.io$", ".com"); // *.io -> *.com return "https://" + hostname + Util.OAUTH_TOKEN_ENDPOINT; } + /** + * Determines whether the supplied host is a bare Contentstack hostname that + * is safe to use as an outbound request target. + * + *

The host must be a bare host name (optionally with a port) — no embedded + * scheme, credentials, path, query, fragment, or whitespace — and must resolve + * to a {@code contentstack.com} or {@code contentstack.io} domain. This guards + * against Server-Side Request Forgery (SSRF) when the host originates from + * untrusted input. + * + * @param candidate the candidate host + * @return true if the host is a trusted, well-formed Contentstack host + */ + private static boolean isTrustedContentstackHost(String candidate) { + if (candidate == null) { + return false; + } + String host = candidate.trim(); + // Reject embedded scheme, credentials, path/query/fragment, whitespace, + // and any other characters that would change the request target. + if (!host.matches("^[A-Za-z0-9.-]+(:\\d{1,5})?$")) { + return false; + } + int portIndex = host.indexOf(':'); + String hostOnly = (portIndex >= 0 ? host.substring(0, portIndex) : host).toLowerCase(); + return hostOnly.equals("contentstack.com") + || hostOnly.equals("contentstack.io") + || hostOnly.endsWith(".contentstack.com") + || hostOnly.endsWith(".contentstack.io"); + } + + /** + * Validates that an explicitly configured OAuth endpoint is a well-formed + * absolute HTTPS URL. Rejecting non-HTTPS schemes prevents the endpoint from + * being pointed at internal services or non-web protocols (SSRF). + * + * @param endpoint the configured endpoint URL + * @return the endpoint, unchanged, when valid + * @throws IllegalArgumentException if the endpoint is not a valid HTTPS URL + */ + private static String validateHttpsEndpoint(String endpoint) { + try { + URL url = new URL(endpoint); + if (!"https".equalsIgnoreCase(url.getProtocol())) { + throw new IllegalArgumentException("OAuth endpoint must use HTTPS: " + endpoint); + } + return endpoint; + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Invalid OAuth endpoint URL: " + endpoint, e); + } + } + /** * Gets the response type, defaulting to "code" * diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java index 56f110a9..8feb5c63 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java @@ -1,6 +1,7 @@ package com.contentstack.cms.oauth; import java.io.IOException; +import java.net.SocketTimeoutException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -112,7 +113,22 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th } // Execute request - Response response = chain.proceed(request); + Response response; + try { + response = chain.proceed(request); + } catch (SocketTimeoutException e) { + if (retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(0, e)) { + long delay = RetryUtil.calculateDelay(retryConfig, retryCount + 1, 0); + try { + Thread.sleep(delay); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("Retry interrupted", ex); + } + return executeRequest(chain, request, retryCount + 1); + } + throw e; + } // Handle error responses if (!response.isSuccessful() && retryCount < retryConfig.getRetryLimit()) { diff --git a/src/main/java/com/contentstack/cms/stack/Entry.java b/src/main/java/com/contentstack/cms/stack/Entry.java index 1f74c5dc..a47b9d5d 100644 --- a/src/main/java/com/contentstack/cms/stack/Entry.java +++ b/src/main/java/com/contentstack/cms/stack/Entry.java @@ -1,10 +1,12 @@ package com.contentstack.cms.stack; import com.contentstack.cms.core.ErrorMessages; +import com.contentstack.cms.core.Util; import com.contentstack.cms.BaseImplementation; import okhttp3.ResponseBody; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.json.simple.JSONObject; import retrofit2.Call; import retrofit2.Retrofit; @@ -68,6 +70,56 @@ private void validateCT() { Objects.requireNonNull(this.contentTypeUid, ERROR_CT_UID); } + private void validateVariantUid(@NotNull String variantUid) { + Objects.requireNonNull(variantUid, ErrorMessages.VARIANT_UID_REQUIRED); + if (variantUid.trim().isEmpty()) { + throw new IllegalArgumentException(ErrorMessages.VARIANT_UID_REQUIRED); + } + } + + /** + * Header map for a variant request when {@code branchUid} is supplied for this call only (does not mutate {@link #headers}). + * Null or blank {@code branchUid} keeps {@link #headers} as-is (stack / {@link #addBranch(String)} behavior). + */ + private Map variantHeadersWithOptionalBranch(@Nullable String branchUid) { + if (branchUid == null || branchUid.trim().isEmpty()) { + return this.headers; + } + HashMap copy = new HashMap<>(this.headers); + copy.put(Util.BRANCH, branchUid); + return copy; + } + + /** + * Sets the branch header for requests scoped to a stack branch (e.g. development). + * Overrides the branch set via {@link com.contentstack.cms.Contentstack#stack(String, String, String)} for this + * {@link Entry} instance only (including entry-variant CRUD, publish, and unpublish). Uses header key {@value Util#BRANCH}. + * + * @param branchUid branch UID or alias target branch UID + * @return this entry instance for chaining + */ + public Entry addBranch(@NotNull String branchUid) { + Objects.requireNonNull(branchUid, ErrorMessages.BRANCH_UID_REQUIRED); + if (branchUid.trim().isEmpty()) { + throw new IllegalArgumentException(ErrorMessages.BRANCH_UID_REQUIRED); + } + this.headers.put(Util.BRANCH, branchUid); + return this; + } + + /** + * Sets {@value Util#X_CS_VARIANT_UID} for {@link #fetch()} / {@link #fetchAsPojo()} to retrieve the base entry with a + * specific variant applied (personalization). + * + * @param variantUid Content variant UID (e.g. {@code cs…}) + * @return this entry instance for chaining + */ + public Entry withAppliedVariantUid(@NotNull String variantUid) { + validateVariantUid(variantUid); + this.headers.put(Util.X_CS_VARIANT_UID, variantUid); + return this; + } + /** * Sets header for the request * @@ -710,6 +762,102 @@ public Call importExisting() { return this.service.importExisting(this.headers, this.contentTypeUid, this.entryUid, this.params); } + /** + * Retrieves all entry variants for this entry. + *

+ * Use {@link #addParam(String, Object)} for optional queries such as {@code locale}, {@code include_workflow}. + * Branch scope: stack {@value Util#BRANCH} from {@link com.contentstack.cms.Contentstack#stack(String, String, String)} + * is forwarded; {@link #addBranch(String)} overrides for this entry only. + * + * @return Retrofit call for GET …/entries/{entry_uid}/variants + * @see Get all entry variants + */ + public Call fetchEntryVariants() { + validateCT(); + validateEntry(); + return this.service.fetchEntryVariants(this.headers, this.contentTypeUid, this.entryUid, this.params); + } + + /** + * Retrieves a single entry variant using {@link #headers} for {@value Util#BRANCH} (stack default and/or {@link #addBranch(String)}). + * + * @param variantUid variant UID path segment + * @return Retrofit call for GET …/variants/{variant_uid} + * @see #fetchEntryVariant(String, String) + */ + public Call fetchEntryVariant(@NotNull String variantUid) { + return fetchEntryVariant(variantUid, null); + } + + /** + * Retrieves a single entry variant with an optional per-call {@value Util#BRANCH} override. + *

+ * When {@code branchUid} is non-blank, it replaces {@value Util#BRANCH} on this request only (stack and {@link #addBranch(String)} + * values are not mutated on the entry). When {@code branchUid} is {@code null} or blank, behavior matches {@link #fetchEntryVariant(String)}. + * {@link #withAppliedVariantUid(String)} ({@value Util#X_CS_VARIANT_UID}) is unrelated to branch. + * + * @param variantUid variant UID path segment + * @param branchUid optional branch UID or alias for this request only; {@code null} or empty to use entry headers + * @return Retrofit call for GET …/variants/{variant_uid} + */ + public Call fetchEntryVariant(@NotNull String variantUid, @Nullable String branchUid) { + validateCT(); + validateEntry(); + validateVariantUid(variantUid); + return this.service.fetchEntryVariant(variantHeadersWithOptionalBranch(branchUid), this.contentTypeUid, + this.entryUid, variantUid, this.params); + } + + /** + * Creates an entry variant. Uses PUT …/variants/{variant_uid} (CMA upsert — same URL as {@link #updateEntryVariant}). + *

+ * Branch scope: inherits stack {@value Util#BRANCH}; override with {@link #addBranch(String)} or {@link #addHeader(String, String)} + * ({@value Util#BRANCH}) on this entry. Variant personalization header {@value Util#X_CS_VARIANT_UID} is orthogonal. + * + * @param variantUid variant UID path segment + * @param requestBody JSON body per API (typically wraps fields under {@code entry}) + * @see Create Entry Variant + */ + public Call createEntryVariant(@NotNull String variantUid, @NotNull JSONObject requestBody) { + validateCT(); + validateEntry(); + validateVariantUid(variantUid); + return this.service.createEntryVariant(this.headers, this.contentTypeUid, this.entryUid, variantUid, this.params, + requestBody); + } + + /** + * Updates an entry variant. Same HTTP request shape as create (PUT upsert). + *

+ * Branch scope: inherits stack {@value Util#BRANCH}; override with {@link #addBranch(String)} or {@link #addHeader(String, String)} + * ({@value Util#BRANCH}) on this entry. + * + * @see Update Entry Variant + */ + public Call updateEntryVariant(@NotNull String variantUid, @NotNull JSONObject requestBody) { + validateCT(); + validateEntry(); + validateVariantUid(variantUid); + return this.service.updateEntryVariant(this.headers, this.contentTypeUid, this.entryUid, variantUid, this.params, + requestBody); + } + + /** + * Deletes an entry variant. + *

+ * Branch scope: inherits stack {@value Util#BRANCH}; override with {@link #addBranch(String)} or {@link #addHeader(String, String)} + * ({@value Util#BRANCH}) on this entry. + * + * @param variantUid variant UID path segment + * @return Retrofit call for DELETE …/variants/{variant_uid} + */ + public Call deleteEntryVariant(@NotNull String variantUid) { + validateCT(); + validateEntry(); + validateVariantUid(variantUid); + return this.service.deleteEntryVariant(this.headers, this.contentTypeUid, this.entryUid, variantUid, this.params); + } + /** * To Publish an entry request lets you publish an entry either immediately or * schedule it for a later date/time. @@ -752,7 +900,25 @@ public Call importExisting() { public Call publish(@NotNull JSONObject requestBody) { validateCT(); validateEntry(); - return this.service.publish(this.headers, this.contentTypeUid, this.entryUid, requestBody); + return this.service.publish(this.headers, this.contentTypeUid, this.entryUid, this.params, requestBody); + } + + /** + * Publishes entry variants using the entry publish endpoint with {@code entry.variants} in the body. + * Sends header {@value Util#API_VERSION}={@value Util#API_VERSION_ENTRY_VARIANTS_PUBLISH} unless already set on this entry instance. + * Use {@link #addParam(String, Object)} for optional {@code locale} query parameter. + *

+ * Branch scope: stack {@value Util#BRANCH} is copied into the publish request headers together with {@code api_version}; + * override with {@link #addBranch(String)} or {@link #addHeader(String, String)} ({@value Util#BRANCH}) on this entry. + * + * @param requestBody full publish payload including {@code entry}, {@code locale}, etc. + */ + public Call publishEntryVariants(@NotNull JSONObject requestBody) { + validateCT(); + validateEntry(); + HashMap publishHeaders = new HashMap<>(this.headers); + publishHeaders.putIfAbsent(Util.API_VERSION, Util.API_VERSION_ENTRY_VARIANTS_PUBLISH); + return this.service.publish(publishHeaders, this.contentTypeUid, this.entryUid, this.params, requestBody); } /** @@ -816,9 +982,23 @@ public Call publishWithReference(@NotNull JSONObject requestBody) public Call unpublish(@NotNull JSONObject requestBody) { validateCT(); validateEntry(); - return this.service.unpublish(this.headers, this.contentTypeUid, this.entryUid, requestBody); + return this.service.unpublish(this.headers, this.contentTypeUid, this.entryUid, this.params, requestBody); } + /** + * Unpublishes entry variants via the entry unpublish endpoint with {@code entry.variants} in the body. + * Sends header {@value Util#API_VERSION}={@value Util#API_VERSION_ENTRY_VARIANTS_PUBLISH} unless already set. + *

+ * Branch scope: stack {@value Util#BRANCH} is forwarded; override with {@link #addBranch(String)} or {@link #addHeader(String, String)} + * ({@value Util#BRANCH}) on this entry. + */ + public Call unpublishEntryVariants(@NotNull JSONObject requestBody) { + validateCT(); + validateEntry(); + HashMap unpublishHeaders = new HashMap<>(this.headers); + unpublishHeaders.putIfAbsent(Util.API_VERSION, Util.API_VERSION_ENTRY_VARIANTS_PUBLISH); + return this.service.unpublish(unpublishHeaders, this.contentTypeUid, this.entryUid, this.params, requestBody); + } /** * Get instance of taxonomy search filter class instance through which we can query on taxonomy based on content type diff --git a/src/main/java/com/contentstack/cms/stack/EntryService.java b/src/main/java/com/contentstack/cms/stack/EntryService.java index 8c5c82fb..cfa6e742 100644 --- a/src/main/java/com/contentstack/cms/stack/EntryService.java +++ b/src/main/java/com/contentstack/cms/stack/EntryService.java @@ -5,7 +5,6 @@ import retrofit2.Call; import retrofit2.http.*; -import java.util.List; import java.util.Map; public interface EntryService { @@ -147,6 +146,7 @@ Call publish( @HeaderMap Map headers, @Path("content_type_uid") String contentTypeUid, @Path("entry_uid") String entryUid, + @QueryMap(encoded = true) Map queryParameters, @Body JSONObject requestBody); @POST("bulk/publish?x-bulk-action=publish") @@ -160,8 +160,58 @@ Call unpublish( @HeaderMap Map headers, @Path("content_type_uid") String contentTypeUid, @Path("entry_uid") String entryUid, + @QueryMap(encoded = true) Map queryParameters, @Body JSONObject requestBody); + @GET("content_types/{content_type_uid}/entries/{entry_uid}/variants") + Call fetchEntryVariants( + @HeaderMap Map headers, + @Path("content_type_uid") String contentTypeUid, + @Path("entry_uid") String entryUid, + @QueryMap(encoded = true) Map queryParameters); + + @GET("content_types/{content_type_uid}/entries/{entry_uid}/variants/{variant_uid}") + Call fetchEntryVariant( + @HeaderMap Map headers, + @Path("content_type_uid") String contentTypeUid, + @Path("entry_uid") String entryUid, + @Path("variant_uid") String variantUid, + @QueryMap(encoded = true) Map queryParameters); + + /** + * Create entry variant (PUT …/variants/{variant_uid}). Same HTTP contract as update — CMA upserts on this path. + */ + @Headers("Content-Type: application/json") + @PUT("content_types/{content_type_uid}/entries/{entry_uid}/variants/{variant_uid}") + Call createEntryVariant( + @HeaderMap Map headers, + @Path("content_type_uid") String contentTypeUid, + @Path("entry_uid") String entryUid, + @Path("variant_uid") String variantUid, + @QueryMap(encoded = true) Map queryParameters, + @Body JSONObject requestBody); + + /** + * Update entry variant — delegates to {@link #createEntryVariant}; API uses one PUT upsert for both operations. + */ + default Call updateEntryVariant( + Map headers, + String contentTypeUid, + String entryUid, + String variantUid, + Map queryParameters, + JSONObject requestBody) { + return createEntryVariant(headers, contentTypeUid, entryUid, variantUid, queryParameters, requestBody); + } + + @DELETE("content_types/{content_type_uid}/entries/{entry_uid}/variants/{variant_uid}") + Call deleteEntryVariant( + @HeaderMap Map headers, + @Path("content_type_uid") String contentTypeUid, + @Path("entry_uid") String entryUid, + @Path("variant_uid") String variantUid, + @QueryMap(encoded = true) Map queryParameters); + @GET("content_types/{content_type_uid}/entries") Call filterTaxonomy( @HeaderMap Map headers, diff --git a/src/main/java/com/contentstack/cms/stack/Taxonomy.java b/src/main/java/com/contentstack/cms/stack/Taxonomy.java index b3abca53..ea81cb6d 100644 --- a/src/main/java/com/contentstack/cms/stack/Taxonomy.java +++ b/src/main/java/com/contentstack/cms/stack/Taxonomy.java @@ -1,6 +1,7 @@ package com.contentstack.cms.stack; import com.contentstack.cms.BaseImplementation; +import com.contentstack.cms.core.ErrorMessages; import okhttp3.ResponseBody; import org.jetbrains.annotations.NotNull; import org.json.simple.JSONObject; @@ -8,6 +9,7 @@ import retrofit2.Retrofit; import java.util.HashMap; +import java.util.Objects; /** @@ -229,6 +231,63 @@ public Call delete(@NotNull String taxonomyId) { return this.taxonomyService.delete(this.headers, taxonomyId); } + /** + * Publish one or more taxonomies to the given environments/locales. + * Call on a collection-level instance, e.g. {@code stack.taxonomy()}. + * + * @param body the request body, e.g. {@code {"locales": [...], "environments": [...], "items": [{"uid": "..."}]}} + * @return the call Example

     {@code
+     *     Response response = taxonomy.publish(body).execute();
+     *     } 
+ */ + public Call publish(@NotNull JSONObject body) { + return this.taxonomyService.publish(this.headers, body); + } + + /** + * Unpublish one or more taxonomies from the given environments/locales. + * Call on a collection-level instance, e.g. {@code stack.taxonomy()}. + * + * @param body the request body, e.g. {@code {"locales": [...], "environments": [...], "items": [{"uid": "..."}]}} + * @return the call Example
     {@code
+     *     Response response = taxonomy.unpublish(body).execute();
+     *     } 
+ */ + public Call unpublish(@NotNull JSONObject body) { + return this.taxonomyService.unpublish(this.headers, body); + } + + /** + * Localize a taxonomy into the given locale. + * Call on an instance-level taxonomy, e.g. {@code stack.taxonomy("taxonomyId")}. + * + * @param body the request body, e.g. {@code {"taxonomy": {"name": "..."}}} + * @param locale the target locale, e.g. {@code hi-in} + * @return the call Example
     {@code
+     *     Response response = taxonomy.localize(body, "hi-in").execute();
+     *     } 
+ */ + public Call localize(@NotNull JSONObject body, @NotNull String locale) { + Objects.requireNonNull(this.taxonomyId, ErrorMessages.TAXONOMY_UID_REQUIRED); + this.params.put("locale", locale); + return this.taxonomyService.localize(this.headers, this.taxonomyId, this.params, body); + } + + /** + * Unlocalize a taxonomy from the given locale. + * Call on an instance-level taxonomy, e.g. {@code stack.taxonomy("taxonomyId")}. + * + * @param locale the locale to remove, e.g. {@code hi-in} + * @return the call Example
     {@code
+     *     Response response = taxonomy.unlocalize("hi-in").execute();
+     *     } 
+ */ + public Call unlocalize(@NotNull String locale) { + Objects.requireNonNull(this.taxonomyId, ErrorMessages.TAXONOMY_UID_REQUIRED); + this.params.put("locale", locale); + return this.taxonomyService.unlocalize(this.headers, this.taxonomyId, this.params); + } + /** * Clear params for internal uses only for testing diff --git a/src/main/java/com/contentstack/cms/stack/TaxonomyService.java b/src/main/java/com/contentstack/cms/stack/TaxonomyService.java index 571a9bcb..ae96ed9e 100644 --- a/src/main/java/com/contentstack/cms/stack/TaxonomyService.java +++ b/src/main/java/com/contentstack/cms/stack/TaxonomyService.java @@ -37,6 +37,29 @@ Call delete( @HeaderMap Map headers, @Path("taxonomy_uid") String uid); + @POST("taxonomies/publish") + Call publish( + @HeaderMap Map headers, + @Body JSONObject body); + + @POST("taxonomies/unpublish") + Call unpublish( + @HeaderMap Map headers, + @Body JSONObject body); + + @POST("taxonomies/{taxonomy_uid}") + Call localize( + @HeaderMap Map headers, + @Path("taxonomy_uid") String uid, + @QueryMap Map params, + @Body JSONObject body); + + @DELETE("taxonomies/{taxonomy_uid}") + Call unlocalize( + @HeaderMap Map headers, + @Path("taxonomy_uid") String uid, + @QueryMap Map params); + // --Terms-- @POST("taxonomies/{taxonomy_uid}/terms") @@ -87,6 +110,21 @@ Call reorder( @QueryMap Map queryParams, @Body JSONObject body); + @POST("taxonomies/{taxonomy_uid}/terms/{term_id}") + Call localizeTerm( + @HeaderMap HashMap headers, + @Path("taxonomy_uid") String taxonomyId, + @Path("term_id") String termId, + @QueryMap Map queryParams, + @Body JSONObject body); + + @DELETE("taxonomies/{taxonomy_uid}/terms/{term_id}") + Call unlocalizeTerm( + @HeaderMap HashMap headers, + @Path("taxonomy_uid") String taxonomyId, + @Path("term_id") String termId, + @QueryMap Map queryParams); + @GET("taxonomies/$all/terms") Call searchTerm( @HeaderMap HashMap headers, diff --git a/src/main/java/com/contentstack/cms/stack/Terms.java b/src/main/java/com/contentstack/cms/stack/Terms.java index 8f3a3a0c..021b09b3 100644 --- a/src/main/java/com/contentstack/cms/stack/Terms.java +++ b/src/main/java/com/contentstack/cms/stack/Terms.java @@ -278,6 +278,37 @@ public Call reorder(@NotNull String termUid, @NotNull JSONObject b return this.taxonomyService.reorder(this.headers, this.taxonomyId, termUid, this.params, body); } + /** + * Localize a term into the given locale. + * + * @param termUid the term to localize + * @param body the request body, e.g. {@code {"term": {"uid": "...", "name": "..."}}} + * @param locale the target locale, e.g. {@code hi-in} + * @return instance of Call

Example

 {@code
+     * Stack stack = new Contentstack.Builder().build().stack(headers);
+     * Term term = stack.taxonomy("taxonomyId").terms().localize("termId", body, "hi-in");
+     * } 
+ */ + public Call localize(@NotNull String termUid, @NotNull JSONObject body, @NotNull String locale) { + this.params.put("locale", locale); + return this.taxonomyService.localizeTerm(this.headers, this.taxonomyId, termUid, this.params, body); + } + + /** + * Unlocalize a term from the given locale. + * + * @param termUid the term to unlocalize + * @param locale the locale to remove, e.g. {@code hi-in} + * @return instance of Call

Example

 {@code
+     * Stack stack = new Contentstack.Builder().build().stack(headers);
+     * Term term = stack.taxonomy("taxonomyId").terms().unlocalize("termId", "hi-in");
+     * } 
+ */ + public Call unlocalize(@NotNull String termUid, @NotNull String locale) { + this.params.put("locale", locale); + return this.taxonomyService.unlocalizeTerm(this.headers, this.taxonomyId, termUid, this.params); + } + /** * Search call. diff --git a/src/test/java/com/contentstack/cms/ContentstackUnitTest.java b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java index 9e84c9b8..881913ee 100644 --- a/src/test/java/com/contentstack/cms/ContentstackUnitTest.java +++ b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java @@ -2,22 +2,31 @@ import com.contentstack.cms.core.AuthInterceptor; import com.contentstack.cms.organization.Organization; +import com.contentstack.cms.stack.Entry; import com.contentstack.cms.stack.Stack; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import okhttp3.Headers; import okhttp3.OkHttpClient; +import okhttp3.Protocol; import okhttp3.Request; import okhttp3.ResponseBody; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.Assertions; +import okio.Buffer; +import org.json.simple.JSONObject; import org.junit.jupiter.api.Test; +import retrofit2.Call; import retrofit2.Response; import java.io.IOException; import java.net.InetSocketAddress; import java.net.Proxy; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -160,6 +169,24 @@ void setVersion() { Assertions.assertEquals("v8", contentstack.version); } + @Test + void setProtocols_http1Only_buildsSuccessfully() { + List protocols = Collections.singletonList(Protocol.HTTP_1_1); + Contentstack contentstack = new Contentstack.Builder() + .setProtocols(protocols) + .build(); + Assertions.assertNotNull(contentstack); + } + + @Test + void setProtocols_http2AndHttp1_buildsSuccessfully() { + List protocols = List.of(Protocol.HTTP_2, Protocol.HTTP_1_1); + Contentstack contentstack = new Contentstack.Builder() + .setProtocols(protocols) + .build(); + Assertions.assertNotNull(contentstack); + } + @Test void setTimeout() { Contentstack contentstack = new Contentstack.Builder() @@ -391,4 +418,71 @@ public void testEarlyAccessHeaderEmpty() { Assertions.assertEquals(0, client.earlyAccess.length); } + @Test + void localizeBodyKeepsNullFieldWhenSerializeNullsEnabled() throws IOException { + Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build(); + + JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("position", null)), "de-de")); + + Assertions.assertTrue(entry.has("position")); + Assertions.assertTrue(entry.get("position").isJsonNull()); + } + + @Test + void localizeBodyDropsNullFieldByDefault() throws IOException { + Contentstack client = new Contentstack.Builder().build(); + + JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("position", null)), "de-de")); + + Assertions.assertFalse(entry.has("position")); + Assertions.assertEquals("example", entry.get("title").getAsString()); + } + + @Test + void localizeBodyKeepsNullInsideGroupFieldWhenSerializeNullsEnabled() throws IOException { + Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build(); + Map group = new HashMap<>(); + group.put("label", "kept"); + group.put("position", null); + + JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("group", group)), "de-de")); + + JsonObject sentGroup = entry.getAsJsonObject("group"); + Assertions.assertTrue(sentGroup.get("position").isJsonNull()); + Assertions.assertEquals("kept", sentGroup.get("label").getAsString()); + } + + @Test + void updateBodyKeepsNullFieldWhenSerializeNullsEnabled() throws IOException { + Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build(); + + JsonObject entry = sentEntry(entryOf(client).update(entryBody(fieldsWith("position", null)))); + + Assertions.assertTrue(entry.has("position")); + Assertions.assertTrue(entry.get("position").isJsonNull()); + } + + private static Map fieldsWith(String field, Object value) { + Map fields = new HashMap<>(); + fields.put("title", "example"); + fields.put(field, value); + return fields; + } + + private static JSONObject entryBody(Map fields) { + Map body = new HashMap<>(); + body.put("entry", fields); + return new JSONObject(body); + } + + private static Entry entryOf(Contentstack client) { + return client.stack("api_key", "management_token").contentType("newspage").entry("entry_uid"); + } + + private static JsonObject sentEntry(Call call) throws IOException { + Buffer buffer = new Buffer(); + call.request().body().writeTo(buffer); + return JsonParser.parseString(buffer.readUtf8()).getAsJsonObject().getAsJsonObject("entry"); + } + } diff --git a/src/test/java/com/contentstack/cms/TestClient.java b/src/test/java/com/contentstack/cms/TestClient.java index 2503e39b..3995228f 100644 --- a/src/test/java/com/contentstack/cms/TestClient.java +++ b/src/test/java/com/contentstack/cms/TestClient.java @@ -1,5 +1,6 @@ package com.contentstack.cms; +import java.util.UUID; import com.contentstack.cms.core.Util; import com.contentstack.cms.stack.Stack; import io.github.cdimascio.dotenv.Dotenv; @@ -17,14 +18,40 @@ public class TestClient { public final static String USER_ID = (env.get("userId") != null) ? env.get("userId") : "c11e668e0295477f"; public final static String OWNERSHIP = (env.get("ownershipToken") != null) ? env.get("ownershipToken") : "ownershipTokenId"; - // file deepcode ignore NonCryptoHardcodedSecret/test: - public final static String API_KEY = (env.get("apiKey") != null) ? env.get("apiKey") : "apiKey99999999"; + public final static String API_KEY = (env.get("apiKey") != null) ? env.get("apiKey") + : "blt" + UUID.randomUUID().toString().replace("-", "").substring(0, 16); public final static String MANAGEMENT_TOKEN = (env.get("managementToken") != null) ? env.get("managementToken") : "managementToken99999999"; public final static String DEV_HOST = (env.get("dev_host") != null) ? env.get("dev_host").trim() : "api.contentstack.io"; + public final static String REGION = (env.get("region") != null) ? env.get("region").trim() : "na"; public final static String VARIANT_GROUP_UID = (env.get("variantGroupUid") != null) ? env.get("variantGroupUid") : "variantGroupUid99999999"; + + /** Content type UID for entry-variant tests (default {@code blog}). */ + public static final String ENTRY_VARIANT_CONTENT_TYPE_UID = + isBlank(env.get("entryVariantContentTypeUid")) ? "blog" : env.get("entryVariantContentTypeUid").trim(); + + /** Stack branch for entry-variant tests (default {@code develop}). */ + public static final String ENTRY_VARIANT_BRANCH = + isBlank(env.get("entryVariantBranch")) ? "develop" : env.get("entryVariantBranch").trim(); + + /** Locale query param for entry-variant tests (default {@code en-us}). */ + public static final String ENTRY_VARIANT_LOCALE = + isBlank(env.get("entryVariantLocale")) ? "en-us" : env.get("entryVariantLocale").trim(); + + /** {@code true} if {@code value} is null, empty, or whitespace-only. */ + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + /** + * {@code true} when {@code apiKey} / {@code managementToken} were not loaded from {@code .env} (defaults apply). + */ + public static boolean isUsingDefaultStackCredentials() { + return env.get("apiKey") == null || env.get("managementToken") == null; + } + private static Contentstack instance; private static Stack stackInstance; diff --git a/src/test/java/com/contentstack/cms/UnitTestSuite.java b/src/test/java/com/contentstack/cms/UnitTestSuite.java index 97df7a51..5b84fb52 100644 --- a/src/test/java/com/contentstack/cms/UnitTestSuite.java +++ b/src/test/java/com/contentstack/cms/UnitTestSuite.java @@ -1,10 +1,14 @@ package com.contentstack.cms; import com.contentstack.cms.core.AuthInterceptorTest; +import com.contentstack.cms.core.EndpointTest; +import com.contentstack.cms.stack.AssetUnitTest; +import com.contentstack.cms.stack.EntryVariantUnitTest; import com.contentstack.cms.stack.EnvironmentUnitTest; import com.contentstack.cms.stack.GlobalFieldUnitTests; import com.contentstack.cms.stack.LocaleUnitTest; import com.contentstack.cms.stack.ReleaseUnitTest; +import com.contentstack.cms.stack.TaxonomyTest; import org.junit.platform.runner.JUnitPlatform; import org.junit.platform.suite.api.SelectClasses; import org.junit.runner.RunWith; @@ -23,12 +27,16 @@ // Core tests AuthInterceptorTest.class, ContentstackUnitTest.class, + EndpointTest.class, // Stack module tests (only public classes) + AssetUnitTest.class, + EntryVariantUnitTest.class, EnvironmentUnitTest.class, GlobalFieldUnitTests.class, LocaleUnitTest.class, - ReleaseUnitTest.class + ReleaseUnitTest.class, + TaxonomyTest.class }) public class UnitTestSuite { } diff --git a/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java b/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java index 310f964d..efe1b867 100644 --- a/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java +++ b/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import java.io.IOException; +import java.net.SocketTimeoutException; public class AuthInterceptorTest { @@ -166,6 +167,89 @@ public Call call() { } } + @Test + @Tag("unit") + public void testRetry_onSocketTimeout_thenSuccess_retriesAndReturnsSuccess() throws IOException { + authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build()); + Request request = new Request.Builder() + .url("https://api.contentstack.io/v3/user") + .get() + .build(); + TimeoutTestChain chain = new TimeoutTestChain(request, 1, 200); + try (Response response = authInterceptor.intercept(chain)) { + Assertions.assertEquals(200, response.code()); + Assertions.assertEquals(2, chain.getProceedCount()); + } + } + + @Test + @Tag("unit") + public void testRetry_onSocketTimeout_exhaustsRetries_throws() { + authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(2).retryDelay(10).build()); + Request request = new Request.Builder() + .url("https://api.contentstack.io/v3/user") + .get() + .build(); + TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200); + Assertions.assertThrows(SocketTimeoutException.class, () -> authInterceptor.intercept(chain)); + Assertions.assertEquals(3, chain.getProceedCount()); + } + + @Test + @Tag("unit") + public void testRetry_onSocketTimeout_zeroRetryLimit_throwsImmediately() { + authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(0).retryDelay(10).build()); + Request request = new Request.Builder() + .url("https://api.contentstack.io/v3/user") + .get() + .build(); + TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200); + Assertions.assertThrows(SocketTimeoutException.class, () -> authInterceptor.intercept(chain)); + Assertions.assertEquals(1, chain.getProceedCount()); + } + + private static class TimeoutTestChain implements Interceptor.Chain { + private final Request originalRequest; + private final int timeoutCount; + private final int successCode; + private int proceedCount = 0; + + TimeoutTestChain(Request request, int timeoutCount, int successCode) { + this.originalRequest = request; + this.timeoutCount = timeoutCount; + this.successCode = successCode; + } + + int getProceedCount() { return proceedCount; } + + @Override + public Request request() { return originalRequest; } + + @Override + public Response proceed(Request request) throws IOException { + proceedCount++; + if (proceedCount <= timeoutCount) { + throw new SocketTimeoutException("timeout"); + } + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(successCode) + .message("OK") + .body(ResponseBody.create("{}", MediaType.parse("application/json"))) + .build(); + } + + @Override public Connection connection() { return null; } + @Override public int connectTimeoutMillis() { return 0; } + @Override public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override public int readTimeoutMillis() { return 0; } + @Override public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override public int writeTimeoutMillis() { return 0; } + @Override public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override public Call call() { return null; } + } + @Test public void AuthInterceptor() { AuthInterceptor expected = new AuthInterceptor("abc"); diff --git a/src/test/java/com/contentstack/cms/core/EndpointTest.java b/src/test/java/com/contentstack/cms/core/EndpointTest.java new file mode 100644 index 00000000..8584ef1c --- /dev/null +++ b/src/test/java/com/contentstack/cms/core/EndpointTest.java @@ -0,0 +1,411 @@ +package com.contentstack.cms.core; + +import com.contentstack.cms.Contentstack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class EndpointTest { + + @BeforeEach + @AfterEach + void resetCache() { + Endpoint.resetCache(); + } + + // ── canonical IDs — contentDelivery ─────────────────────────────────────── + + @Test + void testNaContentDelivery() { + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint("na", "contentDelivery")); + } + + @Test + void testNaContentManagement() { + assertEquals("https://api.contentstack.io", + Endpoint.getContentstackEndpoint("na", "contentManagement")); + } + + @Test + void testEuContentDelivery() { + assertEquals("https://eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentDelivery")); + } + + @Test + void testEuContentManagement() { + assertEquals("https://eu-api.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentManagement")); + } + + @Test + void testAuContentDelivery() { + assertEquals("https://au-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("au", "contentDelivery")); + } + + @Test + void testAzureNaContentDelivery() { + assertEquals("https://azure-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("azure-na", "contentDelivery")); + } + + @Test + void testAzureEuContentDelivery() { + assertEquals("https://azure-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("azure-eu", "contentDelivery")); + } + + @Test + void testGcpNaContentDelivery() { + assertEquals("https://gcp-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery")); + } + + @Test + void testGcpEuContentDelivery() { + assertEquals("https://gcp-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery")); + } + + // ── region aliases resolve to the same endpoint ─────────────────────────── + + @ParameterizedTest + @ValueSource(strings = {"na", "us", "aws-na", "aws_na", "NA", "US", "AWS-NA", "AWS_NA"}) + void testNaAliasesAllResolveToNaCdn(String alias) { + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"eu", "aws-eu", "aws_eu", "EU", "AWS-EU", "AWS_EU"}) + void testEuAliasesAllResolveToEuCdn(String alias) { + assertEquals("https://eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"au", "aws-au", "aws_au", "AU", "AWS-AU", "AWS_AU"}) + void testAuAliasesAllResolveToAuCdn(String alias) { + assertEquals("https://au-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"azure-na", "azure_na", "AZURE-NA", "AZURE_NA"}) + void testAzureNaAliasesResolve(String alias) { + assertEquals("https://azure-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"azure-eu", "azure_eu", "AZURE-EU", "AZURE_EU"}) + void testAzureEuAliasesResolve(String alias) { + assertEquals("https://azure-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"gcp-na", "gcp_na", "GCP-NA", "GCP_NA"}) + void testGcpNaAliasesResolve(String alias) { + assertEquals("https://gcp-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"gcp-eu", "gcp_eu", "GCP-EU", "GCP_EU"}) + void testGcpEuAliasesResolve(String alias) { + assertEquals("https://gcp-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + // ── omitHttps ───────────────────────────────────────────────────────────── + + @Test + void testOmitHttpsFalseKeepsScheme() { + String url = Endpoint.getContentstackEndpoint("na", "contentDelivery", false); + assertTrue(url.startsWith("https://")); + } + + @Test + void testOmitHttpsTrueStripsScheme() { + String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true); + assertFalse(host.startsWith("https://")); + assertEquals("cdn.contentstack.io", host); + } + + @Test + void testOmitHttpsEuCdn() { + assertEquals("eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentDelivery", true)); + } + + @Test + void testOmitHttpsAzureNaApi() { + assertEquals("azure-na-api.contentstack.com", + Endpoint.getContentstackEndpoint("azure-na", "contentManagement", true)); + } + + @Test + void testOmitHttpsGcpEuCdn() { + assertEquals("gcp-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery", true)); + } + + // ── various service keys ─────────────────────────────────────────────────── + + @Test + void testNaAuth() { + assertEquals("https://auth-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "auth")); + } + + @Test + void testNaGraphqlDelivery() { + assertEquals("https://graphql.contentstack.com", + Endpoint.getContentstackEndpoint("na", "graphqlDelivery")); + } + + @Test + void testNaPreview() { + assertEquals("https://rest-preview.contentstack.com", + Endpoint.getContentstackEndpoint("na", "preview")); + } + + @Test + void testNaApplication() { + assertEquals("https://app.contentstack.com", + Endpoint.getContentstackEndpoint("na", "application")); + } + + @Test + void testNaAssetManagement() { + assertEquals("https://am-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "assetManagement")); + } + + @Test + void testNaDeveloperHub() { + assertEquals("https://developerhub-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "developerHub")); + } + + @Test + void testEuAutomate() { + assertEquals("https://eu-prod-automations-api.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "automate")); + } + + @Test + void testNaLaunch() { + assertEquals("https://launch-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "launch")); + } + + // ── all-endpoints map ────────────────────────────────────────────────────── + + @Test + void testGetAllEndpointsForNaNotEmpty() { + Map endpoints = Endpoint.getContentstackEndpoints("na"); + assertNotNull(endpoints); + assertFalse(endpoints.isEmpty()); + } + + @Test + void testGetAllEndpointsForNaContainsExpectedKeys() { + Map endpoints = Endpoint.getContentstackEndpoints("na"); + assertEquals("https://cdn.contentstack.io", endpoints.get("contentDelivery")); + assertEquals("https://api.contentstack.io", endpoints.get("contentManagement")); + assertEquals("https://auth-api.contentstack.com", endpoints.get("auth")); + } + + @Test + void testGetAllEndpointsForEu() { + Map endpoints = Endpoint.getContentstackEndpoints("eu"); + assertEquals("https://eu-cdn.contentstack.com", endpoints.get("contentDelivery")); + assertEquals("https://eu-api.contentstack.com", endpoints.get("contentManagement")); + } + + @Test + void testGetAllEndpointsOmitHttps() { + Map hosts = Endpoint.getContentstackEndpoints("eu", true); + assertEquals("eu-cdn.contentstack.com", hosts.get("contentDelivery")); + assertEquals("eu-api.contentstack.com", hosts.get("contentManagement")); + hosts.values().forEach(v -> assertFalse(v.startsWith("https://"), + "Expected no scheme but got: " + v)); + } + + @Test + void testGetAllEndpointsWithSchemePresentByDefault() { + Map endpoints = Endpoint.getContentstackEndpoints("na"); + endpoints.values().forEach(v -> assertTrue(v.startsWith("https://"), + "Expected https:// but got: " + v)); + } + + // ── error handling ──────────────────────────────────────────────────────── + + @Test + void testEmptyRegionThrows() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("", "contentDelivery")); + assertTrue(ex.getMessage().contains("Empty region")); + } + + @Test + void testNullRegionThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint(null, "contentDelivery")); + } + + @Test + void testInvalidRegionThrows() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("asia-pacific", "contentDelivery")); + assertTrue(ex.getMessage().contains("Invalid region")); + assertTrue(ex.getMessage().contains("asia-pacific")); + } + + @Test + void testUnknownServiceThrows() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("na", "cms")); + assertTrue(ex.getMessage().contains("cms")); + assertTrue(ex.getMessage().contains("na")); + } + + @Test + void testEmptyServiceThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("na", "")); + } + + @Test + void testNullServiceThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("na", null)); + } + + // ── Contentstack class proxy ─────────────────────────────────────────────── + + @Test + void testContentstackProxyWithScheme() { + assertEquals("https://eu-api.contentstack.com", + Contentstack.getContentstackEndpoint("eu", "contentManagement")); + } + + @Test + void testContentstackProxyOmitHttps() { + assertEquals("eu-api.contentstack.com", + Contentstack.getContentstackEndpoint("eu", "contentManagement", true)); + } + + // ── Builder.setRegion() ──────────────────────────────────────────────────── + + @Test + void testBuilderSetRegionEuResolvesCorrectHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("eu") + .build(); + assertEquals("eu-api.contentstack.com", client.getHost()); + } + + @Test + void testBuilderSetRegionNaResolvesCorrectHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("na") + .build(); + assertEquals("api.contentstack.io", client.getHost()); + } + + @Test + void testBuilderSetRegionAzureNaResolvesCorrectHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("azure-na") + .build(); + assertEquals("azure-na-api.contentstack.com", client.getHost()); + } + + @Test + void testBuilderSetRegionGcpEuResolvesCorrectHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("gcp-eu") + .build(); + assertEquals("gcp-eu-api.contentstack.com", client.getHost()); + } + + @Test + void testBuilderSetRegionInvalidThrows() { + assertThrows(IllegalArgumentException.class, + () -> new Contentstack.Builder().setRegion("invalid-region")); + } + + @Test + void testBuilderSetRegionWithAuthtoken() { + Contentstack client = new Contentstack.Builder() + .setAuthtoken("fake_authtoken") + .setRegion("au") + .build(); + assertEquals("au-api.contentstack.com", client.getHost()); + } + + // ── caching — second call returns same result without re-parsing ─────────── + + @Test + void testCachingReturnsSameResult() { + String first = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + String second = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + assertEquals(first, second); + } + + // ── refresh() — forces live download and replaces cache ─────────────────── + + @Test + void testRefreshReturnsPositiveRegionCount() { + int count = Endpoint.refresh(); + assertTrue(count > 0, "refresh() should return at least one region"); + } + + @Test + void testRefreshLoadsAllKnownRegions() { + int count = Endpoint.refresh(); + assertEquals(7, count, "Expected 7 regions from the live registry"); + } + + @Test + void testRefreshUpdatesCache() { + // prime the cache from the bundled file + Endpoint.getContentstackEndpoint("na", "contentDelivery"); + + // force refresh from live + Endpoint.refresh(); + + // subsequent resolution should still work correctly + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint("na", "contentDelivery")); + } + + @Test + void testContentstackRefreshRegionsProxy() { + int count = Contentstack.refreshRegions(); + assertTrue(count > 0); + } + + @Test + void testRefreshThenResolveAllRegions() { + Endpoint.refresh(); + // verify all 7 canonical region IDs still resolve after a live refresh + String[] regions = {"na", "eu", "au", "azure-na", "azure-eu", "gcp-na", "gcp-eu"}; + for (String region : regions) { + assertDoesNotThrow(() -> Endpoint.getContentstackEndpoint(region, "contentManagement"), + "Should resolve contentManagement for region: " + region); + } + } +} diff --git a/src/test/java/com/contentstack/cms/models/LoginDetailTest.java b/src/test/java/com/contentstack/cms/models/LoginDetailTest.java index 5e070556..73769a7d 100644 --- a/src/test/java/com/contentstack/cms/models/LoginDetailTest.java +++ b/src/test/java/com/contentstack/cms/models/LoginDetailTest.java @@ -1,5 +1,6 @@ package com.contentstack.cms.models; +import java.util.UUID; import com.contentstack.cms.core.AuthInterceptor; import okhttp3.Interceptor; import okhttp3.internal.http.RealInterceptorChain; @@ -102,9 +103,9 @@ void getterSetterUserModelLastName() { @Test void getterSetterUserModelUsername() { UserModel userModel = new UserModel(); - // deepcode ignore NoHardcodedCredentials/test: - userModel.setUsername("***REMOVED***"); - Assertions.assertEquals("***REMOVED***", + String username = UUID.randomUUID().toString(); + userModel.setUsername(username); + Assertions.assertEquals(username, userModel.getUsername()); } diff --git a/src/test/java/com/contentstack/cms/oauth/OAuthInterceptorTest.java b/src/test/java/com/contentstack/cms/oauth/OAuthInterceptorTest.java new file mode 100644 index 00000000..580f9268 --- /dev/null +++ b/src/test/java/com/contentstack/cms/oauth/OAuthInterceptorTest.java @@ -0,0 +1,118 @@ +package com.contentstack.cms.oauth; + +import com.contentstack.cms.core.RetryConfig; +import com.contentstack.cms.models.OAuthTokens; +import okhttp3.*; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import java.io.IOException; +import java.net.SocketTimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +@RunWith(MockitoJUnitRunner.class) +public class OAuthInterceptorTest { + + private OAuthInterceptor interceptor; + + @Mock + private OAuthHandler mockHandler; + + @Mock + private OAuthTokens mockTokens; + + @Before + public void setup() { + Mockito.lenient().when(mockTokens.isExpired()).thenReturn(false); + Mockito.lenient().when(mockTokens.hasAccessToken()).thenReturn(true); + Mockito.lenient().when(mockHandler.getTokens()).thenReturn(mockTokens); + Mockito.lenient().when(mockHandler.getAccessToken()).thenReturn("test-access-token"); + + interceptor = new OAuthInterceptor(mockHandler); + interceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build()); + } + + @Test + public void testRetry_onSocketTimeout_thenSuccess_retriesAndReturnsSuccess() throws IOException { + Request request = new Request.Builder() + .url("https://api.contentstack.io/v3/content_types") + .get() + .build(); + TimeoutTestChain chain = new TimeoutTestChain(request, 1, 200); + try (Response response = interceptor.intercept(chain)) { + assertEquals(200, response.code()); + assertEquals(2, chain.getProceedCount()); + } + } + + @Test + public void testRetry_onSocketTimeout_exhaustsRetries_throws() { + Request request = new Request.Builder() + .url("https://api.contentstack.io/v3/content_types") + .get() + .build(); + TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200); + assertThrows(SocketTimeoutException.class, () -> interceptor.intercept(chain)); + assertEquals(4, chain.getProceedCount()); // 1 initial + 3 retries + } + + @Test + public void testRetry_onSocketTimeout_zeroRetryLimit_throwsImmediately() { + interceptor.setRetryConfig(RetryConfig.builder().retryLimit(0).retryDelay(10).build()); + Request request = new Request.Builder() + .url("https://api.contentstack.io/v3/content_types") + .get() + .build(); + TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200); + assertThrows(SocketTimeoutException.class, () -> interceptor.intercept(chain)); + assertEquals(1, chain.getProceedCount()); + } + + private static class TimeoutTestChain implements Interceptor.Chain { + private final Request originalRequest; + private final int timeoutCount; + private final int successCode; + private int proceedCount = 0; + + TimeoutTestChain(Request request, int timeoutCount, int successCode) { + this.originalRequest = request; + this.timeoutCount = timeoutCount; + this.successCode = successCode; + } + + int getProceedCount() { return proceedCount; } + + @Override + public Request request() { return originalRequest; } + + @Override + public Response proceed(Request request) throws IOException { + proceedCount++; + if (proceedCount <= timeoutCount) { + throw new SocketTimeoutException("timeout"); + } + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(successCode) + .message("OK") + .body(ResponseBody.create("{}", MediaType.parse("application/json"))) + .build(); + } + + @Override public Connection connection() { return null; } + @Override public int connectTimeoutMillis() { return 0; } + @Override public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override public int readTimeoutMillis() { return 0; } + @Override public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override public int writeTimeoutMillis() { return 0; } + @Override public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override public Call call() { return null; } + } +} diff --git a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java index ea664960..c0cc32c0 100644 --- a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java +++ b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java @@ -335,6 +335,56 @@ public void testCustomEndpoints() { customTokenEndpoint, tokenUrl); } + @Test + public void testSsrfHostIsRejected() { + // Hosts that merely contain the "contentstack" substring, or that smuggle + // a scheme/credentials/path, must NOT be used as the request target. + String[] maliciousHosts = { + "evil-contentstack.attacker.com", + "contentstack.attacker.com", + "attacker.com", + "attacker.com/contentstack.com", + "user@contentstack.com.attacker.com", + "http://contentstack.com@attacker.com" + }; + + for (String malicious : maliciousHosts) { + OAuthConfig config = OAuthConfig.builder() + .appId(TEST_APP_ID) + .clientId(TEST_CLIENT_ID) + .redirectUri(TEST_REDIRECT_URI) + .host(malicious) + .build(); + + String authUrl = config.getFormattedAuthorizationEndpoint(); + String tokenUrl = config.getTokenEndpoint(); + + assertFalse("Auth URL must not target malicious host " + malicious + ": " + authUrl, + authUrl.contains("attacker.com")); + assertFalse("Token URL must not target malicious host " + malicious + ": " + tokenUrl, + tokenUrl.contains("attacker.com")); + // The request target must fall back to a genuine Contentstack domain. + assertTrue("Auth URL should target a Contentstack host: " + authUrl, + authUrl.startsWith("https://") && authUrl.contains(".contentstack.com/")); + assertTrue("Token URL should target a Contentstack host: " + tokenUrl, + tokenUrl.startsWith("https://") && tokenUrl.contains(".contentstack.com/")); + } + } + + @Test + public void testNonHttpsCustomEndpointRejected() { + // An explicitly configured endpoint must be HTTPS; non-HTTPS schemes that + // could reach internal services or non-web protocols are rejected. + OAuthConfig config = OAuthConfig.builder() + .appId(TEST_APP_ID) + .clientId(TEST_CLIENT_ID) + .redirectUri(TEST_REDIRECT_URI) + .tokenEndpoint("http://169.254.169.254/latest/meta-data") + .build(); + + assertThrows(IllegalArgumentException.class, config::getTokenEndpoint); + } + // ================= diff --git a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java index 4b027193..06404419 100644 --- a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java +++ b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java @@ -8,7 +8,8 @@ @SuppressWarnings("deprecation") @RunWith(JUnitPlatform.class) @SelectClasses({ - TaxonomyAPITest.class, + EndpointAPITest.class, + TaxonomyAPITest.class, AssetAPITest.class, ContentTypeAPITest.class, EntryFieldsAPITest.class, @@ -22,6 +23,7 @@ GlobalFieldAPITest.class, VariantGroupAPITest.class, VariantGroupTest.class, + EntryVariantAPITest.class, ReleaseAPITest.class }) diff --git a/src/test/java/com/contentstack/cms/stack/AssetAPITest.java b/src/test/java/com/contentstack/cms/stack/AssetAPITest.java index e1090709..34da4a33 100644 --- a/src/test/java/com/contentstack/cms/stack/AssetAPITest.java +++ b/src/test/java/com/contentstack/cms/stack/AssetAPITest.java @@ -5,18 +5,14 @@ import okhttp3.Request; import okhttp3.ResponseBody; import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; import org.junit.jupiter.api.*; import retrofit2.Response; import java.io.IOException; import java.util.HashMap; import java.util.Objects; -import com.contentstack.cms.stack.FileUploader; -import org.junit.jupiter.api.Test; -import java.io.File; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; @Tag("API") class AssetAPITest { @@ -51,7 +47,8 @@ void testFindAssets() { Assertions.assertEquals(3, request.headers().size()); Assertions.assertTrue(request.isHttps(), "always works on https"); Assertions.assertEquals("GET", request.method(), "works with GET call"); - Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); Assertions.assertEquals(443, request.url().port(), "port should be 443"); + Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); + Assertions.assertEquals(443, request.url().port(), "port should be 443"); Assertions.assertTrue(request.url().pathSegments().contains("v3"), "the first segment of url should be v3"); Assertions.assertTrue(request.url().pathSegments().contains("assets"), "url segment should contain assets"); Assertions.assertFalse(Objects.requireNonNull(request.url().query()).isEmpty(), @@ -68,66 +65,59 @@ void testFetch() { asset.addParam("include_publish_details", true); asset.addParam("environment", "production"); asset.addParam("relative_urls", false); - - // Headers api_key(required) & authorization(required) asset.addHeader("api_key", API_KEY); asset.addHeader("authorization", MANAGEMENT_TOKEN); asset.addHeader("authtoken", AUTHTOKEN); - // Create Asset Instance to find all assets Request request = asset.fetch().request(); Assertions.assertEquals(3, request.headers().size()); Assertions.assertTrue(request.isHttps(), "always works on https"); Assertions.assertEquals("GET", request.method(), "works with GET call"); - Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); Assertions.assertEquals(443, request.url().port(), "port should be 443"); + Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); + Assertions.assertEquals(443, request.url().port(), "port should be 443"); Assertions.assertTrue(request.url().pathSegments().contains("v3"), "the first segment of url should be v3"); Assertions.assertTrue(request.url().pathSegments().contains("assets"), "url segment should contain assets"); Assertions.assertFalse(Objects.requireNonNull(request.url().query()).isEmpty(), "query params should not be empty"); - } @Order(3) @Test void testGetAssetByFolderUid() throws IOException { asset.clearParams(); - // Headers api_key(required) & authorization(required) asset.addHeader("api_key", API_KEY); asset.addHeader("authorization", MANAGEMENT_TOKEN); - // Create Asset Instance to find all assets Response resp = asset.byFolderUid(_uid).execute(); Assertions.assertEquals(5, resp.raw().request().headers().size()); Assertions.assertTrue(resp.raw().request().headers().names().contains("api_key")); Assertions.assertTrue(resp.raw().request().headers().names().contains("authorization")); Assertions.assertTrue(resp.raw().request().isHttps(), "always works on https"); Assertions.assertEquals("GET", resp.raw().request().method(), "works with GET call"); - Assertions.assertEquals("https", resp.raw().request().url().scheme(), "the scheme should be https"); Assertions.assertEquals(443, resp.raw().request().url().port(), "port should be 443"); + Assertions.assertEquals("https", resp.raw().request().url().scheme(), "the scheme should be https"); + Assertions.assertEquals(443, resp.raw().request().url().port(), "port should be 443"); Assertions.assertTrue(resp.raw().request().url().pathSegments().contains("v3"), "the first segment of url should be v3"); Assertions.assertTrue(resp.raw().request().url().pathSegments().contains("assets"), "url segment should contain assets"); Assertions.assertFalse(Objects.requireNonNull(resp.raw().request().url().query()).isEmpty(), "query params should not be empty"); - } @Test void testAssetSubFolder() throws IOException { asset.clearParams(); - // Headers api_key(required) & authorization(required) asset.addHeader("api_key", API_KEY); asset.addHeader("authtoken", AUTHTOKEN); - // Create Asset Instance to find all assets Request request = asset.subfolder(_uid, true).request(); Assertions.assertTrue(request.headers().names().contains("api_key")); Assertions.assertTrue(request.headers().names().contains("authtoken")); Assertions.assertTrue(request.isHttps(), "always works on https"); Assertions.assertEquals("GET", request.method(), "works with GET call"); - Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); Assertions.assertEquals(443, request.url().port(), "port should be 443"); + Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); + Assertions.assertEquals(443, request.url().port(), "port should be 443"); Assertions.assertTrue(request.url().pathSegments().contains("v3"), "the first segment of url should be v3"); Assertions.assertTrue(request.url().pathSegments().contains("assets"), "url segment should contain assets"); Assertions.assertFalse(Objects.requireNonNull(request.url().query()).isEmpty(), "query params should not be empty"); - } @Test @@ -142,18 +132,17 @@ void testAssetUpload() { String filePath = "/Users/shaileshmishra/Desktop/image.jpeg"; String description = "The calender has been placed to assets by shaileshmishra"; Request request = asset.uploadAsset(filePath, description).request(); - // The assertions Assertions.assertEquals(3, request.headers().size()); Assertions.assertTrue(request.headers().names().contains("api_key")); Assertions.assertTrue(request.headers().names().contains("authtoken")); Assertions.assertTrue(request.isHttps(), "always works on https"); Assertions.assertEquals("POST", request.method(), "works with GET call"); - Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); Assertions.assertEquals(443, request.url().port(), "port should be 443"); + Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); + Assertions.assertEquals(443, request.url().port(), "port should be 443"); Assertions.assertTrue(request.url().pathSegments().contains("v3"), "the first segment of url should be v3"); Assertions.assertTrue(request.url().pathSegments().contains("assets"), "url segment should contain assets"); Assertions.assertFalse(Objects.requireNonNull(request.url().query()).isEmpty(), "query params should not be empty"); - } @Test @@ -161,30 +150,26 @@ void testAssetUpload() { void testAssetReplace() throws IOException { asset.clearParams(); asset = client.stack().asset(_uid); - asset.addParam("include_branch", true); asset.addParam("relative_urls", false); asset.addParam("environment", "production"); - // Headers api_key(required) & authorization(required) asset.addHeader("api_key", API_KEY); asset.addHeader("authorization", MANAGEMENT_TOKEN); - // Create Asset Instance to find all assets String filePath = "/Users/shaileshmishra/Downloads/calendar.png"; Response resp = asset.replace(filePath, "Assets created by ***REMOVED***").execute(); - // The assertions Assertions.assertEquals(6, resp.raw().request().headers().size()); Assertions.assertTrue(resp.raw().request().headers().names().contains("api_key")); Assertions.assertTrue(resp.raw().request().headers().names().contains("authtoken")); Assertions.assertTrue(resp.raw().request().isHttps(), "always works on https"); Assertions.assertEquals("PUT", resp.raw().request().method(), "works with GET call"); - Assertions.assertEquals("https", resp.raw().request().url().scheme(), "the scheme should be https"); Assertions.assertEquals(443, resp.raw().request().url().port(), "port should be 443"); + Assertions.assertEquals("https", resp.raw().request().url().scheme(), "the scheme should be https"); + Assertions.assertEquals(443, resp.raw().request().url().port(), "port should be 443"); Assertions.assertTrue(resp.raw().request().url().pathSegments().contains("v3"), "the first segment of url should be v3"); Assertions.assertTrue(resp.raw().request().url().pathSegments().contains("assets"), "url segment should contain assets"); Assertions.assertFalse(Objects.requireNonNull(resp.raw().request().url().query()).isEmpty(), "query params should not be empty"); - } @Test @@ -198,13 +183,13 @@ void testAssetGeneratePermanentUrl() throws IOException { subBody.put("permanent_url", "https://api.contentstack.io/v3/assets/stack_api_key/asset_UID/sample-slug.jpeg"); body.put("asset", body); Response resp = asset.generatePermanentUrl(body).execute(); - // The assertions Assertions.assertEquals(6, resp.raw().request().headers().size()); Assertions.assertTrue(resp.raw().request().headers().names().contains("api_key")); Assertions.assertTrue(resp.raw().request().headers().names().contains("authtoken")); Assertions.assertTrue(resp.raw().request().isHttps(), "always works on https"); Assertions.assertEquals("PUT", resp.raw().request().method(), "works with GET call"); - Assertions.assertEquals("https", resp.raw().request().url().scheme(), "the scheme should be https"); Assertions.assertEquals(443, resp.raw().request().url().port(), "port should be 443"); + Assertions.assertEquals("https", resp.raw().request().url().scheme(), "the scheme should be https"); + Assertions.assertEquals(443, resp.raw().request().url().port(), "port should be 443"); Assertions.assertTrue(resp.raw().request().url().pathSegments().contains("v3"), "the first segment of url should be v3"); Assertions.assertTrue(resp.raw().request().url().pathSegments().contains("assets"), @@ -216,24 +201,21 @@ void testAssetGeneratePermanentUrl() throws IOException { @Test void testAssetDownloadPermanentUrl() throws IOException { asset = client.stack().asset(_uid); - // Headers api_key(required) & authorization(required) asset.addHeader("api_key", API_KEY); asset.addHeader("authorization", MANAGEMENT_TOKEN); asset.addHeader("authtoken", AUTHTOKEN); - // Create Asset Instance to find all assets Request request = asset.getPermanentUrl("www.google.com/search").request(); - // The assertions Assertions.assertEquals(3, request.headers().size()); Assertions.assertTrue(request.headers().names().contains("api_key")); Assertions.assertTrue(request.headers().names().contains("authtoken")); Assertions.assertTrue(request.isHttps(), "always works on https"); Assertions.assertEquals("GET", request.method(), "works with GET call"); - Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); Assertions.assertEquals(443, request.url().port(), "port should be 443"); + Assertions.assertEquals("https", request.url().scheme(), "the scheme should be https"); + Assertions.assertEquals(443, request.url().port(), "port should be 443"); Assertions.assertTrue(request.url().pathSegments().contains("v3"), "the first segment of url should be v3"); Assertions.assertTrue(request.url().pathSegments().contains("assets"), "url segment should contain assets"); } - @Test void testAssetUploadWithMultipleParams() throws IOException { String description = "The calender has been placed to assets by ***REMOVED***"; @@ -259,36 +241,44 @@ void testFetchSingleAssetPojo() throws IOException { .addParam("include_count", true); Request request = asset.fetchAsPojo().request(); Assertions.assertEquals("GET", request.method()); - Assertions.assertEquals("https", request.url().scheme()); Assertions.assertEquals(443, request.url().port()); + Assertions.assertEquals("https", request.url().scheme()); + Assertions.assertEquals(443, request.url().port()); Assertions.assertTrue(request.url().pathSegments().contains("v3")); - Assertions.assertTrue(request.url().pathSegments().contains("assets")); } + Assertions.assertTrue(request.url().pathSegments().contains("assets")); + } @Test void testFetchAllAssetsPojo() throws IOException { asset = client.stack().asset(); Request request = asset.findAsPojo().request(); Assertions.assertEquals("GET", request.method()); - Assertions.assertEquals("https", request.url().scheme()); Assertions.assertEquals(443, request.url().port()); + Assertions.assertEquals("https", request.url().scheme()); + Assertions.assertEquals(443, request.url().port()); Assertions.assertTrue(request.url().pathSegments().contains("v3")); - Assertions.assertTrue(request.url().pathSegments().contains("assets")); } + Assertions.assertTrue(request.url().pathSegments().contains("assets")); + } @Test void testFetchAssetsByFolderUidPojo() throws IOException { asset = client.stack().asset(); Request request = asset.byFolderUidAsPojo("folder_uid").request(); Assertions.assertEquals("GET", request.method()); - Assertions.assertEquals("https", request.url().scheme()); Assertions.assertEquals(443, request.url().port()); + Assertions.assertEquals("https", request.url().scheme()); + Assertions.assertEquals(443, request.url().port()); Assertions.assertTrue(request.url().pathSegments().contains("v3")); - Assertions.assertTrue(request.url().pathSegments().contains("assets")); } + Assertions.assertTrue(request.url().pathSegments().contains("assets")); + } @Test void testFetchAssetsBySubFolderUidPojo() throws IOException { asset = client.stack().asset(); Request request = asset.subfolderAsPojo("subfolder_uid", true).request(); Assertions.assertEquals("GET", request.method()); - Assertions.assertEquals("https", request.url().scheme()); Assertions.assertEquals(443, request.url().port()); + Assertions.assertEquals("https", request.url().scheme()); + Assertions.assertEquals(443, request.url().port()); Assertions.assertTrue(request.url().pathSegments().contains("v3")); - Assertions.assertTrue(request.url().pathSegments().contains("assets")); } + Assertions.assertTrue(request.url().pathSegments().contains("assets")); + } @Test void testFetchSingleFolderByNamePojo() { @@ -299,25 +289,245 @@ void testFetchSingleFolderByNamePojo() { asset.addParam("query", queryContent); Request request = asset.getSingleFolderByNameAsPojo().request(); Assertions.assertEquals("GET", request.method()); - Assertions.assertEquals("https", request.url().scheme()); Assertions.assertEquals(443, request.url().port()); + Assertions.assertEquals("https", request.url().scheme()); + Assertions.assertEquals(443, request.url().port()); Assertions.assertTrue(request.url().pathSegments().contains("v3")); - Assertions.assertTrue(request.url().pathSegments().contains("assets")); } + Assertions.assertTrue(request.url().pathSegments().contains("assets")); + } @Test void testFetchSubfoldersByParentFolderPojo() { asset = client.stack().asset(); HashMap queryContent = new HashMap<>(); - queryContent.put("is_dir", true); + queryContent.put("is_dir", true); asset.addParam("folder", "test_folder"); - asset.addParam("include_folders", true); + asset.addParam("include_folders", true); asset.addParam("query", queryContent); queryContent.put("parent_uid", "parent_uid"); asset.addParam("query", queryContent); Request request = asset.getSubfolderAsPojo().request(); Assertions.assertEquals("GET", request.method()); - Assertions.assertEquals("https", request.url().scheme()); Assertions.assertEquals(443, request.url().port()); + Assertions.assertEquals("https", request.url().scheme()); + Assertions.assertEquals(443, request.url().port()); Assertions.assertTrue(request.url().pathSegments().contains("v3")); - Assertions.assertTrue(request.url().pathSegments().contains("assets")); } + Assertions.assertTrue(request.url().pathSegments().contains("assets")); + } + + // ==================== Asset Scanning API Tests ==================== + static String uploadedAssetUid; + + @Test + @Order(10) + void testUploadAssetIncludesScanStatusPending() throws IOException, ParseException { + Asset uploadAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(); + uploadAsset.addParam("include_asset_scan_status", true); + uploadAsset.addHeader("api_key", API_KEY); + uploadAsset.addHeader("authorization", MANAGEMENT_TOKEN); + Response response = uploadAsset.uploadAsset("src/test/resources/asset.png", "asset scan test upload").execute(); + Assertions.assertTrue(response.isSuccessful(), "Expected 201 on upload"); + Assertions.assertNotNull(response.body(), "Response body should not be null"); + String body = response.body().string(); + Assertions.assertTrue(body.contains("_asset_scan_status"), + "Upload response should include _asset_scan_status when param is set"); + Assertions.assertTrue(body.contains("pending"), + "Newly uploaded asset scan status should be pending"); + JSONParser parser = new JSONParser(); + JSONObject json = (JSONObject) parser.parse(body); + JSONObject assetObj = (JSONObject) json.get("asset"); + if (assetObj != null) { + uploadedAssetUid = (String) assetObj.get("uid"); + } + Assertions.assertNotNull(uploadedAssetUid, "Could not extract UID of uploaded asset"); + } + + @Test + @Order(11) + void testFindAssetsWithScanStatus() throws IOException { + Assumptions.assumeTrue(uploadedAssetUid != null, "Skipping: no uploaded asset from testUploadAssetIncludesScanStatusPending"); + asset.clearParams(); + asset.addParam("include_asset_scan_status", true); + asset.addHeader("api_key", API_KEY); + asset.addHeader("authorization", MANAGEMENT_TOKEN); + Response response = asset.find().execute(); + Assertions.assertTrue(response.isSuccessful(), "Expected 200"); + Assertions.assertNotNull(response.body()); + String body = response.body().string(); + Assertions.assertTrue(body.contains("_asset_scan_status"), + "Response should include _asset_scan_status field when param is set"); + Assertions.assertTrue(body.contains(uploadedAssetUid), + "Uploaded asset UID should appear in the list response"); + } + + @Test + @Order(12) + void testFetchAssetWithScanStatus() throws IOException { + Assumptions.assumeTrue(uploadedAssetUid != null, "Skipping: no uploaded asset from testUploadAssetIncludesScanStatusPending"); + Asset scanAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(uploadedAssetUid); + scanAsset.addParam("include_asset_scan_status", true); + scanAsset.addHeader("api_key", API_KEY); + scanAsset.addHeader("authorization", MANAGEMENT_TOKEN); + Response response = scanAsset.fetch().execute(); + Assertions.assertTrue(response.raw().request().url().toString().contains("include_asset_scan_status=true")); + Assertions.assertTrue(response.isSuccessful(), "Expected 200 for asset UID: " + uploadedAssetUid); + Assertions.assertNotNull(response.body()); + String body = response.body().string(); + Assertions.assertTrue(body.contains("_asset_scan_status"), + "Response should include _asset_scan_status for the uploaded asset"); + } + + @Test + @Order(13) + void testFindAssetsAllScanStatusesAreValid() throws IOException { + Asset listAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(); + listAsset.addParam("include_asset_scan_status", true); + listAsset.addHeader("api_key", API_KEY); + listAsset.addHeader("authorization", MANAGEMENT_TOKEN); + Response response = listAsset.find().execute(); + Assertions.assertTrue(response.isSuccessful(), "Expected 200"); + Assertions.assertNotNull(response.body()); + String body = response.body().string(); + Assertions.assertTrue(body.contains("_asset_scan_status"), + "Response should include _asset_scan_status when param is set"); + boolean hasValidStatus = body.contains("\"pending\"") || body.contains("\"clean\"") + || body.contains("\"not_scanned\"") || body.contains("\"quarantined\""); + Assertions.assertTrue(hasValidStatus, + "Every _asset_scan_status value must be one of: pending, clean, not_scanned, quarantined"); + } + + @Test + @Order(14) + void testPublishAssetWithApiVersionHeader() throws IOException { + Assumptions.assumeTrue(uploadedAssetUid != null, "Skipping: no uploaded asset from testUploadAssetIncludesScanStatusPending"); + Asset scanAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(uploadedAssetUid); + scanAsset.addHeader("api_key", API_KEY); + scanAsset.addHeader("authorization", MANAGEMENT_TOKEN); + scanAsset.addHeader("api_version", "3.2"); + JSONObject publishBody = new JSONObject(); + JSONObject publishDetails = new JSONObject(); + publishDetails.put("locales", new String[]{"en-us"}); + publishDetails.put("environments", new String[]{"development"}); + publishBody.put("asset", publishDetails); + Response response = scanAsset.publish(publishBody).execute(); + Assertions.assertEquals("3.2", response.raw().request().header("api_version"), + "api_version: 3.2 header must be present on publish for CDA-side scan validation"); + Assertions.assertTrue(response.isSuccessful(), "Expected 200 — publish always returns success regardless of scan status"); + Assertions.assertNotNull(response.body()); + String body = response.body().string(); + Assertions.assertTrue(body.contains("Asset sent for publishing"), + "Publish response notice should confirm asset was sent for publishing"); + } + + @Test + @Order(15) + void testFetchUploadedAssetScanStatusIsValid() throws IOException, ParseException { + Assumptions.assumeTrue(uploadedAssetUid != null, "Skipping: no uploaded asset from testUploadAssetIncludesScanStatusPending"); + Asset scanAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(uploadedAssetUid); + scanAsset.addParam("include_asset_scan_status", true); + scanAsset.addHeader("api_key", API_KEY); + scanAsset.addHeader("authorization", MANAGEMENT_TOKEN); + Response response = scanAsset.fetch().execute(); + Assertions.assertTrue(response.isSuccessful(), "Expected 200 for asset UID: " + uploadedAssetUid); + Assertions.assertNotNull(response.body()); + String body = response.body().string(); + Assertions.assertTrue(body.contains("_asset_scan_status"), + "Response should include _asset_scan_status when param is set"); + JSONParser parser = new JSONParser(); + JSONObject json = (JSONObject) parser.parse(body); + JSONObject assetObj = (JSONObject) json.get("asset"); + Assertions.assertNotNull(assetObj, "Response should contain an 'asset' object"); + String status = (String) assetObj.get("_asset_scan_status"); + Assertions.assertNotNull(status, "_asset_scan_status should be present in asset object"); + java.util.Set validStatuses = java.util.Set.of("pending", "clean", "not_scanned", "quarantined"); + Assertions.assertTrue(validStatuses.contains(status), + "_asset_scan_status value '" + status + "' must be one of: pending, clean, not_scanned, quarantined"); + } + + @Test + @Order(16) + void testFindAssetsWithoutScanStatusParamFieldAbsent() throws IOException { + Asset listAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(); + listAsset.addHeader("api_key", API_KEY); + listAsset.addHeader("authorization", MANAGEMENT_TOKEN); + Response response = listAsset.find().execute(); + Assertions.assertTrue(response.isSuccessful(), "Expected 200"); + Assertions.assertNotNull(response.body()); + String body = response.body().string(); + Assertions.assertFalse(body.contains("_asset_scan_status"), + "_asset_scan_status must be absent when include_asset_scan_status param is not passed"); + } + + @Test + @Order(17) + void testFetchAssetWithoutScanStatusParamFieldAbsent() throws IOException { + Assumptions.assumeTrue(uploadedAssetUid != null, "Skipping: no uploaded asset from testUploadAssetIncludesScanStatusPending"); + Asset scanAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(uploadedAssetUid); + scanAsset.addHeader("api_key", API_KEY); + scanAsset.addHeader("authorization", MANAGEMENT_TOKEN); + Response response = scanAsset.fetch().execute(); + Assertions.assertTrue(response.isSuccessful(), "Expected 200 for asset UID: " + uploadedAssetUid); + Assertions.assertNotNull(response.body()); + String body = response.body().string(); + Assertions.assertFalse(body.contains("_asset_scan_status"), + "_asset_scan_status must be absent when include_asset_scan_status param is not passed"); + } + + @Test + @Order(18) + void testPublishWithoutApiVersionHeaderIsAbsent() throws IOException { + Assumptions.assumeTrue(uploadedAssetUid != null, "Skipping: no uploaded asset from testUploadAssetIncludesScanStatusPending"); + Asset scanAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(uploadedAssetUid); + scanAsset.addHeader("api_key", API_KEY); + scanAsset.addHeader("authorization", MANAGEMENT_TOKEN); + JSONObject publishBody = new JSONObject(); + JSONObject publishDetails = new JSONObject(); + publishDetails.put("locales", new String[]{"en-us"}); + publishDetails.put("environments", new String[]{"development"}); + publishBody.put("asset", publishDetails); + Response response = scanAsset.publish(publishBody).execute(); + Assertions.assertNull(response.raw().request().header("api_version"), + "api_version header must be absent when not explicitly added"); + } + + @Test + @Order(19) + void testUploadWithoutScanStatusParamFieldAbsent() throws IOException { + Asset uploadAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(); + uploadAsset.addHeader("api_key", API_KEY); + uploadAsset.addHeader("authorization", MANAGEMENT_TOKEN); + Response response = uploadAsset.uploadAsset("src/test/resources/asset.png", "negative scan test upload").execute(); + Assertions.assertTrue(response.isSuccessful(), "Expected 201 on upload"); + Assertions.assertNotNull(response.body()); + String body = response.body().string(); + Assertions.assertFalse(body.contains("_asset_scan_status"), + "_asset_scan_status must be absent in upload response when param is not passed"); + try { + JSONParser parser = new JSONParser(); + JSONObject json = (JSONObject) parser.parse(body); + JSONObject assetObj = (JSONObject) json.get("asset"); + if (assetObj != null) { + String negativeTestUid = (String) assetObj.get("uid"); + if (negativeTestUid != null) { + Asset toDelete = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(negativeTestUid); + toDelete.addHeader("api_key", API_KEY); + toDelete.addHeader("authorization", MANAGEMENT_TOKEN); + toDelete.delete().execute(); + } + } + } catch (ParseException ignored) {} + } + + @Test + @Order(20) + void testDeleteUploadedScanAsset() throws IOException { + Assumptions.assumeTrue(uploadedAssetUid != null, "Skipping: no uploaded asset to clean up"); + Asset uploadedAsset = client.stack(API_KEY, MANAGEMENT_TOKEN).asset(uploadedAssetUid); + uploadedAsset.addHeader("api_key", API_KEY); + uploadedAsset.addHeader("authorization", MANAGEMENT_TOKEN); + Response response = uploadedAsset.delete().execute(); + Assertions.assertTrue(response.isSuccessful(), "Expected 200 on delete for asset UID: " + uploadedAssetUid); + } + + // ==================== End Asset Scanning API Tests ==================== @Test @Disabled("disabled to avoid unnecessary asset creation, Tested working fine") @@ -327,8 +537,8 @@ void uploadFile() throws Exception { Asset asset = stack.asset(); String fileName = "/Users/reeshika.hosmani/Downloads/surf-svgrepo-com.svg", parentFolder = "bltd1150f1f7d9411e5", title = "Vacation icon"; String[] tags = {"icon"}; - Response response = asset.uploadAsset(fileName,parentFolder,title,"",tags).execute(); - if(response.isSuccessful()){ + Response response = asset.uploadAsset(fileName, parentFolder, title, "", tags).execute(); + if (response.isSuccessful()) { System.out.println("uploaded asset successfully:" + response.body().string()); } else { System.out.println("Error in uploading" + response.errorBody().string()); diff --git a/src/test/java/com/contentstack/cms/stack/AssetUnitTest.java b/src/test/java/com/contentstack/cms/stack/AssetUnitTest.java index f9c2ad71..0798745a 100644 --- a/src/test/java/com/contentstack/cms/stack/AssetUnitTest.java +++ b/src/test/java/com/contentstack/cms/stack/AssetUnitTest.java @@ -10,9 +10,10 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.Objects; @Tag("unit") -class AssetUnitTest { +public class AssetUnitTest { protected static String AUTHTOKEN = TestClient.AUTHTOKEN; protected static String API_KEY = TestClient.API_KEY; @@ -66,7 +67,7 @@ void testAssetSingle() { Request resp = asset.fetch().request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("GET", resp.method()); - Assertions.assertEquals(2, resp.headers().size()); + Assertions.assertEquals(3, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -146,7 +147,7 @@ void testAssetReplace() { .request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("PUT", resp.method()); - Assertions.assertEquals(2, resp.headers().size()); + Assertions.assertEquals(3, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -196,7 +197,7 @@ void testAssetDelete() { Request resp = asset.delete().request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("DELETE", resp.method()); - Assertions.assertEquals(2, resp.headers().size()); + Assertions.assertEquals(3, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -210,7 +211,7 @@ void testAssetRteInformation() { Request resp = asset.rteInformation().request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("GET", resp.method()); - Assertions.assertEquals(2, resp.headers().size()); + Assertions.assertEquals(3, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -228,7 +229,7 @@ void testAssetSetVersionName() { Request resp = asset.setVersionName(2, body).request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("POST", resp.method()); - Assertions.assertEquals(2, resp.headers().size()); + Assertions.assertEquals(3, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -261,7 +262,7 @@ void testAssetDeleteVersionName() { Request resp = asset.deleteVersionName(2).request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("DELETE", resp.method()); - Assertions.assertEquals(2, resp.headers().size()); + Assertions.assertEquals(3, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -365,7 +366,7 @@ void testAssetUnpublish() { Request resp = asset.unpublish(_body).request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("POST", resp.method()); - Assertions.assertEquals(2, resp.headers().size()); + Assertions.assertEquals(3, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -382,7 +383,7 @@ void testAssetSingleFolder() { Request resp = assetFolder.fetch().request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("GET", resp.method()); - Assertions.assertEquals(0, resp.headers().size()); + Assertions.assertEquals(3, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -443,7 +444,7 @@ void testAssetCreateFolder() { Request resp = asset.folder().create(_body).request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("POST", resp.method()); - Assertions.assertEquals(0, resp.headers().size()); + Assertions.assertEquals(3, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -465,7 +466,7 @@ void testAssetUpdateFolder() { Request resp = asset.folder(_uid).update(_body).request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("PUT", resp.method()); - Assertions.assertEquals(0, resp.headers().size()); + Assertions.assertEquals(2, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -480,7 +481,7 @@ void testAssetDeleteFolder() { Request resp = asset.folder(_uid).delete().request(); Assertions.assertTrue(resp.isHttps()); Assertions.assertEquals("DELETE", resp.method()); - Assertions.assertEquals(0, resp.headers().size()); + Assertions.assertEquals(2, resp.headers().size()); Collection matcher = new ArrayList<>(); matcher.add("api_key"); matcher.add("authorization"); @@ -489,6 +490,106 @@ void testAssetDeleteFolder() { resp.url().query()); Assertions.assertNull(resp.body()); } + // ==================== Asset Scanning Tests ==================== + // Feature: Asset Scanning (plan-based; enabled by default for orgs with damAccess) + // Possible _asset_scan_status values: pending | clean | quarantined | not_scanned + // not_scanned — returned for assets uploaded before scanning was enabled on the stack. + // API tests against a scan-enabled stack are in AssetAPITest. + // ============================================================== + + @Test + void testFetchIncludesScanStatusParam() { + asset.clearParams(); + asset.addParam("include_asset_scan_status", true); + Request resp = asset.fetch().request(); + Assertions.assertEquals("GET", resp.method()); + Assertions.assertEquals("/v3/assets/" + _uid, resp.url().encodedPath()); + Assertions.assertTrue( + Objects.requireNonNull(resp.url().query()).contains("include_asset_scan_status=true")); + Assertions.assertNull(resp.body()); + } + + @Test + void testFindIncludesScanStatusParam() { + asset.clearParams(); + asset.addParam("include_asset_scan_status", true); + Request resp = asset.find().request(); + Assertions.assertEquals("GET", resp.method()); + Assertions.assertEquals("/v3/assets", resp.url().encodedPath()); + Assertions.assertTrue( + Objects.requireNonNull(resp.url().query()).contains("include_asset_scan_status=true")); + Assertions.assertNull(resp.body()); + } + + @Test + void testUploadIncludesScanStatusParam() { + asset.clearParams(); + asset.addParam("include_asset_scan_status", true); + String filePath = "src/test/resources/asset.png"; + Request resp = asset.uploadAsset(filePath, "test upload for scan status").request(); + Assertions.assertEquals("POST", resp.method()); + Assertions.assertEquals("/v3/assets", resp.url().encodedPath()); + Assertions.assertTrue( + Objects.requireNonNull(resp.url().query()).contains("include_asset_scan_status=true")); + Assertions.assertNotNull(resp.body()); + } + + @Test + void testFetchWithoutScanStatusParamExcludesField() { + // Omitting the param means _asset_scan_status is not requested. + // Legacy assets (not_scanned) and new assets alike will not include the field. + asset.clearParams(); + Request resp = asset.fetch().request(); + String query = resp.url().query(); + Assertions.assertTrue(query == null || !query.contains("include_asset_scan_status"), + "include_asset_scan_status must be absent when not explicitly set"); + Assertions.assertNull(resp.body()); + } + + @Test + void testScanStatusParamCoexistsWithOtherParams() { + asset.clearParams(); + asset.addParam("include_asset_scan_status", true); + asset.addParam("include_publish_details", true); + asset.addParam("environment", "production"); + Request resp = asset.fetch().request(); + String query = Objects.requireNonNull(resp.url().query()); + Assertions.assertTrue(query.contains("include_asset_scan_status=true")); + Assertions.assertTrue(query.contains("include_publish_details=true")); + Assertions.assertTrue(query.contains("environment=production")); + } + + @Test + void testPublishIncludesApiVersionHeader() { + // api_version: 3.2 is required for CDA-side scan validation on publish. + // Without it, quarantined assets incorrectly appear as published in the UI. + asset.clearParams(); + asset.addHeader("api_version", "3.2"); + JSONObject body = new JSONObject(); + Request resp = asset.publish(body).request(); + Assertions.assertEquals("POST", resp.method()); + Assertions.assertEquals("/v3/assets/" + _uid + "/publish", resp.url().encodedPath()); + Assertions.assertEquals("3.2", resp.header("api_version")); + Assertions.assertNotNull(resp.body()); + } + + @Test + void testApiVersionHeaderScopedToPublish() { + // api_version: 3.2 must be set explicitly per-call and must not be a global header. + // Setting it globally would affect all API calls and could break other functionality. + HashMap headers = new HashMap<>(); + headers.put(Util.API_KEY, API_KEY); + headers.put(Util.AUTHORIZATION, MANAGEMENT_TOKEN); + Asset freshAsset = TestClient.getClient().stack(headers).asset(_uid); + freshAsset.clearParams(); + Request resp = freshAsset.fetch().request(); + Assertions.assertEquals("GET", resp.method()); + Assertions.assertNull(resp.header("api_version"), + "api_version header must not be present on fetch unless explicitly added"); + } + + // ==================== End Asset Scanning Tests ==================== + @Test void testAssetValidate() { asset = TestClient.getStack().asset(); diff --git a/src/test/java/com/contentstack/cms/stack/EndpointAPITest.java b/src/test/java/com/contentstack/cms/stack/EndpointAPITest.java new file mode 100644 index 00000000..f5498475 --- /dev/null +++ b/src/test/java/com/contentstack/cms/stack/EndpointAPITest.java @@ -0,0 +1,212 @@ +package com.contentstack.cms.stack; + +import com.contentstack.cms.Contentstack; +import com.contentstack.cms.TestClient; +import com.contentstack.cms.core.Endpoint; +import com.contentstack.cms.core.Util; +import okhttp3.Request; +import org.junit.jupiter.api.*; +import retrofit2.Response; + +import java.io.IOException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * API-level tests for the endpoint integration feature. + * + *

URL-structure tests verify the resolved host is wired into every outgoing + * request. Live-call tests make real network requests to the NA management API + * using credentials from {@code .env}. + */ +@Tag("api") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class EndpointAPITest { + + private static final String API_KEY = TestClient.API_KEY; + private static final String MANAGEMENT_TOKEN = TestClient.MANAGEMENT_TOKEN; + private static final String REGION = TestClient.REGION; + + // ── URL structure — all 7 regions ──────────────────────────────────────── + + @Order(1) + @Test + void testNaRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("na").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("api.contentstack.io", req.url().host()); + assertEquals("v3", req.url().pathSegments().get(0)); + } + + @Order(2) + @Test + void testEuRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("eu").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("eu-api.contentstack.com", req.url().host()); + } + + @Order(3) + @Test + void testAuRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("au").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("au-api.contentstack.com", req.url().host()); + } + + @Order(4) + @Test + void testAzureNaRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("azure-na").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("azure-na-api.contentstack.com", req.url().host()); + } + + @Order(5) + @Test + void testAzureEuRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("azure-eu").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("azure-eu-api.contentstack.com", req.url().host()); + } + + @Order(6) + @Test + void testGcpNaRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("gcp-na").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("gcp-na-api.contentstack.com", req.url().host()); + } + + @Order(7) + @Test + void testGcpEuRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("gcp-eu").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("gcp-eu-api.contentstack.com", req.url().host()); + } + + // ── region aliases are wired correctly ──────────────────────────────────── + + @Order(8) + @Test + void testUsAliasResolvesToNaHost() { + Stack stack = clientForRegion("us").stack(API_KEY, MANAGEMENT_TOKEN); + assertEquals("api.contentstack.io", stack.contentType("t").find().request().url().host()); + } + + @Order(9) + @Test + void testAwsEuAliasResolvesToEuHost() { + Stack stack = clientForRegion("aws-eu").stack(API_KEY, MANAGEMENT_TOKEN); + assertEquals("eu-api.contentstack.com", stack.contentType("t").find().request().url().host()); + } + + @Order(10) + @Test + void testAzureNaUnderscoreAliasResolvesCorrectly() { + Stack stack = clientForRegion("azure_na").stack(API_KEY, MANAGEMENT_TOKEN); + assertEquals("azure-na-api.contentstack.com", stack.contentType("t").find().request().url().host()); + } + + @Order(11) + @Test + void testUppercaseRegionAliasResolvesCorrectly() { + Stack stack = clientForRegion("GCP-EU").stack(API_KEY, MANAGEMENT_TOKEN); + assertEquals("gcp-eu-api.contentstack.com", stack.contentType("t").find().request().url().host()); + } + + // ── setRegion wires host into Retrofit base URL ─────────────────────────── + + @Order(12) + @Test + void testSetRegionWiresCorrectBaseUrl() { + Contentstack client = new Contentstack.Builder() + .setRegion("eu") + .build(); + assertTrue(client.getBaseUrl().contains("eu-api.contentstack.com"), + "Base URL should contain EU CMA host"); + } + + @Order(13) + @Test + void testSetRegionNaWiresDefaultHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("na") + .build(); + assertEquals("api.contentstack.io", client.getHost()); + assertTrue(client.getBaseUrl().contains("api.contentstack.io")); + } + + // ── getContentstackEndpoints() returns full map ─────────────────────────── + + @Order(14) + @Test + void testGetAllEndpointsForRegionNotEmpty() { + Map endpoints = Contentstack.getContentstackEndpoints(REGION); + assertNotNull(endpoints); + assertFalse(endpoints.isEmpty()); + assertTrue(endpoints.containsKey("contentManagement")); + assertTrue(endpoints.containsKey("contentDelivery")); + assertTrue(endpoints.containsKey("auth")); + } + + @Order(15) + @Test + void testGetAllEndpointsOmitHttps() { + Map hosts = Contentstack.getContentstackEndpoints("eu", true); + hosts.values().forEach(v -> + assertFalse(v.startsWith("https://"), "Expected no scheme but got: " + v)); + assertEquals("eu-api.contentstack.com", hosts.get("contentManagement")); + } + + // ── live API call — uses env region + credentials ───────────────────────── + + @Order(16) + @Test + void testLiveCallWithSetRegionReturnsResponse() throws IOException { + Contentstack client = new Contentstack.Builder() + .setAuthtoken(TestClient.AUTHTOKEN) + .setRegion(REGION) + .build(); + + String resolvedHost = Endpoint.getContentstackEndpoint(REGION, "contentManagement", true); + assertEquals(resolvedHost, client.getHost()); + + Response response = client.user().getUser().execute(); + // 200 = valid authtoken, 401 = invalid but network + endpoint resolution worked + assertTrue(response.code() == 200 || response.code() == 401, + "Expected 200 or 401, got: " + response.code()); + } + + @Order(17) + @Test + void testLiveStackCallWithSetRegion() throws IOException { + Stack stack = new Contentstack.Builder() + .setAuthtoken(TestClient.AUTHTOKEN) + .setRegion(REGION) + .build() + .stack(API_KEY, MANAGEMENT_TOKEN); + + Request req = stack.contentType("").find().request(); + String expectedHost = Endpoint.getContentstackEndpoint(REGION, "contentManagement", true); + assertEquals(expectedHost, req.url().host()); + assertTrue(req.url().isHttps()); + assertEquals(Util.VERSION, req.url().pathSegments().get(0)); + } + + // ── helper ──────────────────────────────────────────────────────────────── + + private Contentstack clientForRegion(String region) { + return new Contentstack.Builder().setRegion(region).build(); + } +} diff --git a/src/test/java/com/contentstack/cms/stack/EntryVariantAPITest.java b/src/test/java/com/contentstack/cms/stack/EntryVariantAPITest.java new file mode 100644 index 00000000..05a45f37 --- /dev/null +++ b/src/test/java/com/contentstack/cms/stack/EntryVariantAPITest.java @@ -0,0 +1,246 @@ +package com.contentstack.cms.stack; + +import com.contentstack.cms.TestClient; +import com.contentstack.cms.Utils; +import com.contentstack.cms.core.Util; + +import okhttp3.Request; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +/** + * Request-shape checks for entry-variant endpoints (no HTTP — same style as {@link ContentTypeAPITest}). + */ +@Tag("unit") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class EntryVariantAPITest { + + private static final String CREATE_VARIANT_BODY = "entry_variant/create_entry_variant.json"; + private static final String UPDATE_VARIANT_BODY = "entry_variant/update_entry_variant.json"; + private static final String PUBLISH_VARIANT_BODY = "entry_variant/publish_entry_variant.json"; + private static final String UNPUBLISH_VARIANT_BODY = "entry_variant/unpublish_entry_variant.json"; + + private final String contentTypeUid = TestClient.ENTRY_VARIANT_CONTENT_TYPE_UID; + private final String entryUid = "entry_uid"; + private final String variantUid = "variant_uid"; + private final String stackBranch = TestClient.ENTRY_VARIANT_BRANCH == null + ? "develop" + : TestClient.ENTRY_VARIANT_BRANCH.trim(); + + private Stack stack; + private int headerCount; + + @BeforeAll + void setUp() { + /* + * Fresh Stack from the client — avoids mutating {@link TestClient#getStack()} singleton headers + * (which broke other suites such as {@link ReleaseAPITest} when branch was added globally). + */ + stack = TestClient.getClient().stack( + TestClient.API_KEY, + TestClient.MANAGEMENT_TOKEN, + stackBranch); + headerCount = stack.headers.size(); + } + + private Entry freshEntry() { + return stack.contentType(contentTypeUid).entry(entryUid); + } + + private String variantPathSuffix() { + return "/v3/content_types/" + contentTypeUid + "/entries/" + entryUid + "/variants/" + variantUid; + } + + private String variantsCollectionPath() { + return "/v3/content_types/" + contentTypeUid + "/entries/" + entryUid + "/variants"; + } + + /** + * Publish/unpublish fixtures omit {@code variants[].uid}; inject placeholder for request body construction. + */ + @SuppressWarnings("unchecked") + private static void injectVariantUidIntoVariantsArray(JSONObject body, String uid) { + JSONObject ent = (JSONObject) body.get("entry"); + Assertions.assertNotNull(ent, "Body missing entry"); + JSONArray variants = (JSONArray) ent.get("variants"); + Assertions.assertNotNull(variants, "entry.variants missing"); + Assertions.assertFalse(variants.isEmpty(), "entry.variants empty"); + JSONObject v0 = (JSONObject) variants.get(0); + v0.put("uid", uid); + } + + @Test + @Order(1) + void createEntryVariant_requestShape() { + Entry entry = freshEntry(); + entry.clearParams(); + entry.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + JSONObject body = Utils.readJson(CREATE_VARIANT_BODY); + Assertions.assertNotNull(body); + + Request req = entry.createEntryVariant(variantUid, body).request(); + Assertions.assertEquals(headerCount, req.headers().names().size()); + Assertions.assertEquals("PUT", req.method()); + Assertions.assertTrue(req.url().isHttps()); + Assertions.assertEquals(variantPathSuffix(), req.url().encodedPath()); + Assertions.assertEquals("locale=" + TestClient.ENTRY_VARIANT_LOCALE, req.url().encodedQuery()); + Assertions.assertEquals(stackBranch, req.header(Util.BRANCH)); + } + + @Test + @Order(2) + void updateEntryVariant_sameUrlAndMethodAsCreate() { + Entry entry = freshEntry(); + entry.clearParams(); + JSONObject createBody = Utils.readJson(CREATE_VARIANT_BODY); + JSONObject updateBody = Utils.readJson(UPDATE_VARIANT_BODY); + Assertions.assertNotNull(createBody); + Assertions.assertNotNull(updateBody); + + Request createReq = entry.createEntryVariant(variantUid, createBody).request(); + Entry entry2 = freshEntry(); + Request updateReq = entry2.updateEntryVariant(variantUid, updateBody).request(); + + Assertions.assertEquals(createReq.method(), updateReq.method()); + Assertions.assertEquals(createReq.url(), updateReq.url()); + } + + @Test + @Order(3) + void fetchSingleEntryVariant_requestShape() { + Entry entry = freshEntry(); + entry.clearParams(); + entry.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + entry.addParam("include_publish_details", true); + + Request req = entry.fetchEntryVariant(variantUid).request(); + Assertions.assertEquals(headerCount, req.headers().names().size()); + Assertions.assertEquals("GET", req.method()); + Assertions.assertTrue(req.url().isHttps()); + Assertions.assertEquals(variantPathSuffix(), req.url().encodedPath()); + Assertions.assertNotNull(req.url().encodedQuery()); + Assertions.assertTrue(req.url().encodedQuery().contains("locale=" + TestClient.ENTRY_VARIANT_LOCALE)); + Assertions.assertTrue(req.url().encodedQuery().contains("include_publish_details=true")); + } + + @Test + @Order(4) + void fetchAllEntryVariants_requestShape() { + Entry entry = freshEntry(); + entry.clearParams(); + entry.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + + Request req = entry.fetchEntryVariants().request(); + Assertions.assertEquals(headerCount, req.headers().names().size()); + Assertions.assertEquals("GET", req.method()); + Assertions.assertEquals(variantsCollectionPath(), req.url().encodedPath()); + Assertions.assertEquals("locale=" + TestClient.ENTRY_VARIANT_LOCALE, req.url().encodedQuery()); + } + + @Test + @Order(5) + void publishEntryVariants_requestShape() { + Entry entry = freshEntry(); + entry.clearParams(); + entry.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + + JSONObject publishBody = Utils.readJson(PUBLISH_VARIANT_BODY); + Assertions.assertNotNull(publishBody); + injectVariantUidIntoVariantsArray(publishBody, variantUid); + + Request req = entry.publishEntryVariants(publishBody).request(); + Assertions.assertEquals("POST", req.method()); + Assertions.assertTrue(req.url().isHttps()); + Assertions.assertEquals( + "/v3/content_types/" + contentTypeUid + "/entries/" + entryUid + "/publish", + req.url().encodedPath()); + Assertions.assertEquals("locale=" + TestClient.ENTRY_VARIANT_LOCALE, req.url().encodedQuery()); + Assertions.assertEquals(Util.API_VERSION_ENTRY_VARIANTS_PUBLISH, req.header(Util.API_VERSION)); + } + + @Test + @Order(6) + void unpublishEntryVariants_requestShape() { + Entry entry = freshEntry(); + entry.clearParams(); + entry.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + + JSONObject unpublishBody = Utils.readJson(UNPUBLISH_VARIANT_BODY); + Assertions.assertNotNull(unpublishBody); + injectVariantUidIntoVariantsArray(unpublishBody, variantUid); + + Request req = entry.unpublishEntryVariants(unpublishBody).request(); + Assertions.assertEquals("POST", req.method()); + Assertions.assertEquals( + "/v3/content_types/" + contentTypeUid + "/entries/" + entryUid + "/unpublish", + req.url().encodedPath()); + Assertions.assertEquals("locale=" + TestClient.ENTRY_VARIANT_LOCALE, req.url().encodedQuery()); + Assertions.assertEquals(Util.API_VERSION_ENTRY_VARIANTS_PUBLISH, req.header(Util.API_VERSION)); + } + + @Test + @Order(7) + void fetchSingleEntryVariant_implicitVsExplicitBranchHeader() { + Entry plain = freshEntry(); + plain.clearParams(); + plain.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + + Request implicit = plain.fetchEntryVariant(variantUid).request(); + Assertions.assertEquals(stackBranch, implicit.header(Util.BRANCH)); + + Entry withPerCall = freshEntry(); + withPerCall.clearParams(); + withPerCall.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + Request explicit = withPerCall.fetchEntryVariant(variantUid, stackBranch).request(); + Assertions.assertEquals(stackBranch, explicit.header(Util.BRANCH)); + Assertions.assertEquals(implicit.url(), explicit.url()); + } + + @Test + @Order(8) + void fetchSingleEntryVariant_afterAddBranch() { + Entry entry = freshEntry(); + entry.clearParams(); + entry.addBranch(stackBranch); + entry.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + + Request req = entry.fetchEntryVariant(variantUid).request(); + Assertions.assertEquals(stackBranch, req.header(Util.BRANCH)); + Assertions.assertEquals(variantPathSuffix(), req.url().encodedPath()); + } + + @Test + @Order(9) + void fetchSingleEntryVariant_withPerCallBranchOverload() { + Entry entry = freshEntry(); + entry.clearParams(); + entry.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + + Request req = entry.fetchEntryVariant(variantUid, stackBranch).request(); + Assertions.assertEquals(stackBranch, req.header(Util.BRANCH)); + Assertions.assertEquals("GET", req.method()); + Assertions.assertEquals(variantPathSuffix(), req.url().encodedPath()); + } + + @Test + @Order(10) + void deleteEntryVariant_requestShape() { + Entry entry = freshEntry(); + entry.clearParams(); + entry.addParam("locale", TestClient.ENTRY_VARIANT_LOCALE); + + Request req = entry.deleteEntryVariant(variantUid).request(); + Assertions.assertEquals("DELETE", req.method()); + Assertions.assertEquals(variantPathSuffix(), req.url().encodedPath()); + Assertions.assertEquals("locale=" + TestClient.ENTRY_VARIANT_LOCALE, req.url().encodedQuery()); + } +} diff --git a/src/test/java/com/contentstack/cms/stack/EntryVariantUnitTest.java b/src/test/java/com/contentstack/cms/stack/EntryVariantUnitTest.java new file mode 100644 index 00000000..acb67fe5 --- /dev/null +++ b/src/test/java/com/contentstack/cms/stack/EntryVariantUnitTest.java @@ -0,0 +1,235 @@ +package com.contentstack.cms.stack; + +import com.contentstack.cms.Contentstack; +import com.contentstack.cms.TestClient; +import com.contentstack.cms.Utils; +import com.contentstack.cms.core.Util; +import okhttp3.Request; +import org.json.simple.JSONObject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Request-shape checks for entry variants (no HTTP — safe without dev18 / prod variant APIs). + */ +@Tag("unit") +public class EntryVariantUnitTest { + + private static final String CREATE_VARIANT_BODY = "entry_variant/create_entry_variant.json"; + private static final String UPDATE_VARIANT_BODY = "entry_variant/update_entry_variant.json"; + + private static Entry entry; + + @BeforeAll + static void setup() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN); + entry = stack.contentType("blog").entry("entryUid123"); + } + + @Test + void createEntryVariant_requestIsPutWithVariantSegment() { + entry.clearParams(); + entry.addParam("locale", "en-us"); + JSONObject body = Utils.readJson(CREATE_VARIANT_BODY); + Assertions.assertNotNull(body, "Missing " + CREATE_VARIANT_BODY); + + Request req = entry.createEntryVariant("variantUid456", body).request(); + Assertions.assertEquals("PUT", req.method()); + Assertions.assertEquals( + "/v3/content_types/blog/entries/entryUid123/variants/variantUid456", + req.url().encodedPath()); + Assertions.assertEquals("locale=en-us", req.url().encodedQuery()); + } + + @Test + void updateEntryVariant_samePathAndMethodAsCreate() { + entry.clearParams(); + JSONObject createBody = Utils.readJson(CREATE_VARIANT_BODY); + JSONObject updateBody = Utils.readJson(UPDATE_VARIANT_BODY); + Assertions.assertNotNull(createBody, "Missing " + CREATE_VARIANT_BODY); + Assertions.assertNotNull(updateBody, "Missing " + UPDATE_VARIANT_BODY); + + Request createReq = entry.createEntryVariant("sameUid", createBody).request(); + Request updateReq = entry.updateEntryVariant("sameUid", updateBody).request(); + + Assertions.assertEquals(createReq.method(), updateReq.method()); + Assertions.assertEquals(createReq.url(), updateReq.url()); + } + + @Test + void fetchEntryVariants_requestIsGetOnVariantsCollection() { + entry.clearParams(); + Request req = entry.fetchEntryVariants().request(); + Assertions.assertEquals("GET", req.method()); + Assertions.assertEquals( + "/v3/content_types/blog/entries/entryUid123/variants", + req.url().encodedPath()); + } + + @Test + void fetchEntryVariant_requestIsGetOnSingleVariant() { + entry.clearParams(); + Request req = entry.fetchEntryVariant("v1").request(); + Assertions.assertEquals( + "/v3/content_types/blog/entries/entryUid123/variants/v1", + req.url().encodedPath()); + } + + @Test + void publishEntryVariants_addsApiVersionHeaderWhenNotAlreadySet() { + entry.clearParams(); + JSONObject body = new JSONObject(); + Request req = entry.publishEntryVariants(body).request(); + Assertions.assertEquals(Util.API_VERSION_ENTRY_VARIANTS_PUBLISH, req.header(Util.API_VERSION)); + } + + @Test + void unpublishEntryVariants_addsApiVersionHeaderWhenNotAlreadySet() { + entry.clearParams(); + JSONObject body = new JSONObject(); + Request req = entry.unpublishEntryVariants(body).request(); + Assertions.assertEquals(Util.API_VERSION_ENTRY_VARIANTS_PUBLISH, req.header(Util.API_VERSION)); + } + + @Test + void deleteEntryVariant_requestIsDeleteOnVariantPath() { + entry.clearParams(); + Request req = entry.deleteEntryVariant("toRemove").request(); + Assertions.assertEquals("DELETE", req.method()); + Assertions.assertEquals( + "/v3/content_types/blog/entries/entryUid123/variants/toRemove", + req.url().encodedPath()); + } + + @Test + void variantOperations_forwardStackBranchFromThreeArgStack() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN, "feature-branch"); + Entry e = stack.contentType("blog").entry("entryUid123"); + e.clearParams(); + JSONObject body = Utils.readJson(CREATE_VARIANT_BODY); + Assertions.assertNotNull(body, "Missing " + CREATE_VARIANT_BODY); + + Assertions.assertEquals( + "feature-branch", + e.createEntryVariant("variantUid456", body).request().header(Util.BRANCH)); + Assertions.assertEquals( + "feature-branch", + e.fetchEntryVariants().request().header(Util.BRANCH)); + Assertions.assertEquals( + "feature-branch", + e.fetchEntryVariant("v1").request().header(Util.BRANCH)); + Assertions.assertEquals( + "feature-branch", + e.publishEntryVariants(new JSONObject()).request().header(Util.BRANCH)); + Assertions.assertEquals( + "feature-branch", + e.unpublishEntryVariants(new JSONObject()).request().header(Util.BRANCH)); + } + + @Test + void fetchEntryVariant_branchArgumentOverridesStackForThatCallOnly() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN, "stack-branch"); + Entry e = stack.contentType("blog").entry("entryUid123"); + e.clearParams(); + + Request overridden = e.fetchEntryVariant("v1", "call-branch").request(); + Assertions.assertEquals("call-branch", overridden.header(Util.BRANCH)); + + Request defaultBranch = e.fetchEntryVariant("v1").request(); + Assertions.assertEquals("stack-branch", defaultBranch.header(Util.BRANCH)); + + Assertions.assertEquals("stack-branch", e.fetchEntryVariant("v1", null).request().header(Util.BRANCH)); + } + + @Test + void fetchEntryVariant_branchArgumentOverridesEntryLevelBranch() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN, "stack-branch"); + Entry e = stack.contentType("blog").entry("entryUid123").addBranch("entry-branch"); + e.clearParams(); + + Assertions.assertEquals( + "per-call-branch", + e.fetchEntryVariant("v1", "per-call-branch").request().header(Util.BRANCH)); + Assertions.assertEquals( + "entry-branch", + e.fetchEntryVariant("v1").request().header(Util.BRANCH)); + } + + @Test + void variantOperations_entryAddBranchOverridesStackBranch() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN, "stack-branch"); + Entry e = stack.contentType("blog").entry("entryUid123").addBranch("override-branch"); + e.clearParams(); + JSONObject body = Utils.readJson(CREATE_VARIANT_BODY); + Assertions.assertNotNull(body, "Missing " + CREATE_VARIANT_BODY); + + Request req = e.createEntryVariant("variantUid456", body).request(); + Assertions.assertEquals("override-branch", req.header(Util.BRANCH)); + } + + @Test + void deleteEntryVariant_forwardsStackBranchHeader() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN, "branch-for-delete"); + Entry e = stack.contentType("blog").entry("entryUid123"); + e.clearParams(); + Assertions.assertEquals( + "branch-for-delete", + e.deleteEntryVariant("toRemove").request().header(Util.BRANCH)); + } + + @Test + void updateEntryVariant_forwardsStackBranchHeader() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN, "branch-for-update"); + Entry e = stack.contentType("blog").entry("entryUid123"); + e.clearParams(); + JSONObject body = Utils.readJson(UPDATE_VARIANT_BODY); + Assertions.assertNotNull(body, "Missing " + UPDATE_VARIANT_BODY); + Assertions.assertEquals( + "branch-for-update", + e.updateEntryVariant("uid", body).request().header(Util.BRANCH)); + } + + @Test + void addHeaderBranch_matchesAddBranchForVariantRequests() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN); + Entry viaAddHeader = stack.contentType("blog").entry("e1").addHeader(Util.BRANCH, "hdr-branch"); + Entry viaAddBranch = stack.contentType("blog").entry("e2").addBranch("hdr-branch"); + viaAddHeader.clearParams(); + viaAddBranch.clearParams(); + JSONObject body = Utils.readJson(CREATE_VARIANT_BODY); + Assertions.assertNotNull(body, "Missing " + CREATE_VARIANT_BODY); + Assertions.assertEquals( + viaAddBranch.createEntryVariant("v", body).request().header(Util.BRANCH), + viaAddHeader.createEntryVariant("v", body).request().header(Util.BRANCH)); + } + + @Test + void fetchEntryVariant_blankSecondArgUsesStackBranchLikeNull() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN, "stack-branch"); + Entry e = stack.contentType("blog").entry("entryUid123"); + e.clearParams(); + Assertions.assertEquals( + "stack-branch", + e.fetchEntryVariant("v1", "").request().header(Util.BRANCH)); + } + + @Test + void variantOperations_noStackBranch_omitsBranchHeaderWhenNotSetOnEntry() { + Contentstack cms = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build(); + Stack stack = cms.stack(TestClient.API_KEY, TestClient.MANAGEMENT_TOKEN); + Entry e = stack.contentType("blog").entry("entryUid123"); + e.clearParams(); + Assertions.assertNull(e.fetchEntryVariant("v1").request().header(Util.BRANCH)); + } +} diff --git a/src/test/java/com/contentstack/cms/stack/TaxonomyAPITest.java b/src/test/java/com/contentstack/cms/stack/TaxonomyAPITest.java index c1d61f3c..a8d762dd 100644 --- a/src/test/java/com/contentstack/cms/stack/TaxonomyAPITest.java +++ b/src/test/java/com/contentstack/cms/stack/TaxonomyAPITest.java @@ -215,6 +215,96 @@ void descendantsTerm(){ Assertions.assertNull(request.body()); Assertions.assertEquals("include_count=true", request.url().encodedQuery()); } + @Test + void publishTaxonomy() { + JSONObject publishBody = Utils.readJson("mocktaxonomy/publish.json"); + Request request = taxonomy.publish(publishBody).request(); + Assertions.assertEquals("POST", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(3, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals("publish", request.url().pathSegments().get(2)); + Assertions.assertNotNull(request.body()); + } + + @Test + void unpublishTaxonomy() { + JSONObject unpublishBody = Utils.readJson("mocktaxonomy/publish.json"); + Request request = taxonomy.unpublish(unpublishBody).request(); + Assertions.assertEquals("POST", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(3, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals("unpublish", request.url().pathSegments().get(2)); + Assertions.assertNotNull(request.body()); + } + + @Test + void localizeTaxonomy() { + Taxonomy uidTaxonomy = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build() + .stack(API_KEY, MANAGEMENT_TOKEN).taxonomy(_uid); + JSONObject localizeBody = Utils.readJson("mocktaxonomy/localize.json"); + Request request = uidTaxonomy.localize(localizeBody, "hi-in").request(); + Assertions.assertEquals("POST", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(3, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals(_uid, request.url().pathSegments().get(2)); + Assertions.assertEquals("locale=hi-in", request.url().encodedQuery()); + Assertions.assertNotNull(request.body()); + } + + @Test + void unlocalizeTaxonomy() { + Taxonomy uidTaxonomy = new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build() + .stack(API_KEY, MANAGEMENT_TOKEN).taxonomy(_uid); + Request request = uidTaxonomy.unlocalize("hi-in").request(); + Assertions.assertEquals("DELETE", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(3, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals(_uid, request.url().pathSegments().get(2)); + Assertions.assertEquals("locale=hi-in", request.url().encodedQuery()); + Assertions.assertNull(request.body()); + } + + @Test + void localizeTerm() throws IOException { + terms.clearParams(); + JSONObject localizeBody = Utils.readJson("mocktaxonomy/localizeTerm.json"); + Request request = terms.localize("india", localizeBody, "hi-in").request(); + Assertions.assertEquals("POST", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(5, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals(_uid, request.url().pathSegments().get(2)); + Assertions.assertEquals("terms", request.url().pathSegments().get(3)); + Assertions.assertEquals("india", request.url().pathSegments().get(4)); + Assertions.assertEquals("locale=hi-in", request.url().encodedQuery()); + Assertions.assertNotNull(request.body()); + } + + @Test + void unlocalizeTerm() { + terms.clearParams(); + Request request = terms.unlocalize("india", "hi-in").request(); + Assertions.assertEquals("DELETE", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(5, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals(_uid, request.url().pathSegments().get(2)); + Assertions.assertEquals("terms", request.url().pathSegments().get(3)); + Assertions.assertEquals("india", request.url().pathSegments().get(4)); + Assertions.assertEquals("locale=hi-in", request.url().encodedQuery()); + Assertions.assertNull(request.body()); + } + @Test void moveTerms(){ terms.clearParams(); diff --git a/src/test/java/com/contentstack/cms/stack/TaxonomyTest.java b/src/test/java/com/contentstack/cms/stack/TaxonomyTest.java index 693a7db6..d793ccbf 100644 --- a/src/test/java/com/contentstack/cms/stack/TaxonomyTest.java +++ b/src/test/java/com/contentstack/cms/stack/TaxonomyTest.java @@ -2,6 +2,7 @@ import com.contentstack.cms.Contentstack; import com.contentstack.cms.TestClient; +import com.contentstack.cms.core.ErrorMessages; import com.contentstack.cms.core.Util; import okhttp3.Request; import okhttp3.ResponseBody; @@ -12,11 +13,12 @@ import retrofit2.Response; import java.io.IOException; +import java.util.Collections; import java.util.HashMap; @Tag("unit") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class TaxonomyTest { +public class TaxonomyTest { protected static String API_KEY = TestClient.API_KEY; protected static String _uid = TestClient.AUTHTOKEN; @@ -277,6 +279,43 @@ void testAncestorsTerm() { Assertions.assertEquals("include_children_count=false&include_referenced_entries_count=true", request.url().encodedQuery()); } + @Test + void testLocalizeTerm() { + terms.clearParams(); + JSONObject termDetails = new JSONObject(); + termDetails.put("uid", "term_1"); + termDetails.put("name", "Term 1 Hindi"); + JSONObject term = new JSONObject(); + term.put("term", termDetails); + Request request = terms.localize("term_1", term, "hi-in").request(); + Assertions.assertEquals("POST", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(5, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals(_uid, request.url().pathSegments().get(2)); + Assertions.assertEquals("terms", request.url().pathSegments().get(3)); + Assertions.assertEquals("term_1", request.url().pathSegments().get(4)); + Assertions.assertEquals("locale=hi-in", request.url().encodedQuery()); + Assertions.assertNotNull(request.body()); + } + + @Test + void testUnlocalizeTerm() { + terms.clearParams(); + Request request = terms.unlocalize("term_1", "hi-in").request(); + Assertions.assertEquals("DELETE", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(5, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals(_uid, request.url().pathSegments().get(2)); + Assertions.assertEquals("terms", request.url().pathSegments().get(3)); + Assertions.assertEquals("term_1", request.url().pathSegments().get(4)); + Assertions.assertEquals("locale=hi-in", request.url().encodedQuery()); + Assertions.assertNull(request.body()); + } + @Test void findTestAPI() throws IOException { Taxonomy taxonomy = new Contentstack.Builder() @@ -289,6 +328,113 @@ void findTestAPI() throws IOException { System.out.println(response); } + + private static Taxonomy newTaxonomy() { + HashMap headers = new HashMap<>(); + headers.put(Util.API_KEY, API_KEY); + headers.put(Util.AUTHORIZATION, MANAGEMENT_TOKEN); + return new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build().stack(headers).taxonomy(); + } + + private static Taxonomy newTaxonomy(String uid) { + HashMap headers = new HashMap<>(); + headers.put(Util.API_KEY, API_KEY); + headers.put(Util.AUTHORIZATION, MANAGEMENT_TOKEN); + return new Contentstack.Builder().setAuthtoken(TestClient.AUTHTOKEN).build().stack(headers).taxonomy(uid); + } + + @Test + void publishTest() { + JSONObject publishBody = new JSONObject(); + publishBody.put("locales", Collections.singletonList("en-us")); + publishBody.put("environments", Collections.singletonList("production")); + Request request = taxonomy.publish(publishBody).request(); + Assertions.assertEquals("POST", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(3, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals("publish", request.url().pathSegments().get(2)); + Assertions.assertNotNull(request.body()); + } + + @Test + void publishTestWithBranchHeader() { + Taxonomy localTaxonomy = newTaxonomy(); + localTaxonomy.addHeader("branch", "dev"); + JSONObject publishBody = new JSONObject(); + publishBody.put("locales", Collections.singletonList("en-us")); + Request request = localTaxonomy.publish(publishBody).request(); + Assertions.assertEquals("dev", request.header("branch")); + } + + @Test + void unpublishTest() { + JSONObject unpublishBody = new JSONObject(); + unpublishBody.put("locales", Collections.singletonList("en-us")); + unpublishBody.put("environments", Collections.singletonList("production")); + Request request = taxonomy.unpublish(unpublishBody).request(); + Assertions.assertEquals("POST", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(3, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals("unpublish", request.url().pathSegments().get(2)); + Assertions.assertNotNull(request.body()); + } + + @Test + void localizeTest() { + Taxonomy uidTaxonomy = newTaxonomy(_uid); + JSONObject taxonomyDetails = new JSONObject(); + taxonomyDetails.put("uid", _uid); + taxonomyDetails.put("name", "Taxonomy 1"); + JSONObject localizeBody = new JSONObject(); + localizeBody.put("taxonomy", taxonomyDetails); + Request request = uidTaxonomy.localize(localizeBody, "hi-in").request(); + Assertions.assertEquals("POST", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(3, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals(_uid, request.url().pathSegments().get(2)); + Assertions.assertEquals("locale=hi-in", request.url().encodedQuery()); + Assertions.assertNotNull(request.body()); + } + + @Test + void localizeTestWithoutUidThrows() { + JSONObject taxonomyDetails = new JSONObject(); + taxonomyDetails.put("uid", _uid); + taxonomyDetails.put("name", "Taxonomy 1"); + JSONObject localizeBody = new JSONObject(); + localizeBody.put("taxonomy", taxonomyDetails); + NullPointerException exception = Assertions.assertThrows(NullPointerException.class, + () -> taxonomy.localize(localizeBody, "hi-in")); + Assertions.assertEquals(ErrorMessages.TAXONOMY_UID_REQUIRED, exception.getMessage()); + } + + @Test + void unlocalizeTest() { + Taxonomy uidTaxonomy = newTaxonomy(_uid); + Request request = uidTaxonomy.unlocalize("hi-in").request(); + Assertions.assertEquals("DELETE", request.method()); + Assertions.assertTrue(request.url().isHttps()); + Assertions.assertEquals(3, request.url().pathSegments().size()); + Assertions.assertEquals("v3", request.url().pathSegments().get(0)); + Assertions.assertEquals("taxonomies", request.url().pathSegments().get(1)); + Assertions.assertEquals(_uid, request.url().pathSegments().get(2)); + Assertions.assertEquals("locale=hi-in", request.url().encodedQuery()); + Assertions.assertNull(request.body()); + } + + @Test + void unlocalizeTestWithoutUidThrows() { + NullPointerException exception = Assertions.assertThrows(NullPointerException.class, + () -> taxonomy.unlocalize("hi-in")); + Assertions.assertEquals(ErrorMessages.TAXONOMY_UID_REQUIRED, exception.getMessage()); + } + @Test void queryFiltersOnTaxonomy() { Taxonomy taxonomy = new Contentstack.Builder() diff --git a/src/test/resources/entry_variant/create_entry_variant.json b/src/test/resources/entry_variant/create_entry_variant.json new file mode 100644 index 00000000..f8745dad --- /dev/null +++ b/src/test/resources/entry_variant/create_entry_variant.json @@ -0,0 +1,15 @@ +{ + "entry": { + "title": "Blue|v1", + "url": "/blue", + "single_line": "blue variant v1", + "group": [ + { + "single_line": "blue variant group 1" + }, + { + "single_line": "blue variant group 2" + } + ] + } +} diff --git a/src/test/resources/entry_variant/publish_entry_variant.json b/src/test/resources/entry_variant/publish_entry_variant.json new file mode 100644 index 00000000..d3680a24 --- /dev/null +++ b/src/test/resources/entry_variant/publish_entry_variant.json @@ -0,0 +1,16 @@ +{ + "entry": { + "environments": ["development"], + "locales": ["en-us"], + "variants": [ + { + "version": 1 + } + ], + "variant_rules": { + "publish_latest_base": false, + "publish_latest_base_conditionally": true + } + }, + "locale": "en-us" +} diff --git a/src/test/resources/entry_variant/unpublish_entry_variant.json b/src/test/resources/entry_variant/unpublish_entry_variant.json new file mode 100644 index 00000000..daa27068 --- /dev/null +++ b/src/test/resources/entry_variant/unpublish_entry_variant.json @@ -0,0 +1,12 @@ +{ + "entry": { + "environments": ["development"], + "locales": ["en-us"], + "variants": [ + { + "version": 1 + } + ] + }, + "locale": "en-us" +} diff --git a/src/test/resources/entry_variant/update_entry_variant.json b/src/test/resources/entry_variant/update_entry_variant.json new file mode 100644 index 00000000..412cea49 --- /dev/null +++ b/src/test/resources/entry_variant/update_entry_variant.json @@ -0,0 +1,17 @@ +{ + "entry": { + "title": "blue |v2", + "url": "/blue", + "single_line": "blue variant v2 ", + "group": [ + { + "single_line": "Variant 2", + "multi_line": "Variant 2 Multi" + }, + { + "single_line": "Variant 1", + "multi_line": "Variant 1 Multi" + } + ] + } +} diff --git a/src/test/resources/mocktaxonomy/localize.json b/src/test/resources/mocktaxonomy/localize.json new file mode 100644 index 00000000..18ae725d --- /dev/null +++ b/src/test/resources/mocktaxonomy/localize.json @@ -0,0 +1,7 @@ +{ + "taxonomy": { + "uid": "sample_one", + "name": "Sample One in Hindi", + "description": "Description for the sample one taxonomy in Hindi." + } + } \ No newline at end of file diff --git a/src/test/resources/mocktaxonomy/localizeTerm.json b/src/test/resources/mocktaxonomy/localizeTerm.json new file mode 100644 index 00000000..b2d516c9 --- /dev/null +++ b/src/test/resources/mocktaxonomy/localizeTerm.json @@ -0,0 +1,8 @@ +{ + "term": { + "uid": "india", + "name": "India in Hindi", + "parent_uid": null, + "order": 1 + } +} \ No newline at end of file diff --git a/src/test/resources/mocktaxonomy/publish.json b/src/test/resources/mocktaxonomy/publish.json new file mode 100644 index 00000000..931580a1 --- /dev/null +++ b/src/test/resources/mocktaxonomy/publish.json @@ -0,0 +1,6 @@ + +{ + "locales": ["en-us"], + "environments": ["production"], + "items": [{ "uid": "sample_one" }] + } \ No newline at end of file