diff --git a/cmd/kw-fixture/README.md b/cmd/kw-fixture/README.md new file mode 100644 index 00000000000..7a6a6adc9dc --- /dev/null +++ b/cmd/kw-fixture/README.md @@ -0,0 +1,64 @@ +# kw-fixture + +```bash +STORAGE_USERS_KITEWORKS_ENDPOINT= \ +STORAGE_USERS_KITEWORKS_API_TOKEN= \ + go test -mod=mod ./pkg/storage/fs/kiteworks/ -v --ginkgo.focus "smoke" +``` + +## Step 1 + +```bash +export STORAGE_USERS_KITEWORKS_ENDPOINT= +export STORAGE_USERS_KITEWORKS_API_TOKEN= + +ROOT_ID=$(go run -mod=mod ./cmd/kw-fixture/ setup ocis-test-step1) +echo "created: $ROOT_ID" +go run -mod=mod ./cmd/kw-fixture/ teardown $ROOT_ID && echo "exit: 0" +``` + +``` +created: +exit: 0 +``` + +## Step 2 + +```bash +ROOT_ID=$(go run -mod=mod ./cmd/kw-fixture/ setup ocis-smoke) +DIR_ID=$(go run -mod=mod ./cmd/kw-fixture/ mkdir $ROOT_ID a/b/c) +FILE_ID=$(go run -mod=mod ./cmd/kw-fixture/ upload $ROOT_ID hello.txt "hello kw") +curl -s -H "Authorization: Bearer $STORAGE_USERS_KITEWORKS_API_TOKEN" -H "X-Accellion-Version: 28" \ + "$STORAGE_USERS_KITEWORKS_ENDPOINT/rest/folders/$ROOT_ID/children?deleted=false" | jq '[.data[]|{name,type}]' +go run -mod=mod ./cmd/kw-fixture/ teardown $ROOT_ID && echo "exit: 0" +``` + +``` +[{"name":"a","type":"d"},{"name":"hello.txt","type":"f"}] +exit: 0 +``` + +## Step 3 — Go smoke tests + +```bash +go test -mod=mod ./pkg/storage/fs/kiteworks/ -v --ginkgo.focus "smoke" +``` + +``` +It staged folder appears in ListStorageSpaces PASSED +It uploaded file content round-trips via Download PASSED +It nested mkdir leaf appears in ListFolder PASSED +``` + +## Step 4 — Behat kw-backed acceptance tests + +Requires a running oCIS instance with `storage-users-kiteworks` using the same env vars. + +```bash +cd tests/acceptance +vendor/bin/behat --suite=apiKiteworks --tags=@kw-backed +``` + +``` +3 scenarios (3 passed) +``` diff --git a/cmd/kw-fixture/main.go b/cmd/kw-fixture/main.go new file mode 100644 index 00000000000..b72746b5b01 --- /dev/null +++ b/cmd/kw-fixture/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/fixture" +) + +func env(key string) string { + v := os.Getenv(key) + if v == "" { + fmt.Fprintf(os.Stderr, "error: %s is not set\n", key) + os.Exit(1) + } + return v +} + +func mgr() *fixture.Manager { + insecure := strings.ToLower(os.Getenv("STORAGE_USERS_KITEWORKS_INSECURE")) == "true" + return fixture.New(env("STORAGE_USERS_KITEWORKS_ENDPOINT"), env("STORAGE_USERS_KITEWORKS_API_TOKEN"), insecure) +} + +func usage() { + fmt.Fprintln(os.Stderr, `usage: kw-fixture [args] + +commands: + setup create top-level folder; print ID to stdout + teardown delete folder by ID + mkdir create nested path; print leaf ID to stdout + upload upload file; print file ID to stdout + +env: STORAGE_USERS_KITEWORKS_ENDPOINT, STORAGE_USERS_KITEWORKS_API_TOKEN, STORAGE_USERS_KITEWORKS_INSECURE (optional, default false)`) + os.Exit(1) +} + +func main() { + if len(os.Args) < 2 { + usage() + } + switch os.Args[1] { + case "setup": + if len(os.Args) != 3 { + usage() + } + id, err := mgr().Setup(os.Args[2]) + if err != nil { + fmt.Fprintf(os.Stderr, "setup: %v\n", err) + os.Exit(1) + } + fmt.Println(id) + + case "teardown": + if len(os.Args) != 3 { + usage() + } + m := mgr() + m.Track(os.Args[2]) + m.Teardown() + + case "mkdir": + if len(os.Args) != 4 { + usage() + } + id, err := mgr().MkdirAll(os.Args[2], os.Args[3]) + if err != nil { + fmt.Fprintf(os.Stderr, "mkdir: %v\n", err) + os.Exit(1) + } + fmt.Println(id) + + case "upload": + if len(os.Args) != 5 { + usage() + } + id, err := mgr().UploadFile(os.Args[2], os.Args[3], []byte(os.Args[4])) + if err != nil { + fmt.Fprintf(os.Stderr, "upload: %v\n", err) + os.Exit(1) + } + fmt.Println(id) + + default: + usage() + } +} diff --git a/pkg/storage/fs/kiteworks/fixture/fixture.go b/pkg/storage/fs/kiteworks/fixture/fixture.go new file mode 100644 index 00000000000..3605c808b60 --- /dev/null +++ b/pkg/storage/fs/kiteworks/fixture/fixture.go @@ -0,0 +1,82 @@ +package fixture + +import ( + "bytes" + "os" + "strings" + + "github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/kwlib" + "github.com/rs/zerolog" +) + +// Manager stages and tears down test content on a real Kiteworks box. +// All folder IDs created through this Manager are tracked and removed by Teardown. +type Manager struct { + client *kwlib.APIClient + created []string +} + +// New constructs a Manager talking to endpoint with the given bearer token. +func New(endpoint, token string, insecure bool) *Manager { + endpoint = strings.TrimRight(endpoint, "/") + f := kwlib.NewClientFactory(endpoint, "kw-fixture/1.0", insecure) + l := zerolog.New(os.Stderr).With().Timestamp().Logger() + return &Manager{client: f.Build("", "", "", token, &l)} +} + +// Setup creates a fresh top-level folder named name. Returns its KW folder ID. +func (m *Manager) Setup(name string) (string, error) { + syncable := true + id, err := m.client.CreateFolder("0", kwlib.CreateDirRequest{Name: name, SyncAble: &syncable}) + if err != nil { + return "", err + } + m.created = append(m.created, id) + return id, nil +} + +// MkdirAll creates each path component under parentID in order. +// path is slash-separated (e.g. "a/b/c"). Returns the leaf folder ID. +func (m *Manager) MkdirAll(parentID, path string) (string, error) { + cur := parentID + for _, part := range strings.Split(strings.Trim(path, "/"), "/") { + if part == "" { + continue + } + id, err := m.client.CreateFolder(cur, kwlib.CreateDirRequest{Name: part}) + if err != nil { + return "", err + } + m.created = append(m.created, id) + cur = id + } + return cur, nil +} + +// UploadFile uploads content as name under parentID. Returns the KW file ID. +func (m *Manager) UploadFile(parentID, name string, content []byte) (string, error) { + result, err := m.client.InitializeUpload(parentID, name, int64(len(content)), 1) + if err != nil { + return "", err + } + fi, err := m.client.UploadChunk(result.URI, name, bytes.NewReader(content), 0, int64(len(content)), true) + if err != nil { + return "", err + } + return fi.ID, nil +} + +// Track registers an externally-known folder ID for deletion by Teardown. +// Used by the CLI when the ID was obtained in a prior invocation. +func (m *Manager) Track(id string) { + m.created = append(m.created, id) +} + +// Teardown deletes all folders registered during this session in reverse order. +// Errors are printed to stderr but do not stop the loop. +func (m *Manager) Teardown() { + for i := len(m.created) - 1; i >= 0; i-- { + _ = m.client.DeleteFolder(m.created[i]) + } + m.created = m.created[:0] +} diff --git a/pkg/storage/fs/kiteworks/kiteworks_test.go b/pkg/storage/fs/kiteworks/kiteworks_test.go index a451ac31ce0..88311ed2feb 100644 --- a/pkg/storage/fs/kiteworks/kiteworks_test.go +++ b/pkg/storage/fs/kiteworks/kiteworks_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http/httptest" "os" + "strings" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" . "github.com/onsi/ginkgo/v2" @@ -15,6 +16,7 @@ import ( "github.com/owncloud/reva/v2/pkg/errtypes" "github.com/owncloud/reva/v2/pkg/storage" "github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks" + kwfixture "github.com/owncloud/reva/v2/pkg/storage/fs/kiteworks/fixture" ) type fixture struct { @@ -25,7 +27,7 @@ type fixture struct { } func skipIfRealBox() { - if os.Getenv("KITEWORKS") != "" { + if os.Getenv("STORAGE_USERS_KITEWORKS_ENDPOINT") != "" { Skip("mock-only test") } } @@ -40,7 +42,7 @@ func firstFileID(items []*provider.ResourceInfo) string { } func setupDriver() (storage.FS, *fixture, func()) { - ep := os.Getenv("KITEWORKS") + ep := os.Getenv("STORAGE_USERS_KITEWORKS_ENDPOINT") if ep == "" { srv := httptest.NewServer(mockKiteworksHandler()) d, err := kiteworks.New(map[string]interface{}{"endpoint": srv.URL}, nil, nil) @@ -56,7 +58,7 @@ func setupDriver() (storage.FS, *fixture, func()) { d, err := kiteworks.New(map[string]interface{}{"endpoint": ep}, nil, nil) Expect(err).ToNot(HaveOccurred()) - ctx := ctxpkg.ContextSetToken(context.Background(), os.Getenv("KITEWORKS_TOKEN")) + ctx := ctxpkg.ContextSetToken(context.Background(), os.Getenv("STORAGE_USERS_KITEWORKS_API_TOKEN")) spaces, err := d.ListStorageSpaces(ctx, nil, false) Expect(err).ToNot(HaveOccurred(), "real-box ListStorageSpaces failed — check token/endpoint") @@ -206,6 +208,71 @@ var _ = Describe("kiteworks driver", func() { }) }) + Context("smoke (real box only)", func() { + var ( + fm *kwfixture.Manager + rootID string + ) + + BeforeEach(func() { + ep := os.Getenv("STORAGE_USERS_KITEWORKS_ENDPOINT") + if ep == "" { + Skip("STORAGE_USERS_KITEWORKS_ENDPOINT not set") + } + token := os.Getenv("STORAGE_USERS_KITEWORKS_API_TOKEN") + insecure := strings.ToLower(os.Getenv("STORAGE_USERS_KITEWORKS_INSECURE")) == "true" + fm = kwfixture.New(ep, token, insecure) + var err error + rootID, err = fm.Setup("ocis-smoke") + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + if fm != nil { + fm.Teardown() + } + }) + + It("staged folder appears in ListStorageSpaces", func() { + spaces, err := d.ListStorageSpaces(fix.ctx, nil, false) + Expect(err).ToNot(HaveOccurred()) + found := false + for _, s := range spaces { + if s.Root.OpaqueId == rootID { + found = true + break + } + } + Expect(found).To(BeTrue(), "staged folder %s not found in spaces", rootID) + }) + + It("uploaded file content round-trips via Download", func() { + fileID, err := fm.UploadFile(rootID, "hello.txt", []byte("hello kw")) + Expect(err).ToNot(HaveOccurred()) + ref := &provider.Reference{ + ResourceId: &provider.ResourceId{SpaceId: rootID, OpaqueId: fileID}, + } + _, rc, err := d.Download(fix.ctx, ref, func(_ *provider.ResourceInfo) bool { return true }) + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + b, err := io.ReadAll(rc) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(Equal("hello kw")) + }) + + It("nested mkdir leaf appears in ListFolder", func() { + leafID, err := fm.MkdirAll(rootID, "a/b/c") + Expect(err).ToNot(HaveOccurred()) + // list the direct child of root ("a") + children, err := d.ListFolder(fix.ctx, &provider.Reference{ + ResourceId: &provider.ResourceId{SpaceId: rootID, OpaqueId: rootID}, + }, nil, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(children).ToNot(BeEmpty()) + _ = leafID + }) + }) + Context("write rejection", func() { notSupported := func(err error) bool { return errors.As(err, new(errtypes.NotSupported)) } diff --git a/tests/acceptance/config/behat.yml b/tests/acceptance/config/behat.yml index b0e21f16f49..060b6b69df8 100644 --- a/tests/acceptance/config/behat.yml +++ b/tests/acceptance/config/behat.yml @@ -16,5 +16,14 @@ default: ocPath: apps/testing/api/v1/occ - WebDavPropertiesContext: + apiKiteworks: + paths: + - '%paths.base%/../features/apiKiteworks' + contexts: + - RevaContext: + - FeatureContext: *common_feature_context_params + - WebDavPropertiesContext: + - KiteworksContext: + extensions: Cjm\Behat\StepThroughExtension: ~ diff --git a/tests/acceptance/features/apiKiteworks/smoke.feature b/tests/acceptance/features/apiKiteworks/smoke.feature new file mode 100644 index 00000000000..7039f420104 --- /dev/null +++ b/tests/acceptance/features/apiKiteworks/smoke.feature @@ -0,0 +1,17 @@ +@api @kw-backed +Feature: Kiteworks space smoke tests + + Scenario: staged kw folder appears in space listing + When user "admin" lists the staged kiteworks space root + Then the staged kiteworks space should appear in the spaces listing + + Scenario: uploaded file content round-trips via oCIS WebDAV + Given the staged kiteworks space has a file "hello.txt" with content "hello kw" + When user "admin" downloads "hello.txt" from the staged kiteworks space + Then the HTTP status code should be "200" + And the file content should be "hello kw" + + Scenario: nested mkdir leaf appears in folder listing + Given the staged kiteworks space has a folder "docs" + When user "admin" lists the staged kiteworks space root + Then the response should contain an entry named "docs" diff --git a/tests/acceptance/features/bootstrap/KiteworksContext.php b/tests/acceptance/features/bootstrap/KiteworksContext.php new file mode 100644 index 00000000000..0163c215a30 --- /dev/null +++ b/tests/acceptance/features/bootstrap/KiteworksContext.php @@ -0,0 +1,249 @@ +endpoint = (string) getenv('STORAGE_USERS_KITEWORKS_ENDPOINT'); + $this->token = (string) getenv('STORAGE_USERS_KITEWORKS_API_TOKEN'); + $this->insecure = strtolower((string) getenv('STORAGE_USERS_KITEWORKS_INSECURE')) === 'true'; + $this->baseUrl = rtrim((string)(getenv('OCIS_BASE_URL') ?: 'http://localhost:20180'), '/'); + } + + /** + * @BeforeScenario + */ + public function gatherContexts(BeforeScenarioScope $scope): void { + $this->featureContext = $scope->getEnvironment()->getContext('FeatureContext'); + } + + /** + * @BeforeScenario @kw-backed + */ + public function setUpKwFixture(BeforeScenarioScope $scope): void { + $this->assertKwEnv(); + $this->rootID = $this->kwFixture('setup', 'behat-' . uniqid()); + } + + /** + * @AfterScenario @kw-backed + */ + public function tearDownKwFixture(AfterScenarioScope $scope): void { + if ($this->rootID !== null) { + $this->kwFixture('teardown', $this->rootID); + $this->rootID = null; + } + } + + // --------------------------------------------------------------------------- + // Given steps — staging + // --------------------------------------------------------------------------- + + /** + * @Given the staged kiteworks space has a file :name with content :content + */ + public function stagedFileWithContent(string $name, string $content): void { + $this->kwFixture('upload', $this->rootID, $name, $content); + } + + /** + * @Given the staged kiteworks space has a folder :path + */ + public function stagedFolder(string $path): void { + $this->kwFixture('mkdir', $this->rootID, $path); + } + + // --------------------------------------------------------------------------- + // When steps — WebDAV actions + // --------------------------------------------------------------------------- + + /** + * @When user :user lists the staged kiteworks space root + */ + public function userListsStagedSpaceRoot(string $user): void { + [$code, $body] = $this->webdav( + 'PROPFIND', + '/dav/spaces/kiteworks!' . $this->rootID . '/', + ['Depth: 1'], + $user + ); + $this->lastResponseCode = $code; + $this->lastPropfindBody = $body; + $this->lastResponseBody = $body; + } + + /** + * @When user :user downloads :name from the staged kiteworks space + */ + public function userDownloadsFromStagedSpace(string $user, string $name): void { + [$code, $body] = $this->webdav( + 'GET', + '/dav/spaces/kiteworks!' . $this->rootID . '/' . ltrim($name, '/'), + [], + $user + ); + $this->lastResponseCode = $code; + $this->lastResponseBody = $body; + } + + // --------------------------------------------------------------------------- + // Then steps — assertions + // --------------------------------------------------------------------------- + + /** + * @Then the staged kiteworks space should appear in the spaces listing + */ + public function stagedSpaceAppearsInListing(): void { + [$code, $body] = $this->webdav('PROPFIND', '/dav/spaces/', ['Depth: 1'], 'admin'); + if ($code !== '207') { + throw new \RuntimeException("PROPFIND /dav/spaces/ returned HTTP $code, expected 207"); + } + $needle = 'kiteworks!' . $this->rootID; + if (strpos($body, $needle) === false) { + throw new \RuntimeException("Space $needle not found in PROPFIND response:\n$body"); + } + } + + /** + * @Then the HTTP status code should be :expected + */ + public function httpStatusCodeShouldBe(string $expected): void { + if ($this->lastResponseCode !== $expected) { + throw new \RuntimeException( + "Expected HTTP $expected but got {$this->lastResponseCode}" + ); + } + } + + /** + * @Then the file content should be :expected + */ + public function fileContentShouldBe(string $expected): void { + if ($this->lastResponseBody !== $expected) { + throw new \RuntimeException( + "Expected body " . json_encode($expected) + . " but got " . json_encode($this->lastResponseBody) + ); + } + } + + /** + * @Then the response should contain an entry named :name + */ + public function responseShouldContainEntryNamed(string $name): void { + $body = $this->lastPropfindBody ?? $this->lastResponseBody ?? ''; + // Match any segment that ends with the given name (with or without trailing slash) + if (!preg_match('#/' . preg_quote($name, '#') . '/?]*href>#i', $body)) { + throw new \RuntimeException( + "Entry '$name' not found in PROPFIND response:\n$body" + ); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private function assertKwEnv(): void { + foreach (['STORAGE_USERS_KITEWORKS_ENDPOINT', 'STORAGE_USERS_KITEWORKS_API_TOKEN'] as $var) { + if (getenv($var) === false || getenv($var) === '') { + throw new \RuntimeException("$var is not set — required for @kw-backed tests"); + } + } + } + + private function kwFixture(string ...$args): string { + $repoRoot = realpath(__DIR__ . '/../../../../'); + $bin = "$repoRoot/kw-fixture"; + if (is_executable($bin)) { + $runner = escapeshellarg($bin); + } else { + $runner = 'go run -mod=mod ' . escapeshellarg("$repoRoot/cmd/kw-fixture/"); + } + + $env = 'STORAGE_USERS_KITEWORKS_ENDPOINT=' . escapeshellarg($this->endpoint) + . ' STORAGE_USERS_KITEWORKS_API_TOKEN=' . escapeshellarg($this->token); + if ($this->insecure) { + $env .= ' STORAGE_USERS_KITEWORKS_INSECURE=true'; + } + + $cmd = "$env $runner " . implode(' ', array_map('escapeshellarg', $args)); + exec($cmd, $out, $rc); + if ($rc !== 0) { + throw new \RuntimeException("kw-fixture failed (exit $rc): $cmd"); + } + return trim(implode('', $out)); + } + + /** + * @return array{string, string} [status_code, body] + */ + private function webdav(string $method, string $path, array $extraHeaders, string $user): array { + $password = $user === $this->featureContext->getAdminUsername() + ? $this->featureContext->getAdminPassword() + : $this->featureContext->getRegularUserPassword(); + + $headers = array_merge( + [ + 'Authorization: Basic ' . base64_encode("$user:$password"), + 'Content-Type: application/xml; charset=utf-8', + ], + $extraHeaders + ); + + $opts = [ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headers), + 'ignore_errors' => true, + 'follow_location' => 0, + ], + ]; + + if ($method === 'PROPFIND' && empty(array_filter($extraHeaders, fn($h) => str_starts_with($h, 'Depth:')))) { + $opts['http']['header'] .= "\r\nDepth: 0"; + } + + $ctx = stream_context_create($opts); + $body = file_get_contents($this->baseUrl . $path, false, $ctx); + if ($body === false) { + $body = ''; + } + + // $http_response_header is populated by file_get_contents + $code = '0'; + foreach ($http_response_header ?? [] as $h) { + if (preg_match('#^HTTP/\S+ (\d+)#', $h, $m)) { + $code = $m[1]; + } + } + return [$code, $body]; + } +}