Compare commits

..

52 Commits

Author SHA1 Message Date
Julien Neuhart
674f1de183 chore(deps): update Go dependencies 2025-03-28 12:20:27 +01:00
Julien Neuhart
368c4fa1a7 chore(logging): better description for --log-enable-gcp-fields flag [skip ci] 2025-03-28 12:17:18 +01:00
Julien Neuhart
c737579b7f ci(lint): fix golangci-lint action version 2025-03-26 20:11:16 +01:00
Julien Neuhart
0b7c51a323 chore(deps): update golangci-lint to v2 2025-03-26 20:08:34 +01:00
Julien Neuhart
e7c77b7bf3 ci(continous-delivery): fix wrong version 2025-03-26 11:38:56 +01:00
Julien Neuhart
6c727b7e51 refactor(logging): use log-enable-gcp-fields 2025-03-26 11:37:48 +01:00
Hector Zarate
a9754d6a1c fix(logging): correctly map GCP structured log keys (#1159)
* fix: correctly map GCP structured log keys

* Update pkg/modules/logging/logging.go

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2025-03-26 10:28:58 +01:00
Julien Neuhart
218052d5dd chore(tests): fix typo [skip ci] 2025-03-22 18:05:47 +01:00
Julien Neuhart
f112cb748f ci(tests): add go test timeout 2025-03-22 18:02:26 +01:00
Julien Neuhart
67a65c66b6 ci(edge): fix wrong repository and tag 2025-03-21 16:52:57 +01:00
Julien Neuhart
cd6add14a0 ci(build-test-push): add missing Cloud Run tags 2025-03-21 14:15:44 +01:00
Julien Neuhart
33f9f1ddf4 docs(test): add TODOs [ci skip] 2025-03-21 14:14:00 +01:00
Julien Neuhart
2131de77b0 ci(test): no concurrency 2025-03-21 14:09:34 +01:00
Julien Neuhart
0febb60a3f test: add integration tests 2025-03-21 14:09:34 +01:00
Julien Neuhart
820b914291 feat(formatting): add prettier-plugin-sh 2025-03-21 14:09:34 +01:00
Julien Neuhart
536d3ee70d feat(formatting): add prettier 2025-03-21 14:09:34 +01:00
Julien Neuhart
2544d82486 docs(README): add GitHub Trending badge [ci skip] 2025-03-18 10:08:13 +01:00
Julien Neuhart
ea6854fadc docs(godoc): add link to issue 1149 [ci skip] 2025-03-18 08:55:09 +01:00
Julien Neuhart
147e3b1173 docs(godoc): improve the UpdateIndexes godoc 2025-03-17 14:51:24 +01:00
Julien Neuhart
8252142e65 chore(deps): update Go dependencies 2025-03-17 14:01:07 +01:00
Julien Neuhart
6e2270632e chore(logging): remove unit tests - integration tests are inc anyway 2025-03-17 13:56:48 +01:00
Julien Neuhart
7d14546d3b feat(logging): add GCP severity mapping 2025-03-17 13:56:48 +01:00
Julien Neuhart
b31024c362 feat(libreoffice): add updateIndexes form field 2025-03-17 13:32:26 +01:00
Julien Neuhart
85eaef05ad fix(deps): update Go dependencies - chromedp ERROR: unhandled page event *page.EventFrameStartedNavigating 2025-03-06 08:55:57 +01:00
Julien Neuhart
16e4c10862 chore(deps): update Go dependencies 2025-03-05 08:57:48 +01:00
Julien Neuhart
904e0f61a8 fix(pdfengines): split and read metadata pdf engines calls 2025-03-04 18:08:57 +01:00
Zdravko
70953aa66e fix(qpdf): allow warnings (#1135)
* Optionally allow warnings in QPDF operations

* warnings are not errors by default

* Apply suggestions from code review

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

* fix some merge artifacts

* follow the args convention

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2025-02-28 10:43:38 +01:00
Julien Neuhart
d41de5d270 docs(README): switch to new short description [skip ci] 2025-02-21 11:55:12 +01:00
Julien Neuhart
d8603f4f34 feat: update short description 2025-02-21 11:22:22 +01:00
Julien Neuhart
b13a8094ea ci(continous-delivery): remove wrong outputs 2025-02-19 19:42:30 +01:00
Kyohei Fukuda
28e07bad54 docs(README): add pdfme sponsor (#1131) 2025-02-14 14:28:53 +01:00
Julien Neuhart
81cc483166 test: fix issues with go 1.24 2025-02-13 09:21:38 +01:00
Julien Neuhart
8d7cc55c28 chore(go): upgrade to 1.24 2025-02-13 08:44:58 +01:00
Julien Neuhart
083e0e307a chore(deps): update Go dependencies 2025-02-12 16:24:16 +01:00
Julien Neuhart
80f3f89a89 fix(pdfcpu): correct sorting for the output paths of the split method 2025-02-12 11:47:45 +01:00
Julien Neuhart
3dc8c18849 fix(fs): get file by ext sorted by mod time 2025-02-11 17:00:18 +01:00
Julien Neuhart
0a6a97cf07 ci(merge): fix thecodingmachine target 2025-02-07 09:59:08 +01:00
Julien Neuhart
7676d7a239 ci(build-push): fix typo in tag 2025-02-07 09:47:47 +01:00
Julien Neuhart
679715f896 chore(deps): update Go dependencies 2025-02-07 08:43:44 +01:00
Julien Neuhart
d5aa880336 ci(build-push): fix typo in tag name 2025-02-07 08:41:04 +01:00
Julien Neuhart
08daa86766 test(api): add missing context 2025-02-06 09:07:09 +01:00
Julien Neuhart
f0c1952dfe ci(lint): fix missing error handling 2025-02-06 08:51:38 +01:00
Julien Neuhart
198d9115cc chore(deps): switch to github.com/mholt/archives 2025-02-05 17:00:48 +01:00
Julien Neuhart
6b0c01bd3e chore(deps): update Go dependencies 2025-02-05 09:51:58 +01:00
Julien Neuhart
a1848d3c75 feat(conf): override flags' values with their corresponding environment variables' values 2025-02-05 09:51:38 +01:00
Julien Neuhart
a1596f7c62 feat(api): add debug route 2025-02-04 10:20:29 +01:00
Julien Neuhart
740e701ad3 chore(deps): update Go dependencies 2025-02-03 11:53:51 +01:00
Julien Neuhart
71d571a25c fix(Makefile): wrong arg for --pdfengines-convert-engines 2025-02-03 11:36:05 +01:00
Julien Neuhart
2293300182 ci(merge): fix alternate registry target image #2 2025-01-31 15:58:06 +01:00
Julien Neuhart
85c7f2806f ci(merge): fix alternate registry target image 2025-01-31 15:38:14 +01:00
dependabot[bot]
27ecb4fe49 chore(deps): bump codecov/codecov-action from 4 to 5
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-31 14:43:21 +01:00
Julien Neuhart
7afeefe84f ci(actions): add local actions 2025-01-31 14:39:54 +01:00
179 changed files with 9511 additions and 16836 deletions

View File

@@ -1 +1 @@
.git
.git

7
.env
View File

@@ -1,13 +1,12 @@
GOLANG_VERSION=1.23
GOLANG_VERSION=1.24
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOLANGCI_LINT_VERSION=v1.63.4 # See https://github.com/golangci/golangci-lint/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOTENBERG_VERSION=snapshot
DOCKERFILE=build/Dockerfile
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun

View File

@@ -0,0 +1,87 @@
name: Build Test Push
description: Build, test and push Docker images for a given platform
author: Julien Neuhart
inputs:
github_token:
description: The GitHub token
required: true
default: ${{ github.token }}
docker_hub_username:
description: The Docker Hub username
required: true
docker_hub_password:
description: The Docker Hub password
required: true
platform:
description: linux/amd64, linux/386, linux/arm64, linux/arm/v7
required: true
version:
description: Gotenberg version
required: true
skip_integrations_tests:
description: Define whether to skip integration testing
default: false
alternate_repository:
description: Alternate repository to push the tags to
dry_run:
description: Dry run this action
outputs:
tags:
description: Comma separated list of tag
value: ${{ steps.build.outputs.tags }}
tags_cloud_run:
description: Comma separated list of Cloud Run tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run }}
runs:
using: composite
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ inputs.docker_hub_username }}
password: ${{ inputs.docker_hub_password }}
- name: Build ${{ inputs.platform }}
id: build
shell: bash
run: |
.github/actions/build-test-push/build.sh \
--version "${{ inputs.version }}" \
--platform "${{ inputs.platform }}" \
--alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Run integration tests
if: ${{ inputs.skip_integrations_tests != 'true' }}
shell: bash
run: |
.github/actions/build-test-push/test.sh \
--version "${{ inputs.version }}" \
--platform "${{ inputs.platform }}" \
--alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Push
shell: bash
run: |
.github/actions/build-test-push/push.sh \
--tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Outputs
shell: bash
run: |
echo "tags=${{ steps.build.outputs.tags }}"
echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}"

167
.github/actions/build-test-push/build.sh vendored Executable file
View File

@@ -0,0 +1,167 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
version=""
platform=""
alternate_repository=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--version)
version="${2//v/}"
shift 2
;;
--platform)
platform="$2"
shift 2
;;
--alternate-repository)
alternate_repository="$2"
shift 2
;;
--dry-run)
dry_run="$2"
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Build and push 👷"
echo
echo "Gotenberg version: $version"
echo "Target platform: $platform"
if [ -n "$alternate_repository" ]; then
DOCKER_REPOSITORY=$alternate_repository
echo "⚠️ Using $alternate_repository for DOCKER_REPOSITORY"
fi
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
# Build tags arrays.
tags=()
tags_cloud_run=()
IFS='/' read -ra arch <<< "$platform"
IFS='.' read -ra semver <<< "$version"
if [ "${#semver[@]}" -eq 3 ]; then
echo
echo "Semver version detected"
major="${semver[0]}"
minor="${semver[1]}"
patch="${semver[2]}"
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-${arch[1]}")
if [ "$platform" = "linux/amd64" ]; then
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-cloudrun")
fi
else
echo
echo "Non-semver version detected, fallback to $version"
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
if [ "$platform" = "linux/amd64" ]; then
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
fi
fi
tags_flags=()
tags_cloud_run_flags=()
echo "Will use the following tags:"
for tag in "${tags[@]}"; do
tags_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run[@]}"; do
tags_cloud_run_flags+=("-t" "$tag")
echo "- $tag"
done
echo
# Build images.
run_cmd() {
local cmd="$1"
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would run the following command:"
echo "$cmd"
echo
else
echo "⚙️ Running command:"
echo "$cmd"
eval "$cmd"
echo
fi
}
join() {
local delimiter="$1"
shift
local IFS="$delimiter"
echo "$*"
}
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
cmd="docker buildx build \
--build-arg GOLANG_VERSION=$GOLANG_VERSION \
--build-arg GOTENBERG_VERSION=$version \
--build-arg GOTENBERG_USER_GID=$GOTENBERG_USER_GID \
--build-arg GOTENBERG_USER_UID=$GOTENBERG_USER_UID \
--build-arg NOTO_COLOR_EMOJI_VERSION=$NOTO_COLOR_EMOJI_VERSION \
--build-arg PDFTK_VERSION=$PDFTK_VERSION \
--build-arg PDFCPU_VERSION=$PDFCPU_VERSION \
--platform $platform \
--load \
${tags_flags[*]} \
-t $no_arch_tag \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
if [ "$platform" != "linux/amd64" ]; then
echo "⚠️ Skip Cloud Run variant(s)"
echo "✅ Done!"
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
exit 0
fi
cmd="docker build \
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
--build-arg GOTENBERG_VERSION=$version \
${tags_cloud_run_flags[*]} \
-f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
echo "✅ Done!"
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
exit 0

70
.github/actions/build-test-push/push.sh vendored Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
tags=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--tags)
tags="$2"
shift 2
;;
--dry-run)
dry_run=$2
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Push tag(s) 📦"
echo
echo "Tag(s) to push:"
IFS=',' read -ra tags_to_push <<< "$tags"
for tag in "${tags_to_push[@]}"; do
echo "- $tag"
done
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
echo
# Push tags.
run_cmd() {
local cmd="$1"
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would run the following command:"
echo "$cmd"
echo
else
echo "⚙️ Running command:"
echo "$cmd"
eval "$cmd"
echo
fi
}
for tag in "${tags_to_push[@]}"; do
cmd="docker push $tag"
run_cmd "$cmd"
echo "➡️ $tag pushed"
echo
done
echo "✅ Done!"
exit 0

79
.github/actions/build-test-push/test.sh vendored Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
version=""
platform=""
alternate_repository=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--version)
version="${2//v/}"
shift 2
;;
--platform)
platform="$2"
shift 2
;;
--alternate-repository)
alternate_repository="$2"
shift 2
;;
--dry-run)
dry_run="$2"
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Integration testing 🧪"
echo
echo "Gotenberg version: $version"
echo "Target platform: $platform"
repository=$DOCKER_REPOSITORY
if [ -n "$alternate_repository" ]; then
echo "⚠️ Using $alternate_repository for DOCKER_REPOSITORY"
repository=$alternate_repository
fi
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
echo
# Test image.
run_cmd() {
local cmd="$1"
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would run the following command:"
echo "$cmd"
echo
else
echo "⚙️ Running command:"
echo "$cmd"
eval "$cmd"
echo
fi
}
cmd="make test-integration DOCKER_REPOSITORY=$repository GOTENBERG_VERSION=$version PLATFORM=$platform NO_CONCURRENCY=true"
run_cmd "$cmd"
echo "✅ Done!"
exit 0

31
.github/actions/clean/action.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Clean
description: Clean tags from Docker Hub
author: Julien Neuhart
inputs:
docker_hub_username:
description: The Docker Hub username
required: true
docker_hub_password:
description: The Docker Hub password
required: true
tags:
description: Comma separated list of tags to clean
snapshot_version:
description: Snapshot version to clean
dry_run:
description: Dry run this action
runs:
using: composite
steps:
- name: Clean tags from Docker Hub
env:
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
shell: bash
run: |
.github/actions/clean/clean.sh \
--tags "${{ inputs.tags }}" \
--snapshot-version "${{ inputs.snapshot_version }}" \
--dry-run "${{ inputs.dry_run }}"

122
.github/actions/clean/clean.sh vendored Executable file
View File

@@ -0,0 +1,122 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
tags=""
snapshot_version=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--tags)
tags="$2"
shift 2
;;
--snapshot-version)
snapshot_version="${2//v/}"
shift 2
;;
--dry-run)
dry_run="$2"
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Clean tag(s) from Docker Hub 🧹"
echo
IFS=',' read -ra tags_to_delete <<< "$tags"
if [ -n "$snapshot_version" ]; then
tags_to_delete+=("$DOCKER_REGISTRY/snapshot:$snapshot_version")
tags_to_delete+=("$DOCKER_REGISTRY/snapshot:$snapshot_version-cloudrun")
fi
echo "Will delete the following tag(s):"
for tag in "${tags_to_delete[@]}"; do
echo "- $tag"
done
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
echo
# Delete tags.
base_url="https://hub.docker.com/v2"
token=""
if [ "$dry_run" = "true" ]; then
token="placeholder"
echo "🚧 Dry run - would call $base_url to get a token"
echo
else
echo "🌐 Get token from $base_url"
readarray -t lines < <(
curl -s -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"$DOCKERHUB_USERNAME\", \"password\":\"$DOCKERHUB_TOKEN\"}" \
-w "\n%{http_code}" \
"$base_url/users/login"
)
http_code="${lines[-1]}"
unset 'lines[-1]'
json_body=$(printf "%s\n" "${lines[@]}")
if [ "$http_code" -ne "200" ]; then
echo "❌ Wrong HTTP status - $http_code"
echo "$json_body"
exit 1
fi
token=$(jq -r '.token' <<< "$json_body")
echo
fi
if [ -z "$token" ]; then
echo "❌ No token from Docker Hub"
exit 1
fi
for tag in "${tags_to_delete[@]}"; do
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would call $base_url to delete tag $tag"
echo
else
echo "🌐 Delete tag $tag"
IFS=':' read -ra tag_parts <<< "$tag"
readarray -t lines < <(
curl -s -X DELETE \
-H "Authorization: Bearer $token" \
-w "\n%{http_code}" \
"$base_url/repositories/${tag_parts[0]}/tags/${tag_parts[1]}/"
)
http_code="${lines[-1]}"
unset 'lines[-1]'
if [ "$http_code" -ne "200" ] && [ "$http_code" -ne "204" ]; then
echo "❌ Wrong HTTP status - $http_code"
printf '%s\n' "${lines[@]}"
exit 1
fi
echo
fi
done
echo "✅ Done!"
exit 0

48
.github/actions/merge/action.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Merge
description: Merge tags to single multi-platform tags
author: Julien Neuhart
inputs:
github_token:
description: The GitHub token
required: true
default: ${{ github.token }}
docker_hub_username:
description: The Docker Hub username
required: true
docker_hub_password:
description: The Docker Hub password
required: true
tags:
description: Comma separated tags to merge
required: true
alternate_registry:
description: Alternate registry to also push resulting tags
dry_run:
description: Dry run this action
runs:
using: composite
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ inputs.docker_hub_username }}
password: ${{ inputs.docker_hub_password }}
- name: Merge
shell: bash
run: |
.github/actions/merge/merge.sh \
--tags "${{ inputs.tags }}" \
--alternate-registry "${{ inputs.alternate_registry }}" \
--dry-run "${{ inputs.dry_run }}"

107
.github/actions/merge/merge.sh vendored Executable file
View File

@@ -0,0 +1,107 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
tags=""
alternate_registry=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--tags)
tags="$2"
shift 2
;;
--alternate-registry)
alternate_registry="$2"
shift 2
;;
--dry-run)
dry_run=$2
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Merge tag(s) 👷"
echo
echo "Tag(s) to merge:"
IFS=',' read -ra tags_to_merge <<< "$tags"
for tag in "${tags_to_merge[@]}"; do
echo "- $tag"
done
if [ -n "$alternate_registry" ]; then
echo "⚠️ Will also push to $alternate_registry registry"
fi
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
echo
# Build merge map.
declare -A merge_map
for tag in "${tags_to_merge[@]}"; do
target_tag="${tag//-amd64/}"
target_tag="${target_tag//-arm64/}"
target_tag="${target_tag//-arm/}"
target_tag="${target_tag//-386/}"
merge_map["$target_tag"]+="$tag "
done
# Merge tags.
run_cmd() {
local cmd="$1"
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would run the following command:"
echo "$cmd"
echo
else
echo "⚙️ Running command:"
echo "$cmd"
eval "$cmd"
echo
fi
}
for target in "${!merge_map[@]}"; do
IFS=' ' read -ra source_tags <<< "${merge_map[$target]}"
cmd="docker buildx imagetools create \
-t $target \
${source_tags[*]}
"
run_cmd "$cmd"
echo "➡️ $target pushed"
echo
if [ -n "$alternate_registry" ]; then
alternate_target="${target/$DOCKER_REGISTRY/$alternate_registry}"
cmd="docker buildx imagetools create \
-t $alternate_target \
$target
"
run_cmd "$cmd"
echo "➡️ $alternate_target pushed"
echo
fi
done
echo "✅ Done!"
exit 0

View File

@@ -1,6 +1,5 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"

2
.github/stale.yml vendored
View File

@@ -15,4 +15,4 @@ markComment: >
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
closeComment: false

View File

@@ -1,51 +0,0 @@
on:
workflow_call:
inputs:
runs_on:
type: string
required: true
platform:
type: string
required: true
gotenberg_version:
type: string
required: true
alternate_repository:
type: string
default: ''
outputs:
tags:
value: ${{ jobs.build_push.outputs.tags }}
permissions:
contents: read
jobs:
build_push:
name: Build and push Docker images
runs-on: ${{ inputs.runs_on }}
outputs:
tags: ${{ steps.build_push.outputs.tags }}
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push ${{ inputs.platform }}
id: build_push
uses: gotenberg/gotenberg-release-action/actions/build-push@main
with:
version: ${{ inputs.gotenberg_version }}
platform: ${{ inputs.platform }}
alternate_repository: ${{ inputs.alternate_repository }}

View File

@@ -1,28 +0,0 @@
on:
workflow_call:
inputs:
gotenberg_version:
type: string
required: true
alternate_repository:
type: string
default: ''
permissions:
contents: read
jobs:
delete:
name: Delete Docker images
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Delete
uses: gotenberg/gotenberg-release-action/actions/delete@main
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ inputs.gotenberg_version }}
alternate_repository: ${{ inputs.alternate_repository }}

View File

@@ -1,40 +0,0 @@
on:
workflow_call:
inputs:
tags:
type: string
required: true
alternate_registry:
type: string
default: ''
permissions:
contents: read
jobs:
merge_clean:
name: Merge and clean Docker images
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Merge and clean
uses: gotenberg/gotenberg-release-action/actions/merge-clean@main
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: ${{ inputs.tags }}
alternate_registry: ${{ inputs.alternate_registry }}

View File

@@ -2,7 +2,7 @@ name: Continuous Delivery
on:
release:
types: [ published ]
types: [published]
permissions:
contents: read
@@ -10,39 +10,83 @@ permissions:
jobs:
release_amd64:
name: Release linux/amd64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/amd64
gotenberg_version: ${{ github.event.release.tag_name }}
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build and push
id: build_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/amd64
skip_integrations_tests: true
release_386:
name: Release linux/386
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/386
gotenberg_version: ${{ github.event.release.tag_name }}
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build and push
id: build_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/386
skip_integrations_tests: true
release_arm64:
name: Release linux/arm64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm64
gotenberg_version: ${{ github.event.release.tag_name }}
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build and push
id: build_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/arm64
skip_integrations_tests: true
release_arm_v7:
name: Release linux/arm/v7
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm/v7
gotenberg_version: ${{ github.event.release.tag_name }}
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build and push
id: build_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/arm/v7
skip_integrations_tests: true
merge_clean_release_tags:
needs:
@@ -51,8 +95,22 @@ jobs:
- release_arm64
- release_arm_v7
name: Merge and clean release tags
uses: ./.github/workflows/__merge-clean.yml
secrets: inherit
with:
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Merge
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"

View File

@@ -16,103 +16,150 @@ permissions:
contents: write
jobs:
lint:
name: Lint
name: Lint Golang codebase
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: false
- name: Checkout source code
uses: actions/checkout@v4
go-version-file: go.mod
- name: Run linters
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v7
with:
version: v1.63.4
version: v2.0.2
tests:
needs:
- lint
name: Tests
lint-prettier:
name: Lint non-Golang codebase
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout source code
uses: actions/checkout@v4
- name: Build testing environment
run: make build build-tests
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Install Dependencies
run: npm i
- name: Run linters
run: make lint-prettier
test-unit:
needs:
- lint
- lint-prettier
name: Run unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run tests
run: make tests-once
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
run: make test-unit
snapshot_amd64:
if: github.event_name == 'pull_request'
needs:
- tests
- test-unit
name: Snapshot linux/amd64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/amd64
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: pr-${{ github.event.pull_request.number }}
platform: linux/amd64
alternate_repository: snapshot
snapshot_386:
if: github.event_name == 'pull_request'
needs:
- tests
- test-unit
name: Snapshot linux/386
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/386
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: pr-${{ github.event.pull_request.number }}
platform: linux/386
alternate_repository: snapshot
snapshot_arm64:
if: github.event_name == 'pull_request'
needs:
- tests
- test-unit
name: Snapshot linux/arm64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm64
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: pr-${{ github.event.pull_request.number }}
platform: linux/arm64
alternate_repository: snapshot
snapshot_arm_v7:
if: github.event_name == 'pull_request'
needs:
- tests
- test-unit
name: Snapshot linux/arm/v7
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm/v7
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: pr-${{ github.event.pull_request.number }}
platform: linux/arm/v7
alternate_repository: snapshot
merge_clean_snapshot_tags:
needs:
@@ -121,58 +168,112 @@ jobs:
- snapshot_arm64
- snapshot_arm_v7
name: Merge and clean snapshot tags
uses: ./.github/workflows/__merge-clean.yml
secrets: inherit
with:
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Merge
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
edge_amd64:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
- test-unit
name: Edge linux/amd64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/amd64
gotenberg_version: edge
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: edge
platform: linux/amd64
edge_386:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
- test-unit
name: Edge linux/386
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/386
gotenberg_version: edge
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: edge
platform: linux/386
edge_arm64:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
- test-unit
name: Edge linux/arm64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm64
gotenberg_version: edge
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: edge
platform: linux/arm64
edge_arm_v7:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
- test-unit
name: Edge linux/arm/v7
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm/v7
gotenberg_version: edge
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: edge
platform: linux/arm/v7
merge_clean_edge_tags:
needs:
@@ -181,8 +282,22 @@ jobs:
- edge_arm64
- edge_arm_v7
name: Merge and clean edge tags
uses: ./.github/workflows/__merge-clean.yml
secrets: inherit
with:
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Merge
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"

View File

@@ -2,17 +2,22 @@ name: Pull Request Cleanup
on:
pull_request:
types: [ closed ]
types: [closed]
permissions:
contents: read
jobs:
cleanup:
name: Cleanup
uses: ./.github/workflows/__delete.yml
secrets: inherit
with:
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot
name: Cleanup Docker images
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Cleanup
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
snapshot_version: pr-${{ github.event.pull_request.number }}

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
.idea
.vscode
.DS_Store
/node_modules/

View File

@@ -1,56 +1,65 @@
linters-settings:
gci:
sections:
- standard
- default
- prefix(github.com/gotenberg/gotenberg/v8)
skip-generated: true
custom-order: true
version: "2"
run:
issues-exit-code: 1
tests: false
linters:
disable-all: true
default: none
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- copyloopvar
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- copyloopvar
- errcheck
- errname
- exhaustive
- gci
- gofmt
- goimports
- gofumpt
- gosec
- gosimple
- govet
- ineffassign
- importas
- ineffassign
- misspell
- prealloc
- promlinter
#- sloglint
- staticcheck
- tenv
- testableexamples
- tparallel
- typecheck
- unconvert
- unused
- usetesting
- wastedassign
- whitespace
run:
timeout: 5m
issues-exit-code: 1
tests: false
output:
print-issued-lines: true
print-linter-name: true
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gci
- gofmt
- gofumpt
- goimports
settings:
gci:
sections:
- standard
- default
- prefix(github.com/gotenberg/gotenberg/v8)
custom-order: true
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

1
.node-version Normal file
View File

@@ -0,0 +1 @@
23.9.0

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
build

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"plugins": ["prettier-plugin-gherkin", "prettier-plugin-sh"],
"escapeBackslashes": true
}

View File

