mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-07-02 00:17:40 +08:00
1574 lines
42 KiB
Go
1574 lines
42 KiB
Go
package scenario
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/cucumber/godog"
|
|
"github.com/google/uuid"
|
|
"github.com/mholt/archives"
|
|
"github.com/testcontainers/testcontainers-go"
|
|
)
|
|
|
|
var (
|
|
failedScenariosMu sync.Mutex
|
|
failedScenarios []string // file:line paths for re-run
|
|
)
|
|
|
|
// ResetFailedScenarios clears the collected failures.
|
|
func ResetFailedScenarios() {
|
|
failedScenariosMu.Lock()
|
|
defer failedScenariosMu.Unlock()
|
|
failedScenarios = nil
|
|
}
|
|
|
|
// FailedScenarioPaths returns file:line paths of failed scenarios.
|
|
func FailedScenarioPaths() []string {
|
|
failedScenariosMu.Lock()
|
|
defer failedScenariosMu.Unlock()
|
|
result := make([]string, len(failedScenarios))
|
|
copy(result, failedScenarios)
|
|
return result
|
|
}
|
|
|
|
func recordFailedScenario(sc *godog.Scenario) {
|
|
// When godog runs with file:line paths, the Uri may contain
|
|
// the line suffix (e.g., "features/root.feature:4"). Strip it
|
|
// to get the actual file path for reading.
|
|
filePath := sc.Uri
|
|
if idx := strings.LastIndex(filePath, ":"); idx > 0 {
|
|
if _, err := strconv.Atoi(filePath[idx+1:]); err == nil {
|
|
filePath = filePath[:idx]
|
|
}
|
|
}
|
|
|
|
line := findScenarioLine(filePath, sc.Name)
|
|
if line <= 0 {
|
|
return
|
|
}
|
|
|
|
path := fmt.Sprintf("%s:%d", filePath, line)
|
|
|
|
failedScenariosMu.Lock()
|
|
defer failedScenariosMu.Unlock()
|
|
failedScenarios = append(failedScenarios, path)
|
|
}
|
|
|
|
func findScenarioLine(filePath, name string) int {
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
target := "Scenario: " + name
|
|
for i, line := range strings.Split(string(data), "\n") {
|
|
if strings.TrimSpace(line) == target {
|
|
return i + 1
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
type scenario struct {
|
|
resp *httptest.ResponseRecorder
|
|
concurrentResps []*httptest.ResponseRecorder
|
|
workdir string
|
|
teststoreDir string
|
|
gotenbergContainer testcontainers.Container
|
|
gotenbergContainerNetwork *testcontainers.DockerNetwork
|
|
server *server
|
|
hostPort int
|
|
}
|
|
|
|
func (s *scenario) reset(ctx context.Context) error {
|
|
s.resp = httptest.NewRecorder()
|
|
s.concurrentResps = nil
|
|
|
|
err := os.RemoveAll(s.workdir)
|
|
if err != nil {
|
|
return fmt.Errorf("remove workdir %q: %w", s.workdir, err)
|
|
}
|
|
s.workdir = ""
|
|
|
|
if s.server == nil {
|
|
return nil
|
|
}
|
|
|
|
err = s.server.stop(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("stop server: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) iHaveADefaultGotenbergContainer(ctx context.Context) error {
|
|
n, c, err := startGotenbergContainer(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create Gotenberg container: %s", err)
|
|
}
|
|
s.gotenbergContainerNetwork = n
|
|
s.gotenbergContainer = c
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) iHaveAGotenbergContainerWithTheFollowingEnvironmentVariables(ctx context.Context, envTable *godog.Table) error {
|
|
env := make(map[string]string)
|
|
for _, row := range envTable.Rows {
|
|
env[row.Cells[0].Value] = row.Cells[1].Value
|
|
}
|
|
n, c, err := startGotenbergContainer(ctx, env)
|
|
if err != nil {
|
|
return fmt.Errorf("create Gotenberg container: %s", err)
|
|
}
|
|
s.gotenbergContainerNetwork = n
|
|
s.gotenbergContainer = c
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) iHaveAServer(ctx context.Context) error {
|
|
srv, err := newServer(ctx, s.workdir)
|
|
if err != nil {
|
|
return fmt.Errorf("create server: %s", err)
|
|
}
|
|
s.server = srv
|
|
port, err := s.server.start(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("start server: %s", err)
|
|
}
|
|
s.hostPort = port
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) iMakeARequestToGotenberg(ctx context.Context, method, endpoint string) error {
|
|
return s.iMakeARequestToGotenbergWithTheFollowingHeaders(ctx, method, endpoint, nil)
|
|
}
|
|
|
|
func (s *scenario) iMakeARequestToGotenbergWithTheFollowingHeaders(ctx context.Context, method, endpoint string, headersTable *godog.Table) error {
|
|
if s.gotenbergContainer == nil {
|
|
return errors.New("no Gotenberg container")
|
|
}
|
|
|
|
base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
|
|
if err != nil {
|
|
return fmt.Errorf("get container HTTP endpoint: %w", err)
|
|
}
|
|
|
|
headers := make(map[string]string)
|
|
if headersTable != nil {
|
|
for _, row := range headersTable.Rows {
|
|
headers[row.Cells[0].Value] = row.Cells[1].Value
|
|
}
|
|
}
|
|
|
|
resp, err := doRequest(method, fmt.Sprintf("%s%s", base, endpoint), headers, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("read response body: %w", err)
|
|
}
|
|
|
|
s.resp = httptest.NewRecorder()
|
|
s.resp.Code = resp.StatusCode
|
|
for key, values := range resp.Header {
|
|
for _, v := range values {
|
|
s.resp.Header().Add(key, v)
|
|
}
|
|
}
|
|
_, err = s.resp.Body.Write(body)
|
|
if err != nil {
|
|
return fmt.Errorf("write response body: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ctx context.Context, method, endpoint string, dataTable *godog.Table) error {
|
|
if s.gotenbergContainer == nil {
|
|
return errors.New("no Gotenberg container")
|
|
}
|
|
|
|
fields := make(map[string]string)
|
|
files := make(map[string][]string)
|
|
headers := make(map[string]string)
|
|
|
|
for _, row := range dataTable.Rows {
|
|
name := row.Cells[0].Value
|
|
value := row.Cells[1].Value
|
|
kind := row.Cells[2].Value
|
|
|
|
switch kind {
|
|
case "field":
|
|
if name == "downloadFrom" || name == "url" || name == "cookies" {
|
|
fields[name] = strings.ReplaceAll(value, "%d", fmt.Sprintf("%d", s.hostPort))
|
|
continue
|
|
}
|
|
fields[name] = value
|
|
case "file":
|
|
if strings.Contains(value, "teststore") {
|
|
if s.teststoreDir == "" {
|
|
return errors.New("no teststore directory available from previous requests")
|
|
}
|
|
_, err := os.Stat(s.teststoreDir)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", s.teststoreDir)
|
|
}
|
|
value = strings.ReplaceAll(value, "teststore", s.teststoreDir)
|
|
} else {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("get current directory: %w", err)
|
|
}
|
|
value = fmt.Sprintf("%s/%s", wd, value)
|
|
}
|
|
files[name] = append(files[name], value)
|
|
case "header":
|
|
if name == "Gotenberg-Webhook-Url" || name == "Gotenberg-Webhook-Error-Url" || name == "Gotenberg-Webhook-Events-Url" {
|
|
headers[name] = fmt.Sprintf(value, s.hostPort)
|
|
continue
|
|
}
|
|
headers[name] = value
|
|
default:
|
|
return fmt.Errorf("unexpected %q %q", kind, value)
|
|
}
|
|
}
|
|
|
|
base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
|
|
if err != nil {
|
|
return fmt.Errorf("get container HTTP endpoint: %w", err)
|
|
}
|
|
|
|
resp, err := doFormDataRequest(method, fmt.Sprintf("%s%s", base, endpoint), fields, files, headers)
|
|
if err != nil {
|
|
return fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("read response body: %w", err)
|
|
}
|
|
|
|
s.resp = httptest.NewRecorder()
|
|
s.resp.Code = resp.StatusCode
|
|
for key, values := range resp.Header {
|
|
for _, v := range values {
|
|
s.resp.Header().Add(key, v)
|
|
}
|
|
}
|
|
_, err = s.resp.Body.Write(body)
|
|
if err != nil {
|
|
return fmt.Errorf("write response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusNoContent {
|
|
// Gotenberg processes this asynchronously. The webhook test server
|
|
// will save the incoming files under this trace ID directory shortly.
|
|
s.teststoreDir = fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
|
|
return nil
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil
|
|
}
|
|
|
|
cd := resp.Header.Get("Content-Disposition")
|
|
if cd == "" {
|
|
return nil
|
|
}
|
|
|
|
_, params, err := mime.ParseMediaType(cd)
|
|
if err != nil {
|
|
return fmt.Errorf("parse Content-Disposition header: %w", err)
|
|
}
|
|
|
|
filename, ok := params["filename"]
|
|
if !ok {
|
|
return errors.New("no filename in Content-Disposition header")
|
|
}
|
|
|
|
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
|
|
err = os.MkdirAll(dirPath, 0o755)
|
|
if err != nil {
|
|
return fmt.Errorf("create working directory: %w", err)
|
|
}
|
|
|
|
s.teststoreDir = dirPath
|
|
|
|
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
|
|
file, err := os.Create(fpath)
|
|
if err != nil {
|
|
return fmt.Errorf("create file %q: %w", fpath, err)
|
|
}
|
|
defer file.Close()
|
|
|
|
_, err = file.Write(body)
|
|
if err != nil {
|
|
return fmt.Errorf("write file %q: %w", fpath, err)
|
|
}
|
|
|
|
if resp.Header.Get("Content-Type") == "application/zip" {
|
|
var format archives.Zip
|
|
err = format.Extract(ctx, file, func(ctx context.Context, f archives.FileInfo) error {
|
|
source, err := f.Open()
|
|
if err != nil {
|
|
return fmt.Errorf("open file %q: %w", f.Name(), err)
|
|
}
|
|
defer source.Close()
|
|
|
|
targetPath := fmt.Sprintf("%s/%s", dirPath, f.Name())
|
|
target, err := os.Create(targetPath)
|
|
if err != nil {
|
|
return fmt.Errorf("create file %q: %w", targetPath, err)
|
|
}
|
|
defer target.Close()
|
|
|
|
_, err = io.Copy(target, source)
|
|
if err != nil {
|
|
return fmt.Errorf("copy file %q: %w", targetPath, err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count int, method, endpoint string, dataTable *godog.Table) error {
|
|
if s.gotenbergContainer == nil {
|
|
return errors.New("no Gotenberg container")
|
|
}
|
|
|
|
fields := make(map[string]string)
|
|
files := make(map[string][]string)
|
|
headers := make(map[string]string)
|
|
|
|
for _, row := range dataTable.Rows {
|
|
name := row.Cells[0].Value
|
|
value := row.Cells[1].Value
|
|
kind := row.Cells[2].Value
|
|
|
|
switch kind {
|
|
case "field":
|
|
fields[name] = value
|
|
case "file":
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("get current directory: %w", err)
|
|
}
|
|
value = fmt.Sprintf("%s/%s", wd, value)
|
|
files[name] = append(files[name], value)
|
|
case "header":
|
|
headers[name] = value
|
|
default:
|
|
return fmt.Errorf("unexpected %q %q", kind, value)
|
|
}
|
|
}
|
|
|
|
base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
|
|
if err != nil {
|
|
return fmt.Errorf("get container HTTP endpoint: %w", err)
|
|
}
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
wg sync.WaitGroup
|
|
)
|
|
s.concurrentResps = make([]*httptest.ResponseRecorder, 0, count)
|
|
errs := make([]error, 0)
|
|
|
|
for range count {
|
|
wg.Go(func() {
|
|
resp, reqErr := doFormDataRequest(method, fmt.Sprintf("%s%s", base, endpoint), fields, files, headers)
|
|
if reqErr != nil {
|
|
mu.Lock()
|
|
errs = append(errs, fmt.Errorf("do request: %w", reqErr))
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, reqErr := io.ReadAll(resp.Body)
|
|
if reqErr != nil {
|
|
mu.Lock()
|
|
errs = append(errs, fmt.Errorf("read response body: %w", reqErr))
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
rec.Code = resp.StatusCode
|
|
for key, values := range resp.Header {
|
|
for _, v := range values {
|
|
rec.Header().Add(key, v)
|
|
}
|
|
}
|
|
_, _ = rec.Body.Write(body)
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
cd := resp.Header.Get("Content-Disposition")
|
|
if cd != "" {
|
|
_, params, parseErr := mime.ParseMediaType(cd)
|
|
if parseErr == nil {
|
|
if filename, ok := params["filename"]; ok {
|
|
traceID := resp.Header.Get("Gotenberg-Trace")
|
|
dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID)
|
|
|
|
mu.Lock()
|
|
mkErr := os.MkdirAll(dirPath, 0o755)
|
|
mu.Unlock()
|
|
|
|
if mkErr == nil {
|
|
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
|
|
f, fErr := os.Create(fpath)
|
|
if fErr == nil {
|
|
_, _ = f.Write(body)
|
|
f.Close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
mu.Lock()
|
|
s.concurrentResps = append(s.concurrentResps, rec)
|
|
mu.Unlock()
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("concurrent requests failed: %v", errs)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) allConcurrentResponseStatusCodesShouldBe(expected int) error {
|
|
if len(s.concurrentResps) == 0 {
|
|
return errors.New("no concurrent responses recorded")
|
|
}
|
|
|
|
for i, resp := range s.concurrentResps {
|
|
if resp.Code != expected {
|
|
return fmt.Errorf("concurrent response %d: expected status %d, got %d %q", i+1, expected, resp.Code, resp.Body.String())
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) allConcurrentResponsesShouldHavePdfs(expected int) error {
|
|
if len(s.concurrentResps) == 0 {
|
|
return errors.New("no concurrent responses recorded")
|
|
}
|
|
|
|
for i, resp := range s.concurrentResps {
|
|
traceID := resp.Header().Get("Gotenberg-Trace")
|
|
dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID)
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("concurrent response %d: directory %q does not exist", i+1, dirPath)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
|
paths = append(paths, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("concurrent response %d: walk %q: %w", i+1, dirPath, err)
|
|
}
|
|
|
|
if len(paths) != expected {
|
|
return fmt.Errorf("concurrent response %d: expected %d PDF(s), got %d", i+1, expected, len(paths))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) iWaitForTheAsynchronousRequestToWebhook(ctx context.Context) error {
|
|
if s.server == nil {
|
|
return errors.New("server not initialized")
|
|
}
|
|
if s.server.req != nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case err := <-s.server.errChan:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func (s *scenario) theGotenbergContainerShouldLogTheFollowingEntries(ctx context.Context, should string, entriesTable *godog.Table) error {
|
|
if s.gotenbergContainer == nil {
|
|
return errors.New("no Gotenberg container")
|
|
}
|
|
|
|
expected := make([]string, len(entriesTable.Rows))
|
|
for i, row := range entriesTable.Rows {
|
|
expected[i] = row.Cells[0].Value
|
|
}
|
|
|
|
invert := should == "should NOT"
|
|
check := func() error {
|
|
logs, err := containerLogEntries(ctx, s.gotenbergContainer)
|
|
if err != nil {
|
|
return fmt.Errorf("get log entries: %w", err)
|
|
}
|
|
|
|
for _, entry := range expected {
|
|
if !invert && !strings.Contains(logs, entry) {
|
|
return fmt.Errorf("expected log entry %q not found in %q", expected, logs)
|
|
}
|
|
|
|
if invert && strings.Contains(logs, entry) {
|
|
return fmt.Errorf("log entry %q NOT expected", expected)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var err error
|
|
for range 3 {
|
|
err = check()
|
|
if err != nil && !invert {
|
|
// We have to retry as not all logs may have been produced.
|
|
time.Sleep(500 * time.Millisecond)
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *scenario) theResponseStatusCodeShouldBe(expected int) error {
|
|
if expected != s.resp.Code {
|
|
return fmt.Errorf("expected response status code to be: %d, but actual is: %d %q", expected, s.resp.Code, s.resp.Body.String())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) theHeaderValueShouldBe(kind, name string, expected string) error {
|
|
var actual string
|
|
switch {
|
|
case kind == "response":
|
|
actual = s.resp.Header().Get(name)
|
|
case s.server == nil:
|
|
return errors.New("server not initialized")
|
|
case s.server.req == nil:
|
|
return errors.New("no webhook request found")
|
|
default:
|
|
actual = s.server.req.Header.Get(name)
|
|
}
|
|
|
|
if expected != actual {
|
|
return fmt.Errorf("expected %s header %q to be: %q, but actual is: %q", kind, name, expected, actual)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) theWebhookRequestHeaderShouldCarryTraceID(name, expected string) error {
|
|
if s.server == nil {
|
|
return errors.New("server not initialized")
|
|
}
|
|
if s.server.req == nil {
|
|
return errors.New("no webhook request found")
|
|
}
|
|
|
|
value := s.server.req.Header.Get(name)
|
|
if value == "" {
|
|
return fmt.Errorf("expected webhook request header %q to be set, but it is empty", name)
|
|
}
|
|
|
|
// W3C traceparent format: version "-" trace-id "-" parent-id "-" trace-flags.
|
|
parts := strings.Split(value, "-")
|
|
if len(parts) != 4 {
|
|
return fmt.Errorf("expected webhook request header %q to be a valid traceparent, but got %q", name, value)
|
|
}
|
|
|
|
if parts[1] != expected {
|
|
return fmt.Errorf("expected webhook request header %q to carry trace id %q, but actual is %q", name, expected, parts[1])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) theCookieValueShouldBe(kind, name, expected string) error {
|
|
var cookies []*http.Cookie
|
|
switch {
|
|
case kind == "response":
|
|
cookies = s.resp.Result().Cookies()
|
|
case s.server == nil:
|
|
return errors.New("server not initialized")
|
|
case s.server.req == nil:
|
|
return errors.New("no webhook request found")
|
|
default:
|
|
cookies = s.server.req.Cookies()
|
|
}
|
|
|
|
var actual *http.Cookie
|
|
for _, cookie := range cookies {
|
|
if cookie.Name == name {
|
|
actual = cookie
|
|
break
|
|
}
|
|
}
|
|
|
|
if actual == nil {
|
|
if expected != "" {
|
|
return fmt.Errorf("expected %s cookie %q not found", kind, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if expected != actual.Value {
|
|
return fmt.Errorf("expected %s cookie %q to be: %q, but actual is: %q", kind, name, expected, actual.Value)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) theBodyShouldMatchString(kind string, expectedDoc *godog.DocString) error {
|
|
var actual string
|
|
switch {
|
|
case kind == "response":
|
|
actual = s.resp.Body.String()
|
|
case s.server == nil:
|
|
return errors.New("server not initialized")
|
|
case s.server.req == nil:
|
|
return errors.New("no webhook request found")
|
|
default:
|
|
actual = string(s.server.bodyCopy)
|
|
}
|
|
|
|
expected := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
|
|
|
|
if actual != expected {
|
|
return fmt.Errorf("expected %q body to be: %q, but actual is: %q", kind, expected, actual)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) theBodyShouldContainString(kind string, expectedDoc *godog.DocString) error {
|
|
var actual string
|
|
switch {
|
|
case kind == "response":
|
|
actual = s.resp.Body.String()
|
|
case s.server == nil:
|
|
return errors.New("server not initialized")
|
|
case s.server.req == nil:
|
|
return errors.New("no webhook request found")
|
|
default:
|
|
actual = string(s.server.bodyCopy)
|
|
}
|
|
|
|
expected := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
|
|
|
|
if !strings.Contains(actual, expected) {
|
|
return fmt.Errorf("expected %q body to contain: %q, but actual is: %q", kind, expected, actual)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocString) error {
|
|
var body []byte
|
|
switch {
|
|
case kind == "response":
|
|
body = s.resp.Body.Bytes()
|
|
case s.server == nil:
|
|
return errors.New("server not initialized")
|
|
case s.server.req == nil:
|
|
return errors.New("no webhook request found")
|
|
default:
|
|
body = s.server.bodyCopy
|
|
}
|
|
|
|
var expected, actual any
|
|
|
|
content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
|
|
err := json.Unmarshal([]byte(content), &expected)
|
|
if err != nil {
|
|
return fmt.Errorf("unmarshal expected JSON: %w", err)
|
|
}
|
|
|
|
err = json.Unmarshal(body, &actual)
|
|
if err != nil {
|
|
return fmt.Errorf("unmarshal actual JSON: %w", err)
|
|
}
|
|
|
|
err = compareJson(expected, actual)
|
|
if err != nil {
|
|
return fmt.Errorf("expected matching JSON: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) theWebhookEventShouldMatchJSON(ctx context.Context, expectedDoc *godog.DocString) error {
|
|
if s.server == nil {
|
|
return errors.New("server not initialized")
|
|
}
|
|
|
|
// Poll briefly — the event fires right after the main webhook.
|
|
var body []byte
|
|
deadline := time.After(5 * time.Second)
|
|
for {
|
|
body = s.server.getEventBody()
|
|
if body != nil {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
return errors.New("timed out waiting for webhook event")
|
|
default:
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
var expected, actual any
|
|
|
|
content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
|
|
err := json.Unmarshal([]byte(content), &expected)
|
|
if err != nil {
|
|
return fmt.Errorf("unmarshal expected JSON: %w", err)
|
|
}
|
|
|
|
err = json.Unmarshal(body, &actual)
|
|
if err != nil {
|
|
return fmt.Errorf("unmarshal actual JSON: %w", err)
|
|
}
|
|
|
|
err = compareJson(expected, actual)
|
|
if err != nil {
|
|
return fmt.Errorf("expected matching webhook event JSON: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thereShouldBePdfs(expected int, kind string) error {
|
|
dirPath := s.teststoreDir
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", dirPath)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
|
paths = append(paths, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", s.workdir, err)
|
|
}
|
|
|
|
if len(paths) != expected {
|
|
return fmt.Errorf("expected %d PDF(s), but actual is %d", expected, len(paths))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thereShouldBeTheFollowingFiles(kind string, filesTable *godog.Table) error {
|
|
dirPath := s.teststoreDir
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", dirPath)
|
|
}
|
|
|
|
var filenames []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if !info.IsDir() {
|
|
filenames = append(filenames, info.Name())
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", s.workdir, err)
|
|
}
|
|
|
|
for _, row := range filesTable.Rows {
|
|
found := false
|
|
expected := row.Cells[0].Value
|
|
for _, filename := range filenames {
|
|
if strings.HasPrefix(expected, "*_") && strings.Contains(filename, strings.ReplaceAll(expected, "*_", "")) {
|
|
found = true
|
|
}
|
|
if strings.EqualFold(expected, filename) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("expected file %q not found among %q", expected, filenames)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfsShouldBeValidWithAToleranceOf(ctx context.Context, kind, validate string, tolerance int) error {
|
|
dirPath := s.teststoreDir
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", dirPath)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
|
paths = append(paths, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", s.workdir, err)
|
|
}
|
|
|
|
var flavor string
|
|
switch validate {
|
|
case "PDF/A-1b":
|
|
flavor = "1b"
|
|
case "PDF/A-2b":
|
|
flavor = "2b"
|
|
case "PDF/A-3b":
|
|
flavor = "3b"
|
|
case "PDF/UA-1":
|
|
flavor = "ua1"
|
|
case "PDF/UA-2":
|
|
flavor = "ua2"
|
|
default:
|
|
return fmt.Errorf("unknown %q", validate)
|
|
}
|
|
|
|
re := regexp.MustCompile(`failedRules="(\d+)"`)
|
|
for _, path := range paths {
|
|
cmd := []string{
|
|
"verapdf",
|
|
"-f",
|
|
flavor,
|
|
filepath.Base(path),
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
matches := re.FindStringSubmatch(output)
|
|
if len(matches) < 2 {
|
|
return errors.New("expected failed rules")
|
|
}
|
|
|
|
failedRules, err := strconv.Atoi(matches[1])
|
|
if err != nil {
|
|
return fmt.Errorf("convert failed rules value %q to integer: %w", matches[1], err)
|
|
}
|
|
|
|
if tolerance < failedRules {
|
|
return fmt.Errorf("expected failed rules to be inferior or equal to: %d, but actual is %d", tolerance, failedRules)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfShouldHavePages(ctx context.Context, name string, pages int) error {
|
|
var path string
|
|
if !strings.HasPrefix(name, "*_") {
|
|
path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
|
|
|
_, err := os.Stat(path)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("PDF %q does not exist", path)
|
|
}
|
|
} else {
|
|
substr := strings.ReplaceAll(name, "*_", "")
|
|
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.Contains(info.Name(), substr) {
|
|
path = currentPath
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", s.workdir, err)
|
|
}
|
|
}
|
|
|
|
cmd := []string{
|
|
"pdfinfo",
|
|
filepath.Base(path),
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
output = strings.ReplaceAll(output, " ", "")
|
|
re := regexp.MustCompile(`Pages:(\d+)`)
|
|
matches := re.FindStringSubmatch(output)
|
|
|
|
if len(matches) < 2 {
|
|
return errors.New("expected pages")
|
|
}
|
|
|
|
actual, err := strconv.Atoi(matches[1])
|
|
if err != nil {
|
|
return fmt.Errorf("convert pages value %q to integer: %w", matches[1], err)
|
|
}
|
|
|
|
if actual != pages {
|
|
return fmt.Errorf("expected %d pages, but actual is %d", pages, actual)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfShouldHaveImages(ctx context.Context, name string, images int) error {
|
|
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
|
|
|
_, err := os.Stat(path)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("PDF %q does not exist", path)
|
|
}
|
|
|
|
cmd := []string{
|
|
"pdfimages",
|
|
"-list",
|
|
filepath.Base(path),
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
// pdfimages -list outputs a header (2 lines) then one line per image.
|
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
|
actual := 0
|
|
if len(lines) > 2 {
|
|
actual = len(lines) - 2
|
|
}
|
|
|
|
if actual != images {
|
|
return fmt.Errorf("expected %d image(s), but actual is %d", images, actual)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
|
|
var path string
|
|
if !strings.HasPrefix(name, "*_") {
|
|
path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
|
|
|
_, err := os.Stat(path)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("PDF %q does not exist", path)
|
|
}
|
|
} else {
|
|
substr := strings.ReplaceAll(name, "*_", "")
|
|
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.Contains(info.Name(), substr) {
|
|
path = currentPath
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", s.workdir, err)
|
|
}
|
|
}
|
|
|
|
cmd := []string{
|
|
"pdfinfo",
|
|
filepath.Base(path),
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
output = strings.ReplaceAll(output, " ", "")
|
|
re := regexp.MustCompile(`Pagesize:(\d+)x(\d+).*`)
|
|
matches := re.FindStringSubmatch(output)
|
|
|
|
if len(matches) < 3 {
|
|
return errors.New("expected page size")
|
|
}
|
|
|
|
invert := kind == "should NOT"
|
|
|
|
width, err := strconv.Atoi(matches[1])
|
|
if err != nil {
|
|
return fmt.Errorf("convert width value %q to integer: %w", matches[1], err)
|
|
}
|
|
|
|
height, err := strconv.Atoi(matches[2])
|
|
if err != nil {
|
|
return fmt.Errorf("convert height value %q to integer: %w", matches[2], err)
|
|
}
|
|
|
|
if invert && height < width {
|
|
return fmt.Errorf("expected height %d to be greater than width %d", height, width)
|
|
}
|
|
|
|
if !invert && width < height {
|
|
return fmt.Errorf("expected width %d to be greater than height %d", width, height)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// pdfPageText extracts the text of a single page from a produced PDF using
|
|
// pdftotext. name is either a literal filename or a "*_" glob resolved against
|
|
// the test store.
|
|
func (s *scenario) pdfPageText(ctx context.Context, name string, page int) (string, error) {
|
|
var path string
|
|
if !strings.HasPrefix(name, "*_") {
|
|
path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
|
|
|
_, err := os.Stat(path)
|
|
if os.IsNotExist(err) {
|
|
return "", fmt.Errorf("PDF %q does not exist", path)
|
|
}
|
|
} else {
|
|
substr := strings.ReplaceAll(name, "*_", "")
|
|
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.Contains(info.Name(), substr) {
|
|
path = currentPath
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("walk %q: %w", s.workdir, err)
|
|
}
|
|
}
|
|
|
|
cmd := []string{
|
|
"pdftotext",
|
|
"-f",
|
|
fmt.Sprintf("%d", page),
|
|
"-l",
|
|
fmt.Sprintf("%d", page),
|
|
filepath.Base(path),
|
|
"-",
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error {
|
|
output, err := s.pdfPageText(ctx, name, page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
invert := kind == "should NOT"
|
|
|
|
if !invert && !strings.Contains(output, expected.Content) {
|
|
return fmt.Errorf("expected %q not found in %q", expected.Content, output)
|
|
}
|
|
|
|
if invert && strings.Contains(output, expected.Content) {
|
|
return fmt.Errorf("%q found in %q", expected.Content, output)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfShouldHaveContentMatchingAtPage(ctx context.Context, name, kind, pattern string, page int) error {
|
|
output, err := s.pdfPageText(ctx, name, page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return fmt.Errorf("compile pattern %q: %w", pattern, err)
|
|
}
|
|
|
|
invert := kind == "should NOT"
|
|
|
|
if !invert && !re.MatchString(output) {
|
|
return fmt.Errorf("pattern %q not found in %q", pattern, output)
|
|
}
|
|
|
|
if invert && re.MatchString(output) {
|
|
return fmt.Errorf("pattern %q found in %q", pattern, output)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should string) error {
|
|
dirPath := s.teststoreDir
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", dirPath)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
|
paths = append(paths, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", s.workdir, err)
|
|
}
|
|
|
|
invert := should == "should NOT"
|
|
|
|
for _, path := range paths {
|
|
cmd := []string{
|
|
"verapdf",
|
|
"-off",
|
|
"--extract",
|
|
"annotations",
|
|
filepath.Base(path),
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
if invert && strings.Contains(output, "<featuresReport></featuresReport>") {
|
|
return fmt.Errorf("PDF %q is flatten", path)
|
|
}
|
|
|
|
if !invert && !strings.Contains(output, "<featuresReport></featuresReport>") {
|
|
return fmt.Errorf("PDF %q is not flatten", path)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// permissionFlags maps a human action to the permission key reported in a PDF's
|
|
// encryption dictionary.
|
|
var permissionFlags = map[string]string{
|
|
"printing": "print",
|
|
"copying": "copy",
|
|
"modifying": "change",
|
|
"annotating": "addNotes",
|
|
}
|
|
|
|
// thePdfsShouldAllowAction asserts whether every response PDF permits a given
|
|
// action (printing, copying, modifying, annotating). It reads the document's
|
|
// permission flags; an unencrypted document has no restrictions.
|
|
func (s *scenario) thePdfsShouldAllowAction(ctx context.Context, kind, should, action string) error {
|
|
flag, ok := permissionFlags[action]
|
|
if !ok {
|
|
return fmt.Errorf("unsupported permission action %q", action)
|
|
}
|
|
|
|
dirPath := s.teststoreDir
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", dirPath)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
|
paths = append(paths, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", dirPath, err)
|
|
}
|
|
|
|
invert := should == "should NOT"
|
|
for _, path := range paths {
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, []string{"pdfinfo", filepath.Base(path)}, path)
|
|
if err != nil {
|
|
return fmt.Errorf("read permissions of %q: %w", path, err)
|
|
}
|
|
|
|
stripped := strings.ReplaceAll(strings.ReplaceAll(output, " ", ""), "\n", "")
|
|
denied := strings.Contains(stripped, flag+":no")
|
|
allowed := strings.Contains(stripped, flag+":yes")
|
|
|
|
if invert && !denied {
|
|
return fmt.Errorf("expected PDF %q to deny %q, got: %q", path, action, output)
|
|
}
|
|
if !invert && !allowed {
|
|
return fmt.Errorf("expected PDF %q to allow %q, got: %q", path, action, output)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error {
|
|
dirPath := s.teststoreDir
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", dirPath)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
|
paths = append(paths, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", dirPath, err)
|
|
}
|
|
|
|
invert := should == "should NOT"
|
|
re := regexp.MustCompile(`CommandLineError:Incorrectpassword`)
|
|
|
|
for _, path := range paths {
|
|
cmd := []string{
|
|
"pdfinfo",
|
|
filepath.Base(path),
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
output = strings.ReplaceAll(output, " ", "")
|
|
output = strings.ReplaceAll(output, "\n", "")
|
|
matches := re.FindStringSubmatch(output)
|
|
isEncrypted := len(matches) >= 1 && matches[0] == "CommandLineError:Incorrectpassword"
|
|
|
|
if invert && isEncrypted {
|
|
return fmt.Errorf("PDF %q is encrypted", path)
|
|
}
|
|
if !invert && !isEncrypted {
|
|
return fmt.Errorf("PDF %q is not encrypted: %q", path, output)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfsShouldHaveEmbeddedFile(ctx context.Context, kind, should, embed string) error {
|
|
dirPath := s.teststoreDir
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", dirPath)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
|
paths = append(paths, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", dirPath, err)
|
|
}
|
|
|
|
invert := should == "should NOT"
|
|
|
|
for _, path := range paths {
|
|
cmd := []string{
|
|
"verapdf",
|
|
"--off",
|
|
"--loglevel",
|
|
"0",
|
|
"--extract",
|
|
"embeddedFile",
|
|
filepath.Base(path),
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
found := strings.Contains(output, fmt.Sprintf("<fileName>%s</fileName>", embed))
|
|
|
|
if invert && found {
|
|
return fmt.Errorf("embed %q found", embed)
|
|
}
|
|
|
|
if !invert && !found {
|
|
return fmt.Errorf("embed %q not found", embed)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfsShouldHaveEmbeddedFileWithRelationship(ctx context.Context, kind, embed, relationship string) error {
|
|
dirPath := s.teststoreDir
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", dirPath)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
|
paths = append(paths, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", dirPath, err)
|
|
}
|
|
|
|
for _, path := range paths {
|
|
cmd := []string{
|
|
"verapdf",
|
|
"--off",
|
|
"--loglevel",
|
|
"0",
|
|
"--extract",
|
|
"embeddedFile",
|
|
filepath.Base(path),
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
fileNameTag := fmt.Sprintf("<fileName>%s</fileName>", embed)
|
|
relationshipTag := fmt.Sprintf("<afRelationship>%s</afRelationship>", relationship)
|
|
|
|
blocks := strings.Split(output, "</embeddedFile>")
|
|
found := false
|
|
for _, block := range blocks {
|
|
if !strings.Contains(block, fileNameTag) {
|
|
continue
|
|
}
|
|
if !strings.Contains(block, relationshipTag) {
|
|
return fmt.Errorf("embedded file %q missing afRelationship %q", embed, relationship)
|
|
}
|
|
found = true
|
|
break
|
|
}
|
|
|
|
if !found {
|
|
return fmt.Errorf("embedded file %q not found in verapdf output", embed)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *scenario) thePdfsShouldDeclareFacturXConformanceLevel(ctx context.Context, kind, conformanceLevel string) error {
|
|
dirPath := s.teststoreDir
|
|
|
|
_, err := os.Stat(dirPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("directory %q does not exist", dirPath)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
|
paths = append(paths, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walk %q: %w", dirPath, err)
|
|
}
|
|
|
|
for _, path := range paths {
|
|
cmd := []string{
|
|
"pdfinfo",
|
|
"-meta",
|
|
filepath.Base(path),
|
|
}
|
|
|
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
|
if err != nil {
|
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
if !strings.Contains(output, "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#") {
|
|
return errors.New("missing Factur-X namespace in XMP")
|
|
}
|
|
|
|
if !strings.Contains(output, "pdfaExtension:schemas") {
|
|
return errors.New("missing PDF/A extension schema in XMP")
|
|
}
|
|
|
|
conformanceTag := fmt.Sprintf("<fx:ConformanceLevel>%s</fx:ConformanceLevel>", conformanceLevel)
|
|
if !strings.Contains(output, conformanceTag) {
|
|
return fmt.Errorf("missing fx:ConformanceLevel %q in XMP", conformanceLevel)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func InitializeScenario(ctx *godog.ScenarioContext) {
|
|
s := &scenario{}
|
|
ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return ctx, fmt.Errorf("get current directory: %w", err)
|
|
}
|
|
s.workdir = fmt.Sprintf("%s/teststore/%s", wd, uuid.NewString())
|
|
err = os.MkdirAll(s.workdir, 0o755)
|
|
if err != nil {
|
|
return ctx, fmt.Errorf("create working directory: %w", err)
|
|
}
|
|
return ctx, nil
|
|
})
|
|
ctx.Given(`^I have a default Gotenberg container$`, s.iHaveADefaultGotenbergContainer)
|
|
ctx.Given(`^I have a Gotenberg container with the following environment variable\(s\):$`, s.iHaveAGotenbergContainerWithTheFollowingEnvironmentVariables)
|
|
ctx.Given(`^I have a (webhook|static) server$`, s.iHaveAServer)
|
|
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint$`, s.iMakeARequestToGotenberg)
|
|
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders)
|
|
ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders)
|
|
ctx.When(`^I make (\d+) concurrent "(POST)" requests to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeConcurrentRequestsToGotenberg)
|
|
ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook)
|
|
ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries)
|
|
ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe)
|
|
ctx.Then(`^all concurrent response status codes should be (\d+)$`, s.allConcurrentResponseStatusCodesShouldBe)
|
|
ctx.Then(`^all concurrent responses should have (\d+) PDF\(s\)$`, s.allConcurrentResponsesShouldHavePdfs)
|
|
ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
|
|
ctx.Then(`^the webhook request header "([^"]*)" should carry trace id "([^"]*)"$`, s.theWebhookRequestHeaderShouldCarryTraceID)
|
|
ctx.Then(`^the (response|webhook request|file request|server request) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe)
|
|
ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString)
|
|
ctx.Then(`^the (response|webhook request) body should contain string:$`, s.theBodyShouldContainString)
|
|
ctx.Then(`^the (response|webhook request) body should match JSON:$`, s.theBodyShouldMatchJSON)
|
|
ctx.Then(`^the webhook event should match JSON:$`, s.theWebhookEventShouldMatchJSON)
|
|
ctx.Then(`^there should be (\d+) PDF\(s\) in the (response|webhook request)$`, s.thereShouldBePdfs)
|
|
ctx.Then(`^there should be the following file\(s\) in the (response|webhook request):$`, s.thereShouldBeTheFollowingFiles)
|
|
ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf)
|
|
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten)
|
|
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted)
|
|
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) allow "([^"]*)"$`, s.thePdfsShouldAllowAction)
|
|
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile)
|
|
ctx.Then(`^the (response|webhook request) PDF\(s\) should have the "([^"]*)" file embedded with relationship "([^"]*)"$`, s.thePdfsShouldHaveEmbeddedFileWithRelationship)
|
|
ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)
|
|
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
|
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
|
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
|
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
|
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
|
if s.gotenbergContainer != nil {
|
|
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
|
if errTerminate != nil {
|
|
return ctx, fmt.Errorf("terminate Gotenberg container: %w", errTerminate)
|
|
}
|
|
}
|
|
if s.gotenbergContainerNetwork != nil {
|
|
errRemove := s.gotenbergContainerNetwork.Remove(ctx)
|
|
if errRemove != nil {
|
|
return ctx, fmt.Errorf("remove Gotenberg container network: %w", errRemove)
|
|
}
|
|
}
|
|
return ctx, nil
|
|
})
|
|
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
|
if err != nil {
|
|
recordFailedScenario(sc)
|
|
}
|
|
|
|
errReset := s.reset(ctx)
|
|
if errReset != nil {
|
|
return ctx, fmt.Errorf("reset scenario: %w", errReset)
|
|
}
|
|
return ctx, nil
|
|
})
|
|
}
|