diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..60b78f6 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,37 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Lint + # Scoped to the files this plan adds or touches. The pre-existing + # gitlab/ and asset-repo/ scripts are NOT clean under shellcheck; fixing + # them is out of scope here and would bury this change in noise. + # + # SC1091: shellcheck cannot follow `source "$CURRENT_DIR/_api"`. + # SC2154: the workflow engine supplies CODE_REPOSITORY_STRATEGY, + # REPOSITORY_NAME, APPLICATION_ID, ... from the environment, so + # they are legitimately "referenced but not assigned" here. + run: | + shellcheck --shell=bash -e SC1091 -e SC2154 \ + scripts/code-repo/bitbucket/* \ + scripts/code-repo/generate_secrets \ + tests/run \ + tests/lib.sh \ + tests/stubs/* + + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run script tests + run: tests/run diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd6465..c2decef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to `application-lifecycle-manager` will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches a stable API. +## [0.3.0] - 2026-07-13 + +### Added +- Bitbucket Cloud code repository support (`scripts/code-repo/bitbucket`), authenticated with a + dedicated bot user's Atlassian API token over HTTP Basic (the only credential that can enable + Bitbucket Pipelines). +- A `NULLPLATFORM_API_KEY` pipeline secret, alongside the existing `NP_API_KEY`, so the shared + technology templates work under either provisioning path. +- An offline test harness (`tests/run`) and CI running `shellcheck` plus those tests. + + ## [0.2.0] - 2025-11-13 ### Added diff --git a/README.md b/README.md index 997fde3..9ef8a01 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,51 @@ When an application is created, this service coordinates two main tasks: You must configure your code repository provider through **nullplatform platform settings** or the **nullplatform Terraform provider**. -> **Note:** At the moment, this repository supports **GitLab** repositories only. +> **Note:** This repository supports **GitLab** and **Bitbucket Cloud** repositories. + +#### Bitbucket Cloud + +Configure a `bitbucket-configuration` code-repository provider with: + +| Attribute (provider `setup`) | NRN key | Required | Source | Notes | +|---|---|---|---|---| +| `workspace` | `bitbucket.workspace` | yes | provider or env | The Bitbucket workspace slug. | +| `project_key` | `bitbucket.projectKey` | yes | provider or env | The key of the project new repositories are filed under. **Not optional**: omit it and Bitbucket silently assigns the repository to the workspace's oldest project. | +| `email` | `bitbucket.email` | yes | provider or env | The Atlassian account email of the dedicated Bitbucket **bot user**. HTTP Basic username for the API token. | +| `api_token` | `bitbucket.apiToken` | yes | **`BITBUCKET_API_TOKEN` env only** | The bot user's **Atlassian API token** — HTTP Basic password for the REST API and, with the git username `x-bitbucket-api-token-auth`, for git-over-HTTPS. See "credential sourcing" below. | +| `installation_url` | `bitbucket.installationUrl` | no | provider or env | Defaults to `https://bitbucket.org`. | +| `access.default_collaborators` | — | no | provider | See the limitation below. | + +> **Authentication is a dedicated bot user's Atlassian API token.** nullplatform must hold a +> **user-scoped** credential because enabling Bitbucket Pipelines requires a two-step-verification +> (2SV) enabled *user* principal — an OAuth app (2LO) is refused there with a permanent `403`, and a +> workspace access token fails the same check. So: create a dedicated Bitbucket bot user, **enable +> Bitbucket 2SV on it** (this is Bitbucket 2SV, *not* Atlassian-account 2FA), grant it repository / +> pipeline / project admin on the workspace, and issue it an Atlassian API token. This works on +> **every** Bitbucket plan (no Premium required). Atlassian API tokens expire in ≤365 days, so rotate +> it before then. **Bitbucket app passwords are not supported** (Atlassian removed them on 2026-07-28). + +> **Credential sourcing — this is where Bitbucket differs from GitLab.** The API token is stored as a +> proper **secret** (the `bitbucket-configuration` spec marks `api_token` `secret: true`), and +> nullplatform **does not return secret attribute values on authenticated provider reads**. So `np +> provider list` — which ALM uses to load provider config — returns `api_token: null`; the platform +> read *cannot* hand ALM the token. (The GitLab provider only works from the platform read because its +> spec never marked `access_token` secret — a laxity not repeated here.) **Therefore the token's +> primary, documented source is the `BITBUCKET_API_TOKEN` environment variable set on the ALM +> deployment** (`build_context` also honours `BITBUCKET_EMAIL`, `BITBUCKET_WORKSPACE`, +> `BITBUCKET_PROJECT_KEY`, `BITBUCKET_INSTALLATION_URL`). The non-secret fields above are still read +> from the provider record. If the token is absent from both env and record, provisioning fails +> immediately with a message explaining exactly this. *(`np nrn read` can address raw NRN keys but is +> deprecated and is not relied upon here.)* + +**Limitation — collaborators must already be workspace members.** Bitbucket has no API to +invite a user to a workspace, so `add_collaborators` can only *grant repository permissions* to +principals who are already members. If a configured collaborator is not a member, repository +creation fails with an error naming them; a workspace administrator must invite them first. + +**Limitation — no template import API.** Bitbucket Cloud cannot import a repository from a URL, +and forking is no longer usable. Templates are therefore seeded with a real `git clone` + `git +push`, squashed to a single initial commit. **`git` must be available on the agent host.** #### Workflow diff --git a/scripts/code-repo/bitbucket/_api b/scripts/code-repo/bitbucket/_api new file mode 100644 index 0000000..7cadd01 --- /dev/null +++ b/scripts/code-repo/bitbucket/_api @@ -0,0 +1,119 @@ +#!/bin/bash +# +# Shared Bitbucket Cloud REST helpers. Sourced by the step scripts in this +# directory; the leading underscore marks it as "not a workflow step". +# +# IMPORTANT -- every script in this repository is SOURCED by +# `np service workflow exec`, not executed. (Proof: gitlab/create_repository +# uses a bare top-level `return`, and none of these files carry an executable +# bit.) Consequences: +# +# * NEVER `set -euo pipefail` here. It would leak into the workflow shell and +# silently change the behaviour of every later step. +# * `exit 1` aborts the sourcing shell -- that IS how a step fails the +# workflow, and entrypoint then reports "failed" back to nullplatform. +# * `return` skips the rest of a step without failing the workflow. +# +# AUTH: nullplatform authenticates to Bitbucket Cloud with a single dedicated +# bot user's Atlassian API token, using HTTP Basic (email:api_token). This is the +# ONLY credential that can enable Bitbucket Pipelines -- an OAuth app (2LO) is +# refused there with a permanent 403, and a workspace access token fails the same +# 2SV check (both validated live). build_context resolves the email + token and +# exports them; every REST call here sends them as Basic auth, and git transport +# uses the username `x-bitbucket-api-token-auth` with the token as the password. + +# bb_api METHOD PATH [BODY] +# +# Calls the Bitbucket REST API and sets BB_HTTP_CODE and BB_BODY. It never +# fails: every caller branches on the status code, because for Bitbucket the +# code is the meaning (404 = "free" on one endpoint, "never enabled" on +# another; 409 = "exists"; 401 != "missing"). +bb_api() { + local method="$1" + local path="$2" + local body="${3:-}" + + local tmp + tmp=$(mktemp) + + local args=( + -s + -o "$tmp" + -w '%{http_code}' + -X "$method" + -u "${BITBUCKET_EMAIL}:${BITBUCKET_API_TOKEN}" + -H "Content-Type: application/json" + ) + + if [[ -n "$body" ]]; then + args+=(--data "$body") + fi + + # BB_HTTP_CODE and BB_BODY are the outputs of bb_api: they are read by the + # step scripts that source this file, so they are not unused here. + # shellcheck disable=SC2034 + BB_HTTP_CODE=$(curl "${args[@]}" "${BITBUCKET_API_BASE}${path}") + # shellcheck disable=SC2034 + BB_BODY=$(cat "$tmp") + + rm -f "$tmp" +} + +# bb_encode_uuid VALUE +# +# Bitbucket returns UUIDs with LITERAL braces -- {9484702e-...} -- and they must +# be percent-encoded before use as a path parameter. +bb_encode_uuid() { + local value="$1" + + value="${value//\{/%7B}" + value="${value//\}/%7D}" + + printf '%s' "$value" +} + +# bb_scrub +# +# stdin filter that redacts the API token. git prints credentialed remote URLs +# verbatim on failure, so every byte of git output goes through this. (The bot +# email is the Basic-auth username and the git URL's userinfo is the fixed +# literal `x-bitbucket-api-token-auth`; the token is the only secret.) +bb_scrub() { + if [[ -z "${BITBUCKET_API_TOKEN:-}" ]]; then + cat + return + fi + + local escaped + # Escape regex metacharacters (including /) so any token value is literal. + escaped=$(printf '%s' "$BITBUCKET_API_TOKEN" | sed -e 's/[]\/$*.^[]/\\&/g') + + sed -e "s/${escaped}/***/g" +} + +# bb_git ARGS... +# +# Runs git with the terminal prompt disabled, scrubbing the token out of its +# output, and returns GIT's exit status -- not sed's. (A naive +# `git ... | bb_scrub` would always look successful, because a pipeline's status +# is its LAST command. We cannot use `set -o pipefail` to fix that: see the +# header.) +bb_git() { + local output + local status + + output=$(GIT_TERMINAL_PROMPT=0 git "$@" 2>&1) + status=$? + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" | bb_scrub + fi + + return $status +} + +# bb_fail MESSAGE... +bb_fail() { + echo "Error: $*" >&2 + exit 1 +} diff --git a/scripts/code-repo/bitbucket/add_collaborators b/scripts/code-repo/bitbucket/add_collaborators new file mode 100644 index 0000000..dd9d027 --- /dev/null +++ b/scripts/code-repo/bitbucket/add_collaborators @@ -0,0 +1,88 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +# Bitbucket repository permissions are read | write | admin. Accept those +# directly, and map the GitLab role names the platform already stores. +get_permission() { + local role="$1" + + case "$role" in + read | guest | reporter) echo "read" ;; + write | developer | maintainer) echo "write" ;; + admin | owner) echo "admin" ;; + *) echo "write" ;; # mirrors the GitLab provider's default of DEVELOPER + esac +} + +MISSING_MEMBERS=() +FAILED=0 + +# Process substitution, NOT a pipe. `... | while read` would run the loop body in +# a SUBSHELL, where FAILED and MISSING_MEMBERS would be discarded and an `exit 1` +# would kill only the subshell -- which is exactly why the GitLab equivalents of +# this script and of create_secrets can never actually fail the workflow. +while read -r collaborator; do + [[ -z "$collaborator" ]] && continue + + id=$(echo "$collaborator" | jq -r '.id') + role=$(echo "$collaborator" | jq -r '.role') + type=$(echo "$collaborator" | jq -r '.type') + + permission=$(get_permission "$role") + body=$(jq -nc --arg permission "$permission" '{permission: $permission}') + + if [[ "$type" == "user" ]]; then + # Users are addressed by account_id or UUID (usernames were removed for + # GDPR). UUIDs arrive with literal braces and must be percent-encoded. + encoded=$(bb_encode_uuid "$id") + + echo "Granting '$permission' on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} to user '$id'" + + bb_api PUT "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/permissions-config/users/${encoded}" "$body" + elif [[ "$type" == "group" ]]; then + echo "Granting '$permission' on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} to group '$id'" + + bb_api PUT "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/permissions-config/groups/${id}" "$body" + else + echo "Error: unknown collaborator type '$type' for '$id'" + FAILED=1 + continue + fi + + case "$BB_HTTP_CODE" in + 200 | 201 | 204) + echo "Granted '$permission' to '$id'" + ;; + 404) + # ADR-3. repository:admin can only grant permissions to principals that + # are ALREADY workspace members, and Bitbucket has no API to invite one. + # Collect these and report them together, by name, below. + MISSING_MEMBERS+=("$type '$id'") + FAILED=1 + ;; + *) + echo "Error granting '$permission' to '$id' (HTTP $BB_HTTP_CODE): $BB_BODY" + FAILED=1 + ;; + esac +done < <(echo "$CODE_REPOSITORY_COLLABORATORS" | jq -c '.[]?' 2>/dev/null) + +if [[ ${#MISSING_MEMBERS[@]} -gt 0 ]]; then + echo "Error: the following collaborators are not members of the '$BITBUCKET_WORKSPACE' Bitbucket workspace:" >&2 + + for member in "${MISSING_MEMBERS[@]}"; do + echo " - $member" >&2 + done + + echo "Bitbucket has no API to invite a user to a workspace, so nullplatform cannot add them for you. A workspace administrator must invite them at ${BITBUCKET_INSTALLATION_URL}/${BITBUCKET_WORKSPACE}/workspace/settings/members first. The repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} has already been created: once they are members, re-run this application creation with the 'import' strategy." >&2 +fi + +if [[ "$FAILED" -ne 0 ]]; then + exit 1 +fi + +echo "Finished adding collaborators" diff --git a/scripts/code-repo/bitbucket/build_context b/scripts/code-repo/bitbucket/build_context new file mode 100644 index 0000000..f6e3b26 --- /dev/null +++ b/scripts/code-repo/bitbucket/build_context @@ -0,0 +1,155 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +# --- workspace ------------------------------------------------------------- + +if [[ -n "${BITBUCKET_WORKSPACE:-}" ]]; then + echo "Using BITBUCKET_WORKSPACE from environment: $BITBUCKET_WORKSPACE" +else + BITBUCKET_WORKSPACE=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.workspace // empty') + echo "No BITBUCKET_WORKSPACE in environment, using value from platform: $BITBUCKET_WORKSPACE" +fi + +if [[ -z "$BITBUCKET_WORKSPACE" ]]; then + bb_fail "BITBUCKET_WORKSPACE is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.workspace / bitbucket.workspace) or in the environment." +fi + +# --- installation URL and API base ----------------------------------------- + +if [[ -n "${BITBUCKET_INSTALLATION_URL:-}" ]]; then + echo "Using BITBUCKET_INSTALLATION_URL from environment: $BITBUCKET_INSTALLATION_URL" +else + # $CODE_REPOSITORY is `np provider list` output, whose attributes use the + # SCHEMA (snake_case) shape -- exactly like the GitLab provider reads + # .attributes.setup.installation_url. (The camelCase forms bitbucket.* are the + # NRN key names only.) + BITBUCKET_INSTALLATION_URL=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.installation_url // empty') + echo "No BITBUCKET_INSTALLATION_URL in environment, using value from platform: $BITBUCKET_INSTALLATION_URL" +fi + +if [[ -z "$BITBUCKET_INSTALLATION_URL" ]]; then + BITBUCKET_INSTALLATION_URL="https://bitbucket.org" + echo "No installation_url configured, defaulting to: $BITBUCKET_INSTALLATION_URL" +fi + +# Strip any trailing slash so the URLs we build never double up. +BITBUCKET_INSTALLATION_URL="${BITBUCKET_INSTALLATION_URL%/}" + +# Bitbucket Cloud serves its REST API from a DIFFERENT host than the web UI. +# Keeping this a separate variable is the seam a future Data Center flavor would +# use (DC serves REST 1.0 from /rest/api/1.0). +if [[ -z "${BITBUCKET_API_BASE:-}" ]]; then + BITBUCKET_API_BASE="https://api.bitbucket.org/2.0" +fi + +BITBUCKET_API_BASE="${BITBUCKET_API_BASE%/}" + +# --- auth ------------------------------------------------------------------ +# +# nullplatform holds ONE credential for Bitbucket: a dedicated bot user's +# Atlassian API token, plus that user's email (the HTTP Basic username). This is +# a deliberate, validated-live decision, NOT app passwords (removed 2026-07-28), +# NOT OAuth 2LO, and NOT a workspace access token: +# +# * Enabling Bitbucket Pipelines requires a 2SV'd USER principal. An OAuth app +# (client_credentials) is refused with a PERMANENT 403 there, and a workspace +# access token -- explicitly "not tied to a user" -- fails the same check. +# Only a user-scoped API token works, on every plan (no Premium needed). +# * The token is long-lived (<=365 days), so there is no mint/refresh dance. +# +# Every REST call uses HTTP Basic (email:api_token); git transport uses the +# username `x-bitbucket-api-token-auth` with the token as the password. +# +# CREDENTIAL SOURCING -- this is where Bitbucket DIFFERS from GitLab. +# The token is a real SECRET: the bitbucket-configuration spec marks api_token +# `secret: true`, and providers-api NULLIFIES secret attribute values on +# authenticated reads (nullifySecrets in ProviderController). So `np provider +# list` returns .attributes.setup.api_token = null -- the platform read CANNOT +# supply the token. (GitLab's flow only works because its spec never marked +# access_token secret, a laxity we deliberately did not copy.) Therefore the +# token's primary, documented source is the BITBUCKET_API_TOKEN environment +# variable set on the ALM deployment. The record path is still read (harmless +# null in production; forward-compatible if a privileged read is ever wired in), +# but env wins. The non-secret fields (workspace, email, project_key, +# installation_url) DO come back from the provider read as normal. + +if [[ -n "${BITBUCKET_EMAIL:-}" ]]; then + echo "Using BITBUCKET_EMAIL from environment: $BITBUCKET_EMAIL" +else + BITBUCKET_EMAIL=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.email // empty') + echo "No BITBUCKET_EMAIL in environment, using value from platform: $BITBUCKET_EMAIL" +fi + +if [[ -z "$BITBUCKET_EMAIL" ]]; then + bb_fail "BITBUCKET_EMAIL is not set. Configure the bot user's Atlassian account email on the bitbucket-configuration provider (.attributes.setup.email / NRN bitbucket.email) or in the environment. It is the HTTP Basic username for the API token." +fi + +if [[ -n "${BITBUCKET_API_TOKEN:-}" ]]; then + echo "Using BITBUCKET_API_TOKEN from environment (hidden)" +else + # This is null in production (secret attributes are nullified on provider + # reads); kept for forward-compatibility and so the failure below is the + # single decision point. + BITBUCKET_API_TOKEN=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.api_token // empty') + if [[ -n "$BITBUCKET_API_TOKEN" ]]; then + echo "Using BITBUCKET_API_TOKEN from the provider record (hidden)" + fi +fi + +if [[ -z "$BITBUCKET_API_TOKEN" ]]; then + bb_fail "BITBUCKET_API_TOKEN is not set. The bot user's Atlassian API token is stored as a SECRET (setup.api_token / NRN bitbucket.apiToken), and nullplatform deliberately does NOT return secret attribute values on authenticated provider reads (np provider list) -- so, unlike the GitLab provider, ALM cannot read the Bitbucket token from the platform. Set it directly on the ALM deployment as the BITBUCKET_API_TOKEN environment variable. It must belong to a 2SV-enabled Bitbucket user -- that is the only principal Bitbucket lets enable Pipelines. (Bitbucket app passwords were removed on 2026-07-28 and are not supported.)" +fi + +# --- project key ----------------------------------------------------------- +# +# Effectively MANDATORY. Omit project.key when creating a repository and +# Bitbucket does not error -- it SILENTLY files the repo under the workspace's +# OLDEST project. Refuse to run rather than mis-file the customer's repository. + +if [[ -n "${BITBUCKET_PROJECT_KEY:-}" ]]; then + echo "Using BITBUCKET_PROJECT_KEY from environment: $BITBUCKET_PROJECT_KEY" +else + BITBUCKET_PROJECT_KEY=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.project_key // empty') + echo "No BITBUCKET_PROJECT_KEY in environment, using value from platform: $BITBUCKET_PROJECT_KEY" +fi + +if [[ -z "$BITBUCKET_PROJECT_KEY" ]]; then + bb_fail "BITBUCKET_PROJECT_KEY is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.project_key / NRN bitbucket.projectKey) or in the environment. It cannot be omitted: Bitbucket would silently assign the new repository to the oldest project in the workspace." +fi + +# --- repository slug ------------------------------------------------------- +# +# REPOSITORY_NAME is `basename "$REPOSITORY_URL"` (see +# scripts/code-repo/create_code_repository), so it may carry a .git suffix and +# arbitrary case. Bitbucket slugifies the name it is given: send "My-Service" +# and the repository is created at .../my-service, so links.html.href would come +# back different from what we asked for -- and Contract A (core-entities matches +# repository_url by EXACT string) would break. Normalise here, once. + +REPOSITORY_SLUG="${REPOSITORY_NAME%.git}" +REPOSITORY_SLUG=$(echo "$REPOSITORY_SLUG" | tr '[:upper:]' '[:lower:]') + +if [[ -z "$REPOSITORY_SLUG" ]]; then + bb_fail "Could not derive a repository slug from REPOSITORY_NAME='$REPOSITORY_NAME'" +fi + +if [[ "$REPOSITORY_SLUG" != "$REPOSITORY_NAME" ]]; then + echo "Normalised repository name '$REPOSITORY_NAME' to Bitbucket slug '$REPOSITORY_SLUG'" +fi + +echo "Bitbucket workspace: $BITBUCKET_WORKSPACE" +echo "Bitbucket project: $BITBUCKET_PROJECT_KEY" +echo "Bitbucket repository: $REPOSITORY_SLUG" +echo "Bitbucket bot user: $BITBUCKET_EMAIL" + +export BITBUCKET_EMAIL +export BITBUCKET_API_TOKEN +export BITBUCKET_WORKSPACE +export BITBUCKET_PROJECT_KEY +export BITBUCKET_INSTALLATION_URL +export BITBUCKET_API_BASE +export REPOSITORY_SLUG diff --git a/scripts/code-repo/bitbucket/create_repository b/scripts/code-repo/bitbucket/create_repository new file mode 100644 index 0000000..18a3b57 --- /dev/null +++ b/scripts/code-repo/bitbucket/create_repository @@ -0,0 +1,156 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +if [[ "$CODE_REPOSITORY_STRATEGY" == "import" ]]; then + echo "Strategy is set to 'import'. Skipping repo creation." + return +fi + +if ! command -v git >/dev/null 2>&1; then + bb_fail "git is required to seed a Bitbucket repository from a template (Bitbucket Cloud has no import API) but it was not found on PATH." +fi + +# --------------------------------------------------------------------------- +# 1. Create the (empty) repository. +# +# project.key is not optional in practice: omit it and Bitbucket does not error, +# it silently files the repository under the workspace's OLDEST project. +# --------------------------------------------------------------------------- + +CREATE_REPO_BODY=$(jq -nc \ + --arg project_key "$BITBUCKET_PROJECT_KEY" \ + '{scm: "git", is_private: true, fork_policy: "no_forks", project: {key: $project_key}}') + +echo "Creating repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} with body: " +echo "$CREATE_REPO_BODY" | jq . + +bb_api POST "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" "$CREATE_REPO_BODY" + +# Bitbucket answers repository creation with 200, not the 201 you would expect. +# 201 is accepted defensively in case that is ever corrected. +if [[ "$BB_HTTP_CODE" != "200" && "$BB_HTTP_CODE" != "201" ]]; then + bb_fail "Error creating repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} (HTTP $BB_HTTP_CODE): $BB_BODY" +fi + +# Contract A: the canonical repository_url is exactly what the API returned in +# links.html.href -- https://bitbucket.org/{workspace}/{slug}, no .git, no +# trailing slash. NEVER synthesise it, and NEVER read links.clone[]: that +# array's https entry carries .git and may embed userinfo, and its order is not +# contractual. +BITBUCKET_REPOSITORY_URL=$(echo "$BB_BODY" | jq -r '.links.html.href // empty') + +if [[ -z "$BITBUCKET_REPOSITORY_URL" ]]; then + bb_fail "Bitbucket did not return links.html.href for the new repository: $BB_BODY" +fi + +# We push the seed commit to 'main' below, which makes it the default branch. +BITBUCKET_MAIN_BRANCH="main" + +echo "Repository created: $BITBUCKET_REPOSITORY_URL" + +# --------------------------------------------------------------------------- +# 2. Seed from the template (ADR-1). +# +# Bitbucket Cloud has NO import-from-URL API, and forking is dead (cross- +# workspace forks were removed on 2026-07-01; same-workspace forks carry an +# undetachable parent link). A real git clone + push is the only full-fidelity +# option -- and here in ALM we are already in bash with git and a working tree, +# so it is also the natural one. +# +# We squash to a single root commit so the customer's repository does not +# inherit nullplatform's template history: clone --depth 1, drop .git, re-init. +# --------------------------------------------------------------------------- + +if [[ -z "${TEMPLATE_URL:-}" || "$TEMPLATE_URL" == "null" ]]; then + echo "No TEMPLATE_URL set (the application has no template_id); leaving $BITBUCKET_REPOSITORY_URL empty." +else + # For an Atlassian API token the git username MUST be the literal string + # 'x-bitbucket-api-token-auth' and the password is the token itself (the + # 'x-token-auth' form is for OAuth/access tokens and would fail auth here). + PUSH_URL="https://x-bitbucket-api-token-auth:${BITBUCKET_API_TOKEN}@bitbucket.org/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}.git" + + CLONE_URL="$TEMPLATE_URL" + + # A template hosted in Bitbucket is private, so cloning it needs the same + # token. A template on GitHub (the usual case) is public and needs none. + if [[ "$TEMPLATE_URL" == "https://bitbucket.org/"* ]]; then + TEMPLATE_PATH="${TEMPLATE_URL#https://bitbucket.org/}" + TEMPLATE_PATH="${TEMPLATE_PATH%.git}" + CLONE_URL="https://x-bitbucket-api-token-auth:${BITBUCKET_API_TOKEN}@bitbucket.org/${TEMPLATE_PATH}.git" + fi + + SEED_DIR=$(mktemp -d) + + echo "Seeding $BITBUCKET_REPOSITORY_URL from template $TEMPLATE_URL" + + if ! bb_git clone --depth 1 "$CLONE_URL" "$SEED_DIR"; then + rm -rf "$SEED_DIR" + bb_fail "Failed to clone the template $TEMPLATE_URL" + fi + + # Drop the template's history entirely. + rm -rf "${SEED_DIR:?}/.git" + + if ! bb_git -C "$SEED_DIR" init; then + rm -rf "$SEED_DIR" + bb_fail "Failed to initialise the seed repository" + fi + + if ! bb_git -C "$SEED_DIR" add -A; then + rm -rf "$SEED_DIR" + bb_fail "Failed to stage the template files" + fi + + # The agent container has no git identity configured, so pass one inline + # rather than mutating global git config. + if ! bb_git -C "$SEED_DIR" \ + -c user.email="ci@nullplatform.com" \ + -c user.name="nullplatform" \ + commit -m "Initial commit from template"; then + rm -rf "$SEED_DIR" + bb_fail "Failed to create the initial commit" + fi + + # Push HEAD to an explicit ref, so the local branch name (which varies with + # the git version's init.defaultBranch) does not matter. This is the first ref + # in the repo, so Bitbucket makes it the default branch. + if ! bb_git -C "$SEED_DIR" push "$PUSH_URL" "HEAD:refs/heads/${BITBUCKET_MAIN_BRANCH}"; then + rm -rf "$SEED_DIR" + bb_fail "Failed to push the template to $BITBUCKET_REPOSITORY_URL" + fi + + rm -rf "$SEED_DIR" + + echo "Template pushed to $BITBUCKET_REPOSITORY_URL ($BITBUCKET_MAIN_BRANCH)" +fi + +# --------------------------------------------------------------------------- +# 3. Contract A reconciliation. +# +# core-entities stores repository_url verbatim and resolves an application by an +# EXACT string match -- no normalisation, no .git stripping, no lower-casing. If +# the URL the application was created with is not byte-identical to the one +# Bitbucket just gave us (Bitbucket slugifies and lower-cases), then a later +# `np build start` fails with "could not get the app". Fix it now, while we +# still know both values. +# --------------------------------------------------------------------------- + +if [[ "$REPOSITORY_URL" != "$BITBUCKET_REPOSITORY_URL" ]]; then + echo "Application repository_url ('$REPOSITORY_URL') differs from the canonical Bitbucket URL ('$BITBUCKET_REPOSITORY_URL'). Updating application $APPLICATION_ID." + + UPDATE_BODY=$(jq -nc --arg url "$BITBUCKET_REPOSITORY_URL" '{repository_url: $url}') + + if ! np application update --id "$APPLICATION_ID" --body "$UPDATE_BODY" --format json >/dev/null; then + bb_fail "Failed to update application $APPLICATION_ID with the canonical repository_url $BITBUCKET_REPOSITORY_URL. Builds would fail to resolve the application." + fi + + REPOSITORY_URL="$BITBUCKET_REPOSITORY_URL" + export REPOSITORY_URL +fi + +export BITBUCKET_REPOSITORY_URL +export BITBUCKET_MAIN_BRANCH diff --git a/scripts/code-repo/bitbucket/create_secrets b/scripts/code-repo/bitbucket/create_secrets new file mode 100644 index 0000000..a7c8e4e --- /dev/null +++ b/scripts/code-repo/bitbucket/create_secrets @@ -0,0 +1,86 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +# NOTE the spelling: 'pipelines_config' with an UNDERSCORE at repository level. +# (The workspace-level endpoint is hyphenated. That really is how Atlassian +# ships it.) +VARIABLES_PATH="/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/pipelines_config/variables" + +# Bitbucket pipeline variables have NO upsert: POSTing an existing key returns +# 409, and to change one you must PUT to its uuid. So list them once up front. +# +# (Secured values are write-only -- a GET returns "value": "" -- but we only +# need the key and the uuid here.) +bb_api GET "${VARIABLES_PATH}?pagelen=100" + +if [[ "$BB_HTTP_CODE" != "200" ]]; then + bb_fail "Could not list the pipeline variables of ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} (HTTP $BB_HTTP_CODE): $BB_BODY" +fi + +EXISTING_VARIABLES="$BB_BODY" + +FAILED=0 + +# Process substitution, NOT a pipe -- see the note in add_collaborators. +while IFS=$'\t' read -r key value; do + [[ -z "$key" ]] && continue + + echo "Creating secret: $key" + + # Global constraint: every variable carrying a credential is secured. + body=$(jq -nc --arg key "$key" --arg value "$value" \ + '{key: $key, value: $value, secured: true}') + + uuid=$(echo "$EXISTING_VARIABLES" | jq -r --arg key "$key" \ + '.values[]? | select(.key == $key) | .uuid // empty') + + if [[ -n "$uuid" ]]; then + encoded=$(bb_encode_uuid "$uuid") + + bb_api PUT "${VARIABLES_PATH}/${encoded}" "$body" + else + bb_api POST "$VARIABLES_PATH" "$body" + + if [[ "$BB_HTTP_CODE" == "409" ]]; then + # The key exists after all (a race, or it was past the first page). There + # is no upsert, so recover by finding the uuid and updating. + echo "Secret $key already exists; updating it instead" + + bb_api GET "${VARIABLES_PATH}?pagelen=100" + + uuid=$(echo "$BB_BODY" | jq -r --arg key "$key" \ + '.values[]? | select(.key == $key) | .uuid // empty') + + if [[ -z "$uuid" ]]; then + echo "Error: Bitbucket reported that secret $key already exists, but it is not in the variable list" + FAILED=1 + continue + fi + + encoded=$(bb_encode_uuid "$uuid") + + bb_api PUT "${VARIABLES_PATH}/${encoded}" "$body" + fi + fi + + case "$BB_HTTP_CODE" in + 200 | 201) + echo "Secret $key created successfully" + ;; + *) + echo "Error creating $key secret (HTTP $BB_HTTP_CODE): $BB_BODY" + FAILED=1 + ;; + esac +done < <(echo "$CODE_REPOSITORY_SECRETS" | jq -r 'to_entries[] | [.key, (.value | tostring)] | @tsv') + +if [[ "$FAILED" -ne 0 ]]; then + # Diverges deliberately from the GitLab script, which only prints. Without the + # nullplatform API key the pipeline cannot create builds or push assets, and + # the failure would surface much later as a confusing CI error. + exit 1 +fi diff --git a/scripts/code-repo/bitbucket/run_first_build b/scripts/code-repo/bitbucket/run_first_build new file mode 100644 index 0000000..fa11532 --- /dev/null +++ b/scripts/code-repo/bitbucket/run_first_build @@ -0,0 +1,90 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +PIPELINES_CONFIG_PATH="/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/pipelines_config" + +BRANCH="${BITBUCKET_MAIN_BRANCH:-main}" + +# --------------------------------------------------------------------------- +# 1. Enable Pipelines. +# +# Pipelines is OFF by default, even when bitbucket-pipelines.yml is present, so +# nothing can be triggered until it is enabled. Enabling does NOT require the +# yml to exist. The order is: enable -> (yml already pushed) -> trigger. +# +# GOTCHA: GET pipelines_config returns 404 when Pipelines was NEVER enabled on +# the repository. That 404 does NOT mean the repository is missing. +# --------------------------------------------------------------------------- + +bb_api GET "$PIPELINES_CONFIG_PATH" + +case "$BB_HTTP_CODE" in + 200) + if [[ "$(echo "$BB_BODY" | jq -r '.enabled')" == "true" ]]; then + echo "Bitbucket Pipelines is already enabled" + PIPELINES_ENABLED="true" + else + PIPELINES_ENABLED="false" + fi + ;; + 404) + echo "Bitbucket Pipelines has never been enabled on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" + PIPELINES_ENABLED="false" + ;; + *) + bb_fail "Unexpected response reading the pipelines config of ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} (HTTP $BB_HTTP_CODE): $BB_BODY" + ;; +esac + +if [[ "$PIPELINES_ENABLED" != "true" ]]; then + echo "Enabling Bitbucket Pipelines on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" + + bb_api PUT "$PIPELINES_CONFIG_PATH" '{"enabled": true}' + + if [[ "$BB_HTTP_CODE" == "403" ]]; then + # This is NOT a missing scope. Enabling Pipelines requires a 2SV-enabled USER + # principal; the API refuses every other credential here (an OAuth app is + # refused permanently, a workspace access token fails the same check). Name + # the cause so nobody hunts a scopes bug that does not exist. + bb_fail "Bitbucket refused to enable Pipelines on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} (HTTP 403). This means the bot user (${BITBUCKET_EMAIL}) does not have two-step verification enabled on its BITBUCKET account -- 2SV is a prerequisite Bitbucket enforces before Pipelines can be turned on, through the API or the UI. Note this is Bitbucket 2SV, NOT Atlassian-account 2FA. Enable it on the bot user and re-run. Response: $BB_BODY" + fi + + if [[ "$BB_HTTP_CODE" != "200" ]]; then + bb_fail "Could not enable Bitbucket Pipelines (HTTP $BB_HTTP_CODE): $BB_BODY" + fi + + echo "Bitbucket Pipelines enabled" +fi + +# --------------------------------------------------------------------------- +# 2. Trigger the first build. +# +# Note the TRAILING SLASH on /pipelines/ -- the endpoint is spelled that way. +# --------------------------------------------------------------------------- + +TRIGGER_BODY=$(jq -nc --arg branch "$BRANCH" \ + '{target: {type: "pipeline_ref_target", ref_type: "branch", ref_name: $branch}}') + +echo "Triggering the first build on branch '$BRANCH'" + +bb_api POST "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/pipelines/" "$TRIGGER_BODY" + +case "$BB_HTTP_CODE" in + 200 | 201) + echo "Pipeline triggered successfully: build #$(echo "$BB_BODY" | jq -r '.build_number // "?"')" + ;; + 400) + # Triggering when the branch has no bitbucket-pipelines.yml returns 400. An + # imported repository may legitimately not have one yet, and the application + # was created successfully either way -- so warn rather than fail the whole + # workflow. This matches the GitLab step's posture. + echo "Warning: Could not trigger the first build. Bitbucket rejected the request, which usually means branch '$BRANCH' has no bitbucket-pipelines.yml. Response: $BB_BODY" + ;; + *) + echo "Warning: Could not trigger the first build (HTTP $BB_HTTP_CODE): $BB_BODY" + ;; +esac diff --git a/scripts/code-repo/bitbucket/validate_repository_does_not_exist b/scripts/code-repo/bitbucket/validate_repository_does_not_exist new file mode 100644 index 0000000..f9ec877 --- /dev/null +++ b/scripts/code-repo/bitbucket/validate_repository_does_not_exist @@ -0,0 +1,51 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +echo "Checking if repository exists: ${BITBUCKET_INSTALLATION_URL}/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" + +bb_api GET "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" + +case "$BB_HTTP_CODE" in + 200) + REPOSITORY_EXISTS="true" + ;; + 404) + # 404 is the ONLY response that means "this slug is free". + REPOSITORY_EXISTS="false" + ;; + 401 | 403) + bb_fail "Bitbucket rejected the credentials (HTTP $BB_HTTP_CODE) while checking ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}. This is NOT 'repository does not exist'. Check the bot user's API token (bitbucket.email / bitbucket.apiToken) and that it carries the repository admin scope. Response: $BB_BODY" + ;; + *) + bb_fail "Unexpected response from Bitbucket (HTTP $BB_HTTP_CODE) while checking ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}. Response: $BB_BODY" + ;; +esac + +if [[ "$REPOSITORY_EXISTS" == "true" ]]; then + if [[ "$CODE_REPOSITORY_STRATEGY" == "create" ]]; then + bb_fail "Repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} already exists but strategy is set to 'create'. Please use a different repository name or change strategy to 'import'." + fi + + # Contract A: take the canonical URL from links.html.href. Never synthesise + # it, and never read links.clone[] -- that array's https entry carries a .git + # suffix and has historically embedded userinfo. + BITBUCKET_REPOSITORY_URL=$(echo "$BB_BODY" | jq -r '.links.html.href // empty') + BITBUCKET_MAIN_BRANCH=$(echo "$BB_BODY" | jq -r '.mainbranch.name // "main"') + + if [[ -z "$BITBUCKET_REPOSITORY_URL" ]]; then + bb_fail "Bitbucket did not return links.html.href for ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}: $BB_BODY" + fi + + export BITBUCKET_REPOSITORY_URL + export BITBUCKET_MAIN_BRANCH + + echo "Repository found ($BITBUCKET_REPOSITORY_URL, default branch '$BITBUCKET_MAIN_BRANCH') and strategy is set to 'import'. Proceeding with import configuration..." +elif [[ "$CODE_REPOSITORY_STRATEGY" == "import" ]]; then + bb_fail "Repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} does not exist but strategy is set to 'import'. Please create the repository first or change strategy to 'create'." +else + echo "Repository does not exist and strategy is set to 'create'. Proceeding with repository creation..." +fi diff --git a/scripts/code-repo/generate_secrets b/scripts/code-repo/generate_secrets index 98c855a..868358f 100644 --- a/scripts/code-repo/generate_secrets +++ b/scripts/code-repo/generate_secrets @@ -28,6 +28,17 @@ else exit 1 fi -CODE_REPOSITORY_SECRETS="{\"NP_API_KEY\": \"$API_KEY\"}" +# Both names carry the same CI api key. +# +# NP_API_KEY is what this repository has always emitted and what the existing +# GitLab-provisioned templates read. NULLPLATFORM_API_KEY is the platform-wide +# canonical name -- it is what main-application-workflow-manager injects, and +# therefore what the shared technology templates read. A template is used by +# BOTH provisioning paths, so both names must be present. +# +# jq (rather than string interpolation) so an api key containing characters that +# are significant in JSON cannot corrupt the payload. +CODE_REPOSITORY_SECRETS=$(jq -nc --arg api_key "$API_KEY" \ + '{NP_API_KEY: $api_key, NULLPLATFORM_API_KEY: $api_key}') export CODE_REPOSITORY_SECRETS \ No newline at end of file diff --git a/tests/cases/add_collaborators_grants_write b/tests/cases/add_collaborators_grants_write new file mode 100755 index 0000000..6d516a7 --- /dev/null +++ b/tests/cases/add_collaborators_grants_write @@ -0,0 +1,25 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_COLLABORATORS='[ + {"id":"557058:abc","role":"developer","type":"user"}, + {"id":"platform-team","role":"maintainer","type":"group"} +]' + +fixture PUT "/repositories/acme/my-service/permissions-config/users/557058:abc" 200 '{}' +fixture PUT "/repositories/acme/my-service/permissions-config/groups/platform-team" 200 '{}' + +run_step add_collaborators +assert_status 0 || exit 1 + +# GitLab role names must map onto Bitbucket's read|write|admin enum. +body=$(request_body PUT "/repositories/acme/my-service/permissions-config/users/557058:abc") + +if [[ "$(echo "$body" | jq -r '.permission')" != "write" ]]; then + echo " FAIL: expected permission 'write' for a developer, got: $body" + exit 1 +fi + +assert_contains "Finished adding collaborators" || exit 1 diff --git a/tests/cases/add_collaborators_reports_non_members b/tests/cases/add_collaborators_reports_non_members new file mode 100755 index 0000000..4c04b2b --- /dev/null +++ b/tests/cases/add_collaborators_reports_non_members @@ -0,0 +1,17 @@ +#!/bin/bash +# ADR-3: we cannot invite anyone to the workspace. Fail loudly and name them. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_COLLABORATORS='[{"id":"{aaaa-bbbb}","role":"developer","type":"user"}]' + +fixture PUT "/repositories/acme/my-service/permissions-config/users/%7Baaaa-bbbb%7D" 404 \ + '{"type":"error","error":{"message":"Not found"}}' + +run_step add_collaborators + +assert_status 1 || exit 1 +assert_contains "not members of the 'acme' Bitbucket workspace" || exit 1 +assert_contains "{aaaa-bbbb}" || exit 1 +assert_contains "workspace/settings/members" || exit 1 diff --git a/tests/cases/build_context_normalises_slug b/tests/cases/build_context_normalises_slug new file mode 100755 index 0000000..e26eb02 --- /dev/null +++ b/tests/cases/build_context_normalises_slug @@ -0,0 +1,25 @@ +#!/bin/bash +# REPOSITORY_NAME comes from `basename "$REPOSITORY_URL"` and is neither +# lower-cased nor .git-stripped. Bitbucket slugifies, so an un-normalised name +# would create the repo at a path we did not ask for -- and links.html.href +# would then disagree with the application's repository_url (Contract A). +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="My-Service.git" +export BITBUCKET_API_TOKEN="env-token-xyz" +export CODE_REPOSITORY='{"attributes":{"setup":{ + "workspace":"acme", + "project_key":"APP", + "email":"bot@nullplatform.com", + "api_token":null +}}}' + +slug=$(capture_export build_context REPOSITORY_SLUG) + +if [[ "$slug" != "my-service" ]]; then + echo " FAIL: expected slug 'my-service', got '$slug'" + exit 1 +fi diff --git a/tests/cases/build_context_requires_credentials b/tests/cases/build_context_requires_credentials new file mode 100755 index 0000000..dd8eecf --- /dev/null +++ b/tests/cases/build_context_requires_credentials @@ -0,0 +1,26 @@ +#!/bin/bash +# The api token is a SECRET: `np provider list` returns api_token: null, so the +# platform read cannot supply it. With no BITBUCKET_API_TOKEN in the environment +# either, build_context must fail loudly, EXPLAIN why (secret not returned by +# provider reads) and say HOW to configure it. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="my-service" + +# Production-shaped provider record: snake_case, secret value nulled, no env token. +export CODE_REPOSITORY='{"attributes":{"setup":{ + "workspace":"acme", + "project_key":"APP", + "email":"bot@nullplatform.com", + "api_token":null +}}}' + +run_step build_context + +assert_status 1 || exit 1 +assert_contains "BITBUCKET_API_TOKEN is not set" || exit 1 +assert_contains "secret" || exit 1 +assert_contains "api_token" || exit 1 diff --git a/tests/cases/build_context_requires_project_key b/tests/cases/build_context_requires_project_key new file mode 100755 index 0000000..06d5c51 --- /dev/null +++ b/tests/cases/build_context_requires_project_key @@ -0,0 +1,22 @@ +#!/bin/bash +# project.key is effectively mandatory: omit it on create and Bitbucket SILENTLY +# files the repo under the workspace's OLDEST project. Fail early and loudly +# rather than mis-file the customer's repository. (Token comes from the env, as +# in production; the record is the snake_case provider-list shape.) +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="my-service" +export BITBUCKET_API_TOKEN="env-token-xyz" +export CODE_REPOSITORY='{"attributes":{"setup":{ + "workspace":"acme", + "email":"bot@nullplatform.com", + "api_token":null +}}}' + +run_step build_context + +assert_status 1 || exit 1 +assert_contains "BITBUCKET_PROJECT_KEY is not set" || exit 1 diff --git a/tests/cases/build_context_token_from_env b/tests/cases/build_context_token_from_env new file mode 100755 index 0000000..a27fdb7 --- /dev/null +++ b/tests/cases/build_context_token_from_env @@ -0,0 +1,54 @@ +#!/bin/bash +# The Bitbucket api token is stored as a SECRET, so `np provider list` returns +# api_token: null (secret attributes are nullified on authenticated reads). The +# token is therefore supplied to the ALM deployment via the BITBUCKET_API_TOKEN +# env var, while the NON-secret fields (email, workspace, project_key) still come +# from the provider record -- in the schema's snake_case shape. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="my-service" + +# The credential the ALM deployment holds (what the platform cannot return). +export BITBUCKET_API_TOKEN="env-token-xyz" + +# Exactly what `np provider list` returns: snake_case attributes, secret nulled. +export CODE_REPOSITORY='{"attributes":{"setup":{ + "workspace":"acme", + "project_key":"APP", + "installation_url":"https://bitbucket.org", + "email":"bot@nullplatform.com", + "api_token":null +}}}' + +token=$(capture_export build_context BITBUCKET_API_TOKEN) +email=$(capture_export build_context BITBUCKET_EMAIL) +proj=$(capture_export build_context BITBUCKET_PROJECT_KEY) + +if [[ "$token" != "env-token-xyz" ]]; then + echo " FAIL: expected the env-provided token, got '$token'" + exit 1 +fi + +# Non-secret fields resolve from the record via the snake_case paths. +if [[ "$email" != "bot@nullplatform.com" ]]; then + echo " FAIL: expected the bot email from the record, got '$email'" + exit 1 +fi + +if [[ "$proj" != "APP" ]]; then + echo " FAIL: expected project key 'APP' from the record (.attributes.setup.project_key), got '$proj'" + exit 1 +fi + +run_step build_context +assert_status 0 || exit 1 + +# The token must never reach the logs. +if [[ "$STEP_OUTPUT" == *"env-token-xyz"* ]]; then + echo " FAIL: the API token leaked into the step output:" + printf '%s\n' "$STEP_OUTPUT" | sed 's/^/ /' + exit 1 +fi diff --git a/tests/cases/create_repository_reconciles_repository_url b/tests/cases/create_repository_reconciles_repository_url new file mode 100755 index 0000000..a821b05 --- /dev/null +++ b/tests/cases/create_repository_reconciles_repository_url @@ -0,0 +1,24 @@ +#!/bin/bash +# Contract A: core-entities resolves an application by an EXACT string match on +# repository_url. Bitbucket lower-cases slugs, so if the application was created +# with a mixed-case URL the canonical URL differs -- and `np build start` would +# later fail with "could not get the app". Reconcile at provisioning time. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" +export TEMPLATE_URL="" +export REPOSITORY_URL="https://bitbucket.org/acme/My-Service" + +fixture POST "/repositories/acme/my-service" 200 \ + '{"links":{"html":{"href":"https://bitbucket.org/acme/my-service"}}}' + +run_step create_repository +assert_status 0 || exit 1 + +if ! grep -q 'application update' "$BB_CALLS"; then + echo " FAIL: expected the application's repository_url to be reconciled. Calls:" + sed 's/^/ /' "$BB_CALLS" + exit 1 +fi diff --git a/tests/cases/create_repository_skips_on_import b/tests/cases/create_repository_skips_on_import new file mode 100755 index 0000000..2686c35 --- /dev/null +++ b/tests/cases/create_repository_skips_on_import @@ -0,0 +1,18 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="import" +export REPOSITORY_URL="https://bitbucket.org/acme/my-service" + +run_step create_repository + +assert_status 0 || exit 1 +assert_contains "Skipping repo creation" || exit 1 + +if [[ -s "$BB_CALLS" ]]; then + echo " FAIL: import strategy must not create anything. Calls:" + sed 's/^/ /' "$BB_CALLS" + exit 1 +fi diff --git a/tests/cases/create_repository_uses_html_href b/tests/cases/create_repository_uses_html_href new file mode 100755 index 0000000..6f5c477 --- /dev/null +++ b/tests/cases/create_repository_uses_html_href @@ -0,0 +1,45 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" +export TEMPLATE_URL="https://github.com/nullplatform/technology-templates-nodejs" +export REPOSITORY_URL="https://bitbucket.org/acme/my-service" + +# Bitbucket answers repository creation with 200, NOT 201. links.clone[] carries +# a .git suffix and may embed userinfo; links.html.href is the canonical URL. +fixture POST "/repositories/acme/my-service" 200 '{ + "links": { + "html": {"href": "https://bitbucket.org/acme/my-service"}, + "clone": [{"name": "https", "href": "https://acme@bitbucket.org/acme/my-service.git"}] + } +}' + +url=$(capture_export create_repository BITBUCKET_REPOSITORY_URL) + +if [[ "$url" != "https://bitbucket.org/acme/my-service" ]]; then + echo " FAIL: expected the canonical links.html.href, got '$url'" + exit 1 +fi + +run_step create_repository +assert_status 0 || exit 1 + +# project.key must be sent, or Bitbucket silently files the repo under the +# workspace's OLDEST project. +body=$(request_body POST "/repositories/acme/my-service") + +if [[ "$(echo "$body" | jq -r '.project.key')" != "APP" ]]; then + echo " FAIL: create body did not carry project.key=APP -- got: $body" + exit 1 +fi + +if [[ "$(echo "$body" | jq -r '.is_private')" != "true" ]]; then + echo " FAIL: repository was not created private -- got: $body" + exit 1 +fi + +# ADR-1: there is no import API, so the template is seeded by git clone + push. +assert_called_git "clone" || exit 1 +assert_called_git "push" || exit 1 diff --git a/tests/cases/create_secrets_creates_new b/tests/cases/create_secrets_creates_new new file mode 100755 index 0000000..2245d91 --- /dev/null +++ b/tests/cases/create_secrets_creates_new @@ -0,0 +1,23 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_SECRETS='{"NULLPLATFORM_API_KEY":"abc123"}' + +VARS="/repositories/acme/my-service/pipelines_config/variables" + +fixture GET "${VARS}?pagelen=100" 200 '{"values":[]}' +fixture POST "$VARS" 201 '{"key":"NULLPLATFORM_API_KEY"}' + +run_step create_secrets + +assert_status 0 || exit 1 +assert_contains "Secret NULLPLATFORM_API_KEY created successfully" || exit 1 + +body=$(request_body POST "$VARS") + +if [[ "$(echo "$body" | jq -r '.secured')" != "true" ]]; then + echo " FAIL: the credential was not created with secured:true -- got: $body" + exit 1 +fi diff --git a/tests/cases/create_secrets_secures_and_updates b/tests/cases/create_secrets_secures_and_updates new file mode 100755 index 0000000..7134e17 --- /dev/null +++ b/tests/cases/create_secrets_secures_and_updates @@ -0,0 +1,32 @@ +#!/bin/bash +# There is no upsert. Exercise the full 409 recovery: list -> POST -> 409 -> +# re-list to find the uuid -> PUT. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_SECRETS='{"NP_API_KEY":"abc123"}' + +VARS="/repositories/acme/my-service/pipelines_config/variables" + +# 1st list: the key is not there yet... +fixture_seq GET "${VARS}?pagelen=100" 1 200 '{"values":[]}' +# ...so we POST -- and Bitbucket says 409 (it was created in between). +fixture POST "$VARS" 409 '{"type":"error","error":{"message":"already exists"}}' +# 2nd list: now we can see its uuid. +fixture_seq GET "${VARS}?pagelen=100" 2 200 \ + '{"values":[{"key":"NP_API_KEY","uuid":"{1234-abcd}"}]}' +# Update by uuid, with the literal braces percent-encoded. +fixture PUT "${VARS}/%7B1234-abcd%7D" 200 '{"key":"NP_API_KEY"}' + +run_step create_secrets + +assert_status 0 || exit 1 +assert_called PUT "${VARS}/%7B1234-abcd%7D" || exit 1 + +body=$(request_body PUT "${VARS}/%7B1234-abcd%7D") + +if [[ "$(echo "$body" | jq -r '.secured')" != "true" ]]; then + echo " FAIL: the credential was not stored with secured:true -- got: $body" + exit 1 +fi diff --git a/tests/cases/generate_secrets_emits_canonical_key b/tests/cases/generate_secrets_emits_canonical_key new file mode 100755 index 0000000..0f6cb53 --- /dev/null +++ b/tests/cases/generate_secrets_emits_canonical_key @@ -0,0 +1,40 @@ +#!/bin/bash +# The templates are SHARED by both provisioning paths, so both must inject the +# CI credential under the same canonical name. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export NAMESPACE_SLUG="ns" +export NAMESPACE_ID="1" +export APPLICATION_SLUG="app" +export APPLICATION_ID="2" +export NRN="organization=1:account=2" + +# The np stub returns '{}', so give api-key create a real answer. +cat > "$TEST_TMP/np" <<'EOF' +#!/bin/bash +printf 'np\t%s\n' "$*" >> "$BB_CALLS" +if [[ "$1" == "api-key" ]]; then + echo '{"id": 99, "api_key": "np-secret-value"}' +else + echo '{}' +fi +EOF +chmod +x "$TEST_TMP/np" +export PATH="$TEST_TMP:$PATH" + +secrets=$( + cd "$REPO_ROOT" || exit 1 + source scripts/code-repo/generate_secrets >/dev/null 2>&1 + printf '%s' "$CODE_REPOSITORY_SECRETS" +) + +for key in NP_API_KEY NULLPLATFORM_API_KEY; do + value=$(echo "$secrets" | jq -r --arg k "$key" '.[$k] // empty') + + if [[ "$value" != "np-secret-value" ]]; then + echo " FAIL: expected $key to carry the CI api key, got '$value' from: $secrets" + exit 1 + fi +done diff --git a/tests/cases/run_first_build_enables_pipelines b/tests/cases/run_first_build_enables_pipelines new file mode 100755 index 0000000..c76db41 --- /dev/null +++ b/tests/cases/run_first_build_enables_pipelines @@ -0,0 +1,28 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +CONFIG="/repositories/acme/my-service/pipelines_config" + +# GET pipelines_config 404s when Pipelines has NEVER been enabled. That does NOT +# mean the repository is missing -- it means "not enabled yet, go PUT it". +fixture GET "$CONFIG" 404 '{"type":"error"}' +fixture PUT "$CONFIG" 200 '{"enabled":true}' +fixture POST "/repositories/acme/my-service/pipelines/" 201 '{"build_number":1}' + +run_step run_first_build + +assert_status 0 || exit 1 +assert_called PUT "$CONFIG" || exit 1 +assert_called POST "/repositories/acme/my-service/pipelines/" || exit 1 +assert_contains "build #1" || exit 1 + +# Enabling must come BEFORE triggering. +enable_line=$(grep -nF "$(printf 'PUT\t%s\t' "$CONFIG")" "$BB_CALLS" | cut -d: -f1 | head -1) +trigger_line=$(grep -nF "$(printf 'POST\t/repositories/acme/my-service/pipelines/\t')" "$BB_CALLS" | cut -d: -f1 | head -1) + +if [[ "$enable_line" -ge "$trigger_line" ]]; then + echo " FAIL: Pipelines must be enabled before a build is triggered" + exit 1 +fi diff --git a/tests/cases/run_first_build_warns_without_yml b/tests/cases/run_first_build_warns_without_yml new file mode 100755 index 0000000..2e27e95 --- /dev/null +++ b/tests/cases/run_first_build_warns_without_yml @@ -0,0 +1,18 @@ +#!/bin/bash +# Triggering with no bitbucket-pipelines.yml on the branch returns 400. The app +# was still created successfully, so warn -- do not fail the workflow. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +CONFIG="/repositories/acme/my-service/pipelines_config" + +fixture GET "$CONFIG" 200 '{"enabled":true}' +fixture POST "/repositories/acme/my-service/pipelines/" 400 \ + '{"type":"error","error":{"message":"No pipelines configuration"}}' + +run_step run_first_build + +assert_status 0 || exit 1 +assert_contains "Warning: Could not trigger the first build" || exit 1 +assert_contains "bitbucket-pipelines.yml" || exit 1 diff --git a/tests/cases/validate_401_is_not_free b/tests/cases/validate_401_is_not_free new file mode 100755 index 0000000..df29a5e --- /dev/null +++ b/tests/cases/validate_401_is_not_free @@ -0,0 +1,17 @@ +#!/bin/bash +# The dangerous one. A 401/403 is NOT "the slug is free": treating it as such +# would send us on to create a repository we cannot see, and the failure would +# surface much later as something unrelated. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" + +fixture GET "/repositories/acme/my-service" 401 \ + '{"type":"error","error":{"message":"Unauthorized"}}' + +run_step validate_repository_does_not_exist + +assert_status 1 || exit 1 +assert_contains "This is NOT 'repository does not exist'" || exit 1 diff --git a/tests/cases/validate_404_means_free b/tests/cases/validate_404_means_free new file mode 100755 index 0000000..0016fcc --- /dev/null +++ b/tests/cases/validate_404_means_free @@ -0,0 +1,14 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" + +fixture GET "/repositories/acme/my-service" 404 '{"type":"error"}' + +run_step validate_repository_does_not_exist + +assert_status 0 || exit 1 +assert_called GET "/repositories/acme/my-service" || exit 1 +assert_contains "Repository does not exist and strategy is set to 'create'" || exit 1 diff --git a/tests/cases/validate_existing_repo_on_create_fails b/tests/cases/validate_existing_repo_on_create_fails new file mode 100755 index 0000000..76c44ac --- /dev/null +++ b/tests/cases/validate_existing_repo_on_create_fails @@ -0,0 +1,14 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" + +fixture GET "/repositories/acme/my-service" 200 \ + '{"links":{"html":{"href":"https://bitbucket.org/acme/my-service"}},"mainbranch":{"name":"main"}}' + +run_step validate_repository_does_not_exist + +assert_status 1 || exit 1 +assert_contains "already exists but strategy is set to 'create'" || exit 1 diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..3604a11 --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# Shared helpers for the code-repo script tests. +# +# The scripts under test are SOURCED, not executed (see the top of +# scripts/code-repo/bitbucket/_api). run_step therefore sources them inside a +# command substitution: a bare `exit 1` in a step then ends that subshell -- it +# reports a failing step without killing the test runner. + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +setup() { + TEST_TMP=$(mktemp -d) + + BB_FIXTURES="$TEST_TMP/fixtures" + BB_CALLS="$TEST_TMP/calls.tsv" + + mkdir -p "$BB_FIXTURES" + : > "$BB_CALLS" + + export BB_FIXTURES BB_CALLS + export PATH="$REPO_ROOT/tests/stubs:$PATH" + + # The context that build_context exports for every later step. + export BITBUCKET_EMAIL="bot@nullplatform.com" + export BITBUCKET_API_TOKEN="test-token" + export BITBUCKET_WORKSPACE="acme" + export BITBUCKET_PROJECT_KEY="APP" + export BITBUCKET_INSTALLATION_URL="https://bitbucket.org" + export BITBUCKET_API_BASE="https://api.bitbucket.org/2.0" + export REPOSITORY_SLUG="my-service" + export REPOSITORY_NAME="my-service" + export APPLICATION_ID="42" +} + +teardown() { + rm -rf "$TEST_TMP" +} + +# clear_context -- drop everything build_context is meant to derive for itself. +# setup() pre-exports that context because it is what the steps AFTER +# build_context consume; a build_context test must start from a clean slate. +clear_context() { + unset BITBUCKET_EMAIL BITBUCKET_API_TOKEN BITBUCKET_WORKSPACE \ + BITBUCKET_PROJECT_KEY BITBUCKET_INSTALLATION_URL BITBUCKET_API_BASE \ + REPOSITORY_SLUG +} + +# fixture METHOD PATH CODE [BODY] +fixture() { + local method="$1" path="$2" code="$3" body="${4:-}" + local key + + key="${method}_$(printf '%s' "$path" | sed -e 's|[^A-Za-z0-9]|_|g')" + + printf '%s' "$code" > "$BB_FIXTURES/${key}.code" + printf '%s' "$body" > "$BB_FIXTURES/${key}.body" +} + +# fixture_seq METHOD PATH N CODE [BODY] -- response for the N-th call. +fixture_seq() { + local method="$1" path="$2" n="$3" code="$4" body="${5:-}" + local key + + key="${method}_$(printf '%s' "$path" | sed -e 's|[^A-Za-z0-9]|_|g')" + + printf '%s' "$code" > "$BB_FIXTURES/${key}.${n}.code" + printf '%s' "$body" > "$BB_FIXTURES/${key}.${n}.body" +} + +# run_step NAME -> STEP_OUTPUT, STEP_STATUS +run_step() { + local step="$1" + + # shellcheck disable=SC1090 + STEP_OUTPUT=$(cd "$REPO_ROOT" && source "scripts/code-repo/bitbucket/$step" 2>&1) + STEP_STATUS=$? +} + +# capture_export NAME VAR -> the value the step exported into VAR +capture_export() { + local step="$1" var="$2" + + ( + cd "$REPO_ROOT" || exit 1 + # shellcheck disable=SC1090 + source "scripts/code-repo/bitbucket/$step" >/dev/null 2>&1 + printf '%s' "${!var}" + ) +} + +# request_body METHOD PATH -> the JSON body sent with that request +request_body() { + local method="$1" path="$2" + + grep -F "$(printf '%s\t%s\t' "$method" "$path")" "$BB_CALLS" | head -1 | cut -f3 +} + +assert_status() { + local expected="$1" + + if [[ "$STEP_STATUS" != "$expected" ]]; then + echo " FAIL: expected exit status $expected, got $STEP_STATUS" + echo " step output:" + printf '%s\n' "$STEP_OUTPUT" | sed 's/^/ /' + return 1 + fi +} + +assert_contains() { + local needle="$1" + local haystack="${2:-$STEP_OUTPUT}" + + if [[ "$haystack" != *"$needle"* ]]; then + echo " FAIL: expected to find '$needle' in:" + printf '%s\n' "$haystack" | sed 's/^/ /' + return 1 + fi +} + +assert_called() { + local method="$1" path="$2" + + if ! grep -qF "$(printf '%s\t%s\t' "$method" "$path")" "$BB_CALLS"; then + echo " FAIL: expected a '$method $path' call. Calls made:" + sed 's/^/ /' "$BB_CALLS" + return 1 + fi +} + +assert_called_git() { + local subcommand="$1" + + # Records are "git". Match the subcommand as a whitespace- + # delimited token anywhere in the args. The leading TAB is itself a boundary, + # so the first alternative matches a subcommand that comes immediately after + # it (clone, init, add), while `.*[[:space:]]` matches one that follows other + # args (push after `-C `). + if ! grep -qE "^git([[:space:]]|.*[[:space:]])${subcommand}([[:space:]]|$)" "$BB_CALLS"; then + echo " FAIL: expected a 'git $subcommand'. Calls made:" + sed 's/^/ /' "$BB_CALLS" + return 1 + fi +} diff --git a/tests/run b/tests/run new file mode 100755 index 0000000..4ff2a8d --- /dev/null +++ b/tests/run @@ -0,0 +1,38 @@ +#!/bin/bash +# Test runner for the code-repo provider scripts. +# +# tests/run # everything +# tests/run # one case +# +# Each file in tests/cases is an independent bash script that exits non-zero on +# failure. Deliberately no bats: this repo ships nothing but bash, curl and jq, +# and its tests should need nothing more either. + +cd "$(dirname "$0")/.." || exit 1 + +pass=0 +fail=0 + +for case_file in tests/cases/*; do + [[ -f "$case_file" ]] || continue + + name=$(basename "$case_file") + + if [[ -n "$1" && "$name" != "$1" ]]; then + continue + fi + + if output=$(bash "$case_file" 2>&1); then + echo "ok $name" + pass=$((pass + 1)) + else + echo "FAIL $name" + printf '%s\n' "$output" | sed 's/^/ /' + fail=$((fail + 1)) + fi +done + +echo "" +echo "$pass passed, $fail failed" + +[[ "$fail" -eq 0 ]] diff --git a/tests/smoke_bitbucket_live b/tests/smoke_bitbucket_live new file mode 100755 index 0000000..9e7c6ba --- /dev/null +++ b/tests/smoke_bitbucket_live @@ -0,0 +1,108 @@ +#!/bin/bash +# +# Live end-to-end verification against a REAL, throwaway Bitbucket workspace. +# +# This is NOT part of `tests/run` and does NOT run in CI: it needs real +# credentials and it creates real repositories. It is the only thing that proves +# the API contracts this provider is built on (repo-create returns 200 not 201; +# GET pipelines_config 404s before Pipelines is enabled; pipeline variables have +# no upsert; and that the bot user's API token can enable Pipelines -- the one +# thing an OAuth app or workspace token cannot do). +# +# nullplatform authenticates with a single dedicated bot user's Atlassian API +# token, over HTTP Basic (email:api_token). The bot user MUST have Bitbucket +# two-step verification enabled or the Pipelines-enable step returns 403. +# +# BITBUCKET_EMAIL=bot@nullplatform.com \ +# BITBUCKET_API_TOKEN= \ +# BITBUCKET_WORKSPACE=np-scratch \ +# BITBUCKET_PROJECT_KEY=SCRATCH \ +# TEMPLATE_URL=https://github.com/nullplatform/technology-templates-nodejs \ +# tests/smoke_bitbucket_live +# +# Add KEEP=1 to skip the cleanup and inspect the repository afterwards. + +cd "$(dirname "$0")/.." || exit 1 + +SLUG="${SMOKE_SLUG:-np-smoke-$(date +%s)}" + +# Stand in for the parts of the workflow context that create_code_repository and +# generate_secrets would normally provide. We do NOT call generate_secrets here: +# it would mint a real nullplatform CI api key. +export CODE_REPOSITORY_STRATEGY="create" +export REPOSITORY_NAME="$SLUG" +export REPOSITORY_URL="https://bitbucket.org/${BITBUCKET_WORKSPACE}/${SLUG}" +export CODE_REPOSITORY='{"attributes":{"setup":{},"access":{}}}' +export CODE_REPOSITORY_COLLABORATORS='[]' +export CODE_REPOSITORY_SECRETS='{"NULLPLATFORM_API_KEY":"smoke-test-value"}' +export APPLICATION_ID="0" + +echo "=== smoke: ${BITBUCKET_WORKSPACE}/${SLUG} (bot user: ${BITBUCKET_EMAIL}) ===" + +# Each step is sourced, exactly as the workflow engine does it. A step that +# fails calls `exit 1`, which ends this script too -- which is the point. +for step in build_context validate_repository_does_not_exist create_repository \ + add_collaborators create_secrets run_first_build; do + echo "" + echo "--- $step ---" + + # shellcheck disable=SC1090 + source "scripts/code-repo/bitbucket/$step" +done + +echo "" +echo "--- assertions ---" + +# Contract A: the persisted URL must be exactly https://bitbucket.org/{ws}/{slug} +# -- no .git, no trailing slash, no userinfo. +EXPECTED="https://bitbucket.org/${BITBUCKET_WORKSPACE}/${SLUG}" + +if [[ "$BITBUCKET_REPOSITORY_URL" != "$EXPECTED" ]]; then + echo "FAIL: Contract A violated." + echo " expected: $EXPECTED" + echo " got: $BITBUCKET_REPOSITORY_URL" + exit 1 +fi + +echo "ok Contract A: $BITBUCKET_REPOSITORY_URL" + +# The template must actually be in the repo, as ONE root commit. +COMMITS=$(curl -s -u "${BITBUCKET_EMAIL}:${BITBUCKET_API_TOKEN}" \ + "https://api.bitbucket.org/2.0/repositories/${BITBUCKET_WORKSPACE}/${SLUG}/commits/main" \ + | jq '.values | length') + +if [[ "$COMMITS" != "1" ]]; then + echo "FAIL: expected exactly 1 commit on main (a squashed template), found: $COMMITS" + exit 1 +fi + +echo "ok template seeded as a single root commit" + +# The credential must be stored secured (its value is then write-only). +SECURED=$(curl -s -u "${BITBUCKET_EMAIL}:${BITBUCKET_API_TOKEN}" \ + "https://api.bitbucket.org/2.0/repositories/${BITBUCKET_WORKSPACE}/${SLUG}/pipelines_config/variables?pagelen=100" \ + | jq -r '.values[] | select(.key == "NULLPLATFORM_API_KEY") | .secured') + +if [[ "$SECURED" != "true" ]]; then + echo "FAIL: NULLPLATFORM_API_KEY is not secured (got: '$SECURED')" + exit 1 +fi + +echo "ok NULLPLATFORM_API_KEY stored with secured:true" + +if [[ -n "${KEEP:-}" ]]; then + echo "" + echo "KEEP set -- leaving $BITBUCKET_REPOSITORY_URL in place." + exit 0 +fi + +echo "" +echo "--- cleanup ---" + +CLEANUP_CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \ + -u "${BITBUCKET_EMAIL}:${BITBUCKET_API_TOKEN}" \ + "https://api.bitbucket.org/2.0/repositories/${BITBUCKET_WORKSPACE}/${SLUG}") + +echo "deleted ${BITBUCKET_WORKSPACE}/${SLUG} (HTTP $CLEANUP_CODE)" +echo "" +echo "=== smoke passed (bot user: ${BITBUCKET_EMAIL}) ===" diff --git a/tests/stubs/curl b/tests/stubs/curl new file mode 100755 index 0000000..a5fff1b --- /dev/null +++ b/tests/stubs/curl @@ -0,0 +1,72 @@ +#!/bin/bash +# Fixture-driven `curl` stub used by tests/run. +# +# Resolves a canned response by METHOD + request path from $BB_FIXTURES, and +# appends every request (method, path, body) to $BB_CALLS so tests can assert on +# what was actually sent. +# +# Honours the exact flag shapes the scripts use: +# bb_api: -s -o FILE -w '%{http_code}' -X METHOD -H ... [--data BODY] URL +# OAuth mint: -s -u KEY:SECRET -X POST -d grant_type=client_credentials URL +# +# As with real curl, the status code is echoed to stdout ONLY when -w asks for +# it; the OAuth mint pipes raw stdout into jq and must not see a code appended. + +method="GET" +out="" +data="" +url="" +want_code=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -X) method="$2"; shift 2 ;; + -o) out="$2"; shift 2 ;; + -w) want_code=1; shift 2 ;; + -H|-u) shift 2 ;; + -d) data="$2"; shift 2 ;; + --data) data="$2"; shift 2 ;; + -s|-S|-f) shift ;; + *) url="$1"; shift ;; + esac +done + +path="${url#https://api.bitbucket.org/2.0}" + +printf '%s\t%s\t%s\n' "$method" "$path" "$data" >> "$BB_CALLS" + +key="${method}_$(printf '%s' "$path" | sed -e 's|[^A-Za-z0-9]|_|g')" + +# Per-key call counter, so a test can queue a different response for the 2nd +# call to the same endpoint (the 409 -> re-list -> PUT flow needs this). +count_file="$BB_FIXTURES/.count_${key}" +n=1 +if [[ -f "$count_file" ]]; then + n=$(( $(cat "$count_file") + 1 )) +fi +printf '%s' "$n" > "$count_file" + +code_file="$BB_FIXTURES/${key}.${n}.code" +body_file="$BB_FIXTURES/${key}.${n}.body" + +if [[ ! -f "$code_file" ]]; then + code_file="$BB_FIXTURES/${key}.code" + body_file="$BB_FIXTURES/${key}.body" +fi + +# 599 = "the script made a call the test never stubbed". Fail loudly. +code="599" +body="" + +[[ -f "$code_file" ]] && code=$(cat "$code_file") +[[ -f "$body_file" ]] && body=$(cat "$body_file") + +if [[ -n "$out" ]]; then + printf '%s' "$body" > "$out" +else + printf '%s' "$body" +fi + +if [[ "$want_code" -eq 1 ]]; then + printf '%s' "$code" +fi diff --git a/tests/stubs/git b/tests/stubs/git new file mode 100755 index 0000000..9e81ab8 --- /dev/null +++ b/tests/stubs/git @@ -0,0 +1,18 @@ +#!/bin/bash +# `git` stub: records the invocation and fakes a clone. + +printf 'git\t%s\n' "$*" >> "$BB_CALLS" + +# Emulate `git clone --depth 1 `: create_repository expects the +# target directory to exist and hold the template's files afterwards. +if [[ "$1" == "clone" ]]; then + target="" + for arg in "$@"; do + target="$arg" + done + + mkdir -p "$target/.git" + echo "template" > "$target/README.md" +fi + +exit 0 diff --git a/tests/stubs/np b/tests/stubs/np new file mode 100755 index 0000000..1684f45 --- /dev/null +++ b/tests/stubs/np @@ -0,0 +1,6 @@ +#!/bin/bash +# `np` CLI stub: records the invocation, returns empty JSON. + +printf 'np\t%s\n' "$*" >> "$BB_CALLS" + +echo '{}'