@@ -2,10 +2,7 @@ include .env
.PHONY: help
help: ## Show the help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: it
it: build build-tests ## Initialize the development environment
@grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: build
build: ## Build the Gotenberg's Docker image
@@ -37,6 +34,7 @@ API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false
API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=10
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_AUTO_START=false
@@ -62,7 +60,7 @@ LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES=
LOG_ENABLE_GCP_FIELDS=false
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_FLATTEN_ENGINES=qpdf
@@ -107,6 +105,7 @@ run: ## Start a Gotenberg container
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--api-enable-debug-route=$(API_ENABLE_DEBUG_ROUTE) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
@@ -132,10 +131,10 @@ run: ## Start a Gotenberg container
--log-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \
--log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_FLATTEN_ENGINES) \
--pdfengines-flatten-engines=$(PDFENGINES_FLATTEN_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
@@ -154,39 +153,42 @@ run: ## Start a Gotenberg container
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
--webhook-disable=$(WEBHOOK_DISABLE)
.PHONY: build-tests
build-tests: ## Build the tests' Docker image
docker build \
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
--build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
-f test/Dockerfile .
.PHONY: test-unit
test-unit: ## Run unit tests
go test -race ./...
.PHONY: tests
tests: ## Start the testing environment
docker run --rm -it \
-v $(PWD):/tests \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
bash
PLATFORM=
NO_CONCURRENCY=false
.PHONY: tests-once
tests-once: ## Run the tests once (prefer the "tests" command while developing)
docker run --rm \
-v $(PWD):/tests \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
gotest
.PHONY: test-integration
test-integration: ## Run integration tests
go test -timeout 20m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
--gotenberg-version=$(GOTENBERG_VERSION) \
--gotenberg-container-platform=$(PLATFORM) \
--no-concurrency=$(NO_CONCURRENCY)
.PHONY: lint
lint: ## Lint Golang codebase
golangci-lint run
.PHONY: lint-prettier
lint-prettier: ## Lint non-Golang codebase
npx prettier --check .
.PHONY: lint-todo
lint-todo: ## Find TODOs in Golang codebase
golangci-lint run --no-config --disable-all --enable godox
# go install mvdan.cc/gofumpt@latest
# go install github.com/daixiang0/gci@latest
.PHONY: fmt
fmt: ## Format the code and "optimize" the dependencies
gofumpt -l -w .
gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v8)" --skip-generated --skip-vendor --custom-order .
fmt: ## Format Golang codebase and "optimize" the dependencies
golangci-lint fmt
go mod tidy
.PHONY: prettify
prettify: ## Format non-Golang codebase
npx prettier --write .
# go install golang.org/x/tools/cmd/godoc@latest
.PHONY: godoc
godoc: ## Run a webserver with Gotenberg godoc

View File

@@ -1,21 +1,22 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for PDF files</p>
<p align="center">A containerized API for seamless PDF conversion</p>
<p align="center">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
<br>
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a>
<a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
<a href="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"><img alt="Code coverage" src="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
</p>
---
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
## Quick Start
@@ -45,6 +46,9 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
<a href="https://zolsec.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/gotenberg/gotenberg/assets/8983173/707ccc97-a79b-4dcb-8fc8-6827366e5be3" alt="Zolsec Logo" width="333" height="163" />
</a>
<a href="https://pdfme.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/user-attachments/assets/2a75dd40-ca18-4d34-acd5-5dd474595168" alt="pdfme Logo" width="333" height="163" />
</a>
</p>
Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️

View File

@@ -2,25 +2,25 @@
## Supported Versions
Please ensure to keep your environment up-to-date and use only the latest version of Gotenberg.
Please ensure to keep your environment up-to-date and use only the latest version of Gotenberg.
Security updates and patches will be applied only to the most recent version.
## Reporting a Vulnerability
Your help in identifying vulnerabilities in our project is much appreciated.
Your help in identifying vulnerabilities in our project is much appreciated.
We take all reports regarding security seriously.
If you discover a security vulnerability, please refrain from publishing it publicly.
Instead, kindly send us the details via email to *neuhart [dot] julien [at] gmail [dot] com*.
If you discover a security vulnerability, please refrain from publishing it publicly.
Instead, kindly send us the details via email to _neuhart [dot] julien [at] gmail [dot] com_.
In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg.
In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg.
In your message, please include:
* A detailed description of the vulnerability.
* The steps to reproduce the issue.
* Any potential impact of the vulnerability on the users or system.
- A detailed description of the vulnerability.
- The steps to reproduce the issue.
- Any potential impact of the vulnerability on the users or system.
Please remember that this process is done in a *'best-effort'* manner.
Please remember that this process is done in a _'best-effort'_ manner.
This means we strive to respond and act as quickly as possible, but the speed may vary depending on the severity of
the issue and our resources.
@@ -28,14 +28,14 @@ Thank you in advance for helping to keep our project safe!
## Disclosure Policy
Once we have received your vulnerability report, we will work to validate and reproduce the issue.
Once we have received your vulnerability report, we will work to validate and reproduce the issue.
If we can confirm the vulnerability, we will proceed to:
* Work on a fix and a release timeline.
* Notify you when the fix has been implemented and released.
* Credit you for discovering the vulnerability (unless you request anonymity).
* Please note that we will do our best to keep you informed about the progress towards resolving the issue.
- Work on a fix and a release timeline.
- Notify you when the fix has been implemented and released.
- Credit you for discovering the vulnerability (unless you request anonymity).
- Please note that we will do our best to keep you informed about the progress towards resolving the issue.
## Comments on this Policy
If you have suggestions on how this process could be improved, please submit a pull request.
If you have suggestions on how this process could be improved, please submit a pull request.

View File

@@ -63,7 +63,7 @@ ARG NOTO_COLOR_EMOJI_VERSION
ARG PDFTK_VERSION
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A Docker-powered stateless API for PDF files." \
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
@@ -171,7 +171,7 @@ RUN \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bookworm-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.0.1/unoconv -o /usr/bin/unoconverter &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.1.1/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\
# unoconverter will look for the Python binary, which has to be at version 3.
ln -s /usr/bin/python3 /usr/bin/python &&\

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -23,7 +24,7 @@ const banner = `
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
/___/
A Docker-powered stateless API for PDF files.
A containerized API for seamless PDF conversion.
Version: %s
-------------------------------------------------------
`
@@ -50,14 +51,42 @@ func Run() {
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
// Parse the flags...
// Parse the flags.
err := fs.Parse(os.Args[1:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// ...and create a wrapper around those.
// Override their values if the corresponding environment variables are
// set.
fs.VisitAll(func(f *flag.Flag) {
envName := strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
val, ok := os.LookupEnv(envName)
if !ok {
return
}
sliceVal, ok := f.Value.(flag.SliceValue)
if ok {
// We don't want to append the values (default pflag behavior).
items := strings.Split(val, ",")
err = sliceVal.Replace(items)
if err != nil {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
}
return
}
err = f.Value.Set(val)
if err != nil {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
}
})
// Create a wrapper around our flags.
parsedFlags := gotenberg.ParsedFlags{FlagSet: fs}
// Get the graceful shutdown duration.
@@ -108,6 +137,9 @@ func Run() {
}(l.(gotenberg.SystemLogger))
}
// Build the debug data.
gotenberg.BuildDebug(ctx)
quit := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes).

109
go.mod
View File

@@ -1,72 +1,127 @@
module github.com/gotenberg/gotenberg/v8
go 1.23.0
go 1.24.0
require (
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20250126231910-1730200a0f74
github.com/chromedp/chromedp v0.12.1
github.com/golang/snappy v0.0.4 // indirect
github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8
github.com/chromedp/chromedp v0.13.3
github.com/google/uuid v1.6.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.27
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/prometheus/client_golang v1.20.5
github.com/prometheus/client_golang v1.21.1
github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.6
github.com/ulikunitz/xz v0.5.12 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.34.0
golang.org/x/sync v0.10.0
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0
golang.org/x/text v0.21.0
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.38.0
golang.org/x/sync v0.12.0
golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.30.0
golang.org/x/text v0.23.0
)
require (
github.com/dlclark/regexp2 v1.11.4
github.com/shirou/gopsutil/v4 v4.24.12
github.com/cucumber/godog v0.15.0
github.com/dlclark/regexp2 v1.11.5
github.com/docker/docker v28.0.4+incompatible
github.com/docker/go-connections v0.5.0
github.com/mholt/archives v0.1.0
github.com/shirou/gopsutil/v4 v4.25.2
github.com/testcontainers/testcontainers-go v0.36.0
)
require (
dario.cat/mergo v1.0.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.1.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sorairolake/lzip-go v0.3.7 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/therootcompany/xz v1.0.1 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

516
go.sum
View File

@@ -1,6 +1,34 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/STARRY-S/zip v0.2.2 h1:8QeCbIi1Z9U5MgoDARJR1ClbBo9RD46SmVy+dl0woCk=
github.com/STARRY-S/zip v0.2.2/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -9,26 +37,76 @@ github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEX
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A=
github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250126231910-1730200a0f74 h1:oul0R+ZyorVxVNr7bALbPolKvbXlpIefB4lDv5sjt00=
github.com/chromedp/cdproto v0.0.0-20250126231910-1730200a0f74/go.mod h1:RTGuBeCeabAJGi3OZf71a6cGa7oYBfBP75VJZFLv6SU=
github.com/chromedp/chromedp v0.12.1 h1:kBMblXk7xH5/6j3K9uk8d7/c+fzXWiUsCsPte0VMwOA=
github.com/chromedp/chromedp v0.12.1/go.mod h1:F6+wdq9LKFDMoyxhq46ZLz4VLXrsrCAR3sFqJz4Nqc0=
github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8 h1:AqW2bDQf67Zbq6Tpop/+yJSIknxhiQecO2B8jNYTAPs=
github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.13.3 h1:c6nTn97XQBykzcXiGYL5LLebw3h3CEyrCihm4HquYh0=
github.com/chromedp/chromedp v0.13.3/go.mod h1:khsDP9OP20GrowpJfZ7N05iGCwcAYxk7qf9AZBzR3Qw=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
github.com/cucumber/godog v0.15.0 h1:51AL8lBXF3f0cyA5CV4TnJFCTHpgiy+1x1Hb3TtZUmo=
github.com/cucumber/godog v0.15.0/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces=
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY=
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
@@ -38,32 +116,93 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
@@ -72,96 +211,345 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/mholt/archives v0.1.0 h1:FacgJyrjiuyomTuNA92X5GyRBRZjE43Y/lrzKIlF35Q=
github.com/mholt/archives v0.1.0/go.mod h1:j/Ire/jm42GN7h90F5kzj6hf6ZFzEH66de+hmjEKu+I=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode/v2 v2.1.1 h1:OJaYalXdliBUXPmC8CZGQ7oZDxzX1/5mQmgn0/GASew=
github.com/nwaples/rardecode/v2 v2.1.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4=
github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sorairolake/lzip-go v0.3.7 h1:vP2uiD/NoklLyzYMdgOWkZME0ulkSfVTTE4MNRKCwNs=
github.com/sorairolake/lzip-go v0.3.7/go.mod h1:THOHr0FlNVCw2eOIEE9shFJAG1QxQg/pf2XUPAmNIqg=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/testcontainers/testcontainers-go v0.36.0 h1:YpffyLuHtdp5EUsI5mT4sRw8GZhO/5ozyDT1xWGXt00=
github.com/testcontainers/testcontainers-go v0.36.0/go.mod h1:yk73GVJ0KUZIHUtFna6MO7QS144qYpoY8lEEtU9Hed0=
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

180
package-lock.json generated Normal file
View File

@@ -0,0 +1,180 @@
{
"name": "gotenberg",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"prettier": "3.5.2",
"prettier-plugin-gherkin": "^3.1.1",
"prettier-plugin-sh": "^0.15.0"
}
},
"node_modules/@cucumber/gherkin": {
"version": "27.0.0",
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-27.0.0.tgz",
"integrity": "sha512-j5rCsjqzRiC3iVTier3sa0kzyNbkcAmF7xr7jKnyO7qDeK3Z8Ye1P3KSVpeQRMY+KCDJ3WbTDdyxH0FwfA/fIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cucumber/messages": ">=19.1.4 <=22"
}
},
"node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-22.0.0.tgz",
"integrity": "sha512-EuaUtYte9ilkxcKmfqGF9pJsHRUU0jwie5ukuZ/1NPTuHS1LxHPsGEODK17RPRbZHOFhqybNzG2rHAwThxEymg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/uuid": "9.0.1",
"class-transformer": "0.5.1",
"reflect-metadata": "0.1.13",
"uuid": "9.0.0"
}
},
"node_modules/@cucumber/gherkin/node_modules/@types/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
"dev": true,
"license": "MIT"
},
"node_modules/@cucumber/gherkin/node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@cucumber/messages": {
"version": "23.0.0",
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-23.0.0.tgz",
"integrity": "sha512-2ZWKNnikNMBnle3P3cgy+fgzhABrY4oBbiWp9wcI8ANdT4LlYRN6jQ6j/9CQGTDGRfkyRtr/5VkYq7q24XCsuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/uuid": "9.0.6",
"class-transformer": "0.5.1",
"reflect-metadata": "0.1.13",
"uuid": "9.0.1"
}
},
"node_modules/@types/uuid": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.6.tgz",
"integrity": "sha512-BT2Krtx4xaO6iwzwMFUYvWBWkV2pr37zD68Vmp1CDV196MzczBRxuEpD6Pr395HAgebC/co7hOphs53r8V7jew==",
"dev": true,
"license": "MIT"
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"dev": true,
"license": "MIT"
},
"node_modules/mvdan-sh": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz",
"integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/prettier": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz",
"integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-gherkin": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-3.1.1.tgz",
"integrity": "sha512-cCfjqKMdR2a8jOf8yKg32iUfRq0Dfmx+K2qTMOD/ixJJq0Rp7QS8BaoABWC7CDGXbvOkVeWOvzhxWvYcEbjglw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cucumber/gherkin": "^27.0.0",
"@cucumber/messages": "^23.0.0",
"prettier": "^3.0.0"
}
},
"node_modules/prettier-plugin-sh": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.15.0.tgz",
"integrity": "sha512-U0PikJr/yr2bzzARl43qI0mApBj0C1xdAfA04AZa6LnvIKawXHhuy2fFo6LNA7weRzGlAiNbaEFfKMFo0nZr/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mvdan-sh": "^0.10.1",
"sh-syntax": "^0.4.2"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
},
"peerDependencies": {
"prettier": "^3.0.3"
}
},
"node_modules/reflect-metadata": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
"integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/sh-syntax": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.2.tgz",
"integrity": "sha512-/l2UZ5fhGZLVZa16XQM9/Vq/hezGGbdHeVEA01uWjOL1+7Ek/gt6FquW0iKKws4a9AYPYvlz6RyVvjh3JxOteg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
}
}
}

7
package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"devDependencies": {
"prettier": "3.5.2",
"prettier-plugin-gherkin": "^3.1.1",
"prettier-plugin-sh": "^0.15.0"
}
}

View File

