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, "") { return fmt.Errorf("PDF %q is flatten", path) } if !invert && !strings.Contains(output, "") { 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("%s", 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("%s", embed) relationshipTag := fmt.Sprintf("%s", relationship) blocks := strings.Split(output, "") 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("%s", 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 }) }