@@ -1,306 +0,0 @@
package gotenberg
import (
"context"
"testing"
"time"
"go.uber.org/zap"
)
func TestCommand(t *testing.T) {
cmd := Command(zap.NewNop(), "foo")
if !cmd.process.SysProcAttr.Setpgid {
t.Error("expected cmd.process.SysProcAttr.Setpgid to be true")
}
}
func TestCommandContext(t *testing.T) {
tests := []struct {
scenario string
ctx context.Context
expectCommandContextError bool
}{
{
scenario: "nominal behavior",
ctx: context.Background(),
expectCommandContextError: false,
},
{
scenario: "nil context",
ctx: nil,
expectCommandContextError: true,
},
}
for _, tc := range tests {
t.Run(tc.scenario, func(t *testing.T) {
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
if err == nil && !cmd.process.SysProcAttr.Setpgid {
t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true")
}
if !tc.expectCommandContextError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectCommandContextError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestCmd_Start(t *testing.T) {
tests := []struct {
scenario string
cmd *Cmd
expectStartError bool
}{
{
scenario: "nominal behavior",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectStartError: false,
},
{
scenario: "start error",
cmd: Command(zap.NewNop(), "foo"),
expectStartError: true,
},
}
for _, tc := range tests {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.cmd.Start()
if !tc.expectStartError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectStartError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestCmd_Wait(t *testing.T) {
tests := []struct {
scenario string
cmd *Cmd
expectWaitError bool
}{
{
scenario: "nominal behavior",
cmd: func() *Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectWaitError: false,
},
{
scenario: "wait error",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectWaitError: true,
},
}
for _, tc := range tests {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.cmd.Wait()
if !tc.expectWaitError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectWaitError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestCmd_Exec(t *testing.T) {
tests := []struct {
scenario string
cmd *Cmd
timeout time.Duration
expectExecError bool
}{
{
scenario: "nominal behavior",
cmd: func() *Cmd {
cmd, err := CommandContext(context.Background(), zap.NewNop(), "echo", "Hello", "World")
if err != nil {
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
}
return cmd
}(),
expectExecError: false,
},
{
scenario: "nil context",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectExecError: true,
},
{
scenario: "start error",
cmd: func() *Cmd {
cmd, err := CommandContext(context.Background(), zap.NewNop(), "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectExecError: true,
},
{
scenario: "context done",
cmd: Command(zap.NewNop(), "sleep", "2"),
timeout: time.Duration(1) * time.Second,
expectExecError: true,
},
}
for _, tc := range tests {
t.Run(tc.scenario, func(t *testing.T) {
if tc.timeout > 0 {
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
defer cancel()
tc.cmd.ctx = ctx
}
_, err := tc.cmd.Exec()
if !tc.expectExecError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectExecError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestCmd_pipeOutput(t *testing.T) {
tests := []struct {
scenario string
cmd *Cmd
run bool
expectPipeOutputError bool
}{
{
scenario: "nominal behavior",
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
run: true,
expectPipeOutputError: false,
},
{
scenario: "no debug, no pipe",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
run: false,
expectPipeOutputError: false,
},
{
scenario: "stdout already piped",
cmd: func() *Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StdoutPipe()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
run: false,
expectPipeOutputError: true,
},
{
scenario: "stderr already piped",
cmd: func() *Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StderrPipe()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
run: false,
expectPipeOutputError: true,
},
}
for _, tc := range tests {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.cmd.pipeOutput()
if tc.run {
errStart := tc.cmd.process.Start()
if errStart != nil {
t.Fatalf("expected no error but got: %v", err)
}
}
if !tc.expectPipeOutputError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectPipeOutputError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestCmd_Kill(t *testing.T) {
tests := []struct {
scenario string
cmd *Cmd
}{
{
scenario: "nominal behavior",
cmd: func() *Cmd {
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
},
{
scenario: "no process",
cmd: &Cmd{logger: zap.NewNop()},
},
{
scenario: "process already killed",
cmd: func() *Cmd {
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
},
}
for _, tc := range tests {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.cmd.Kill()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}

View File

@@ -5,23 +5,6 @@ import (
"testing"
)
func TestNewContext(t *testing.T) {
if NewContext(ParsedFlags{}, nil) == nil {
t.Error("expected a non-nil value")
}
}
func TestContext_ParsedFlags(t *testing.T) {
ctx := NewContext(ParsedFlags{}, nil)
actual := ctx.ParsedFlags()
expect := ParsedFlags{}
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContext_Module(t *testing.T) {
for _, tc := range []struct {
scenario string

68
pkg/gotenberg/debug.go Normal file
View File

@@ -0,0 +1,68 @@
package gotenberg
import (
"runtime"
"sort"
"sync"
flag "github.com/spf13/pflag"
)
// DebugInfo gathers data for debugging.
type DebugInfo struct {
Version string `json:"version"`
Architecture string `json:"architecture"`
Modules []string `json:"modules"`
ModulesAdditionalData map[string]map[string]interface{} `json:"modules_additional_data"`
Flags map[string]interface{} `json:"flags"`
}
// BuildDebug builds the debug data from modules.
func BuildDebug(ctx *Context) {
debugMu.Lock()
defer debugMu.Unlock()
debug = &DebugInfo{
Version: Version,
Architecture: runtime.GOARCH,
Modules: make([]string, len(ctx.moduleInstances)),
ModulesAdditionalData: make(map[string]map[string]interface{}),
Flags: make(map[string]interface{}),
}
i := 0
for ID, mod := range ctx.moduleInstances {
debug.Modules[i] = ID
i++
debuggable, ok := mod.(Debuggable)
if !ok {
continue
}
debug.ModulesAdditionalData[ID] = debuggable.Debug()
}
sort.Sort(AlphanumericSort(debug.Modules))
ctx.ParsedFlags().VisitAll(func(f *flag.Flag) {
debug.Flags[f.Name] = f.Value.String()
})
}
// Debug returns the debug data.
func Debug() DebugInfo {
debugMu.Lock()
defer debugMu.Unlock()
if debug == nil {
return DebugInfo{}
}
return *debug
}
var (
debug *DebugInfo
debugMu sync.Mutex
)

View File

@@ -0,0 +1,72 @@
package gotenberg
import (
"reflect"
"runtime"
"testing"
flag "github.com/spf13/pflag"
)
func TestBuildDebug(t *testing.T) {
if !reflect.DeepEqual(Debug(), DebugInfo{}) {
t.Errorf("Debug() should return empty debug data")
}
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
fs.String("foo", "bar", "Set foo")
ctx := NewContext(ParsedFlags{
FlagSet: fs,
}, func() []ModuleDescriptor {
mod1 := &struct {
ModuleMock
}{}
mod1.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod1 }}
}
mod2 := &struct {
ModuleMock
DebuggableMock
}{}
mod2.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "bar", New: func() Module { return mod2 }}
}
mod2.DebugMock = func() map[string]interface{} {
return map[string]interface{}{
"foo": "bar",
}
}
return []ModuleDescriptor{mod1.Descriptor(), mod2.Descriptor()}
}())
// Load modules.
_, err := ctx.Modules(new(Module))
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
// Build debug data.
BuildDebug(ctx)
expect := DebugInfo{
Version: Version,
Architecture: runtime.GOARCH,
Modules: []string{
"bar",
"foo",
},
ModulesAdditionalData: map[string]map[string]interface{}{
"bar": {
"foo": "bar",
},
},
Flags: map[string]interface{}{
"foo": "bar",
},
}
if !reflect.DeepEqual(expect, Debug()) {
t.Errorf("expected '%+v', bug got '%+v'", expect, Debug())
}
}

View File

@@ -3,8 +3,6 @@ package gotenberg
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
@@ -86,25 +84,6 @@ func (fs *FileSystem) MkdirAll() (string, error) {
return path, nil
}
// WalkDir walks through the root level of a directory and returns a list of
// files paths that match the specified file extension.
func WalkDir(dir, ext string) ([]string, error) {
var files []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if info.IsDir() {
return nil
}
if strings.EqualFold(filepath.Ext(info.Name()), ext) {
files = append(files, path)
}
return nil
})
return files, err
}
// Interface guards.
var (
_ MkdirAll = (*OsMkdirAll)(nil)

View File

@@ -1,222 +0,0 @@
package gotenberg
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/google/uuid"
)
func TestOsMkdirAll_MkdirAll(t *testing.T) {
dirPath, err := NewFileSystem(new(OsMkdirAll)).MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestOsPathRename_Rename(t *testing.T) {
dirPath, err := NewFileSystem(new(OsMkdirAll)).MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
path := "/tests/test/testdata/api/sample1.txt"
copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
in, err := os.Open(path)
if err != nil {
t.Fatalf("open file: %v", err)
}
defer func() {
err := in.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}()
out, err := os.Create(copyPath)
if err != nil {
t.Fatalf("create new file: %v", err)
}
defer func() {
err := out.Close()
if err != nil {
t.Fatalf("close new file: %v", err)
}
}()
_, err = io.Copy(out, in)
if err != nil {
t.Fatalf("copy file to new file: %v", err)
}
rename := new(OsPathRename)
newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
err = rename.Rename(copyPath, newPath)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestFileSystem_WorkingDir(t *testing.T) {
fs := NewFileSystem(new(MkdirAllMock))
dirName := fs.WorkingDir()
if dirName == "" {
t.Error("expected directory name but got empty string")
}
}
func TestFileSystem_WorkingDirPath(t *testing.T) {
fs := NewFileSystem(new(MkdirAllMock))
expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir())
if fs.WorkingDirPath() != expectedPath {
t.Errorf("expected path '%s' but got '%s'", expectedPath, fs.WorkingDirPath())
}
}
func TestFileSystem_NewDirPath(t *testing.T) {
fs := NewFileSystem(new(MkdirAllMock))
newDir := fs.NewDirPath()
expectedPrefix := fs.WorkingDirPath()
if !strings.HasPrefix(newDir, expectedPrefix) {
t.Errorf("expected new directory to start with '%s' but got '%s'", expectedPrefix, newDir)
}
}
func TestFileSystem_MkdirAll(t *testing.T) {
for _, tc := range []struct {
scenario string
mkdirAll MkdirAll
expectError bool
}{
{
scenario: "error",
mkdirAll: &MkdirAllMock{
MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("foo")
},
},
expectError: true,
},
{
scenario: "success",
mkdirAll: &MkdirAllMock{
MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
},
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := NewFileSystem(tc.mkdirAll)
_, err := fs.MkdirAll()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestWalkDir(t *testing.T) {
for _, tc := range []struct {
scenario string
dir string
ext string
expectError bool
expectFiles []string
}{
{
scenario: "directory does not exist",
dir: uuid.NewString(),
ext: ".pdf",
expectError: true,
},
{
scenario: "find PDF files",
dir: func() string {
path := fmt.Sprintf("%s/a_directory", os.TempDir())
err := os.MkdirAll(path, 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/a_foo_file.pdf", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_bar_file.PDF", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_baz_file.txt", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return path
}(),
ext: ".pdf",
expectError: false,
expectFiles: []string{"/tmp/a_directory/a_bar_file.PDF", "/tmp/a_directory/a_foo_file.pdf"},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
defer func() {
err := os.RemoveAll(tc.dir)
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
files, err := WalkDir(tc.dir, tc.ext)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectError && err != nil {
return
}
if !reflect.DeepEqual(files, tc.expectFiles) {
t.Errorf("expected files %+v, but got %+v", tc.expectFiles, files)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package gotenberg
import (
"fmt"
"os"
"path"
"testing"
"time"
@@ -31,7 +32,7 @@ func TestGarbageCollect(t *testing.T) {
err := os.MkdirAll(path, 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_foo_file", path), []byte{1}, 0o755)
@@ -51,7 +52,7 @@ func TestGarbageCollect(t *testing.T) {
return path
}(),
includeSubstr: []string{"foo", fmt.Sprintf("%s/a_directory/a_bar_file", os.TempDir())},
includeSubstr: []string{"foo", path.Join(os.TempDir(), "/a_directory/a_bar_file")},
expectError: false,
expectExists: []string{"a_baz_file"},
expectNotExists: []string{"a_foo_file", "a_bar_file"},

View File

@@ -1,23 +0,0 @@
package gotenberg
import (
"testing"
"go.uber.org/zap"
)
func TestLeveledLogger_Error(t *testing.T) {
NewLeveledLogger(zap.NewNop()).Error("foo")
}
func TestLeveledLogger_Warn(t *testing.T) {
NewLeveledLogger(zap.NewNop()).Warn("foo")
}
func TestLeveledLogger_Info(t *testing.T) {
NewLeveledLogger(zap.NewNop()).Info("foo")
}
func TestLeveledLogger_Debug(t *testing.T) {
NewLeveledLogger(zap.NewNop()).Debug("foo")
}

View File

@@ -34,6 +34,14 @@ func (mod *ValidatorMock) Validate() error {
return mod.ValidateMock()
}
type DebuggableMock struct {
DebugMock func() map[string]interface{}
}
func (mod *DebuggableMock) Debug() map[string]interface{} {
return mod.DebugMock()
}
// PdfEngineMock is a mock for the [PdfEngine] interface.
//
//nolint:dupl

View File

@@ -1,249 +0,0 @@
package gotenberg
import (
"context"
"os"
"testing"
"go.uber.org/zap"
)
func TestModuleMock(t *testing.T) {
mock := &ModuleMock{
DescriptorMock: func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module {
return nil
}}
},
}
if mock.Descriptor().ID != "foo" {
t.Errorf("expected ID '%s' from ModuleMock.Descriptor, but got '%s'", "foo", mock.Descriptor().ID)
}
}
func TestProvisionerMock(t *testing.T) {
mock := &ProvisionerMock{
ProvisionMock: func(*Context) error {
return nil
},
}
err := mock.Provision(&Context{})
if err != nil {
t.Errorf("expected no error from ProvisionerMock.Provision, but got: %v", err)
}
}
func TestValidatorMock(t *testing.T) {
mock := &ValidatorMock{
ValidateMock: func() error {
return nil
},
}
err := mock.Validate()
if err != nil {
t.Errorf("expected no error from ValidatorMock.Validate, but got: %v", err)
}
}
func TestPDFEngineMock(t *testing.T) {
mock := &PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
return nil
},
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, nil
},
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
}
err := mock.Merge(context.Background(), zap.NewNop(), nil, "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err)
}
_, err = mock.Split(context.Background(), zap.NewNop(), SplitMode{}, "", "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Split, but got: %v", err)
}
err = mock.Flatten(context.Background(), zap.NewNop(), "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
}
err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
}
_, err = mock.ReadMetadata(context.Background(), zap.NewNop(), "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.ReadMetadata, but got: %v", err)
}
err = mock.WriteMetadata(context.Background(), zap.NewNop(), map[string]interface{}{}, "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.WriteMetadata but got: %v", err)
}
}
func TestPDFEngineProviderMock(t *testing.T) {
mock := &PdfEngineProviderMock{
PdfEngineMock: func() (PdfEngine, error) {
return new(PdfEngineMock), nil
},
}
_, err := mock.PdfEngine()
if err != nil {
t.Errorf("expected no error from PdfEngineProviderMock.PdfEngine, but got: %v", err)
}
}
func TestProcessMock(t *testing.T) {
mock := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
return nil
},
StopMock: func(logger *zap.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
return true
},
}
err := mock.Start(zap.NewNop())
if err != nil {
t.Errorf("expected no error from ProcessMock.Start, but got: %v", err)
}
err = mock.Stop(zap.NewNop())
if err != nil {
t.Errorf("expected no error from ProcessMock.Stop, but got: %v", err)
}
healthy := mock.Healthy(zap.NewNop())
if !healthy {
t.Error("expected true from ProcessMock.Healthy, but got false")
}
}
func TestProcessSupervisorMock(t *testing.T) {
mock := &ProcessSupervisorMock{
LaunchMock: func() error {
return nil
},
ShutdownMock: func() error {
return nil
},
HealthyMock: func() bool {
return true
},
RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return nil
},
ReqQueueSizeMock: func() int64 {
return 0
},
RestartsCountMock: func() int64 {
return 0
},
}
err := mock.Launch()
if err != nil {
t.Errorf("expected no error from ProcessSupervisorMock.Launch, but got: %v", err)
}
err = mock.Shutdown()
if err != nil {
t.Errorf("expected no error from ProcessSupervisorMock.Shutdown, but got: %v", err)
}
healthy := mock.Healthy()
if !healthy {
t.Error("expected true from ProcessSupervisorMock.Healthy, but got false")
}
err = mock.Run(context.TODO(), zap.NewNop(), nil)
if err != nil {
t.Errorf("expected no error from ProcessSupervisorMock.Run, but got: %v", err)
}
size := mock.ReqQueueSize()
if size != 0 {
t.Errorf("expected 0 from ProcessSupervisorMock.ReqQueueSize, but got: %d", size)
}
restarts := mock.RestartsCount()
if restarts != 0 {
t.Errorf("expected 0 from ProcessSupervisorMock.RestartsCount, but got: %d", restarts)
}
}
func TestLoggerProviderMock(t *testing.T) {
mock := &LoggerProviderMock{
LoggerMock: func(mod Module) (*zap.Logger, error) {
return nil, nil
},
}
_, err := mock.Logger(new(ModuleMock))
if err != nil {
t.Errorf("expected no error from LoggerProviderMock.Logger, but got: %v", err)
}
}
func TestMetricsProviderMock(t *testing.T) {
mock := &MetricsProviderMock{
MetricsMock: func() ([]Metric, error) {
return nil, nil
},
}
_, err := mock.Metrics()
if err != nil {
t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err)
}
}
func TestMkdirAllMock(t *testing.T) {
mock := &MkdirAllMock{
MkdirAllMock: func(dir string, perm os.FileMode) error {
return nil
},
}
err := mock.MkdirAll("/foo", 0o755)
if err != nil {
t.Errorf("expected no error from MkdirAllMock.MkdirAll, but got: %v", err)
}
}
func TestPathRenameMock(t *testing.T) {
mock := &PathRenameMock{
RenameMock: func(oldpath, newpath string) error {
return nil
},
}
err := mock.Rename("", "")
if err != nil {
t.Errorf("expected no error from PathRenameMock.Rename, but got: %v", err)
}
}

View File

@@ -75,6 +75,12 @@ type SystemLogger interface {
SystemMessages() []string
}
// Debuggable is a module interface for modules which want to provide
// additional debug data.
type Debuggable interface {
Debug() map[string]interface{}
}
// MustRegisterModule registers a module.
//
// To register a module, create an init() method in the module main go file:

View File

@@ -7,7 +7,8 @@ import (
)
// AlphanumericSort implements sort.Interface and helps to sort strings
// alphanumerically.
// alphanumerically by either a numeric prefix or, if missing, a numeric
// suffix.
//
// See: https://github.com/gotenberg/gotenberg/issues/805.
type AlphanumericSort []string
@@ -21,20 +22,20 @@ func (s AlphanumericSort) Swap(i, j int) {
}
func (s AlphanumericSort) Less(i, j int) bool {
numI, restI := extractPrefix(s[i])
numJ, restJ := extractPrefix(s[j])
numI, restI := extractNumber(s[i])
numJ, restJ := extractNumber(s[j])
// Compares numerical prefixes if they exist.
// If both strings contain a number, compare them numerically.
if numI != -1 && numJ != -1 {
if numI != numJ {
return numI < numJ
}
// If numbers are equal, falls back to string comparison of the rest.
// If the numbers are equal, compare the "rest" strings.
return restI < restJ
}
// If one has a numerical prefix and the other doesn't, the one with the
// number comes first.
// If one contains a number and the other doesn't, the one with the number
// comes first.
if numI != -1 {
return true
}
@@ -42,28 +43,52 @@ func (s AlphanumericSort) Less(i, j int) bool {
return false
}
// If neither has a numerical prefix, compare as strings
// Neither has a number; fall back to lexicographical order.
return s[i] < s[j]
}
// extractPrefix attempts to extract a numerical prefix and the rest of the filename
func extractPrefix(filename string) (int, string) {
matches := numPrefixRegexp.FindStringSubmatch(filename)
if len(matches) > 2 {
prefix, err := strconv.Atoi(matches[1])
if err == nil {
return prefix, matches[2]
// extractNumber attempts to extract a numeric portion from the filename.
// It first checks for a numeric prefix (digits at the beginning).
// If none is found, it next attempts to match a number immediately before the
// extension (for filenames such as "sample1_1.pdf").
// If that fails, it then attempts a trailing numeric pattern.
// If no number is found, it returns -1 and the original string.
func extractNumber(str string) (int, string) {
// Check for a numeric prefix.
if matches := prefixRegexp.FindStringSubmatch(str); len(matches) > 2 {
if num, err := strconv.Atoi(matches[1]); err == nil {
return num, matches[2]
}
}
// Returns -1 if no numerical prefix is found, indicating to just compare
// as strings.
return -1, filename
// Check for a number immediately before an extension.
if matches := extensionSuffixRegexp.FindStringSubmatch(str); len(matches) > 3 {
if num, err := strconv.Atoi(matches[2]); err == nil {
// Remove the numeric block but keep the extension.
return num, matches[1] + matches[3]
}
}
// Check for a trailing number (with no extension following).
if matches := suffixRegexp.FindStringSubmatch(str); len(matches) > 2 {
if num, err := strconv.Atoi(matches[2]); err == nil {
return num, matches[1]
}
}
// No numeric portion found.
return -1, str
}
var numPrefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`)
// Regular expressions used by extractNumber.
var (
// Matches a numeric prefix: one or more digits at the start.
prefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`)
// Matches a numeric block immediately before a file extension.
extensionSuffixRegexp = regexp.MustCompile(`^(.*?)(\d+)(\.[^.]+)$`)
// Matches a trailing numeric sequence when there is no extension.
suffixRegexp = regexp.MustCompile(`^(.*?)(\d+)$`)
)
// Interface guard.
var (
_ sort.Interface = (*AlphanumericSort)(nil)
)
var _ sort.Interface = (*AlphanumericSort)(nil)

View File

@@ -17,6 +17,16 @@ func TestAlphanumericSort(t *testing.T) {
values: []string{"10qux.pdf", "2_baz.txt", "2_aza.txt", "1bar.pdf", "Afoo.txt", "Bbar.docx", "25zeta.txt", "3.pdf", "4_foo.pdf"},
expectedSort: []string{"1bar.pdf", "2_aza.txt", "2_baz.txt", "3.pdf", "4_foo.pdf", "10qux.pdf", "25zeta.txt", "Afoo.txt", "Bbar.docx"},
},
{
scenario: "numeric suffixes with extensions",
values: []string{"sample1_10.pdf", "sample1_11.pdf", "sample1_4.pdf", "sample1_3.pdf", "sample1_1.pdf", "sample1_2.pdf"},
expectedSort: []string{"sample1_1.pdf", "sample1_2.pdf", "sample1_3.pdf", "sample1_4.pdf", "sample1_10.pdf", "sample1_11.pdf"},
},
{
scenario: "numeric suffixes",
values: []string{"sample1_10", "sample1_11", "sample1_4", "sample1_3", "sample1_1", "sample1_2"},
expectedSort: []string{"sample1_1", "sample1_2", "sample1_3", "sample1_4", "sample1_10", "sample1_11"},
},
{
scenario: "hrtime (PHP library)",
values: []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"},

View File

@@ -42,6 +42,7 @@ type Api struct {
basicAuthPassword string
downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool
enableDebugRoute bool
routes []Route
externalMiddlewares []Middleware
@@ -187,6 +188,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
fs.Bool("api-enable-debug-route", false, "Enable the debug route")
return fs
}(),
New: func() gotenberg.Module { return new(Api) },
@@ -212,6 +214,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
// Port from env?
portEnvVar := flags.MustString("api-port-from-env")
@@ -365,9 +368,10 @@ func (a *Api) Validate() error {
return err
}
routesMap := make(map[string]string, len(a.routes)+2)
routesMap := make(map[string]string, len(a.routes)+3)
routesMap["/health"] = "/health"
routesMap["/version"] = "/version"
routesMap["/debug"] = "/debug"
for _, route := range a.routes {
if route.Path == "" {
@@ -529,7 +533,7 @@ func (a *Api) Start() error {
hardTimeoutMiddleware(hardTimeout),
)
// ...and the version route.
// ...the version route.
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "version"),
func(c echo.Context) error {
@@ -538,6 +542,17 @@ func (a *Api) Start() error {
securityMiddleware,
)
// ...and the debug route.
if a.enableDebugRoute {
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "debug"),
func(c echo.Context) error {
return c.JSONPretty(http.StatusOK, gotenberg.Debug(), " ")
},
securityMiddleware,
)
}
// Wait for all modules to be ready.
ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout)
defer cancel()

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
package api
import (
"compress/flate"
"context"
"encoding/json"
"errors"
@@ -19,7 +18,7 @@ import (
"github.com/google/uuid"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3"
"github.com/mholt/archives"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm"
@@ -468,22 +467,33 @@ func (ctx *Context) BuildOutputFile() (string, error) {
if len(ctx.outputPaths) == 1 {
ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
return ctx.outputPaths[0], nil
}
z := archiver.Zip{
CompressionLevel: flate.DefaultCompression,
MkdirAll: true,
SelectiveCompression: true,
ContinueOnError: false,
OverwriteExisting: false,
ImplicitTopLevelFolder: false,
filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
f := make(map[string]string)
for _, outputPath := range ctx.outputPaths {
f[outputPath] = ""
}
return f
}())
if err != nil {
return "", fmt.Errorf("create files info: %w", err)
}
archivePath := ctx.GeneratePath(".zip")
out, err := os.Create(archivePath)
if err != nil {
return "", fmt.Errorf("create zip file: %w", err)
}
defer func(out *os.File) {
err := out.Close()
if err != nil {
ctx.logger.Error(fmt.Sprintf("close zip file: %s", err))
}
}(out)
err := z.Archive(ctx.outputPaths, archivePath)
err = archives.Zip{}.Archive(ctx.Context, out, filesInfo)
if err != nil {
return "", fmt.Errorf("archive output files: %w", err)
}

View File

@@ -1,836 +0,0 @@
package api
import (
"bytes"
"context"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestNewContext(t *testing.T) {
defaultAllowList, err := regexp2.Compile("", 0)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
defaultDenyList, err := regexp2.Compile("", 0)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
defaultDownloadFromCfg := downloadFromConfig{
allowList: defaultAllowList,
denyList: defaultDenyList,
maxRetry: 1,
disable: false,
}
for _, tc := range []struct {
scenario string
request *http.Request
bodyLimit int64
downloadFromCfg downloadFromConfig
downloadFromSrv *echo.Echo
expectContext *Context
expectError bool
expectHttpError bool
expectHttpStatus int
}{
{
scenario: "http.ErrNotMultipart",
request: httptest.NewRequest(http.MethodPost, "/", nil),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusUnsupportedMediaType,
},
{
scenario: "http.ErrMissingBoundary",
request: func() *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm)
return req
}(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusUnsupportedMediaType,
},
{
scenario: "malformed body",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "request entity too large: form values",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("key", "value")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
bodyLimit: 1,
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "request entity too large: downloadFrom",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
bodyLimit: 45, // form values = 44 bytes.
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "request entity too large: form files",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = part.Write([]byte("foo"))
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
bodyLimit: 1,
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "invalid downloadFrom form field: cannot unmarshal",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: no URL",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: filtered URL",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"https://foo.bar"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromCfg: func() downloadFromConfig {
denyList, err := regexp2.Compile("https://foo.bar", 0)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return downloadFromConfig{allowList: defaultAllowList, denyList: denyList, maxRetry: 1, disable: false}
}(),
expectError: true,
},
{
scenario: "invalid downloadFrom form field: unreachable URL",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: invalid status code",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
return c.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: no 'Content-Disposition' header",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: malformed 'Content-Disposition' header",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, ";;")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: no filename parameter in 'Content-Disposition' header",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, "inline;")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "success",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = part.Write([]byte("foo"))
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
if c.Request().Header.Get("User-Agent") != "Gotenberg" {
t.Fatalf("expected 'Gotenberg' from header 'User-Agent', but got '%s'", c.Request().Header.Get("User-Agent"))
}
if c.Request().Header.Get("X-Foo") != "Bar" {
t.Fatalf("expected 'Bar' from header 'X-Foo', but got '%s'", c.Request().Header.Get("X-Foo"))
}
if c.Request().Header.Get("Gotenberg-Trace") != "123" {
t.Fatalf("expected '123' from header 'Gotenberg-Trace', but got '%s'", c.Request().Header.Get("Gotenberg-Trace"))
}
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectContext: &Context{
values: map[string][]string{
"foo": {"foo"},
"downloadFrom": {
`[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`,
},
},
files: map[string]string{
"foo.txt": "foo.txt",
"bar.txt": "bar.txt", // downloadFrom.
},
},
expectError: false,
expectHttpError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.downloadFromSrv != nil {
go func() {
err := tc.downloadFromSrv.Start(":80")
if !errors.Is(err, http.ErrServerClosed) {
t.Error(err)
return
}
}()
defer func() {
err := tc.downloadFromSrv.Shutdown(context.TODO())
if err != nil {
t.Error(err)
}
}()
}
handler := func(c echo.Context) error {
ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123")
defer cancel()
// Context already cancelled.
defer cancel()
if err != nil {
return err
}
if tc.expectContext != nil {
if !reflect.DeepEqual(tc.expectContext.values, ctx.values) {
t.Fatalf("expected context.values to be %v but got %v", tc.expectContext.values, ctx.values)
}
if len(tc.expectContext.files) != len(ctx.files) {
t.Fatalf("expected context.files to contain %d items but got %d", len(tc.expectContext.files), len(ctx.files))
}
for key, value := range tc.expectContext.files {
if !strings.HasSuffix(ctx.files[key], value) {
t.Fatalf("expected context.files to contain '%s' but got '%s'", value, ctx.files[key])
}
}
}
return nil
}
recorder := httptest.NewRecorder()
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, recorder)
err := handler(c)
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
var httpErr HttpError
isHttpError := errors.As(err, &httpErr)
if tc.expectHttpError && !isHttpError {
t.Errorf("expected an HTTP error but got: %v", err)
}
if !tc.expectHttpError && isHttpError {
t.Errorf("expected no HTTP error but got one: %v", httpErr)
}
if err != nil && tc.expectHttpError && isHttpError {
status, _ := httpErr.HttpError()
if status != tc.expectHttpStatus {
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
}
}
})
}
}
func TestContext_Request(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", nil)
recorder := httptest.NewRecorder()
c := echo.New().NewContext(request, recorder)
ctx := &Context{
echoCtx: c,
}
if !reflect.DeepEqual(ctx.Request(), c.Request()) {
t.Errorf("expected %v but got %v", ctx.Request(), c.Request())
}
}
func TestContext_FormData(t *testing.T) {
ctx := &Context{
values: map[string][]string{
"foo": {"foo"},
},
files: map[string]string{
"foo.txt": "/foo.txt",
},
}
actual := ctx.FormData()
expect := &FormData{
values: ctx.values,
files: ctx.files,
}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got %+v", expect, actual)
}
}
func TestContext_CreateSubDirectory(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
expectError bool
}{
{
scenario: "failure",
ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("cannot rename")
}}},
expectError: true,
},
{
scenario: "success",
ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.logger = zap.NewNop()
_, err := tc.ctx.CreateSubDirectory("foo")
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestContext_GeneratePath(t *testing.T) {
ctx := &Context{
dirPath: "/foo",
}
path := ctx.GeneratePath(".pdf")
if !strings.HasPrefix(path, ctx.dirPath) {
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
}
}
func TestContext_Rename(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
expectError bool
}{
{
scenario: "failure",
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return errors.New("cannot rename")
}}},
expectError: true,
},
{
scenario: "success",
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.logger = zap.NewNop()
err := tc.ctx.Rename("", "")
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestContext_AddOutputPaths(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
path string
expectCount int
expectError bool
}{
{
scenario: "ErrContextAlreadyClosed",
ctx: &Context{cancelled: true},
expectCount: 0,
expectError: true,
},
{
scenario: "ErrOutOfBoundsOutputPath",
ctx: &Context{dirPath: "/foo"},
path: "/bar/foo.txt",
expectCount: 0,
expectError: true,
},
{
scenario: "success",
ctx: &Context{dirPath: "/foo"},
path: "/foo/foo.txt",
expectCount: 1,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.ctx.AddOutputPaths(tc.path)
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(tc.ctx.outputPaths) != tc.expectCount {
t.Errorf("expected %d output paths but got %d", tc.expectCount, len(tc.ctx.outputPaths))
}
})
}
}
func TestContext_Log(t *testing.T) {
expect := zap.NewNop()
ctx := Context{logger: expect}
actual := ctx.Log()
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContext_BuildOutputFile(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
expectError bool
}{
{
scenario: "ErrContextAlreadyClosed",
ctx: &Context{cancelled: true},
expectError: true,
},
{
scenario: "no output path",
ctx: &Context{},
expectError: true,
},
{
scenario: "success: one output path",
ctx: &Context{outputPaths: []string{"foo.txt"}},
expectError: false,
},
{
scenario: "cannot archive: invalid output paths",
ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}},
expectError: true,
},
{
scenario: "success: many output paths",
ctx: &Context{
outputPaths: []string{
"/tests/test/testdata/api/sample1.txt",
"/tests/test/testdata/api/sample1.txt",
},
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
dirPath, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected no erro but got: %v", err)
}
defer func() {
err := os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
tc.ctx.dirPath = dirPath
tc.ctx.logger = zap.NewNop()
_, err = tc.ctx.BuildOutputFile()
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestContext_OutputFilename(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
outputPath string
expectOutputFilename string
}{
{
scenario: "with Gotenberg-Output-Filename header",
ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
c.Request().Header.Set("Gotenberg-Output-Filename", "foo")
return &Context{echoCtx: c}
}(),
outputPath: "/foo/bar.txt",
expectOutputFilename: "foo.txt",
},
{
scenario: "without custom filename",
ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
return &Context{echoCtx: c}
}(),
outputPath: "/foo/foo.txt",
expectOutputFilename: "foo.txt",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
actual := tc.ctx.OutputFilename(tc.outputPath)
if actual != tc.expectOutputFilename {
t.Errorf("expected '%s' but got '%s'", tc.expectOutputFilename, actual)
}
})
}
}

View File

@@ -1425,36 +1425,36 @@ func TestFormData_Content(t *testing.T) {
scenario: "file does exist without file extension",
form: &FormData{
files: map[string]string{
"foo": "/tests/test/testdata/api/sample1.txt",
"foo": "testdata/sample.txt",
},
},
filename: "foo",
defaultValue: "",
expect: "foo",
expect: "This is a text from a text file.",
expectError: false,
},
{
scenario: "file does exist with an uppercase file extension",
form: &FormData{
files: map[string]string{
"foo.TXT": "/tests/test/testdata/api/sample1.txt",
"foo.TXT": "testdata/sample.txt",
},
},
filename: "foo.txt",
defaultValue: "",
expect: "foo",
expect: "This is a text from a text file.",
expectError: false,
},
{
scenario: "file does exist without a lowercase file extension",
form: &FormData{
files: map[string]string{
"foo.txt": "/tests/test/testdata/api/sample1.txt",
"foo.txt": "testdata/sample.txt",
},
},
filename: "foo.txt",
defaultValue: "",
expect: "foo",
expect: "This is a text from a text file.",
expectError: false,
},
} {
@@ -1519,33 +1519,33 @@ func TestFormData_MandatoryContent(t *testing.T) {
scenario: "mandatory file does exist without file extension",
form: &FormData{
files: map[string]string{
"foo": "/tests/test/testdata/api/sample1.txt",
"foo": "testdata/sample.txt",
},
},
filename: "foo",
expect: "foo",
expect: "This is a text from a text file.",
expectError: false,
},
{
scenario: "mandatory file does exist with an uppercase file extension",
form: &FormData{
files: map[string]string{
"foo.TXT": "/tests/test/testdata/api/sample1.txt",
"foo.TXT": "testdata/sample.txt",
},
},
filename: "foo.txt",
expect: "foo",
expect: "This is a text from a text file.",
expectError: false,
},
{
scenario: "mandatory file does exist without a lowercase file extension",
form: &FormData{
files: map[string]string{
"foo.txt": "/tests/test/testdata/api/sample1.txt",
"foo.txt": "testdata/sample.txt",
},
},
filename: "foo.txt",
expect: "foo",
expect: "This is a text from a text file.",
expectError: false,
},
} {

View File

@@ -1,584 +0,0 @@
package api
import (
"bytes"
"context"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestParseError(t *testing.T) {
for i, tc := range []struct {
err error
expectStatus int
expectMessage string
}{
{
err: echo.ErrInternalServerError,
expectStatus: http.StatusInternalServerError,
expectMessage: http.StatusText(http.StatusInternalServerError),
},
{
err: gotenberg.ErrFiltered,
expectStatus: http.StatusForbidden,
expectMessage: http.StatusText(http.StatusForbidden),
},
{
err: gotenberg.ErrMaximumQueueSizeExceeded,
expectStatus: http.StatusTooManyRequests,
expectMessage: http.StatusText(http.StatusTooManyRequests),
},
{
err: gotenberg.ErrPdfSplitModeNotSupported,
expectStatus: http.StatusBadRequest,
expectMessage: "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues",
},
{
err: gotenberg.ErrPdfFormatNotSupported,
expectStatus: http.StatusBadRequest,
expectMessage: "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues",
},
{
err: gotenberg.ErrPdfEngineMetadataValueNotSupported,
expectStatus: http.StatusBadRequest,
expectMessage: "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues",
},
{
err: WrapError(
errors.New("foo"),
NewSentinelHttpError(http.StatusBadRequest, "foo"),
),
expectStatus: http.StatusBadRequest,
expectMessage: "foo",
},
} {
actualStatus, actualMessage := ParseError(tc.err)
if actualStatus != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, actualStatus)
}
if actualMessage != tc.expectMessage {
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, actualMessage)
}
}
}
func TestHttpErrorHandler(t *testing.T) {
for i, tc := range []struct {
err error
expectStatus int
expectMessage string
}{
{
err: echo.ErrInternalServerError,
expectStatus: http.StatusInternalServerError,
expectMessage: http.StatusText(http.StatusInternalServerError),
},
{
err: context.DeadlineExceeded,
expectStatus: http.StatusServiceUnavailable,
expectMessage: http.StatusText(http.StatusServiceUnavailable),
},
{
err: WrapError(
errors.New("foo"),
NewSentinelHttpError(http.StatusBadRequest, "foo"),
),
expectStatus: http.StatusBadRequest,
expectMessage: "foo",
},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
c.Set("logger", zap.NewNop())
handler := httpErrorHandler()
handler(tc.err, c)
contentType := recorder.Header().Get(echo.HeaderContentType)
if contentType != echo.MIMETextPlainCharsetUTF8 {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8, contentType)
}
// Note: we cannot test the trace header in the response here, as it is set in the trace middleware.
if recorder.Code != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
}
if recorder.Body.String() != tc.expectMessage {
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, recorder.Body.String())
}
}
}
func TestLatencyMiddleware(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
err := latencyMiddleware()(
func(c echo.Context) error {
return nil
},
)(c)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
startTime := c.Get("startTime").(time.Time)
now := time.Now()
if now.Before(startTime) {
t.Errorf("expected start time %s to be < %s", startTime, now)
}
}
func TestRootPathMiddleware(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
err := rootPathMiddleware("foo")(
func(c echo.Context) error {
return nil
},
)(c)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
rootPath := c.Get("rootPath").(string)
if rootPath != "foo" {
t.Errorf("expected '%s' but got '%s", "foo", rootPath)
}
}
func TestTraceMiddleware(t *testing.T) {
for i, tc := range []struct {
trace string
}{
{
trace: "foo",
},
{
trace: "",
},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
if tc.trace != "" {
c.Request().Header.Set("Gotenberg-Trace", tc.trace)
}
err := traceMiddleware("Gotenberg-Trace")(
func(c echo.Context) error {
return nil
},
)(c)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
trace := c.Get("trace").(string)
if trace == "" {
t.Errorf("test %d: expected non empty trace in context", i)
}
if tc.trace != "" && trace != tc.trace {
t.Errorf("test %d: expected context trace '%s' but got '%s'", i, tc.trace, trace)
}
if tc.trace == "" && trace == tc.trace {
t.Errorf("test %d: expected context trace different from '%s' but got '%s'", i, tc.trace, trace)
}
responseTrace := recorder.Header().Get("Gotenberg-Trace")
if tc.trace != "" && responseTrace != tc.trace {
t.Errorf("test %d: expected header trace '%s' but got '%s'", i, tc.trace, responseTrace)
}
if tc.trace == "" && responseTrace == tc.trace {
t.Errorf("test %d: expected header trace different from '%s' but got '%s'", i, tc.trace, responseTrace)
}
}
}
func TestBasicAuthMiddleware(t *testing.T) {
for _, tc := range []struct {
scenario string
request *http.Request
username string
password string
expectError bool
}{
{
scenario: "invalid basic auth",
request: func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("invalid", "invalid")
return req
}(),
username: "foo",
password: "bar",
expectError: true,
},
{
scenario: "valid basic auth",
request: func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("foo", "bar")
return req
}(),
username: "foo",
password: "bar",
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
recorder := httptest.NewRecorder()
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, recorder)
err := basicAuthMiddleware(tc.username, tc.password)(func(c echo.Context) error {
return nil
})(c)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestLoggerMiddleware(t *testing.T) {
for i, tc := range []struct {
request *http.Request
next echo.HandlerFunc
skipLogging bool
}{
{
request: httptest.NewRequest(http.MethodGet, "/", nil),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return errors.New("foo")
}
}(),
},
{
request: httptest.NewRequest(http.MethodGet, "/health", nil),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
skipLogging: true,
},
{
request: httptest.NewRequest(http.MethodGet, "/health", nil),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
},
} {
recorder := httptest.NewRecorder()
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, recorder)
c.Set("startTime", time.Now())
c.Set("trace", "foo")
c.Set("rootPath", "/")
var disableLoggingForPaths []string
if tc.skipLogging {
disableLoggingForPaths = append(disableLoggingForPaths, tc.request.RequestURI)
}
err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c)
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestContextMiddleware(t *testing.T) {
buildMultipartFormDataRequest := func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
for i, tc := range []struct {
request *http.Request
next echo.HandlerFunc
expectErr bool
expectStatus int
expectContentType string
expectFilename string
}{
{
request: httptest.NewRequest(http.MethodGet, "/", nil),
expectErr: true,
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return ErrAsyncProcess
}
}(),
expectStatus: http.StatusNoContent,
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return ErrNoOutputFile
}
}(),
expectStatus: http.StatusOK,
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return errors.New("foo")
}
}(),
expectErr: true,
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
expectErr: true,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Output-Filename", "foo")
return req
}(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample2.pdf",
}
return nil
}
}(),
expectStatus: http.StatusOK,
expectContentType: "application/pdf",
expectFilename: "foo.pdf",
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample1.txt",
"/tests/test/testdata/api/sample2.pdf",
}
return nil
}
}(),
expectStatus: http.StatusOK,
expectContentType: "application/zip",
},
} {
recorder := httptest.NewRecorder()
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, recorder)
c.Set("logger", zap.NewNop())
c.Set("traceHeader", "Gotenberg-Trace")
c.Set("trace", "foo")
c.Set("startTime", time.Now())
err := contextMiddleware(gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if err != nil {
continue
}
if recorder.Code != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
}
if tc.expectStatus == http.StatusNoContent {
continue
}
contentType := recorder.Header().Get(echo.HeaderContentType)
if contentType != tc.expectContentType {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, tc.expectContentType, contentType)
}
contentDisposition := recorder.Header().Get(echo.HeaderContentDisposition)
if !strings.Contains(contentDisposition, tc.expectFilename) {
t.Errorf("test %d: expected %s '%s' to contain '%s'", i, echo.HeaderContentDisposition, contentDisposition, tc.expectFilename)
}
}
}
func TestHardTimeoutMiddleware(t *testing.T) {
for i, tc := range []struct {
next echo.HandlerFunc
timeout time.Duration
expectErr bool
expectHardTimeout bool
}{
{
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
timeout: time.Duration(100) * time.Millisecond,
},
{
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
panic("foo")
}
}(),
timeout: time.Duration(100) * time.Millisecond,
expectErr: true,
expectHardTimeout: true,
},
{
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return errors.New("foo")
}
}(),
timeout: time.Duration(100) * time.Millisecond,
expectErr: true,
},
{
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
time.Sleep(time.Duration(200) * time.Millisecond)
return nil
}
}(),
timeout: time.Duration(100) * time.Millisecond,
expectErr: true,
expectHardTimeout: true,
},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
c.Set("logger", zap.NewNop())
err := hardTimeoutMiddleware(tc.timeout)(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
var isHardTimeout bool
if err != nil {
isHardTimeout = strings.Contains(err.Error(), "hard timeout")
}
if tc.expectHardTimeout && !isHardTimeout {
t.Errorf("test %d: expected hard timeout error but got: %v", i, err)
}
if !tc.expectHardTimeout && isHardTimeout {
t.Errorf("test %d: expected no hard timeout error but got one: %v", i, err)
}
}
}

View File

@@ -83,7 +83,7 @@ func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.setEchoContext(c)
func (ctx *ContextMock) SetEchoContext(c echo.Context) {
ctx.Context.echoCtx = c
ctx.echoCtx = c
}
// SetMkdirAll sets the [gotenberg.MkdirAll].
@@ -91,7 +91,7 @@ func (ctx *ContextMock) SetEchoContext(c echo.Context) {
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetMkdirAll(mkdirAll)
func (ctx *ContextMock) SetMkdirAll(mkdirAll gotenberg.MkdirAll) {
ctx.Context.mkdirAll = mkdirAll
ctx.mkdirAll = mkdirAll
}
// SetPathRename sets the [gotenberg.PathRename].
@@ -99,7 +99,7 @@ func (ctx *ContextMock) SetMkdirAll(mkdirAll gotenberg.MkdirAll) {
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.setPathRename(rename)
func (ctx *ContextMock) SetPathRename(rename gotenberg.PathRename) {
ctx.Context.pathRename = rename
ctx.pathRename = rename
}
// RouterMock is a mock for the [Router] interface.

View File

@@ -1,193 +0,0 @@
package api
import (
"reflect"
"testing"
"github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestContextMock_SetDirPath(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetDirPath("/foo")
actual := mock.dirPath
expect := "/foo"
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestContextMock_DirPath(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetDirPath("/foo")
actual := mock.DirPath()
expect := "/foo"
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestContextMock_SetValues(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetValues(map[string][]string{
"foo": {"foo"},
})
actual := mock.values
expect := map[string][]string{
"foo": {"foo"},
}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
}
}
func TestContextMock_SetFiles(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetFiles(map[string]string{
"foo": "/foo",
})
actual := mock.files
expect := map[string]string{
"foo": "/foo",
}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
}
}
func TestContextMock_SetCancelled(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetCancelled(true)
actual := mock.cancelled
if !actual {
t.Errorf("expected %t but got %t", true, actual)
}
}
func TestContextMock_OutputPaths(t *testing.T) {
mock := ContextMock{
&Context{
outputPaths: []string{"/foo"},
},
}
actual := mock.OutputPaths()
expect := []string{"/foo"}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
}
}
func TestContextMock_SetLogger(t *testing.T) {
mock := ContextMock{&Context{}}
expect := zap.NewNop()
mock.SetLogger(expect)
actual := mock.logger
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContextMock_SetEchoContext(t *testing.T) {
mock := ContextMock{&Context{}}
expect := echo.New().NewContext(nil, nil)
mock.SetEchoContext(expect)
actual := mock.echoCtx
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContextMock_SetMkdirAll(t *testing.T) {
mock := ContextMock{&Context{}}
expect := new(gotenberg.OsMkdirAll)
mock.SetMkdirAll(expect)
actual := mock.mkdirAll
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContextMock_SetPathRename(t *testing.T) {
mock := ContextMock{&Context{}}
expect := new(gotenberg.OsPathRename)
mock.SetPathRename(expect)
actual := mock.pathRename
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestRouterMock(t *testing.T) {
mock := &RouterMock{
RoutesMock: func() ([]Route, error) {
return nil, nil
},
}
_, err := mock.Routes()
if err != nil {
t.Errorf("expected no error from RouterMock.Routes, but got: %v", err)
}
}
func TestMiddlewareProviderMock(t *testing.T) {
mock := &MiddlewareProviderMock{
MiddlewaresMock: func() ([]Middleware, error) {
return nil, nil
},
}
_, err := mock.Middlewares()
if err != nil {
t.Errorf("expected no error from MiddlewareProviderMock.Middlewares, but got: %v", err)
}
}
func TestHealthCheckerMock(t *testing.T) {
mock := &HealthCheckerMock{
ChecksMock: func() ([]health.CheckerOption, error) {
return nil, nil
},
ReadyMock: func() error {
return nil
},
}
_, err := mock.Checks()
if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err)
}
err = mock.Ready()
if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Ready, but got: %v", err)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,9 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/alexliesenfeld/health"
@@ -484,6 +487,23 @@ func (mod *Chromium) Stop(ctx context.Context) error {
return fmt.Errorf("stop Chromium: %w", err)
}
// Debug returns additional debug data.
func (mod *Chromium) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
debug["version"] = strings.TrimSpace(string(output))
return debug
}
// Metrics returns the metrics.
func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
@@ -593,6 +613,7 @@ var (
_ gotenberg.Provisioner = (*Chromium)(nil)
_ gotenberg.Validator = (*Chromium)(nil)
_ gotenberg.App = (*Chromium)(nil)
_ gotenberg.Debuggable = (*Chromium)(nil)
_ gotenberg.MetricsProvider = (*Chromium)(nil)
_ api.HealthChecker = (*Chromium)(nil)
_ api.Router = (*Chromium)(nil)

View File

@@ -1,589 +0,0 @@
package chromium
import (
"context"
"errors"
"os"
"reflect"
"testing"
"time"
"github.com/alexliesenfeld/health"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestDefaultOptions(t *testing.T) {
actual := DefaultOptions()
notExpect := Options{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestDefaultPdfOptions(t *testing.T) {
actual := DefaultPdfOptions()
notExpect := PdfOptions{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestDefaultScreenshotOptions(t *testing.T) {
actual := DefaultScreenshotOptions()
notExpect := ScreenshotOptions{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestChromium_Descriptor(t *testing.T) {
descriptor := new(Chromium).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Chromium))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestChromium_Provision(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectError bool
}{
{
scenario: "no logger provider",
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectError: true,
},
{
scenario: "no logger from logger provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "no PDF engine provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "no PDF engine from PDF engine provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "provision success",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return new(gotenberg.PdfEngineMock), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
err := mod.Provision(tc.ctx)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.args = browserArguments{
binPath: tc.binPath,
}
err := mod.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
expectError: false,
},
{
scenario: "auto-start success",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "auto-start failed",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.autoStart = tc.autoStart
mod.supervisor = tc.supervisor
err := mod.Start()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_StartupMessage(t *testing.T) {
mod := new(Chromium)
mod.autoStart = true
autoStartMsg := mod.StartupMessage()
mod.autoStart = false
noAutoStartMsg := mod.StartupMessage()
if autoStartMsg == noAutoStartMsg {
t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
}
}
func TestChromium_Stop(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "stop success",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "stop failed",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.logger = zap.NewNop()
mod.supervisor = tc.supervisor
ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
cancel()
err := mod.Stop(ctx)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_Metrics(t *testing.T) {
mod := new(Chromium)
mod.supervisor = &gotenberg.ProcessSupervisorMock{
ReqQueueSizeMock: func() int64 {
return 10
},
RestartsCountMock: func() int64 {
return 0
},
}
metrics, err := mod.Metrics()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
}
actual := metrics[0].Read()
if actual != float64(10) {
t.Errorf("expected %f for chromium_requests_queue_size, but got %f", float64(10), actual)
}
actual = metrics[1].Read()
if actual != float64(0) {
t.Errorf("expected %f for chromium_restarts_count, but got %f", float64(0), actual)
}
}
func TestChromium_Checks(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
expectAvailabilityStatus health.AvailabilityStatus
}{
{
scenario: "healthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return true
}},
expectAvailabilityStatus: health.StatusUp,
},
{
scenario: "unhealthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return false
}},
expectAvailabilityStatus: health.StatusDown,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.supervisor = tc.supervisor
checks, err := mod.Checks()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
checker := health.NewChecker(checks...)
result := checker.Check(context.Background())
if result.Status != tc.expectAvailabilityStatus {
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
}
})
}
}
func TestChromium_Ready(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
startTimeout time.Duration
browser browser
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: false,
},
{
scenario: "auto-start: context done",
autoStart: true,
startTimeout: time.Duration(200) * time.Millisecond,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: true,
},
{
scenario: "auto-start success",
autoStart: true,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return true
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.autoStart = tc.autoStart
mod.args = browserArguments{wsUrlReadTimeout: tc.startTimeout}
mod.browser = tc.browser
err := mod.Ready()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_Chromium(t *testing.T) {
mod := new(Chromium)
_, err := mod.Chromium()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestChromium_Routes(t *testing.T) {
for _, tc := range []struct {
scenario string
expectRoutes int
disableRoutes bool
}{
{
scenario: "routes not disabled",
expectRoutes: 6,
disableRoutes: false,
},
{
scenario: "routes disabled",
expectRoutes: 0,
disableRoutes: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.disableRoutes = tc.disableRoutes
routes, err := mod.Routes()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectRoutes != len(routes) {
t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
}
})
}
}
func TestChromium_Pdf(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
browser browser
expectError bool
}{
{
scenario: "PDF task success",
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
expectError: false,
},
{
scenario: "PDF task error",
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return errors.New("PDF task error")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return task()
}}
mod.browser = tc.browser
err := mod.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_Screenshot(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
browser browser
expectError bool
}{
{
scenario: "Screenshot task success",
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
}},
expectError: false,
},
{
scenario: "Screenshot task error",
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return errors.New("screenshot task error")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return task()
}}
mod.browser = tc.browser
err := mod.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}

View File

@@ -1,24 +0,0 @@
package chromium
import (
"testing"
"go.uber.org/zap"
)
func TestDebugLogger_Write(t *testing.T) {
actual, err := (&debugLogger{logger: zap.NewNop()}).Write([]byte("foo"))
expected := len([]byte("foo"))
if actual != expected {
t.Errorf("expected %d but got %d", expected, actual)
}
if err != nil {
t.Errorf("expected not error but got: %v", err)
}
}
func TestDebugLogger_Printf(t *testing.T) {
(&debugLogger{logger: zap.NewNop()}).Printf("%s", "foo")
}

View File

@@ -1,50 +0,0 @@
package chromium
import (
"context"
"testing"
"go.uber.org/zap"
)
func TestApiMock(t *testing.T) {
mock := &ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
},
ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
},
}
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
if err != nil {
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
}
err = mock.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
if err != nil {
t.Errorf("expected no error from ApiMock.Screenshot, but got: %v", err)
}
}
func TestBrowserMock(t *testing.T) {
mock := &browserMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
},
screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
},
}
err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
if err != nil {
t.Errorf("expected no error from browserMock.pdf, but got: %v", err)
}
err = mock.screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
if err != nil {
t.Errorf("expected no error from browserMock.screenshot, but got: %v", err)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,10 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"reflect"
"strings"
"syscall"
"github.com/barasher/go-exiftool"
"go.uber.org/zap"
@@ -53,6 +56,23 @@ func (engine *ExifTool) Validate() error {
return nil
}
// Debug returns additional debug data.
func (engine *ExifTool) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
debug["version"] = strings.TrimSpace(string(output))
return debug
}
// Merge is not available in this implementation.
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
@@ -161,5 +181,6 @@ var (
_ gotenberg.Module = (*ExifTool)(nil)
_ gotenberg.Provisioner = (*ExifTool)(nil)
_ gotenberg.Validator = (*ExifTool)(nil)
_ gotenberg.Debuggable = (*ExifTool)(nil)
_ gotenberg.PdfEngine = (*ExifTool)(nil)
)

View File

@@ -1,371 +0,0 @@
package exiftool
import (
"context"
"errors"
"fmt"
"io"
"os"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestExifTool_Descriptor(t *testing.T) {
descriptor := new(ExifTool).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(ExifTool))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestExifTool_Provision(t *testing.T) {
engine := new(ExifTool)
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
err := engine.Provision(ctx)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestExifTool_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("EXIFTOOL_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(ExifTool)
engine.binPath = tc.binPath
err := engine.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestExiftool_Merge(t *testing.T) {
engine := new(ExifTool)
err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_Split(t *testing.T) {
engine := new(ExifTool)
_, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_Flatten(t *testing.T) {
engine := new(ExifTool)
err := engine.Flatten(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_Convert(t *testing.T) {
engine := new(ExifTool)
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_ReadMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
inputPath string
expectMetadata map[string]interface{}
expectError bool
}{
{
scenario: "invalid input path",
inputPath: "foo",
expectMetadata: nil,
expectError: true,
},
{
scenario: "success",
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectMetadata: map[string]interface{}{
"FileName": "sample1.pdf",
"FileTypeExtension": "pdf",
"MIMEType": "application/pdf",
"PDFVersion": 1.4,
"PageCount": float64(3),
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(ExifTool)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), tc.inputPath)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectMetadata != nil && err == nil {
for k, v := range tc.expectMetadata {
if v2, ok := metadata[k]; !ok || v != v2 {
t.Errorf("expected entry %s with value %v to exists", k, v)
}
}
}
})
}
}
func TestExiftool_WriteMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
createCopy bool
inputPath string
metadata map[string]interface{}
expectMetadata map[string]interface{}
expectError bool
expectedError error
}{
{
scenario: "invalid input path",
createCopy: false,
inputPath: "foo",
expectError: true,
},
{
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (not string array)",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Unsupported": []interface{}{
"foo",
1,
},
},
expectError: true,
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
},
{
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (default)",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Unsupported": map[string]interface{}{},
},
expectError: true,
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
},
{
scenario: "success (interface array to string array)",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Keywords": []interface{}{
"first",
"second",
},
},
expectMetadata: map[string]interface{}{
"Keywords": []interface{}{
"first",
"second",
},
},
expectError: false,
},
{
scenario: "success",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreationDate": "2006-09-18T16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": []string{
"first",
"second",
},
"Marked": true,
"ModDate": "2006-09-18T16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown",
// Those are not valid PDF metadata.
"int": 1,
"int64": int64(2),
"float32": float32(2.2),
"float64": 3.3,
},
expectMetadata: map[string]interface{}{
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreationDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": []interface{}{
"first",
"second",
},
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown",
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(ExifTool)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
var destinationPath string
if tc.createCopy {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error no but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
source, err := os.Open(tc.inputPath)
if err != nil {
t.Fatalf("open source file: %v", err)
}
defer func(source *os.File) {
err := source.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(source)
destination, err := os.Create(destinationPath)
if err != nil {
t.Fatalf("create destination file: %v", err)
}
defer func(destination *os.File) {
err := destination.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(destination)
_, err = io.Copy(destination, source)
if err != nil {
t.Fatalf("copy source into destination: %v", err)
}
} else {
destinationPath = tc.inputPath
}
err = engine.WriteMetadata(context.Background(), zap.NewNop(), tc.metadata, destinationPath)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
if tc.expectError {
return
}
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), destinationPath)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectMetadata != nil && err == nil {
for k, v := range tc.expectMetadata {
v2, ok := metadata[k]
if !ok {
t.Errorf("expected entry %s with value %v to exists, but got none", k, v)
continue
}
switch v2.(type) {
case []interface{}:
for i, entry := range v.([]interface{}) {
if entry != v2.([]interface{})[i] {
t.Errorf("expected entry %s to contain value %v, but got %v", k, entry, v2.([]interface{})[i])
}
}
default:
if v != v2 {
t.Errorf("expected entry %s with value %v to exists, but got %v", k, v, v2)
}
}
}
}
})
}
}

View File

@@ -5,6 +5,9 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/alexliesenfeld/health"
@@ -59,6 +62,11 @@ type Options struct {
// PageRanges allows to select the pages to convert.
PageRanges string
// UpdateIndexes specifies whether to update the indexes before conversion,
// keeping in mind that doing so might result in missing links in the final
// PDF.
UpdateIndexes bool
// ExportFormFields specifies whether form fields are exported as widgets
// or only their fixed print representation is exported.
ExportFormFields bool
@@ -149,6 +157,7 @@ func DefaultOptions() Options {
Password: "",
Landscape: false,
PageRanges: "",
UpdateIndexes: true,
ExportFormFields: true,
AllowDuplicateFieldNames: false,
ExportBookmarks: true,
@@ -305,6 +314,23 @@ func (a *Api) Stop(ctx context.Context) error {
return fmt.Errorf("stop LibreOffice: %w", err)
}
// Debug returns additional debug data.
func (a *Api) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
debug["version"] = strings.TrimSpace(string(output))
return debug
}
// Metrics returns the metrics.
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
@@ -536,6 +562,7 @@ var (
_ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*Api)(nil)
_ gotenberg.Debuggable = (*Api)(nil)
_ gotenberg.MetricsProvider = (*Api)(nil)
_ api.HealthChecker = (*Api)(nil)
_ Uno = (*Api)(nil)

View File

@@ -1,474 +0,0 @@
package api
import (
"context"
"errors"
"os"
"reflect"
"testing"
"time"
"github.com/alexliesenfeld/health"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestDefaultOptions(t *testing.T) {
actual := DefaultOptions()
notExpect := Options{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestApi_Descriptor(t *testing.T) {
descriptor := new(Api).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Api))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestApi_Provision(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectError bool
}{
{
scenario: "no logger provider",
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Api).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectError: true,
},
{
scenario: "no logger from logger provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Api).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "provision success",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Api).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
err := a.Provision(tc.ctx)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestApi_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
unoBinPath string
expectError bool
}{
{
scenario: "empty LibreOffice bin path",
binPath: "",
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
expectError: true,
},
{
scenario: "LibreOffice bin path does not exist",
binPath: "/foo",
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
expectError: true,
},
{
scenario: "empty uno bin path",
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
unoBinPath: "",
expectError: true,
},
{
scenario: "uno bin path does not exist",
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
unoBinPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.args = libreOfficeArguments{
binPath: tc.binPath,
unoBinPath: tc.unoBinPath,
}
err := a.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestApi_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
expectError: false,
},
{
scenario: "auto-start success",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "auto-start failed",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.autoStart = tc.autoStart
a.supervisor = tc.supervisor
err := a.Start()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestApi_StartupMessage(t *testing.T) {
a := new(Api)
a.autoStart = true
autoStartMsg := a.StartupMessage()
a.autoStart = false
noAutoStartMsg := a.StartupMessage()
if autoStartMsg == noAutoStartMsg {
t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
}
}
func TestApi_Stop(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "stop success",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "stop failed",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.logger = zap.NewNop()
a.supervisor = tc.supervisor
ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
cancel()
err := a.Stop(ctx)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestApi_Metrics(t *testing.T) {
a := new(Api)
a.supervisor = &gotenberg.ProcessSupervisorMock{
ReqQueueSizeMock: func() int64 {
return 10
},
RestartsCountMock: func() int64 {
return 0
},
}
metrics, err := a.Metrics()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
}
actual := metrics[0].Read()
if actual != float64(10) {
t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual)
}
actual = metrics[1].Read()
if actual != float64(0) {
t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual)
}
}
func TestApi_Checks(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
expectAvailabilityStatus health.AvailabilityStatus
}{
{
scenario: "healthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return true
}},
expectAvailabilityStatus: health.StatusUp,
},
{
scenario: "unhealthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return false
}},
expectAvailabilityStatus: health.StatusDown,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.supervisor = tc.supervisor
checks, err := a.Checks()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
checker := health.NewChecker(checks...)
result := checker.Check(context.Background())
if result.Status != tc.expectAvailabilityStatus {
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
}
})
}
}
func TestChromium_Ready(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
startTimeout time.Duration
libreOffice libreOffice
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
startTimeout: time.Duration(30) * time.Second,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: false,
},
{
scenario: "auto-start: context done",
autoStart: true,
startTimeout: time.Duration(200) * time.Millisecond,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: true,
},
{
scenario: "auto-start success",
autoStart: true,
startTimeout: time.Duration(30) * time.Second,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return true
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.autoStart = tc.autoStart
a.args = libreOfficeArguments{startTimeout: tc.startTimeout}
a.libreOffice = tc.libreOffice
err := a.Ready()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestApi_LibreOffice(t *testing.T) {
a := new(Api)
_, err := a.LibreOffice()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestApi_Pdf(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
libreOffice libreOffice
expectError bool
}{
{
scenario: "PDF task success",
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return nil
}},
expectError: false,
},
{
scenario: "PDF task error",
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return errors.New("PDF task error")
}},
expectError: true,
},
{
scenario: "ErrCoreDumped",
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return ErrCoreDumped
}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return task()
}}
a.libreOffice = tc.libreOffice
err := a.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestApi_Extensions(t *testing.T) {
a := new(Api)
extensions := a.Extensions()
actual := len(extensions)
expect := 130
if actual != expect {
t.Errorf("expected %d extensions, but got %d", expect, actual)
}
}

View File

@@ -275,10 +275,15 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--printer", "PaperOrientation=landscape")
}
// See: https://github.com/gotenberg/gotenberg/issues/1149.
if options.PageRanges != "" {
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
}
if !options.UpdateIndexes {
args = append(args, "--disable-update-indexes")
}
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))

View File

@@ -1,699 +0,0 @@
package api
import (
"context"
"errors"
"fmt"
"io"
"os"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestLibreOfficeProcess_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
expectError bool
cleanup bool
}{
{
scenario: "successful start",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
expectError: false,
cleanup: true,
},
{
scenario: "LibreOffice already started",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(true)
return p
}(),
expectError: true,
cleanup: false,
},
{
scenario: "non-exit code 81 on first start",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: "foo",
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
expectError: true,
cleanup: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
err := tc.libreOffice.Start(logger)
if tc.cleanup {
defer func(p libreOffice, logger *zap.Logger) {
err = p.Stop(logger)
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}(tc.libreOffice, logger)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestLibreOfficeProcess_Stop(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
setup func(libreOffice libreOffice, logger *zap.Logger) error
expectError bool
}{
{
scenario: "successful stop",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
setup: func(p libreOffice, logger *zap.Logger) error {
return p.Start(logger)
},
expectError: false,
},
{
scenario: "LibreOffice already stopped",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(false)
return p
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
if tc.setup != nil {
err := tc.setup(tc.libreOffice, logger)
if err != nil {
t.Fatalf("setup error: %v", err)
}
}
err := tc.libreOffice.Stop(logger)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestLibreOfficeProcess_Healthy(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
setup func(libreOffice libreOffice, logger *zap.Logger) error
expectHealthy bool
cleanup bool
}{
{
scenario: "healthy LibreOffice",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
setup: func(p libreOffice, logger *zap.Logger) error {
return p.Start(logger)
},
expectHealthy: true,
cleanup: true,
},
{
scenario: "LibreOffice not started",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(false)
return p
}(),
expectHealthy: false,
cleanup: false,
},
{
scenario: "unhealthy LibreOffice",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(true)
p.socketPort = 12345
return p
}(),
expectHealthy: false,
cleanup: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
if tc.setup != nil {
err := tc.setup(tc.libreOffice, logger)
if err != nil {
t.Fatalf("setup error: %v", err)
}
}
if tc.cleanup {
defer func(p libreOffice, logger *zap.Logger) {
err := p.Stop(logger)
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}(tc.libreOffice, logger)
}
healthy := tc.libreOffice.Healthy(logger)
if !tc.expectHealthy && healthy {
t.Fatal("expected unhealthy LibreOffice but got an healthy one")
}
if tc.expectHealthy && !healthy {
t.Fatal("expected a healthy LibreOffice but got an unhealthy one")
}
})
}
}
func TestLibreOfficeProcess_pdf(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
fs *gotenberg.FileSystem
options Options
cancelledCtx bool
start bool
expectError bool
expectedError error
}{
{
scenario: "LibreOffice not started",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(false)
return p
}(),
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
cancelledCtx: false,
start: false,
expectError: true,
},
{
scenario: "ErrInvalidPdfFormats",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.socketPort = 12345
p.isStarted.Store(true)
return p
}(),
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}},
cancelledCtx: false,
start: false,
expectError: true,
expectedError: ErrInvalidPdfFormats,
},
{
scenario: "ErrUnoException",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
options: Options{PageRanges: "foo"},
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
cancelledCtx: false,
start: true,
expectError: true,
expectedError: ErrUnoException,
},
{
scenario: "ErrRuntimeException",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
options: Options{Password: "foo"},
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
in, err := os.Open("/tests/test/testdata/libreoffice/protected.docx")
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
defer func() {
err := in.Close()
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
}()
out, err := os.Create(fmt.Sprintf("%s/protected.docx", fs.WorkingDirPath()))
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
defer func() {
err := out.Close()
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
}()
_, err = io.Copy(out, in)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
return fs
}(),
cancelledCtx: false,
start: true,
expectError: true,
expectedError: ErrRuntimeException,
},
{
scenario: "context done",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
cancelledCtx: true,
start: true,
expectError: true,
},
{
scenario: "success (default options)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (not default options)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{
Password: "", // Ok, the only exception in this list.
Landscape: true,
PageRanges: "1",
ExportFormFields: false,
AllowDuplicateFieldNames: true,
ExportBookmarks: false,
ExportBookmarksToPdfDestination: true,
ExportPlaceholders: true,
ExportNotes: true,
ExportNotesPages: true,
ExportOnlyNotesPages: true,
ExportNotesInMargin: true,
ConvertOooTargetToPdfTarget: true,
ExportLinksRelativeFsys: true,
ExportHiddenSlides: true,
SkipEmptyPages: true,
AddOriginalDocumentAsStream: true,
SinglePageSheets: true,
LosslessImageCompression: true,
Quality: 100,
ReduceImageResolution: true,
MaxImageResolution: 600,
},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/A-1b)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/A-2b)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA2b}},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/A-3b)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA3b}},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/UA)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfUa: true}},
cancelledCtx: false,
start: true,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
// Force the debug level.
logger := zap.NewExample()
defer func() {
err := os.RemoveAll(tc.fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}()
if tc.start {
err := tc.libreOffice.Start(logger)
if err != nil {
t.Fatalf("setup error: %v", err)
}
defer func(p libreOffice, logger *zap.Logger) {
err = p.Stop(logger)
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}(tc.libreOffice, logger)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(5)*time.Second)
defer cancel()
if tc.cancelledCtx {
cancel()
}
err := tc.libreOffice.pdf(
ctx,
logger,
fmt.Sprintf("%s/document.txt", tc.fs.WorkingDirPath()),
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
tc.options,
)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
})
}
}
func TestNonBasicLatinCharactersGuard(t *testing.T) {
for _, tc := range []struct {
scenario string
fs *gotenberg.FileSystem
filename string
expectSameInputPath bool
expectError bool
}{
{
scenario: "basic latin characters",
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
filename: "document.txt",
expectSameInputPath: true,
expectError: false,
},
{
scenario: "non-basic latin characters",
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/éèßàùä.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
filename: "éèßàùä.txt",
expectSameInputPath: false,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
defer func() {
err := os.RemoveAll(tc.fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}()
inputPath := fmt.Sprintf("%s/%s", tc.fs.WorkingDirPath(), tc.filename)
newInputPath, err := nonBasicLatinCharactersGuard(
zap.NewNop(),
inputPath,
)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectSameInputPath && newInputPath != inputPath {
t.Fatalf("expected same input path, but got '%s'", newInputPath)
}
if !tc.expectSameInputPath && newInputPath == inputPath {
t.Fatalf("expected different input path, but got same '%s'", newInputPath)
}
})
}
}

View File

@@ -1,94 +0,0 @@
package api
import (
"context"
"testing"
"go.uber.org/zap"
)
func TestApiMock(t *testing.T) {
mock := &ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return nil
},
ExtensionsMock: func() []string {
return nil
},
}
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
if err != nil {
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
}
ext := mock.Extensions()
if ext != nil {
t.Errorf("expected nil result from ApiMock.Extensions, but got: %v", ext)
}
}
func TestProviderMock(t *testing.T) {
mock := &ProviderMock{
LibreOfficeMock: func() (Uno, error) {
return nil, nil
},
}
_, err := mock.LibreOffice()
if err != nil {
t.Errorf("expected no error from ProviderMock.LibreOffice, but got: %v", err)
}
}
func TestLibreOfficeMock(t *testing.T) {
for _, tc := range []struct {
scenario string
mock *libreOfficeMock
expectError bool
}{
{
scenario: "success",
mock: &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return nil
},
},
expectError: false,
},
{
scenario: "ErrCoreDumped (first call)",
mock: &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return ErrCoreDumped
},
},
expectError: true,
},
{
scenario: "ErrCoreDumped (second call)",
mock: func() *libreOfficeMock {
m := &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return ErrCoreDumped
},
}
m.pdf(context.Background(), zap.NewNop(), "", "", Options{})
return m
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
if !tc.expectError && err != nil {
t.Fatalf("expected no error from libreOfficeMock.pdf but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error from libreOfficeMock.pdf but got none")
}
})
}
}

View File

@@ -1,196 +0,0 @@
package libreoffice
import (
"errors"
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
)
func TestLibreOffice_Descriptor(t *testing.T) {
descriptor := new(LibreOffice).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(LibreOffice))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestLibreOffice_Provision(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectError bool
}{
{
scenario: "no LibreOffice API provider",
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectError: true,
},
{
scenario: "no LibreOffice API from LibreOffice API provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
libreofficeapi.ProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "no PDF engine provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
libreofficeapi.ProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return new(libreofficeapi.ApiMock), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "no PDF engine from PDF engine provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
libreofficeapi.ProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return new(libreofficeapi.ApiMock), nil
}
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "provision success",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
libreofficeapi.ProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return new(libreofficeapi.ApiMock), nil
}
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return new(gotenberg.PdfEngineMock), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(LibreOffice)
err := mod.Provision(tc.ctx)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestLibreOffice_Routes(t *testing.T) {
for _, tc := range []struct {
scenario string
expectRoutes int
disableRoutes bool
}{
{
scenario: "routes not disabled",
expectRoutes: 1,
disableRoutes: false,
},
{
scenario: "routes disabled",
expectRoutes: 0,
disableRoutes: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(LibreOffice)
mod.disableRoutes = tc.disableRoutes
routes, err := mod.Routes()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectRoutes != len(routes) {
t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
}
})
}
}

View File

@@ -1,210 +0,0 @@
package pdfengine
import (
"context"
"errors"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
)
func TestLibreOfficePdfEngine_Descriptor(t *testing.T) {
descriptor := new(LibreOfficePdfEngine).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(LibreOfficePdfEngine))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestLibreOfficePdfEngine_Provider(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectError bool
}{
{
scenario: "no LibreOffice API provider",
ctx: gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
),
expectError: true,
},
{
scenario: "no API from LibreOffice API provider",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
api.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LibreOfficeMock = func() (api.Uno, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "provision success",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
api.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LibreOfficeMock = func() (api.Uno, error) {
return new(api.ApiMock), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(LibreOfficePdfEngine)
err := engine.Provision(tc.ctx)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestLibreOfficePdfEngine_Merge(t *testing.T) {
engine := new(LibreOfficePdfEngine)
err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_Split(t *testing.T) {
engine := new(LibreOfficePdfEngine)
_, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_Flatten(t *testing.T) {
engine := new(LibreOfficePdfEngine)
err := engine.Flatten(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
api api.Uno
expectError bool
expectedError error
}{
{
scenario: "convert success",
api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return nil
},
},
expectError: false,
},
{
scenario: "ErrInvalidPdfFormats",
api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return api.ErrInvalidPdfFormats
},
},
expectError: true,
expectedError: gotenberg.ErrPdfFormatNotSupported,
},
{
scenario: "convert fail",
api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return errors.New("foo")
},
},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := &LibreOfficePdfEngine{unoApi: tc.api}
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
})
}
}
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
engine := new(LibreOfficePdfEngine)
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
engine := new(LibreOfficePdfEngine)
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}

View File

@@ -1,7 +1,6 @@
package libreoffice
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -39,6 +38,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
password string
landscape bool
nativePageRanges string
updateIndexes bool
exportFormFields bool
allowDuplicateFieldNames bool
exportBookmarks bool
@@ -68,6 +68,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
String("password", &password, defaultOptions.Password).
Bool("landscape", &landscape, defaultOptions.Landscape).
String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
Bool("updateIndexes", &updateIndexes, defaultOptions.UpdateIndexes).
Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames).
Bool("exportBookmarks", &exportBookmarks, defaultOptions.ExportBookmarks).
@@ -127,15 +128,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
}).
Bool("nativePdfFormats", &nativePdfFormats, true).
Bool("merge", &merge, false).
Custom("metadata", func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
return nil
}).
Bool("flatten", &flatten, false).
Validate()
if err != nil {
@@ -149,6 +141,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
Password: password,
Landscape: landscape,
PageRanges: nativePageRanges,
UpdateIndexes: updateIndexes,
ExportFormFields: exportFormFields,
AllowDuplicateFieldNames: allowDuplicateFieldNames,
ExportBookmarks: exportBookmarks,

View File

@@ -1,817 +0,0 @@
package libreoffice
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"slices"
"testing"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
)
func TestConvertRoute(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
libreOffice libreofficeapi.Uno
engine gotenberg.PdfEngine
expectOptions libreofficeapi.Options
expectError bool
expectHttpError bool
expectHttpStatus int
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "missing at least one mandatory file",
ctx: &api.ContextMock{Context: new(api.Context)},
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid quality form field (not an integer)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"quality": {
"foo",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid quality form field (< 1)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"quality": {
"0",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid quality form field (> 100)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"quality": {
"101",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid maxImageResolution form field (not an integer)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"maxImageResolution": {
"foo",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid maxImageResolution form field (not in range)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"maxImageResolution": {
"1",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid metadata form field",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"metadata": {
"foo",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfFormatNotSupported (nativePdfFormats)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return libreofficeapi.ErrInvalidPdfFormats
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "ErrUnoException",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"nativePageRanges": {
"foo",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return libreofficeapi.ErrUnoException
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "ErrRuntimeException",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"password": {
"invalid",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return libreofficeapi.ErrRuntimeException
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "error from LibreOffice",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return errors.New("foo")
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "PDF engine merge error",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"merge": {
"true",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "PDF engine split error",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "PDF engine convert error",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"pdfa": {
gotenberg.PdfA1b,
},
"nativePdfFormats": {
"false",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "PDF engine write metadata error",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "PDF engine flatten error",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"flatten": {
"true",
},
})
ctx.SetCancelled(true)
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "cannot add output paths",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetCancelled(true)
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "success (single file)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"quality": {
"100",
},
"maxImageResolution": {
"1200",
},
"merge": {
"true",
},
"pdfa": {
gotenberg.PdfA1b,
},
"pdfua": {
"true",
},
"nativePdfFormats": {
"false",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
"flatten": {
"true",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
},
{
scenario: "success (many files)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
"document2.doc": "/document2.doc",
})
ctx.SetValues(map[string][]string{
"pdfa": {
gotenberg.PdfA1b,
},
"pdfua": {
"true",
},
"nativePdfFormats": {
"false",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
"flatten": {
"true",
},
})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx", ".doc"}
},
},
engine: &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 3,
expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf", "/document2.doc.pdf"},
},
{
scenario: "success with native PDF/A & PDF/UA",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"pdfa": {
gotenberg.PdfA1b,
},
"pdfua": {
"true",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
},
{
scenario: "success with split (many files)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 4,
expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"},
},
{
scenario: "success with merge and split",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"merge": {
"true",
},
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 2,
},
{
scenario: "success with split and native PDF/A & PDF/UA (many files)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
"pdfa": {
gotenberg.PdfA1b,
},
"pdfua": {
"true",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 4,
expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
err := convertRoute(tc.libreOffice, tc.engine).Handler(c)
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
var httpErr api.HttpError
isHttpError := errors.As(err, &httpErr)
if tc.expectHttpError && !isHttpError {
t.Errorf("expected an HTTP error but got: %v", err)
}
if !tc.expectHttpError && isHttpError {
t.Errorf("expected no HTTP error but got one: %v", httpErr)
}
if err != nil && tc.expectHttpError && isHttpError {
status, _ := httpErr.HttpError()
if status != tc.expectHttpStatus {
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
for _, path := range tc.expectOutputPaths {
if !slices.Contains(tc.ctx.OutputPaths(), path) {
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
}
}
})
}
}

View File

@@ -0,0 +1,49 @@
package logging
import (
"fmt"
"go.uber.org/zap/zapcore"
)
// Foreground colors.
// Copy pasted from go.uber.org/zap/internal/color/color.go
const (
black color = iota + 30
red
green
yellow
blue
magenta
cyan
white
)
type color uint8
func (c color) Add(s string) string {
return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s)
}
func levelToColor(l zapcore.Level) color {
switch l {
case zapcore.DebugLevel:
return cyan
case zapcore.InfoLevel:
return blue
case zapcore.WarnLevel:
return yellow
case zapcore.ErrorLevel:
return red
case zapcore.DPanicLevel:
return red
case zapcore.PanicLevel:
return red
case zapcore.FatalLevel:
return red
case zapcore.InvalidLevel:
return red
default:
return red
}
}

View File

@@ -0,0 +1,36 @@
package logging
import "go.uber.org/zap/zapcore"
func gcpSeverity(l zapcore.Level) string {
switch l {
case zapcore.DebugLevel:
return "DEBUG"
case zapcore.InfoLevel:
return "INFO"
case zapcore.WarnLevel:
return "WARNING"
case zapcore.ErrorLevel:
return "ERROR"
case zapcore.DPanicLevel:
return "CRITICAL"
case zapcore.PanicLevel:
return "ALERT"
case zapcore.FatalLevel:
return "EMERGENCY"
case zapcore.InvalidLevel:
return "DEFAULT"
default:
return "DEFAULT"
}
}
func gcpSeverityEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(gcpSeverity(l))
}
func gcpSeverityColorEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
severity := gcpSeverity(l)
c := levelToColor(l)
enc.AppendString(c.Add(severity))
}

View File

@@ -34,9 +34,10 @@ const (
// Logging is a module which implements the [gotenberg.LoggerProvider]
// interface.
type Logging struct {
level string
format string
fieldsPrefix string
level string
format string
fieldsPrefix string
enableGcpFields bool
}
// Descriptor returns a [Logging]'s module descriptor.
@@ -48,6 +49,14 @@ func (log *Logging) Descriptor() gotenberg.ModuleDescriptor {
fs.String("log-level", infoLoggingLevel, fmt.Sprintf("Choose the level of logging detail. Options include %s, %s, %s, or %s", errorLoggingLevel, warnLoggingLevel, infoLoggingLevel, debugLoggingLevel))
fs.String("log-format", autoLoggingFormat, fmt.Sprintf("Specify the format of logging. Options include %s, %s, or %s", autoLoggingFormat, jsonLoggingFormat, textLoggingFormat))
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs")
fs.Bool("log-enable-gcp-fields", false, "Enable Google Cloud Platform fields - namely: time, message, severity")
// Deprecated flags.
fs.Bool("log-enable-gcp-severity", false, "Enable Google Cloud Platform severity mapping")
err := fs.MarkDeprecated("log-enable-gcp-severity", "use log-enable-gcp-fields instead")
if err != nil {
panic(err)
}
return fs
}(),
@@ -62,6 +71,7 @@ func (log *Logging) Provision(ctx *gotenberg.Context) error {
log.level = flags.MustString("log-level")
log.format = flags.MustString("log-format")
log.fieldsPrefix = flags.MustString("log-fields-prefix")
log.enableGcpFields = flags.MustDeprecatedBool("log-enable-gcp-severity", "log-enable-gcp-fields")
return nil
}
@@ -101,7 +111,7 @@ func (log *Logging) Logger(mod gotenberg.Module) (*zap.Logger, error) {
return nil, fmt.Errorf("get log level: %w", err)
}
encoder, err := newLogEncoder(log.format)
encoder, err := newLogEncoder(log.format, log.enableGcpFields)
if err != nil {
return nil, fmt.Errorf("get log encoder: %w", err)
}
@@ -166,26 +176,44 @@ func newLogLevel(level string) (zapcore.Level, error) {
return lvl, nil
}
func newLogEncoder(format string) (zapcore.Encoder, error) {
func newLogEncoder(format string, gcpFields bool) (zapcore.Encoder, error) {
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
encCfg := zap.NewProductionEncoderConfig()
// Normalize the log format based on the output device.
if format == autoLoggingFormat {
if isTerminal {
format = textLoggingFormat
} else {
format = jsonLoggingFormat
}
}
// Use a human-readable time format if running in a terminal.
if isTerminal {
// If interactive terminal, make output more human-readable by default.
// Credits: https://github.com/caddyserver/caddy/blob/v2.1.1/logging.go#L671.
encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) {
encoder.AppendString(ts.Local().Format("2006/01/02 15:04:05.000"))
}
}
if format == textLoggingFormat || format == autoLoggingFormat {
// Configure level encoding based on format and GCP settings.
if format == textLoggingFormat && isTerminal {
if gcpFields {
encCfg.EncodeLevel = gcpSeverityColorEncoder
} else {
encCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
}
if format == autoLoggingFormat && isTerminal {
format = textLoggingFormat
} else if format == autoLoggingFormat {
format = jsonLoggingFormat
// For non-text (JSON) or when GCP fields are requested outside a terminal text output,
// adjust the configuration to use GCP-specific field names and encoders.
if gcpFields && format != textLoggingFormat {
encCfg.EncodeLevel = gcpSeverityEncoder
encCfg.TimeKey = "time"
encCfg.LevelKey = "severity"
encCfg.MessageKey = "message"
encCfg.EncodeTime = zapcore.ISO8601TimeEncoder
encCfg.EncodeDuration = zapcore.MillisDurationEncoder
}
switch format {

View File

@@ -1,347 +0,0 @@
package logging
import (
"fmt"
"reflect"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestLogging_Descriptor(t *testing.T) {
descriptor := new(Logging).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Logging))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestLogging_Provision(t *testing.T) {
for _, tc := range []struct {
scenario string
level string
format string
fieldsPrefix string
expectLevel string
expectFormat string
expectFieldsPrefix string
}{
{
scenario: "default values",
expectLevel: infoLoggingLevel,
expectFormat: autoLoggingFormat,
expectFieldsPrefix: "",
},
{
scenario: "explicit values",
level: "debug",
format: "json",
fieldsPrefix: "gotenberg",
expectLevel: debugLoggingLevel,
expectFormat: jsonLoggingFormat,
expectFieldsPrefix: "gotenberg",
},
{
scenario: "wrong values", // no validation at this point.
level: "foo",
format: "foo",
expectLevel: "foo",
expectFormat: "foo",
expectFieldsPrefix: "",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
var flags []string
if tc.level != "" {
flags = append(flags, "--log-level", tc.level)
}
if tc.format != "" {
flags = append(flags, "--log-format", tc.format)
}
if tc.fieldsPrefix != "" {
flags = append(flags, "--log-fields-prefix", tc.fieldsPrefix)
}
logging := new(Logging)
fs := logging.Descriptor().FlagSet
err := fs.Parse(flags)
if err != nil {
t.Fatalf("expected no error while parsing flags but got: %v", err)
}
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{FlagSet: fs}, nil)
err = logging.Provision(ctx)
if err != nil {
t.Fatalf("expected no error while provisioning but got: %v", err)
}
if logging.level != tc.expectLevel {
t.Errorf("expected logging level '%s' but got '%s'", tc.expectLevel, logging.level)
}
if logging.format != tc.expectFormat {
t.Errorf("expected logging format '%s' but got '%s'", tc.expectFormat, logging.format)
}
if logging.fieldsPrefix != tc.expectFieldsPrefix {
t.Errorf("expected logging fields prefix '%s' but got '%s'", tc.expectFieldsPrefix, logging.fieldsPrefix)
}
})
}
}
func TestLogging_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
level string
format string
expectError bool
}{
{
scenario: "invalid level",
level: "foo",
expectError: true,
},
{
scenario: "invalid format",
level: debugLoggingLevel,
format: "foo",
expectError: true,
},
{
scenario: "valid level and format",
level: debugLoggingLevel,
format: autoLoggingFormat,
},
} {
logging := new(Logging)
logging.level = tc.level
logging.format = tc.format
err := logging.Validate()
if tc.expectError && err == nil {
t.Errorf("%s: expected error but got: %v", tc.scenario, err)
}
if !tc.expectError && err != nil {
t.Errorf("%s: expected no error but got: %v", tc.scenario, err)
}
}
}
func TestLogging_Logger(t *testing.T) {
for _, tc := range []struct {
scenario string
level string
format string
fieldsPrefix string
expectError bool
}{
{
scenario: "invalid level",
level: "foo",
expectError: true,
},
{
scenario: "invalid format",
level: debugLoggingLevel,
format: "foo",
expectError: true,
},
{
scenario: "valid level and format",
level: debugLoggingLevel,
format: autoLoggingFormat,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logging := new(Logging)
logging.level = tc.level
logging.format = tc.format
logging.fieldsPrefix = tc.fieldsPrefix
_, err := logging.Logger(&gotenberg.ModuleMock{
DescriptorMock: func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "mock", New: nil}
},
})
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestCustomCore(t *testing.T) {
for _, tc := range []struct {
scenario string
level zapcore.Level
fieldsPrefix string
expectEntry bool
}{
{
scenario: "level enabled",
level: zapcore.DebugLevel,
fieldsPrefix: "gotenberg",
expectEntry: true,
},
{
scenario: "no fields prefix",
level: zapcore.DebugLevel,
expectEntry: true,
},
{
scenario: "level disabled",
level: zapcore.ErrorLevel,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
core, obsvr := observer.New(tc.level)
lgr := zap.New(customCore{
Core: core,
fieldsPrefix: tc.fieldsPrefix,
}).With(zap.String("a_field", "a value"))
lgr.Debug("a debug message", zap.String("another_field", "another value"))
entries := obsvr.TakeAll()
if tc.expectEntry && len(entries) == 0 {
t.Fatal("expected an entry")
}
if !tc.expectEntry && len(entries) != 0 {
t.Fatal("expected no entry")
}
var prefix string
if tc.fieldsPrefix != "" {
prefix = tc.fieldsPrefix + "_"
}
for _, entry := range entries {
fields := entry.Context
if len(fields) != 2 {
t.Fatalf("expected 2 fields but got %d", len(fields))
}
if fields[0].Key != fmt.Sprintf("%sa_field", prefix) {
t.Errorf("expected 'gotenberg_a_field' but got '%s'", fields[0].Key)
}
if fields[1].Key != fmt.Sprintf("%sanother_field", prefix) {
t.Errorf("expected 'gotenberg_another_field' but got '%s'", fields[1].Key)
}
}
})
}
}
func Test_newLogLevel(t *testing.T) {
for _, tc := range []struct {
scenario string
level string
expectZapLevel zapcore.Level
expectError bool
}{
{
scenario: "error level",
level: errorLoggingLevel,
expectZapLevel: zapcore.ErrorLevel,
},
{
scenario: "warning level",
level: warnLoggingLevel,
expectZapLevel: zapcore.WarnLevel,
},
{
scenario: "info level",
level: infoLoggingLevel,
expectZapLevel: zapcore.InfoLevel,
},
{
scenario: "debug level",
level: debugLoggingLevel,
expectZapLevel: zapcore.DebugLevel,
},
{
scenario: "invalid level",
level: "foo",
expectZapLevel: zapcore.InvalidLevel,
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
actual, err := newLogLevel(tc.level)
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectZapLevel != actual {
t.Errorf("expected %d level but got %d", tc.expectZapLevel, actual)
}
})
}
}
func Test_newLogEncoder(t *testing.T) {
for _, tc := range []struct {
scenario string
format string
expectError bool
}{
{
scenario: "auto format",
format: autoLoggingFormat,
},
{
scenario: "text format",
format: textLoggingFormat,
},
{
scenario: "json format",
format: jsonLoggingFormat,
},
{
scenario: "invalid format",
format: "foo",
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := newLogEncoder(tc.format)
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if !tc.expectError && err != nil {
t.Errorf("expected no error but got: %v", err)
}
})
}
}

View File

@@ -5,7 +5,11 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"go.uber.org/zap"
@@ -52,6 +56,32 @@ func (engine *PdfCpu) Validate() error {
return nil
}
// Debug returns additional debug data.
func (engine *PdfCpu) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
debug["version"] = "Unable to determine pdfcpu version"
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "pdfcpu:") {
debug["version"] = strings.TrimSpace(strings.TrimPrefix(line, "pdfcpu:"))
break
}
}
return debug
}
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
@@ -99,11 +129,25 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb
return nil, fmt.Errorf("split PDFs with pdfcpu: %w", err)
}
outputPaths, err := gotenberg.WalkDir(outputDirPath, ".pdf")
var outputPaths []string
err = filepath.Walk(outputDirPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if info.IsDir() {
return nil
}
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
outputPaths = append(outputPaths, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err)
}
sort.Sort(gotenberg.AlphanumericSort(outputPaths))
return outputPaths, nil
}
@@ -132,5 +176,6 @@ var (
_ gotenberg.Module = (*PdfCpu)(nil)
_ gotenberg.Provisioner = (*PdfCpu)(nil)
_ gotenberg.Validator = (*PdfCpu)(nil)
_ gotenberg.Debuggable = (*PdfCpu)(nil)
_ gotenberg.PdfEngine = (*PdfCpu)(nil)
)

View File

@@ -1,276 +0,0 @@
package pdfcpu
import (
"context"
"errors"
"os"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestPdfCpu_Descriptor(t *testing.T) {
descriptor := new(PdfCpu).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(PdfCpu))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestPdfCpu_Provision(t *testing.T) {
engine := new(PdfCpu)
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
err := engine.Provision(ctx)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestPdfCpu_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("PDFTK_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
engine.binPath = tc.binPath
err := engine.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPdfCpu_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
inputPaths []string
expectError bool
}{
{
scenario: "invalid context",
ctx: nil,
expectError: true,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
inputPaths: []string{
"foo",
},
expectError: true,
},
{
scenario: "single file success",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
},
expectError: false,
},
{
scenario: "many files success",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf",
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPdfCpu_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (intervals)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 3,
},
{
scenario: "success (pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
if tc.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestPdfCpu_Flatten(t *testing.T) {
mod := new(PdfCpu)
err := mod.Flatten(context.TODO(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestPdfCpu_Convert(t *testing.T) {
mod := new(PdfCpu)
err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
engine := new(PdfCpu)
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
engine := new(PdfCpu)
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}

View File

@@ -74,16 +74,14 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod
var err error
var mu sync.Mutex // to safely append errors.
resultChan := make(chan splitResult, len(multi.splitEngines))
for _, engine := range multi.splitEngines {
resultChan := make(chan splitResult, 1)
go func(engine gotenberg.PdfEngine) {
outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath)
resultChan <- splitResult{outputPaths: outputPaths, err: err}
}(engine)
}
for range multi.splitEngines {
select {
case result := <-resultChan:
if result.err != nil {
@@ -160,16 +158,14 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
var err error
var mu sync.Mutex // to safely append errors.
resultChan := make(chan readMetadataResult, len(multi.readMetadataEngines))
for _, engine := range multi.readMetadataEngines {
resultChan := make(chan readMetadataResult, 1)
go func(engine gotenberg.PdfEngine) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
resultChan <- readMetadataResult{metadata: metadata, err: err}
}(engine)
}
for range multi.readMetadataEngines {
select {
case result := <-resultChan:
if result.err != nil {

View File

@@ -51,6 +51,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
// Deprecated flags.
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default")
err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method")
if err != nil {

View File

@@ -1,417 +0,0 @@
package pdfengines
import (
"errors"
"fmt"
"reflect"
"strings"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestPdfEngines_Descriptor(t *testing.T) {
descriptor := new(PdfEngines).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(PdfEngines))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestPdfEngines_Provision(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectedMergePdfEngines []string
expectedSplitPdfEngines []string
expectedFlattenPdfEngines []string
expectedConvertPdfEngines []string
expectedReadMetadataPdfEngines []string
expectedWriteMetadataPdfEngines []string
expectError bool
}{
{
scenario: "no selection from user",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
engine := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PdfEngineMock
}{}
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }}
}
engine.ValidateMock = func() error {
return nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(PdfEngines).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
engine.Descriptor(),
},
)
}(),
expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
expectedSplitPdfEngines: []string{"pdfcpu", "qpdf", "pdftk"},
expectedFlattenPdfEngines: []string{"qpdf"},
expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
expectedReadMetadataPdfEngines: []string{"exiftool"},
expectedWriteMetadataPdfEngines: []string{"exiftool"},
expectError: false,
},
{
scenario: "selection from user",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
engine1 := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PdfEngineMock
}{}
engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "a", New: func() gotenberg.Module { return engine1 }}
}
engine1.ValidateMock = func() error {
return nil
}
engine2 := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PdfEngineMock
}{}
engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "b", New: func() gotenberg.Module { return engine2 }}
}
engine2.ValidateMock = func() error {
return nil
}
fs := new(PdfEngines).Descriptor().FlagSet
err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-split-engines=a", "--pdfengines-flatten-engines=c", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
engine1.Descriptor(),
engine2.Descriptor(),
},
)
}(),
expectedMergePdfEngines: []string{"b"},
expectedSplitPdfEngines: []string{"a"},
expectedFlattenPdfEngines: []string{"c"},
expectedConvertPdfEngines: []string{"b"},
expectedReadMetadataPdfEngines: []string{"a"},
expectedWriteMetadataPdfEngines: []string{"a"},
expectError: false,
},
{
scenario: "no valid PDF engine",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
engine := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PdfEngineMock
}{}
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }}
}
engine.ValidateMock = func() error {
return errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(PdfEngines).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
engine.Descriptor(),
},
)
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(PdfEngines)
err := mod.Provision(tc.ctx)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if len(tc.expectedMergePdfEngines) != len(mod.mergeNames) {
t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
}
if len(tc.expectedFlattenPdfEngines) != len(mod.flattenNames) {
t.Fatalf("expected %d flatten names but got %d", len(tc.expectedFlattenPdfEngines), len(mod.flattenNames))
}
if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
}
if len(tc.expectedReadMetadataPdfEngines) != len(mod.readMetadataNames) {
t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames))
}
if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMetadataNames) {
t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMetadataNames))
}
for index, name := range mod.mergeNames {
if name != tc.expectedMergePdfEngines[index] {
t.Fatalf("expected merge name at index %d to be %s, but got: %s", index, name, tc.expectedMergePdfEngines[index])
}
}
for index, name := range mod.splitNames {
if name != tc.expectedSplitPdfEngines[index] {
t.Fatalf("expected split name at index %d to be %s, but got: %s", index, name, tc.expectedSplitPdfEngines[index])
}
}
for index, name := range mod.convertNames {
if name != tc.expectedConvertPdfEngines[index] {
t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index])
}
}
for index, name := range mod.readMetadataNames {
if name != tc.expectedReadMetadataPdfEngines[index] {
t.Fatalf("expected read metadata name at index %d to be %s, but got: %s", index, name, tc.expectedReadMetadataPdfEngines[index])
}
}
for index, name := range mod.writeMetadataNames {
if name != tc.expectedWriteMetadataPdfEngines[index] {
t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index])
}
}
})
}
}
func TestPdfEngines_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
names []string
engines []gotenberg.PdfEngine
expectError bool
}{
{
scenario: "existing PDF engine",
names: []string{"foo"},
engines: func() []gotenberg.PdfEngine {
engine := &struct {
gotenberg.ModuleMock
gotenberg.PdfEngineMock
}{}
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }}
}
return []gotenberg.PdfEngine{
engine,
}
}(),
expectError: false,
},
{
scenario: "non-existing bar PDF engine",
names: []string{"foo", "bar", "baz"},
engines: func() []gotenberg.PdfEngine {
engine1 := &struct {
gotenberg.ModuleMock
gotenberg.PdfEngineMock
}{}
engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }}
}
engine2 := &struct {
gotenberg.ModuleMock
gotenberg.PdfEngineMock
}{}
engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return engine2 }}
}
return []gotenberg.PdfEngine{
engine1,
engine2,
}
}(),
expectError: true,
},
{
scenario: "no PDF engine",
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := PdfEngines{
mergeNames: tc.names,
convertNames: tc.names,
readMetadataNames: tc.names,
writeMetadataNames: tc.names,
engines: tc.engines,
}
err := mod.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPdfEngines_SystemMessages(t *testing.T) {
mod := new(PdfEngines)
mod.mergeNames = []string{"foo", "bar"}
mod.splitNames = []string{"foo", "bar"}
mod.convertNames = []string{"foo", "bar"}
mod.readMetadataNames = []string{"foo", "bar"}
mod.writeMetadataNames = []string{"foo", "bar"}
expectedMessages := 6
messages := mod.SystemMessages()
if len(messages) != expectedMessages {
t.Errorf("expected %d message(s), but got %d", expectedMessages, len(messages))
}
expect := []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
}
for i, message := range messages {
if message != expect[i] {
t.Errorf("expected message at index %d to be %s, but got %s", i, message, expect[i])
}
}
}
func TestPdfEngines_PdfEngine(t *testing.T) {
mod := PdfEngines{
mergeNames: []string{"foo", "bar"},
splitNames: []string{"foo", "bar"},
convertNames: []string{"foo", "bar"},
readMetadataNames: []string{"foo", "bar"},
writeMetadataNames: []string{"foo", "bar"},
engines: func() []gotenberg.PdfEngine {
engine1 := &struct {
gotenberg.ModuleMock
gotenberg.PdfEngineMock
}{}
engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }}
}
engine2 := &struct {
gotenberg.ModuleMock
gotenberg.PdfEngineMock
}{}
engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine2 }}
}
return []gotenberg.PdfEngine{
engine1,
engine2,
}
}(),
}
_, err := mod.PdfEngine()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestPdfEngines_Routes(t *testing.T) {
for _, tc := range []struct {
scenario string
expectRoutes int
disableRoutes bool
}{
{
scenario: "routes not disabled",
expectRoutes: 6,
disableRoutes: false,
},
{
scenario: "routes disabled",
expectRoutes: 0,
disableRoutes: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(PdfEngines)
mod.disableRoutes = tc.disableRoutes
routes, err := mod.Routes()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectRoutes != len(routes) {
t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
package pdftk
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"go.uber.org/zap"
@@ -52,6 +55,29 @@ func (engine *PdfTk) Validate() error {
return nil
}
// Debug returns additional debug data.
func (engine *PdfTk) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
lines := bytes.SplitN(output, []byte("\n"), 2)
if len(lines) > 0 {
debug["version"] = string(lines[0])
} else {
debug["version"] = "Unable to determine PDFtk version"
}
return debug
}
// Split splits a given PDF file.
func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
@@ -124,5 +150,6 @@ var (
_ gotenberg.Module = (*PdfTk)(nil)
_ gotenberg.Provisioner = (*PdfTk)(nil)
_ gotenberg.Validator = (*PdfTk)(nil)
_ gotenberg.Debuggable = (*PdfTk)(nil)
_ gotenberg.PdfEngine = (*PdfTk)(nil)
)

View File

@@ -1,269 +0,0 @@
package pdftk
import (
"context"
"errors"
"os"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestPdfTk_Descriptor(t *testing.T) {
descriptor := new(PdfTk).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(PdfTk))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestPdfTk_Provision(t *testing.T) {
engine := new(PdfTk)
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
err := engine.Provision(ctx)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestPdfTk_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("PDFTK_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfTk)
engine.binPath = tc.binPath
err := engine.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPdfTk_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
inputPaths []string
expectError bool
}{
{
scenario: "invalid context",
ctx: nil,
expectError: true,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
inputPaths: []string{
"foo",
},
expectError: true,
},
{
scenario: "single file success",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
},
expectError: false,
},
{
scenario: "many files success",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf",
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfTk)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPdfCpu_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfTk)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
if tc.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestPdfTk_Flatten(t *testing.T) {
engine := new(PdfTk)
err := engine.Flatten(context.TODO(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestPdfTk_Convert(t *testing.T) {
engine := new(PdfTk)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
engine := new(PdfTk)
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
engine := new(PdfTk)
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}

View File

@@ -1,360 +0,0 @@
package prometheus
import (
"errors"
"reflect"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestPrometheus_Descriptor(t *testing.T) {
descriptor := new(Prometheus).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Prometheus))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestPrometheus_Provision(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectMetrics []gotenberg.Metric
expectError bool
}{
{
scenario: "disable collect",
ctx: func() *gotenberg.Context {
fs := new(Prometheus).Descriptor().FlagSet
err := fs.Parse([]string{"--prometheus-disable-collect=true"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
expectError: false,
},
{
scenario: "invalid metrics provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.MetricsProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.ValidateMock = func() error {
return errors.New("foo")
}
mod.MetricsMock = func() ([]gotenberg.Metric, error) {
return nil, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Prometheus).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "invalid metrics from metrics provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.MetricsProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.ValidateMock = func() error {
return nil
}
mod.MetricsMock = func() ([]gotenberg.Metric, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Prometheus).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "provision success",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.MetricsProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.ValidateMock = func() error {
return nil
}
mod.MetricsMock = func() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "foo",
Description: "Bar.",
},
}, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Prometheus).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectMetrics: []gotenberg.Metric{
{
Name: "foo",
Description: "Bar.",
},
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Prometheus)
err := mod.Provision(tc.ctx)
if !reflect.DeepEqual(mod.metrics, tc.expectMetrics) {
t.Fatalf("expected metrics %+v, but got: %+v", tc.expectMetrics, mod.metrics)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPrometheus_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
namespace string
metrics []gotenberg.Metric
disableCollect bool
expectError bool
}{
{
scenario: "collect disabled",
namespace: "foo",
disableCollect: true,
expectError: false,
},
{
scenario: "empty namespace",
namespace: "",
disableCollect: false,
expectError: true,
},
{
scenario: "empty metric name",
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "",
},
},
disableCollect: false,
expectError: true,
},
{
scenario: "nil read metric method",
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: nil,
},
},
disableCollect: false,
expectError: true,
},
{
scenario: "already registered metric",
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: func() float64 {
return 0
},
},
{
Name: "foo",
Read: func() float64 {
return 0
},
},
},
disableCollect: false,
expectError: true,
},
{
scenario: "validate success",
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: func() float64 {
return 0
},
},
{
Name: "bar",
Read: func() float64 {
return 0
},
},
},
disableCollect: false,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := &Prometheus{
namespace: tc.namespace,
metrics: tc.metrics,
disableCollect: tc.disableCollect,
}
err := mod.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPrometheus_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
metrics []gotenberg.Metric
disableCollect bool
}{
{
scenario: "collect disabled",
disableCollect: true,
},
{
scenario: "start success",
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: func() float64 {
return 0
},
},
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := &Prometheus{
namespace: "foo",
interval: time.Duration(1) * time.Second,
metrics: tc.metrics,
disableCollect: tc.disableCollect,
registry: prometheus.NewRegistry(),
}
err := mod.Start()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
})
}
}
func TestPrometheus_StartupMessage(t *testing.T) {
mod := new(Prometheus)
mod.disableCollect = true
disableCollectMsg := mod.StartupMessage()
mod.disableCollect = false
noDisableCollectMsg := mod.StartupMessage()
if disableCollectMsg == noDisableCollectMsg {
t.Errorf("expected differrent startup messages if collect is disabled or not, but got '%s'", disableCollectMsg)
}
}
func TestPrometheus_Stop(t *testing.T) {
err := new(Prometheus).Stop(nil)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestPrometheus_Routes(t *testing.T) {
for _, tc := range []struct {
scenario string
disableCollect bool
expectRoutes int
}{
{
scenario: "collect disabled",
disableCollect: true,
expectRoutes: 0,
},
{
scenario: "routes not disabled",
disableCollect: false,
expectRoutes: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := &Prometheus{
disableCollect: tc.disableCollect,
registry: prometheus.NewRegistry(),
}
routes, err := mod.Routes()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectRoutes != len(routes) {
t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
}
})
}
}

View File

@@ -1,11 +1,14 @@
package qpdf
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"go.uber.org/zap"
@@ -19,7 +22,8 @@ func init() {
// QPdf abstracts the CLI tool QPDF and implements the [gotenberg.PdfEngine]
// interface.
type QPdf struct {
binPath string
binPath string
globalArgs []string
}
// Descriptor returns a [QPdf]'s module descriptor.
@@ -38,6 +42,8 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
}
engine.binPath = binPath
// Warnings should not cause errors.
engine.globalArgs = []string{"--warning-exit-0"}
return nil
}
@@ -52,6 +58,29 @@ func (engine *QPdf) Validate() error {
return nil
}
// Debug returns additional debug data.
func (engine *QPdf) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
lines := bytes.SplitN(output, []byte("\n"), 2)
if len(lines) > 0 {
debug["version"] = string(lines[0])
} else {
debug["version"] = "Unable to determine QPDF version"
}
return debug
}
// Split splits a given PDF file.
func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
@@ -62,7 +91,10 @@ func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenber
if !mode.Unify {
return nil, fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
args = append(args, inputPath, "--pages", ".", mode.Span, "--", outputPath)
args = append(args, inputPath)
args = append(args, engine.globalArgs...)
args = append(args, "--pages", ".", mode.Span)
args = append(args, "--", outputPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
@@ -84,6 +116,7 @@ func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenber
func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
args = append(args, "--empty")
args = append(args, engine.globalArgs...)
args = append(args, "--pages")
args = append(args, inputPaths...)
args = append(args, "--", outputPath)
@@ -105,10 +138,11 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
// original annotations.
func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
var args []string
args = append(args, inputPath)
args = append(args, "--generate-appearances")
args = append(args, "--flatten-annotations=all")
args = append(args, "--replace-input")
args = append(args, inputPath)
args = append(args, engine.globalArgs...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
@@ -142,5 +176,6 @@ var (
_ gotenberg.Module = (*QPdf)(nil)
_ gotenberg.Provisioner = (*QPdf)(nil)
_ gotenberg.Validator = (*QPdf)(nil)
_ gotenberg.Debuggable = (*QPdf)(nil)
_ gotenberg.PdfEngine = (*QPdf)(nil)
)

View File

@@ -1,357 +0,0 @@
package qpdf
import (
"context"
"errors"
"fmt"
"io"
"os"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestQPdf_Descriptor(t *testing.T) {
descriptor := new(QPdf).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(QPdf))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestQPdf_Provision(t *testing.T) {
engine := new(QPdf)
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
err := engine.Provision(ctx)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestQPdf_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("QPDF_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf)
engine.binPath = tc.binPath
err := engine.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestQPdf_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
inputPaths []string
expectError bool
}{
{
scenario: "invalid context",
ctx: nil,
expectError: true,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
inputPaths: []string{
"foo",
},
expectError: true,
},
{
scenario: "single file success",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
},
expectError: false,
},
{
scenario: "many files success",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf",
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestQPdf_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
if tc.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestQPdf_Flatten(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
inputPath string
createCopy bool
expectError bool
}{
{
scenario: "invalid context",
ctx: nil,
expectError: true,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
inputPath: "foo.pdf",
expectError: true,
},
{
scenario: "success",
ctx: context.TODO(),
inputPath: "/tests/test/testdata/pdfengines/sample3.pdf",
createCopy: true,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
var destinationPath string
if tc.createCopy {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error no but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
source, err := os.Open(tc.inputPath)
if err != nil {
t.Fatalf("open source file: %v", err)
}
defer func(source *os.File) {
err := source.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(source)
destination, err := os.Create(destinationPath)
if err != nil {
t.Fatalf("create destination file: %v", err)
}
defer func(destination *os.File) {
err := destination.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(destination)
_, err = io.Copy(destination, source)
if err != nil {
t.Fatalf("copy source into destination: %v", err)
}
} else {
destinationPath = tc.inputPath
}
err = engine.Flatten(tc.ctx, zap.NewNop(), destinationPath)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestQPdf_Convert(t *testing.T) {
engine := new(QPdf)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
engine := new(QPdf)
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
engine := new(QPdf)
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}

View File

@@ -1,582 +0,0 @@
package webhook
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
func TestWebhookMiddlewareGuards(t *testing.T) {
buildMultipartFormDataRequest := func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
buildWebhookModule := func() *Webhook {
return &Webhook{
allowList: regexp2.MustCompile("", 0),
denyList: regexp2.MustCompile("", 0),
errorAllowList: regexp2.MustCompile("", 0),
errorDenyList: regexp2.MustCompile("", 0),
maxRetry: 0,
retryMinWait: 0,
retryMaxWait: 0,
disable: false,
}
}
for _, tc := range []struct {
scenario string
request *http.Request
mod *Webhook
next echo.HandlerFunc
noDeadline bool
expectError bool
expectHttpError bool
expectHttpStatus int
}{
{
scenario: "no webhook URL, skip middleware",
request: buildMultipartFormDataRequest(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
noDeadline: false,
expectError: false,
expectHttpError: false,
},
{
scenario: "no webhook error URL",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "context has no deadline",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: buildWebhookModule(),
noDeadline: true,
expectError: true,
},
{
scenario: "webhook URL is not allowed",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: func() *Webhook {
mod := buildWebhookModule()
mod.allowList = regexp2.MustCompile("bar", 0)
return mod
}(),
noDeadline: false,
expectError: true,
},
{
scenario: "webhook URL is denied",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: func() *Webhook {
mod := buildWebhookModule()
mod.denyList = regexp2.MustCompile("foo", 0)
return mod
}(),
noDeadline: false,
expectError: true,
},
{
scenario: "webhook error URL is not allowed",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: func() *Webhook {
mod := buildWebhookModule()
mod.errorAllowList = regexp2.MustCompile("foo", 0)
return mod
}(),
noDeadline: false,
expectError: true,
},
{
scenario: "webhook error URL is denied",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: func() *Webhook {
mod := buildWebhookModule()
mod.errorDenyList = regexp2.MustCompile("bar", 0)
return mod
}(),
noDeadline: false,
expectError: true,
},
{
scenario: "invalid webhook method (GET)",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodGet)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid webhook error method (GET)",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "valid webhook method (POST) but invalid webhook error method (GET)",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodPost)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "valid webhook method (PATH) but invalid webhook error method (GET)",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodPatch)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "valid webhook method (PUT) but invalid webhook error method (GET)",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodPut)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid webhook extra HTTP headers",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", "foo")
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, httptest.NewRecorder())
if tc.noDeadline {
ctx := &api.ContextMock{Context: &api.Context{Context: context.Background()}}
ctx.SetEchoContext(c)
c.Set("context", ctx.Context)
c.Set("cancel", func() context.CancelFunc {
return nil
}())
} else {
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
ctx.SetEchoContext(c)
c.Set("context", ctx.Context)
c.Set("cancel", cancel)
}
err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
var httpErr api.HttpError
isHttpError := errors.As(err, &httpErr)
if tc.expectHttpError && !isHttpError {
t.Errorf("expected an HTTP error but got: %v", err)
}
if !tc.expectHttpError && isHttpError {
t.Errorf("expected no HTTP error but got one: %v", httpErr)
}
if err != nil && tc.expectHttpError && isHttpError {
status, _ := httpErr.HttpError()
if status != tc.expectHttpStatus {
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
}
}
})
}
}
func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
buildMultipartFormDataRequest := func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
buildWebhookModule := func() *Webhook {
return &Webhook{
allowList: regexp2.MustCompile("", 0),
denyList: regexp2.MustCompile("", 0),
errorAllowList: regexp2.MustCompile("", 0),
errorDenyList: regexp2.MustCompile("", 0),
maxRetry: 0,
retryMinWait: 0,
retryMaxWait: 0,
clientTimeout: time.Duration(30) * time.Second,
disable: false,
}
}
for _, tc := range []struct {
scenario string
request *http.Request
mod *Webhook
next echo.HandlerFunc
expectWebhookContentType string
expectWebhookMethod string
expectWebhookExtraHttpHeaders map[string]string
expectWebhookFilename string
expectWebhookErrorStatus int
expectWebhookErrorMessage string
returnedError *echo.HTTPError
}{
{
scenario: "next handler return an error",
request: buildMultipartFormDataRequest(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return errors.New("foo")
}
}(),
expectWebhookContentType: echo.MIMEApplicationJSON,
expectWebhookMethod: http.MethodPost,
expectWebhookErrorStatus: http.StatusInternalServerError,
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
},
{
scenario: "next handler return an HTTP error",
request: buildMultipartFormDataRequest(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return api.NewSentinelHttpError(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
}
}(),
expectWebhookContentType: echo.MIMEApplicationJSON,
expectWebhookMethod: http.MethodPost,
expectWebhookErrorStatus: http.StatusBadRequest,
expectWebhookErrorMessage: http.StatusText(http.StatusBadRequest),
},
{
scenario: "success",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Output-Filename", "foo")
req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", `{ "foo": "bar" }`)
return req
}(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
return ctx.AddOutputPaths("/tests/test/testdata/api/sample2.pdf")
}
}(),
expectWebhookContentType: "application/pdf",
expectWebhookMethod: http.MethodPost,
expectWebhookFilename: "foo",
expectWebhookExtraHttpHeaders: map[string]string{"foo": "bar"},
},
{
scenario: "success (return an error)",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Output-Filename", "foo")
return req
}(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
return ctx.AddOutputPaths("/tests/test/testdata/api/sample1.pdf")
}
}(),
returnedError: echo.ErrInternalServerError,
expectWebhookContentType: echo.MIMEApplicationJSON,
expectWebhookMethod: http.MethodPost,
expectWebhookErrorStatus: http.StatusInternalServerError,
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
expectWebhookFilename: "foo",
},
} {
func() {
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, httptest.NewRecorder())
c.Set("logger", zap.NewNop())
c.Set("traceHeader", "Gotenberg-Trace")
c.Set("trace", "foo")
c.Set("startTime", time.Now())
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
ctx.SetLogger(zap.NewNop())
ctx.SetEchoContext(c)
c.Set("context", ctx.Context)
c.Set("cancel", cancel)
webhook := echo.New()
webhook.HideBanner = true
webhook.HidePort = true
webhookPort := rand.Intn(65535-1025+1) + 1025
c.Request().Header.Set("Gotenberg-Webhook-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
c.Request().Header.Set("Gotenberg-Webhook-Error-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
errChan := make(chan error, 1)
webhook.POST(
"/",
func() echo.HandlerFunc {
return func(c echo.Context) error {
contentType := c.Request().Header.Get(echo.HeaderContentType)
if contentType != tc.expectWebhookContentType {
t.Errorf("expected '%s' '%s' but got '%s'", echo.HeaderContentType, tc.expectWebhookContentType, contentType)
}
trace := c.Request().Header.Get("Gotenberg-Trace")
if trace != "foo" {
t.Errorf("expected '%s' '%s' but got '%s'", "Gotenberg-Trace", "foo", trace)
}
method := c.Request().Method
if method != tc.expectWebhookMethod {
t.Errorf("expected HTTP method '%s' but got '%s'", tc.expectWebhookMethod, method)
}
for key, expect := range tc.expectWebhookExtraHttpHeaders {
actual := c.Request().Header.Get(key)
if actual != expect {
t.Errorf("expected '%s' '%s' but got '%s'", key, expect, actual)
}
}
if contentType == echo.MIMEApplicationJSON {
body, err := io.ReadAll(c.Request().Body)
if err != nil {
errChan <- err
return nil
}
result := struct {
Status int `json:"status"`
Message string `json:"message"`
}{}
err = json.Unmarshal(body, &result)
if err != nil {
errChan <- err
return nil
}
if result.Status != tc.expectWebhookErrorStatus {
t.Errorf("expected status %d from JSON but got %d", tc.expectWebhookErrorStatus, result.Status)
}
if result.Message != tc.expectWebhookErrorMessage {
t.Errorf("expected message '%s' from JSON but got '%s'", tc.expectWebhookErrorMessage, result.Message)
}
errChan <- nil
return nil
}
contentLength := c.Request().Header.Get(echo.HeaderContentLength)
if contentLength == "" {
t.Errorf("expected non empty '%s'", echo.HeaderContentLength)
}
contentDisposition := c.Request().Header.Get(echo.HeaderContentDisposition)
if !strings.Contains(contentDisposition, tc.expectWebhookFilename) {
t.Errorf("expected '%s' '%s' to contain '%s'", echo.HeaderContentDisposition, contentDisposition, tc.expectWebhookFilename)
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
errChan <- err
return nil
}
if body == nil || len(body) == 0 {
t.Error("expected non nil body")
}
errChan <- nil
if tc.returnedError != nil {
return tc.returnedError
}
return nil
}
}(),
)
go func() {
err := webhook.Start(fmt.Sprintf(":%d", webhookPort))
if !errors.Is(err, http.ErrServerClosed) {
t.Errorf("expected no error but got: %v", err)
}
}()
defer func() {
err := webhook.Shutdown(context.TODO())
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}()
err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
if err != nil && !errors.Is(err, api.ErrAsyncProcess) {
t.Errorf("expected no error but got: %v", err)
}
err = <-errChan
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}()
}
}

View File

@@ -1,65 +0,0 @@
package webhook
import (
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestWebhook_Descriptor(t *testing.T) {
descriptor := new(Webhook).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Webhook))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestWebhook_Provision(t *testing.T) {
mod := new(Webhook)
ctx := gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Webhook).Descriptor().FlagSet,
},
nil,
)
err := mod.Provision(ctx)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestWebhook_Middlewares(t *testing.T) {
for _, tc := range []struct {
scenario string
disable bool
expectMiddlewares int
}{
{
scenario: "webhook disabled",
disable: true,
expectMiddlewares: 0,
},
{
scenario: "webhook enabled",
disable: false,
expectMiddlewares: 1,
},
} {
mod := new(Webhook)
mod.disable = tc.disable
middlewares, err := mod.Middlewares()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectMiddlewares != len(middlewares) {
t.Errorf("expected %d middlewares but got %d", tc.expectMiddlewares, len(middlewares))
}
}
}

View File

@@ -1,50 +0,0 @@
ARG GOLANG_VERSION
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
ARG GOLANGCI_LINT_VERSION
FROM golang:$GOLANG_VERSION-bookworm AS golang
# We're extending the Gotenberg's Docker image because our code relies on external
# dependencies like Google Chrome, LibreOffice, etc.
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
USER root
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
ENV CGO_ENABLED=1
COPY --from=golang /usr/local/go /usr/local/go
RUN apt-get update -qq &&\
apt-get upgrade -yqq &&\
apt-get install -y -qq --no-install-recommends \
sudo \
# gcc for cgo.
g++ \
gcc \
libc6-dev \
make \
pkg-config &&\
rm -rf /var/lib/apt/lists/* &&\
mkdir -p "$GOPATH/src" "$GOPATH/bin" &&\
chmod -R 777 "$GOPATH" &&\
adduser gotenberg sudo &&\
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers &&\
# We cannot use $PATH in the next command (print $PATH instead of the environment variable value).
sed -i 's#/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin#/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/go/bin:/usr/local/go/bin#g' /etc/sudoers &&\
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin $GOLANGCI_LINT_VERSION &&\
go install mvdan.cc/gofumpt@latest &&\
go install github.com/daixiang0/gci@latest
COPY ./test/docker-entrypoint.sh /usr/bin/docker-entrypoint.sh
COPY ./test/golint.sh /usr/bin/golint
COPY ./test/gotest.sh /usr/bin/gotest
COPY ./test/gotodos.sh /usr/bin/gotodos
# Pristine working directory.
WORKDIR /tests
ENTRYPOINT [ "/usr/bin/tini", "--", "docker-entrypoint.sh" ]

View File

@@ -1,59 +0,0 @@
#!/bin/bash
# This entrypoint allows us to set the UID and GID of the host user so that
# our testing environment does not override files permissions from the host.
# Credits: https://github.com/thecodingmachine/docker-images-php.
set +e
mkdir -p testing_file_system_rights.foo
chmod 700 testing_file_system_rights.foo
su gotenberg -c "touch testing_file_system_rights.foo/foo > /dev/null 2>&1"
HAS_CONSISTENT_RIGHTS=$?
if [[ "$HAS_CONSISTENT_RIGHTS" != "0" ]]; then
# If not specified, the DOCKER_USER is the owner of the current working directory (heuristic!).
DOCKER_USER=$(stat -c '%u' "$(pwd)")
else
# macOs or Windows.
# Note: in most cases, we don't care about the rights (they are not respected).
FILE_OWNER=$(stat -c '%u' "testing_file_system_rights.foo/foo")
if [[ "$FILE_OWNER" == "0" ]]; then
# If root, we are likely on a Windows host.
# All files will belong to root, but it does not matter as everybody can write/delete
# those (0777 access rights).
DOCKER_USER=gotenberg
else
# In case of a NFS mount (common on macOS), the created files will belong to the NFS user.
DOCKER_USER=$FILE_OWNER
fi
fi
rm -rf testing_file_system_rights.foo
set -e
unset HAS_CONSISTENT_RIGHTS
# Note: DOCKER_USER is either a username (if the user exists in the container),
# otherwise a user ID (a user from the host).
# DOCKER_USER is an ID.
if [[ "$DOCKER_USER" =~ ^[0-9]+$ ]] ; then
# Let's change the gotenberg user's ID in order to match this free ID.
usermod -u "$DOCKER_USER" -G sudo gotenberg
DOCKER_USER=gotenberg
fi
DOCKER_USER_ID=$(id -ur $DOCKER_USER)
# Fix access rights to stdout and stderr.
set +e
chown $DOCKER_USER /proc/self/fd/{1,2}
set -e
# Install modules.
set -x
go mod download
go mod tidy
set +x
# Run the command with the correct user.
exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@"

View File

@@ -1,5 +0,0 @@
#!/bin/bash
set -x
golangci-lint run

View File

@@ -1,10 +0,0 @@
#!/bin/bash
set -x
go test -race -covermode=atomic -coverprofile=/tests/coverage.txt ./...
RESULT=$?
go tool cover -html=coverage.txt -o /tests/coverage.html
exit $RESULT

View File

@@ -1,8 +0,0 @@
#!/bin/bash
set -x
golangci-lint run \
--no-config \
--disable-all \
--enable godox

2
test/integration/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package integration contains everything related to integration testing.
package integration

View File

@@ -0,0 +1,855 @@
# TODO:
# 1. JavaScript disabled on some feature.
Feature: /forms/chromium/convert/html
Scenario: POST /forms/chromium/convert/html (Default)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Scenario: POST /forms/chromium/convert/html (Single Page)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-12-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 12 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "foo.pdf" PDF should have the following content at page 12:
"""
Page 12
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-12-html/index.html | file |
| singlePage | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
# page-break-after: always; tells the browser's print engine to force a page break after each element,
# even when calculating a large enough paper height, Chromium's PDF rendering will still honor those page break
# directives.
"""
Page 12
"""
Scenario: POST /forms/chromium/convert/html (Landscape)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should NOT be set to landscape orientation
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| landscape | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should be set to landscape orientation
Scenario: POST /forms/chromium/convert/html (Native Page Ranges)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-12-html/index.html | file |
| nativePageRanges | 2-3 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 2 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "foo.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/html (Header & Footer)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-12-html/index.html | file |
| files | testdata/header-footer-html/header.html | file |
| files | testdata/header-footer-html/footer.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 12 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Pages 12
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
1 of 12
"""
Then the "foo.pdf" PDF should have the following content at page 12:
"""
Pages 12
"""
Then the "foo.pdf" PDF should have the following content at page 12:
"""
12 of 12
"""
Scenario: POST /forms/chromium/convert/html (Wait Delay)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| waitDelay | 2.5s | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/html (Wait For Expression)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| waitForExpression | window.globalVar === 'ready' | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/html (Emulated Media Type)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| emulatedMediaType | print | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| emulatedMediaType | screen | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Scenario: POST /forms/chromium/convert/html (Default Allow / Deny Lists)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the Gotenberg container should log the following entries:
| 'file:///etc/passwd' matches the expression from the denied list |
Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
Then the response status code should be 403
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Forbidden
"""
Scenario: POST /forms/chromium/convert/html (Main URL does match denied list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_LIST | ^file:///tmp.* |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
Then the response status code should be 403
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Forbidden
"""
Scenario: POST /forms/chromium/convert/html (Request does not match the allowed list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | ^file:///tmp.* |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the Gotenberg container should log the following entries:
| 'file:///etc/passwd' does not match the expression from the allowed list |
Scenario: POST /forms/chromium/convert/html (JavaScript Enabled)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
JavaScript is enabled.
"""
Scenario: POST /forms/chromium/convert/html (JavaScript Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_DISABLE_JAVASCRIPT | true |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
JavaScript is enabled.
"""
Scenario: POST /forms/chromium/convert/html (Fail On Resource HTTP Status Codes)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| failOnResourceHttpStatusCodes | [499,599] | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid HTTP status code from resources:
https://httpstat.us/400 - 400: Bad Request
"""
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| failOnResourceLoadingFailed | true | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
Chromium failed to load resources
"""
Then the response body should contain string:
"""
resource Stylesheet: net::ERR_CONNECTION_REFUSED
"""
Then the response body should contain string:
"""
resource Stylesheet: net::ERR_FILE_NOT_FOUND
"""
Scenario: POST /forms/chromium/convert/html (Fail On Console Exceptions)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| failOnConsoleExceptions | true | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
Chromium console exceptions
"""
Then the response body should contain string:
"""
exception "Uncaught" (61:12): Error: Exception 1
"""
Then the response body should contain string:
"""
exception "Uncaught" (65:12): Error: Exception 2
"""
Scenario: POST /forms/chromium/convert/html (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| singlePage | foo | field |
| paperWidth | foo | field |
| paperHeight | foo | field |
| marginTop | foo | field |
| marginBottom | foo | field |
| marginLeft | foo | field |
| marginRight | foo | field |
| preferCssPageSize | foo | field |
| generateDocumentOutline | foo | field |
| printBackground | foo | field |
| omitBackground | foo | field |
| landscape | foo | field |
| scale | foo | field |
| waitDelay | foo | field |
| emulatedMediaType | foo | field |
| failOnHttpStatusCodes | foo | field |
| failOnResourceHttpStatusCodes | foo | field |
| failOnResourceLoadingFailed | foo | field |
| failOnConsoleExceptions | foo | field |
| skipNetworkIdleEvent | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'skipNetworkIdleEvent' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnResourceHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceLoadingFailed' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnConsoleExceptions' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'waitDelay' is invalid (got 'foo', resulting to time: invalid duration "foo"); form field 'emulatedMediaType' is invalid (got 'foo', resulting to wrong value, expected either 'screen', 'print' or empty); form field 'omitBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'printBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'scale' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'singlePage' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'paperWidth' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'paperHeight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginTop' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginBottom' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginLeft' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginRight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'preferCssPageSize' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateDocumentOutline' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form file 'index.html' is required
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| omitBackground | true | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
omitBackground requires printBackground set to true
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| paperWidth | 0 | field |
| paperHeight | 0 | field |
| marginTop | 1000000 | field |
| marginBottom | 1000000 | field |
| marginLeft | 1000000 | field |
| marginRight | 1000000 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Chromium does not handle the provided settings; please check for aberrant form values
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| nativePageRanges | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Chromium does not handle the page ranges 'foo' (nativePageRanges)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| waitForExpression | undefined | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
The expression 'undefined' (waitForExpression) returned an exception or undefined
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| cookies | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'cookies' is invalid (got 'foo', resulting to unmarshal cookies: invalid character 'o' in literal false (expecting 'a'))
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| cookies | [{"name":"yummy_cookie","value":"choco"}] | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'cookies' is invalid (got '[{"name":"yummy_cookie","value":"choco"}]', resulting to cookie 0 must have its name, value and domain set)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| extraHttpHeaders | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got 'foo', resulting to unmarshal extraHttpHeaders: invalid character 'o' in literal false (expecting 'a'))
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| extraHttpHeaders | {"foo":"bar;scope;;"} | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope;;"}', resulting to invalid scope '' for header 'foo')
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| extraHttpHeaders | {"foo":"bar;scope=*."} | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| splitMode | foo | field |
| splitSpan | 2 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages')
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| splitMode | intervals | field |
| splitSpan | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| splitMode | pages | field |
| splitSpan | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| pdfa | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| pdfua | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| metadata | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
"""
Scenario: POST /forms/chromium/convert/html (Split Intervals)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-3-html/index.html | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 2 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_0.pdf" PDF should have the following content at page 2:
"""
Page 2
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/html (Split Pages)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-3-html/index.html | file |
| splitMode | pages | field |
| splitSpan | 2- | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 1 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/html (Split Pages & Unify)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-3-html/index.html | file |
| splitMode | pages | field |
| splitSpan | 2- | field |
| splitUnify | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 2 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "foo.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/html (Split Many PDFs - Lot of Pages)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-12-html/index.html | file |
| splitMode | intervals | field |
| splitSpan | 1 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 12 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
| *_2.pdf |
| *_3.pdf |
| *_4.pdf |
| *_5.pdf |
| *_6.pdf |
| *_7.pdf |
| *_8.pdf |
| *_9.pdf |
| *_10.pdf |
| *_11.pdf |
Then the "*_0.pdf" PDF should have 1 page(s)
Then the "*_11.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_11.pdf" PDF should have the following content at page 1:
"""
Page 12
"""
Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Scenario: POST /forms/chromium/convert/html (Split & PDF/A-1b & PDF/UA-1)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-3-html/index.html | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 2 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_0.pdf" PDF should have the following content at page 2:
"""
Page 2
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Scenario: POST /forms/chromium/convert/html (Metadata)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown"
}
}
"""
Scenario: POST /forms/chromium/convert/html (Flatten)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| flatten | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be flatten
Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| flatten | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 7 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be flatten
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown"
}
}
"""
Scenario: POST /forms/chromium/convert/html (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
Then the response status code should be 404
Scenario: POST /forms/chromium/convert/html (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| Gotenberg-Trace | forms_chromium_convert_html | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html"
Then the Gotenberg container should log the following entries:
| "trace":"forms_chromium_convert_html" |
Scenario: POST /forms/chromium/convert/html (Download From)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page-1-html/index.html","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
Then the response status code should be 200
Then the file request header "X-Foo" should be "bar"
Then the response header "Content-Type" should be "application/pdf"
Scenario: POST /forms/chromium/convert/html (Webhook)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Then there should be the following file(s) in the webhook request:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Scenario: POST /forms/chromium/convert/html (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
Then the response status code should be 401
Scenario: POST /foo/forms/chromium/convert/html (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ROOT_PATH | /foo/ |
When I make a "POST" request to Gotenberg at the "/foo/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"

View File

@@ -0,0 +1,980 @@
# TODO:
# 1. JavaScript disabled on some feature.
Feature: /forms/chromium/convert/markdown
Scenario: POST /forms/chromium/convert/markdown (Default)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Scenario: POST /forms/chromium/convert/markdown (Single Page)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-12-markdown/index.html | file |
| files | testdata/pages-12-markdown/page_1.md | file |
| files | testdata/pages-12-markdown/page_2.md | file |
| files | testdata/pages-12-markdown/page_3.md | file |
| files | testdata/pages-12-markdown/page_4.md | file |
| files | testdata/pages-12-markdown/page_5.md | file |
| files | testdata/pages-12-markdown/page_6.md | file |
| files | testdata/pages-12-markdown/page_7.md | file |
| files | testdata/pages-12-markdown/page_8.md | file |
| files | testdata/pages-12-markdown/page_9.md | file |
| files | testdata/pages-12-markdown/page_10.md | file |
| files | testdata/pages-12-markdown/page_11.md | file |
| files | testdata/pages-12-markdown/page_12.md | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 12 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "foo.pdf" PDF should have the following content at page 12:
"""
Page 12
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-12-markdown/index.html | file |
| files | testdata/pages-12-markdown/page_1.md | file |
| files | testdata/pages-12-markdown/page_2.md | file |
| files | testdata/pages-12-markdown/page_3.md | file |
| files | testdata/pages-12-markdown/page_4.md | file |
| files | testdata/pages-12-markdown/page_5.md | file |
| files | testdata/pages-12-markdown/page_6.md | file |
| files | testdata/pages-12-markdown/page_7.md | file |
| files | testdata/pages-12-markdown/page_8.md | file |
| files | testdata/pages-12-markdown/page_9.md | file |
| files | testdata/pages-12-markdown/page_10.md | file |
| files | testdata/pages-12-markdown/page_11.md | file |
| files | testdata/pages-12-markdown/page_12.md | file |
| singlePage | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
# page-break-after: always; tells the browser's print engine to force a page break after each element,
# even when calculating a large enough paper height, Chromium's PDF rendering will still honor those page break
# directives.
"""
Page 12
"""
Scenario: POST /forms/chromium/convert/markdown (Landscape)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should NOT be set to landscape orientation
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| landscape | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should be set to landscape orientation
Scenario: POST /forms/chromium/convert/markdown (Native Page Ranges)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-12-markdown/index.html | file |
| files | testdata/pages-12-markdown/page_1.md | file |
| files | testdata/pages-12-markdown/page_2.md | file |
| files | testdata/pages-12-markdown/page_3.md | file |
| files | testdata/pages-12-markdown/page_4.md | file |
| files | testdata/pages-12-markdown/page_5.md | file |
| files | testdata/pages-12-markdown/page_6.md | file |
| files | testdata/pages-12-markdown/page_7.md | file |
| files | testdata/pages-12-markdown/page_8.md | file |
| files | testdata/pages-12-markdown/page_9.md | file |
| files | testdata/pages-12-markdown/page_10.md | file |
| files | testdata/pages-12-markdown/page_11.md | file |
| files | testdata/pages-12-markdown/page_12.md | file |
| nativePageRanges | 2-3 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 2 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "foo.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/markdown (Header & Footer)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-12-markdown/index.html | file |
| files | testdata/pages-12-markdown/page_1.md | file |
| files | testdata/pages-12-markdown/page_2.md | file |
| files | testdata/pages-12-markdown/page_3.md | file |
| files | testdata/pages-12-markdown/page_4.md | file |
| files | testdata/pages-12-markdown/page_5.md | file |
| files | testdata/pages-12-markdown/page_6.md | file |
| files | testdata/pages-12-markdown/page_7.md | file |
| files | testdata/pages-12-markdown/page_8.md | file |
| files | testdata/pages-12-markdown/page_9.md | file |
| files | testdata/pages-12-markdown/page_10.md | file |
| files | testdata/pages-12-markdown/page_11.md | file |
| files | testdata/pages-12-markdown/page_12.md | file |
| files | testdata/header-footer-html/header.html | file |
| files | testdata/header-footer-html/footer.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 12 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Pages 12
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
1 of 12
"""
Then the "foo.pdf" PDF should have the following content at page 12:
"""
Pages 12
"""
Then the "foo.pdf" PDF should have the following content at page 12:
"""
12 of 12
"""
Scenario: POST /forms/chromium/convert/markdown (Wait Delay)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| waitDelay | 2.5s | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/markdown (Wait For Expression)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| waitForExpression | window.globalVar === 'ready' | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/markdown (Emulated Media Type)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| emulatedMediaType | print | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| emulatedMediaType | screen | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Scenario: POST /forms/chromium/convert/markdown (Default Allow / Deny Lists)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the Gotenberg container should log the following entries:
| 'file:///etc/passwd' matches the expression from the denied list |
Scenario: POST /forms/chromium/convert/markdown (Main URL does NOT match allowed list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
Then the response status code should be 403
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Forbidden
"""
Scenario: POST /forms/chromium/convert/markdown (Main URL does match denied list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_LIST | ^file:///tmp.* |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
Then the response status code should be 403
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Forbidden
"""
Scenario: POST /forms/chromium/convert/markdown (Request does not match the allowed list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | ^file:///tmp.* |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the Gotenberg container should log the following entries:
| 'file:///etc/passwd' does not match the expression from the allowed list |
Scenario: POST /forms/chromium/convert/markdown (JavaScript Enabled)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
JavaScript is enabled.
"""
Scenario: POST /forms/chromium/convert/markdown (JavaScript Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_DISABLE_JAVASCRIPT | true |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
JavaScript is enabled.
"""
Scenario: POST /forms/chromium/convert/markdown (Fail On Resource HTTP Status Codes)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| failOnResourceHttpStatusCodes | [499,599] | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid HTTP status code from resources:
https://httpstat.us/400 - 400: Bad Request
"""
Scenario: POST /forms/chromium/convert/markdown (Fail On Resource Loading Failed)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| failOnResourceLoadingFailed | true | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
Chromium failed to load resources
"""
Then the response body should contain string:
"""
resource Stylesheet: net::ERR_CONNECTION_REFUSED
"""
Then the response body should contain string:
"""
resource Stylesheet: net::ERR_FILE_NOT_FOUND
"""
Scenario: POST /forms/chromium/convert/markdown (Fail On Console Exceptions)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file |
| failOnConsoleExceptions | true | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
Chromium console exceptions
"""
Then the response body should contain string:
"""
exception "Uncaught" (93:12): Error: Exception 1
"""
Then the response body should contain string:
"""
exception "Uncaught" (97:12): Error: Exception 2
"""
Scenario: POST /forms/chromium/convert/markdown (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-3-markdown/index.html | file |
| files | testdata/pages-3-markdown/page_1.md | file |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Markdown file(s) not found: 'page_2.md'; 'page_3.md'
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| singlePage | foo | field |
| paperWidth | foo | field |
| paperHeight | foo | field |
| marginTop | foo | field |
| marginBottom | foo | field |
| marginLeft | foo | field |
| marginRight | foo | field |
| preferCssPageSize | foo | field |
| generateDocumentOutline | foo | field |
| printBackground | foo | field |
| omitBackground | foo | field |
| landscape | foo | field |
| scale | foo | field |
| waitDelay | foo | field |
| emulatedMediaType | foo | field |
| failOnHttpStatusCodes | foo | field |
| failOnResourceHttpStatusCodes | foo | field |
| failOnResourceLoadingFailed | foo | field |
| failOnConsoleExceptions | foo | field |
| skipNetworkIdleEvent | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'skipNetworkIdleEvent' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnResourceHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceLoadingFailed' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnConsoleExceptions' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'waitDelay' is invalid (got 'foo', resulting to time: invalid duration "foo"); form field 'emulatedMediaType' is invalid (got 'foo', resulting to wrong value, expected either 'screen', 'print' or empty); form field 'omitBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'printBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'scale' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'singlePage' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'paperWidth' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'paperHeight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginTop' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginBottom' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginLeft' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginRight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'preferCssPageSize' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateDocumentOutline' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form file 'index.html' is required; no form file found for extensions: [.md]
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| omitBackground | true | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
omitBackground requires printBackground set to true
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| paperWidth | 0 | field |
| paperHeight | 0 | field |
| marginTop | 1000000 | field |
| marginBottom | 1000000 | field |
| marginLeft | 1000000 | field |
| marginRight | 1000000 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Chromium does not handle the provided settings; please check for aberrant form values
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| nativePageRanges | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Chromium does not handle the page ranges 'foo' (nativePageRanges)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| waitForExpression | undefined | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
The expression 'undefined' (waitForExpression) returned an exception or undefined
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| cookies | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'cookies' is invalid (got 'foo', resulting to unmarshal cookies: invalid character 'o' in literal false (expecting 'a'))
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| cookies | [{"name":"yummy_cookie","value":"choco"}] | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'cookies' is invalid (got '[{"name":"yummy_cookie","value":"choco"}]', resulting to cookie 0 must have its name, value and domain set)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| extraHttpHeaders | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got 'foo', resulting to unmarshal extraHttpHeaders: invalid character 'o' in literal false (expecting 'a'))
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| extraHttpHeaders | {"foo":"bar;scope;;"} | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope;;"}', resulting to invalid scope '' for header 'foo')
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| extraHttpHeaders | {"foo":"bar;scope=*."} | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| splitMode | foo | field |
| splitSpan | 2 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages')
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| splitMode | intervals | field |
| splitSpan | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| splitMode | pages | field |
| splitSpan | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| pdfa | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| pdfua | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| metadata | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
"""
Scenario: POST /forms/chromium/convert/markdown (Split Intervals)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-3-markdown/index.html | file |
| files | testdata/pages-3-markdown/page_1.md | file |
| files | testdata/pages-3-markdown/page_2.md | file |
| files | testdata/pages-3-markdown/page_3.md | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 2 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_0.pdf" PDF should have the following content at page 2:
"""
Page 2
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/markdown (Split Pages)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-3-markdown/index.html | file |
| files | testdata/pages-3-markdown/page_1.md | file |
| files | testdata/pages-3-markdown/page_2.md | file |
| files | testdata/pages-3-markdown/page_3.md | file |
| splitMode | pages | field |
| splitSpan | 2- | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 1 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/markdown (Split Pages & Unify)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-3-markdown/index.html | file |
| files | testdata/pages-3-markdown/page_1.md | file |
| files | testdata/pages-3-markdown/page_2.md | file |
| files | testdata/pages-3-markdown/page_3.md | file |
| splitMode | pages | field |
| splitSpan | 2- | field |
| splitUnify | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 2 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "foo.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/markdown (Split Many PDFs - Lot of Pages)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-12-markdown/index.html | file |
| files | testdata/pages-12-markdown/page_1.md | file |
| files | testdata/pages-12-markdown/page_2.md | file |
| files | testdata/pages-12-markdown/page_3.md | file |
| files | testdata/pages-12-markdown/page_4.md | file |
| files | testdata/pages-12-markdown/page_5.md | file |
| files | testdata/pages-12-markdown/page_6.md | file |
| files | testdata/pages-12-markdown/page_7.md | file |
| files | testdata/pages-12-markdown/page_8.md | file |
| files | testdata/pages-12-markdown/page_9.md | file |
| files | testdata/pages-12-markdown/page_10.md | file |
| files | testdata/pages-12-markdown/page_11.md | file |
| files | testdata/pages-12-markdown/page_12.md | file |
| splitMode | intervals | field |
| splitSpan | 1 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 12 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
| *_2.pdf |
| *_3.pdf |
| *_4.pdf |
| *_5.pdf |
| *_6.pdf |
| *_7.pdf |
| *_8.pdf |
| *_9.pdf |
| *_10.pdf |
| *_11.pdf |
Then the "*_0.pdf" PDF should have 1 page(s)
Then the "*_11.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_11.pdf" PDF should have the following content at page 1:
"""
Page 12
"""
Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Scenario: POST /forms/chromium/convert/markdown (Split & PDF/A-1b & PDF/UA-1)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/pages-3-markdown/index.html | file |
| files | testdata/pages-3-markdown/page_1.md | file |
| files | testdata/pages-3-markdown/page_2.md | file |
| files | testdata/pages-3-markdown/page_3.md | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 2 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_0.pdf" PDF should have the following content at page 2:
"""
Page 2
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Scenario: POST /forms/chromium/convert/markdown (Metadata)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown"
}
}
"""
Scenario: POST /forms/chromium/convert/markdown (Flatten)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| flatten | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be flatten
Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| flatten | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 7 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be flatten
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown"
}
}
"""
Scenario: POST /forms/chromium/convert/markdown (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
Then the response status code should be 404
Scenario: POST /forms/chromium/convert/markdown (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| Gotenberg-Trace | forms_chromium_convert_html | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html"
Then the Gotenberg container should log the following entries:
| "trace":"forms_chromium_convert_html" |
Scenario: POST /forms/chromium/convert/markdown (Download From)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page-1-markdown/index.html","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
| files | testdata/page-1-markdown/page_1.md | file |
Then the response status code should be 200
Then the file request header "X-Foo" should be "bar"
Then the response header "Content-Type" should be "application/pdf"
Scenario: POST /forms/chromium/convert/markdown (Webhook)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| Gotenberg-Output-Filename | foo | header |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Then there should be the following file(s) in the webhook request:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Scenario: POST /forms/chromium/convert/markdown (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
Then the response status code should be 401
Scenario: POST /foo/forms/chromium/convert/markdown (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ROOT_PATH | /foo/ |
When I make a "POST" request to Gotenberg at the "/foo/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"

View File

@@ -0,0 +1,932 @@
# TODO:
# 1. JavaScript disabled on some feature.
Feature: /forms/chromium/convert/url
Scenario: POST /forms/chromium/convert/url (Default)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Scenario: POST /forms/chromium/convert/url (Single Page)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 12 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "foo.pdf" PDF should have the following content at page 12:
"""
Page 12
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
| singlePage | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
# page-break-after: always; tells the browser's print engine to force a page break after each element,
# even when calculating a large enough paper height, Chromium's PDF rendering will still honor those page break
# directives.
"""
Page 12
"""
Scenario: POST /forms/chromium/convert/url (Landscape)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should NOT be set to landscape orientation
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| landscape | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should be set to landscape orientation
Scenario: POST /forms/chromium/convert/url (Native Page Ranges)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
| nativePageRanges | 2-3 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 2 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "foo.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/url (Header & Footer)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
| files | testdata/header-footer-html/header.html | file |
| files | testdata/header-footer-html/footer.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 12 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Pages 12
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
1 of 12
"""
Then the "foo.pdf" PDF should have the following content at page 12:
"""
Pages 12
"""
Then the "foo.pdf" PDF should have the following content at page 12:
"""
12 of 12
"""
Scenario: POST /forms/chromium/convert/url (Custom HTTP Headers)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| userAgent | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) | field |
| extraHttpHeaders | {"X-Header":"foo","X-Scoped-Header-1":"bar;scope=https?:\\/\\/([a-zA-Z0-9-]+\\\\.)*domain\\\\.com\\/.*","X-Scoped-Header-2":"baz;scope=https?:\\/\\/([a-zA-Z0-9-]+\\\\.)*docker\\\\.internal:(\\\\d+)\\/.*"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the server request header "User-Agent" should be "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)"
Then the server request header "X-Header" should be "foo"
Then the server request header "X-Scoped-Header-1" should be ""
Then the server request header "X-Scoped-Header-2" should be "baz"
Scenario: POST /forms/chromium/convert/url (Cookies)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| cookies | [{"name":"cookie_1","value":"foo","domain":"host.docker.internal:%d"},{"name":"cookie_2","value":"bar","domain":"domain.com"}] | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the server request cookie "cookie_1" should be "foo"
Then the server request cookie "cookie_2" should be ""
Scenario: POST /forms/chromium/convert/url (Wait Delay)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| waitDelay | 2.5s | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/url (Wait For Expression)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| waitForExpression | window.globalVar === 'ready' | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/url (Emulated Media Type)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| emulatedMediaType | print | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| emulatedMediaType | screen | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Scenario: POST /forms/chromium/convert/url (Default Allow / Deny Lists)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the Gotenberg container should NOT log the following entries:
# Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS.
| 'file:///etc/passwd' matches the expression from the denied list |
Scenario: POST /forms/chromium/convert/url (Main URL does NOT match allowed list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
Then the response status code should be 403
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Forbidden
"""
Scenario: POST /forms/chromium/convert/url (Main URL does match denied list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_LIST | ^http.* |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
Then the response status code should be 403
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Forbidden
"""
Scenario: POST /forms/chromium/convert/url (Request does not match the allowed list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | ^.* |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the Gotenberg container should NOT log the following entries:
# Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS.
| 'file:///etc/passwd' does not match the expression from the allowed list |
Scenario: POST /forms/chromium/convert/url (JavaScript Enabled)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
JavaScript is enabled.
"""
Scenario: POST /forms/chromium/convert/url (JavaScript Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_DISABLE_JAVASCRIPT | true |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
JavaScript is enabled.
"""
Scenario: POST /forms/chromium/convert/url (Fail On Resource HTTP Status Codes)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| failOnResourceHttpStatusCodes | [499,599] | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
Invalid HTTP status code from resources:
"""
Then the response body should contain string:
"""
/favicon.ico - 404: Not Found
"""
Scenario: POST /forms/chromium/convert/url (Fail On Resource Loading Failed)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| failOnResourceLoadingFailed | true | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
Chromium failed to load resources
"""
Then the response body should contain string:
"""
resource Stylesheet: net::ERR_CONNECTION_REFUSED
"""
Scenario: POST /forms/chromium/convert/url (Fail On Console Exceptions)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| failOnConsoleExceptions | true | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
Chromium console exceptions
"""
Then the response body should contain string:
"""
exception "Uncaught" (56:12): Error: Exception 1
"""
Then the response body should contain string:
"""
exception "Uncaught" (60:12): Error: Exception 2
"""
Scenario: POST /forms/chromium/convert/url (Bad Request)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| singlePage | foo | field |
| paperWidth | foo | field |
| paperHeight | foo | field |
| marginTop | foo | field |
| marginBottom | foo | field |
| marginLeft | foo | field |
| marginRight | foo | field |
| preferCssPageSize | foo | field |
| generateDocumentOutline | foo | field |
| printBackground | foo | field |
| omitBackground | foo | field |
| landscape | foo | field |
| scale | foo | field |
| waitDelay | foo | field |
| emulatedMediaType | foo | field |
| failOnHttpStatusCodes | foo | field |
| failOnResourceHttpStatusCodes | foo | field |
| failOnResourceLoadingFailed | foo | field |
| failOnConsoleExceptions | foo | field |
| skipNetworkIdleEvent | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'skipNetworkIdleEvent' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnResourceHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceLoadingFailed' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnConsoleExceptions' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'waitDelay' is invalid (got 'foo', resulting to time: invalid duration "foo"); form field 'emulatedMediaType' is invalid (got 'foo', resulting to wrong value, expected either 'screen', 'print' or empty); form field 'omitBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'printBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'scale' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'singlePage' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'paperWidth' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'paperHeight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginTop' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginBottom' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginLeft' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginRight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'preferCssPageSize' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateDocumentOutline' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'url' is required
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| omitBackground | true | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
omitBackground requires printBackground set to true
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| paperWidth | 0 | field |
| paperHeight | 0 | field |
| marginTop | 1000000 | field |
| marginBottom | 1000000 | field |
| marginLeft | 1000000 | field |
| marginRight | 1000000 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Chromium does not handle the provided settings; please check for aberrant form values
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| nativePageRanges | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Chromium does not handle the page ranges 'foo' (nativePageRanges)
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| waitForExpression | undefined | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
The expression 'undefined' (waitForExpression) returned an exception or undefined
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| cookies | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'cookies' is invalid (got 'foo', resulting to unmarshal cookies: invalid character 'o' in literal false (expecting 'a'))
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| cookies | [{"name":"yummy_cookie","value":"choco"}] | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'cookies' is invalid (got '[{"name":"yummy_cookie","value":"choco"}]', resulting to cookie 0 must have its name, value and domain set)
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| extraHttpHeaders | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got 'foo', resulting to unmarshal extraHttpHeaders: invalid character 'o' in literal false (expecting 'a'))
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| extraHttpHeaders | {"foo":"bar;scope;;"} | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope;;"}', resulting to invalid scope '' for header 'foo')
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| extraHttpHeaders | {"foo":"bar;scope=*."} | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| splitMode | foo | field |
| splitSpan | 2 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages')
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| splitMode | intervals | field |
| splitSpan | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax)
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| splitMode | pages | field |
| splitSpan | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| pdfa | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| pdfua | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| metadata | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
"""
Scenario: POST /forms/chromium/convert/url (Split Intervals)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
| splitMode | intervals | field |
| splitSpan | 2 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 2 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_0.pdf" PDF should have the following content at page 2:
"""
Page 2
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/url (Split Pages)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
| splitMode | pages | field |
| splitSpan | 2- | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 1 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/url (Split Pages & Unify)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
| splitMode | pages | field |
| splitSpan | 2- | field |
| splitUnify | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 2 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "foo.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Scenario: POST /forms/chromium/convert/url (Split Many PDFs - Lot of Pages)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
| splitMode | intervals | field |
| splitSpan | 1 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 12 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
| *_2.pdf |
| *_3.pdf |
| *_4.pdf |
| *_5.pdf |
| *_6.pdf |
| *_7.pdf |
| *_8.pdf |
| *_9.pdf |
| *_10.pdf |
| *_11.pdf |
Then the "*_0.pdf" PDF should have 1 page(s)
Then the "*_11.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_11.pdf" PDF should have the following content at page 1:
"""
Page 12
"""
Scenario: POST /forms/chromium/convert/url (PDF/A-1b & PDF/UA-1)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Scenario: POST /forms/chromium/convert/url (Split & PDF/A-1b & PDF/UA-1)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 2 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_0.pdf" PDF should have the following content at page 2:
"""
Page 2
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Scenario: POST /forms/chromium/convert/url (Metadata)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown"
}
}
"""
Scenario: POST /forms/chromium/convert/url (Flatten)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| flatten | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be flatten
Scenario: POST /forms/chromium/convert/url (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| flatten | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 7 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be flatten
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown"
}
}
"""
Scenario: POST /forms/chromium/convert/url (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_DISABLE_ROUTES | true |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
Then the response status code should be 404
Scenario: POST /forms/chromium/convert/url (Gotenberg Trace)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| Gotenberg-Trace | forms_chromium_convert_url | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_url"
Then the Gotenberg container should log the following entries:
| "trace":"forms_chromium_convert_url" |
Scenario: POST /forms/chromium/convert/url (Webhook)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| Gotenberg-Output-Filename | foo | header |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Then there should be the following file(s) in the webhook request:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Scenario: POST /forms/chromium/convert/url (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
Then the response status code should be 401
Scenario: POST /foo/forms/chromium/convert/url (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ROOT_PATH | /foo/ |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/foo/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"

View File

@@ -0,0 +1,147 @@
Feature: /debug
Scenario: GET /debug (Disabled)
Given I have a default Gotenberg container
When I make a "GET" request to Gotenberg at the "/debug" endpoint
Then the response status code should be 404
Scenario: GET /debug (Enabled)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
When I make a "GET" request to Gotenberg at the "/debug" endpoint
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"version": "{version}",
"architecture": "ignore",
"modules": [
"api",
"chromium",
"exiftool",
"libreoffice",
"libreoffice-api",
"libreoffice-pdfengine",
"logging",
"pdfcpu",
"pdfengines",
"pdftk",
"prometheus",
"qpdf",
"webhook"
],
"modules_additional_data": {
"chromium": {
"version": "ignore"
},
"exiftool": {
"version": "ignore"
},
"libreoffice-api": {
"version": "ignore"
},
"pdfcpu": {
"version": "ignore"
},
"pdftk": {
"version": "ignore"
},
"qpdf": {
"version": "ignore"
}
},
"flags": {
"api-bind-ip": "",
"api-body-limit": "",
"api-disable-download-from": "false",
"api-disable-health-check-logging": "false",
"api-download-from-allow-list": "",
"api-download-from-deny-list": "",
"api-download-from-max-retry": "4",
"api-enable-basic-auth": "false",
"api-enable-debug-route": "true",
"api-port": "3000",
"api-port-from-env": "",
"api-root-path": "/",
"api-start-timeout": "30s",
"api-timeout": "30s",
"api-tls-cert-file": "",
"api-tls-key-file": "",
"api-trace-header": "Gotenberg-Trace",
"chromium-allow-file-access-from-files": "false",
"chromium-allow-insecure-localhost": "false",
"chromium-allow-list": "",
"chromium-auto-start": "false",
"chromium-clear-cache": "false",
"chromium-clear-cookies": "false",
"chromium-deny-list": "^file:(?!//\\/tmp/).*",
"chromium-disable-javascript": "false",
"chromium-disable-routes": "false",
"chromium-disable-web-security": "false",
"chromium-host-resolver-rules": "",
"chromium-ignore-certificate-errors": "false",
"chromium-incognito": "false",
"chromium-max-queue-size": "0",
"chromium-proxy-server": "",
"chromium-restart-after": "10",
"chromium-start-timeout": "20s",
"gotenberg-graceful-shutdown-duration": "30s",
"libreoffice-auto-start": "false",
"libreoffice-disable-routes": "false",
"libreoffice-max-queue-size": "0",
"libreoffice-restart-after": "10",
"libreoffice-start-timeout": "20s",
"log-fields-prefix": "",
"log-format": "auto",
"log-level": "info",
"pdfengines-convert-engines": "[libreoffice-pdfengine]",
"pdfengines-disable-routes": "false",
"pdfengines-engines": "[]",
"pdfengines-flatten-engines": "[qpdf]",
"pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]",
"pdfengines-read-metadata-engines": "[exiftool]",
"pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]",
"pdfengines-write-metadata-engines": "[exiftool]",
"prometheus-collect-interval": "1s",
"prometheus-disable-collect": "false",
"prometheus-disable-route-logging": "false",
"prometheus-namespace": "gotenberg",
"webhook-allow-list": "",
"webhook-client-timeout": "30s",
"webhook-deny-list": "",
"webhook-disable": "false",
"webhook-error-allow-list": "",
"webhook-error-deny-list": "",
"webhook-max-retry": "4",
"webhook-retry-max-wait": "30s",
"webhook-retry-min-wait": "1s"
}
}
"""
Scenario: GET /debug (Gotenberg Trace)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
When I make a "GET" request to Gotenberg at the "/debug" endpoint with the following header(s):
| Gotenberg-Trace | debug |
Then the response status code should be 200
Then the response header "Gotenberg-Trace" should be "debug"
Then the Gotenberg container should log the following entries:
| "trace":"debug" |
Scenario: GET /debug (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "GET" request to Gotenberg at the "/debug" endpoint
Then the response status code should be 401
Scenario: GET /foo/debug (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ROOT_PATH | /foo/ |
When I make a "GET" request to Gotenberg at the "/foo/debug" endpoint
Then the response status code should be 200

View File

@@ -0,0 +1,107 @@
# TODO:
# 1. Check if down for each module.
# 2. Restarting modules do not make health check fail.
Feature: /health
Scenario: GET /health
Given I have a default Gotenberg container
When I make a "GET" request to Gotenberg at the "/health" endpoint
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json; charset=utf-8"
Then the response body should match JSON:
"""
{
"status": "up",
"details": {
"chromium": {
"status": "up",
"timestamp": "ignore"
},
"libreoffice": {
"status": "up",
"timestamp": "ignore"
}
}
}
"""
Then the Gotenberg container should log the following entries:
| "path":"/health" |
Scenario: GET /health (No Logging)
Given I have a Gotenberg container with the following environment variable(s):
| API_DISABLE_HEALTH_CHECK_LOGGING | true |
When I make a "GET" request to Gotenberg at the "/health" endpoint
Then the response status code should be 200
Then the Gotenberg container should NOT log the following entries:
| "path":"/health" |
Scenario: GET /health (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "GET" request to Gotenberg at the "/health" endpoint with the following header(s):
| Gotenberg-Trace | get_health |
Then the response status code should be 200
Then the response header "Gotenberg-Trace" should be "get_health"
Then the Gotenberg container should log the following entries:
| "trace":"get_health" |
Scenario: GET /health (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "GET" request to Gotenberg at the "/health" endpoint
Then the response status code should be 200
Scenario: GET /foo/health (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ROOT_PATH | /foo/ |
When I make a "GET" request to Gotenberg at the "/foo/health" endpoint
Then the response status code should be 200
Scenario: HEAD /health
Given I have a default Gotenberg container
When I make a "HEAD" request to Gotenberg at the "/health" endpoint
Then the response status code should be 200
Then the response body should match string:
"""
"""
Then the Gotenberg container should log the following entries:
| "path":"/health" |
Scenario: HEAD /health (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "HEAD" request to Gotenberg at the "/health" endpoint with the following header(s):
| Gotenberg-Trace | head_health |
Then the response status code should be 200
Then the response header "Gotenberg-Trace" should be "head_health"
Then the Gotenberg container should log the following entries:
| "trace":"head_health" |
Scenario: HEAD /health (No Logging)
Given I have a Gotenberg container with the following environment variable(s):
| API_DISABLE_HEALTH_CHECK_LOGGING | true |
When I make a "HEAD" request to Gotenberg at the "/health" endpoint
Then the response status code should be 200
Then the Gotenberg container should NOT log the following entries:
| "path":"/health" |
Scenario: HEAD /health (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "HEAD" request to Gotenberg at the "/health" endpoint
Then the response status code should be 200
Scenario: HEAD /foo/health (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ROOT_PATH | /foo/ |
When I make a "HEAD" request to Gotenberg at the "/foo/health" endpoint
Then the response status code should be 200
# TODO:
# 1. Check if down for each module.
# 2. Restarting modules do not make health check fail.

View File

@@ -0,0 +1,614 @@
Feature: /forms/libreoffice/convert
Scenario: POST /forms/libreoffice/convert (Single Document)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Scenario: POST /forms/libreoffice/convert (Many Documents)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| files | testdata/page_2.docx | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.zip |
| page_1.docx.pdf |
| page_2.docx.pdf |
Then the "page_1.docx.pdf" PDF should have 1 page(s)
Then the "page_2.docx.pdf" PDF should have 1 page(s)
Then the "page_1.docx.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "page_2.docx.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Scenario: POST /forms/libreoffice/convert (Protected)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/protected_page_1.docx | file |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/protected_page_1.docx | file |
| password | foo | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/libreoffice/convert (Landscape)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| Gotenberg-Output-Filename | foo | header |
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should NOT be set to landscape orientation
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| landscape | true | field |
| Gotenberg-Output-Filename | foo | header |
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should be set to landscape orientation
Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Single Document)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| nativePageRanges | 2-3 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 2 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "foo.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Many Documents)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| files | testdata/pages_12.docx | file |
| nativePageRanges | 2-3 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.zip |
| pages_3.docx.pdf |
| pages_12.docx.pdf |
Then the "pages_3.docx.pdf" PDF should have 2 page(s)
Then the "pages_12.docx.pdf" PDF should have 2 page(s)
Then the "pages_3.docx.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "pages_3.docx.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Then the "pages_12.docx.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "pages_12.docx.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Scenario: POST /forms/libreoffice/convert (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| landscape | foo | field |
| exportFormFields | foo | field |
| allowDuplicateFieldNames | foo | field |
| exportBookmarks | foo | field |
| exportBookmarksToPdfDestination | foo | field |
| exportPlaceholders | foo | field |
| exportNotes | foo | field |
| exportNotesPages | foo | field |
| exportOnlyNotesPages | foo | field |
| exportNotesInMargin | foo | field |
| convertOooTargetToPdfTarget | foo | field |
| exportLinksRelativeFsys | foo | field |
| exportHiddenSlides | foo | field |
| skipEmptyPages | foo | field |
| addOriginalDocumentAsStream | foo | field |
| singlePageSheets | foo | field |
| losslessImageCompression | foo | field |
| quality | -1 | field |
| reduceImageResolution | foo | field |
| maxImageResolution | 10 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: no form file found for extensions: [.123 .602 .abw .bib .bmp .cdr .cgm .cmx .csv .cwk .dbf .dif .doc .docm .docx .dot .dotm .dotx .dxf .emf .eps .epub .fodg .fodp .fods .fodt .fopd .gif .htm .html .hwp .jpeg .jpg .key .ltx .lwp .mcw .met .mml .mw .numbers .odd .odg .odm .odp .ods .odt .otg .oth .otp .ots .ott .pages .pbm .pcd .pct .pcx .pdb .pdf .pgm .png .pot .potm .potx .ppm .pps .ppt .pptm .pptx .psd .psw .pub .pwp .pxl .ras .rtf .sda .sdc .sdd .sdp .sdw .sgl .slk .smf .stc .std .sti .stw .svg .svm .swf .sxc .sxd .sxg .sxi .sxm .sxw .tga .tif .tiff .txt .uof .uop .uos .uot .vdx .vor .vsd .vsdm .vsdx .wb2 .wk1 .wks .wmf .wpd .wpg .wps .xbm .xhtml .xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xpm .zabw]; form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportFormFields' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'allowDuplicateFieldNames' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportBookmarks' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportBookmarksToPdfDestination' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportPlaceholders' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportNotes' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportNotesPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportOnlyNotesPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportNotesInMargin' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'convertOooTargetToPdfTarget' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportLinksRelativeFsys' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportHiddenSlides' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'skipEmptyPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'addOriginalDocumentAsStream' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'singlePageSheets' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'losslessImageCompression' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'quality' is invalid (got '-1', resulting to value is inferior to 1); form field 'reduceImageResolution' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'maxImageResolution' is invalid (got '10', resulting to value is not 75, 150, 300, 600 or 1200)
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| nativePageRanges | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
LibreOffice failed to process a document: possible causes include malformed page ranges 'foo' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| password | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
LibreOffice failed to process a document: possible causes include malformed page ranges '' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/protected_page_1.docx | file |
| password | bar | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| files | testdata/page_2.docx | file |
| merge | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'merge' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| splitMode | foo | field |
| splitSpan | 2 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages')
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| splitMode | intervals | field |
| splitSpan | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax)
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| splitMode | pages | field |
| splitSpan | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| pdfa | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
A PDF format in '{PdfA:foo PdfUa:false}' is not supported
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| pdfua | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| metadata | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
"""
Scenario: POST /forms/libreoffice/convert (Merge)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| files | testdata/page_2.docx | file |
| merge | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 2 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "foo.pdf" PDF should have the following content at page 2:
"""
Page 2
"""
Scenario: POST /forms/libreoffice/convert (Merge & Split)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| files | testdata/page_2.docx | file |
| merge | true | field |
| splitMode | intervals | field |
| splitSpan | 1 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| *_0.pdf |
| *_1.pdf |
Then the "*_0.pdf" PDF should have 1 page(s)
Then the "*_1.pdf" PDF should have 1 page(s)
Then the "*_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "*_1.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Scenario: POST /forms/libreoffice/convert (Split Intervals)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| pages_3.docx_0.pdf |
| pages_3.docx_1.pdf |
Then the "pages_3.docx_0.pdf" PDF should have 2 page(s)
Then the "pages_3.docx_1.pdf" PDF should have 1 page(s)
Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "pages_3.docx_0.pdf" PDF should have the following content at page 2:
"""
Page 2
"""
Then the "pages_3.docx_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Scenario: POST /forms/libreoffice/convert (Split Pages)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| splitMode | pages | field |
| splitSpan | 2- | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| pages_3.docx_0.pdf |
| pages_3.docx_1.pdf |
Then the "pages_3.docx_0.pdf" PDF should have 1 page(s)
Then the "pages_3.docx_1.pdf" PDF should have 1 page(s)
Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "pages_3.docx_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Scenario: POST /forms/libreoffice/convert (Split Pages & Unify)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| splitMode | pages | field |
| splitSpan | 2- | field |
| splitUnify | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| pages_3.docx.pdf |
Then the "pages_3.docx.pdf" PDF should have 2 page(s)
Then the "pages_3.docx.pdf" PDF should have the following content at page 1:
"""
Page 2
"""
Then the "pages_3.docx.pdf" PDF should have the following content at page 2:
"""
Page 3
"""
Scenario: POST /forms/libreoffice/convert (Split Many PDFs - Lot of Pages)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_12.docx | file |
| files | testdata/pages_3.docx | file |
| splitMode | intervals | field |
| splitSpan | 1 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 15 PDF(s) in the response
Then there should be the following file(s) in the response:
| pages_3.docx_0.pdf |
| pages_3.docx_1.pdf |
| pages_3.docx_2.pdf |
| pages_12.docx_0.pdf |
| pages_12.docx_1.pdf |
| pages_12.docx_2.pdf |
| pages_12.docx_3.pdf |
| pages_12.docx_4.pdf |
| pages_12.docx_5.pdf |
| pages_12.docx_6.pdf |
| pages_12.docx_7.pdf |
| pages_12.docx_8.pdf |
| pages_12.docx_9.pdf |
| pages_12.docx_10.pdf |
| pages_12.docx_11.pdf |
Then the "pages_3.docx_0.pdf" PDF should have 1 page(s)
Then the "pages_3.docx_2.pdf" PDF should have 1 page(s)
Then the "pages_12.docx_0.pdf" PDF should have 1 page(s)
Then the "pages_12.docx_11.pdf" PDF should have 1 page(s)
Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "pages_3.docx_2.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Then the "pages_12.docx_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "pages_12.docx_11.pdf" PDF should have the following content at page 1:
"""
Page 12
"""
Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Scenario: POST /forms/libreoffice/convert (Split & PDF/A-1b & PDF/UA-1)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| pages_3.docx_0.pdf |
| pages_3.docx_1.pdf |
Then the "pages_3.docx_0.pdf" PDF should have 2 page(s)
Then the "pages_3.docx_1.pdf" PDF should have 1 page(s)
Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Then the "pages_3.docx_0.pdf" PDF should have the following content at page 2:
"""
Page 2
"""
Then the "pages_3.docx_1.pdf" PDF should have the following content at page 1:
"""
Page 3
"""
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Scenario: POST /forms/libreoffice/convert (Metadata)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown"
}
}
"""
Scenario: POST /forms/libreoffice/convert (Flatten)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| flatten | true | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be flatten
Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| flatten | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 7 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be flatten
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown"
}
}
"""
Scenario: POST /forms/libreoffice/convert (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| LIBREOFFICE_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
Then the response status code should be 404
Scenario: POST /forms/libreoffice/convert (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| Gotenberg-Trace | forms_libreoffice_convert | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_libreoffice_convert"
Then the Gotenberg container should log the following entries:
| "trace":"forms_libreoffice_convert" |
Scenario: POST /forms/libreoffice/convert (Download From)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.docx","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
Then the response status code should be 200
Then the file request header "X-Foo" should be "bar"
Then the response header "Content-Type" should be "application/pdf"
Scenario: POST /forms/libreoffice/convert (Webhook)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| Gotenberg-Output-Filename | foo | header |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Then there should be the following file(s) in the webhook request:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Page 1
"""
Scenario: POST /forms/libreoffice/convert (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
Then the response status code should be 401
Scenario: POST /foo/forms/libreoffice/convert (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ROOT_PATH | /foo/ |
When I make a "POST" request to Gotenberg at the "/foo/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"

Some files were not shown because too many files have changed in this diff Show More