diff --git a/.github/workflows/integration-v2.yml b/.github/workflows/integration-v2.yml new file mode 100644 index 00000000..4b2b11e7 --- /dev/null +++ b/.github/workflows/integration-v2.yml @@ -0,0 +1,76 @@ +name: integration-v2 + +on: + pull_request: + push: + branches: [main] + +jobs: + v2-integration: + runs-on: ubuntu-22.04 + steps: + - name: Checkout sda-cli + uses: actions/checkout@v4 + with: + path: sda-cli + + - name: Checkout sensitive-data-archive (pinned) + uses: actions/checkout@v4 + with: + repository: neicnordic/sensitive-data-archive + ref: 608878fa453770fcb3962bf0239366905c125982 # bump in docs/v2-dev-stack-pin.md when the contract changes + path: sensitive-data-archive + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: sda-cli/go.mod + + - name: Boot v2 dev stack + working-directory: sensitive-data-archive + run: | + make build-all + PR_NUMBER=$(date +%F) make dev-download-v2-up + + - name: Wait for services + run: | + for i in {1..120}; do + if curl -sf http://localhost:8085/health/ready \ + && curl -sf http://localhost:8000/tokens > /dev/null; then + echo "download service and mockauth ready" + exit 0 + fi + sleep 2 + done + echo "services did not come up in 240s" + exit 1 + + - name: Fetch dev token + id: token + run: | + TOKEN=$(curl -s http://localhost:8000/tokens | jq -r '.[0]') + if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "failed to fetch dev token" + exit 1 + fi + echo "::add-mask::$TOKEN" + echo "TOKEN=$TOKEN" >> "$GITHUB_OUTPUT" + + - name: Run v2 integration tests + working-directory: sda-cli + env: + DOWNLOAD_V2_URL: http://localhost:8085 + DOWNLOAD_V2_TOKEN: ${{ steps.token.outputs.TOKEN }} + run: | + go test -tags=integration -v ./... + + - name: Dump all service logs on failure + if: failure() + working-directory: sensitive-data-archive + run: | + docker compose --project-name download-v2-dev logs || true + + - name: Tear down (always) + if: always() + working-directory: sensitive-data-archive + run: PR_NUMBER=$(date +%F) make dev-download-v2-down || true diff --git a/docs/v2-dev-stack-pin.md b/docs/v2-dev-stack-pin.md new file mode 100644 index 00000000..b240d9da --- /dev/null +++ b/docs/v2-dev-stack-pin.md @@ -0,0 +1,23 @@ +# SDA v2 dev-stack pin + +Integration tests for `--api-version v2` boot the SDA v2 dev stack from +`neicnordic/sensitive-data-archive` at the commit below. Bump when the v2 +server contract changes or when features we depend on land in main. + +**Current pin:** `608878fa453770fcb3962bf0239366905c125982` +**Updated:** 2026-04-22 +**Why this commit:** Latest `origin/main` commit that directly touched +`dev-tools/download-v2-dev/` — specifically, the final round of Copilot +review fixes on the dev-compose that was introduced in +neicnordic/sensitive-data-archive#2368 +(`feat(download): add lightweight dev compose for v2 API`). Pinning here +gives us the dev stack in its reviewed-and-stabilized shape; later +`origin/main` commits change unrelated areas (e.g. readiness probes, +s3inbox) that could shift CI behavior without touching the file we depend +on. + +## Bumping + +1. Read the diff: `git log .. -- sda/cmd/download/ dev-tools/download-v2-dev/` +2. Run the pinned commit locally: `git checkout && make dev-download-v2-up && go test -tags integration ./...` +3. If tests pass, update `.github/workflows/integration-v2.yml` and this file in the same commit. diff --git a/download/download_test.go b/download/download_test.go index ebe5bde4..43503e3a 100644 --- a/download/download_test.go +++ b/download/download_test.go @@ -151,8 +151,11 @@ func (s *DownloadTestSuite) TestInvalidUrl() { ) } -func (s *DownloadTestSuite) TestDownload_APIVersionV2_NotYetImplemented() { - // Set everything Download() requires so it reaches the downloadclient.New factory. +func (s *DownloadTestSuite) TestDownload_APIVersionV2_StubsNotImplemented() { + // v2 factory now returns a real V2Client (#675). The error surfaces + // when Download() hits a stubbed method: for the single-file path, + // getFileIDURL calls client.ListFiles which returns + // "V2Client.ListFiles not implemented until #676". oldDatasetID, oldURL, oldAPIVersion := datasetID, URL, apiVersionFlag datasetID = "TES01" URL = s.httpTestServer.URL @@ -167,7 +170,7 @@ func (s *DownloadTestSuite) TestDownload_APIVersionV2_NotYetImplemented() { err := Download([]string{"files/file1.c4gh"}, s.configFilePath, "test") require.Error(s.T(), err) - assert.Contains(s.T(), err.Error(), "not yet implemented") + assert.Contains(s.T(), err.Error(), "not implemented until #676") } func (s *DownloadTestSuite) TestDownloadOneFileWithPublicKey() { diff --git a/download/integration_v2_test.go b/download/integration_v2_test.go new file mode 100644 index 00000000..5e45ae6c --- /dev/null +++ b/download/integration_v2_test.go @@ -0,0 +1,37 @@ +//go:build integration + +package download_test + +import ( + "context" + "os" + "testing" + + "github.com/NBISweden/sda-cli/downloadclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestV2_ListDatasets_Smoke calls the real v2 dev stack. Requires: +// - dev-tools/download-v2-dev/ stack is up (make dev-download-v2-up) +// - DOWNLOAD_V2_URL env var (default http://localhost:8085) +// - DOWNLOAD_V2_TOKEN env var (dev token from mockauth) +func TestV2_ListDatasets_Smoke(t *testing.T) { + baseURL := os.Getenv("DOWNLOAD_V2_URL") + if baseURL == "" { + baseURL = "http://localhost:8085" + } + token := os.Getenv("DOWNLOAD_V2_TOKEN") + require.NotEmpty(t, token, "DOWNLOAD_V2_TOKEN must be set (curl /tokens on mockauth)") + + client, err := downloadclient.New(downloadclient.Config{ + BaseURL: baseURL, + Token: token, + ClientVersion: "test", + }, "v2") + require.NoError(t, err) + + got, err := client.ListDatasets(context.Background()) + require.NoError(t, err) + assert.Contains(t, got, "EGAD00000000001", "dev stack should expose seeded dataset EGAD00000000001") +} diff --git a/downloadclient/downloadclient.go b/downloadclient/downloadclient.go index 91aa164a..72ed6cdd 100644 --- a/downloadclient/downloadclient.go +++ b/downloadclient/downloadclient.go @@ -13,7 +13,7 @@ import ( type Config struct { BaseURL string // e.g. "https://download.example.org" Token string // bearer token (raw, no "Bearer " prefix) - ClientVersion string // sda-cli version (e.g. v1.2.3); sent as SDA-Client-Version header + ClientVersion string // sda-cli version (e.g. v1.2.3); SDA-Client-Version on v1, User-Agent "sda-cli/" on v2 } // Client is the SDA download API abstraction for list-family operations. @@ -50,19 +50,18 @@ func WithV1CookieJar(jar *cookiejar.PersistentJar) Option { // error out anyway. func ValidateVersion(apiVersion string) error { switch apiVersion { - case "v1": + case "v1", "v2": return nil - case "v2": - return errors.New("--api-version v2 is not yet implemented; see #663 for progress") default: return fmt.Errorf("unsupported --api-version %q (v1 or v2)", apiVersion) } } -// New returns a Client for the requested apiVersion. Returns an error if -// apiVersion is unsupported, BaseURL is empty or unparseable, or Token is -// empty. ClientVersion is optional (header only) and not validated. -// Today accepts "v1" only; "v2" errors. Extended in #675 to return a V2Client. +// New returns a Client for the requested apiVersion. "v1" returns a V1Client; +// "v2" returns a V2Client (minimal, some methods are stubs until later PRs +// of issue #663, see V2Client doc). Returns an error if apiVersion is +// unsupported, BaseURL is empty or unparseable, or Token is empty. +// ClientVersion is optional (header only) and not validated. func New(cfg Config, apiVersion string, opts ...Option) (Client, error) { if err := ValidateVersion(apiVersion); err != nil { return nil, err @@ -75,9 +74,16 @@ func New(cfg Config, apiVersion string, opts ...Option) (Client, error) { for _, opt := range opts { opt(&o) } - // ValidateVersion above guarantees apiVersion is "v1" (the only - // branch that returns a client in this implementation). - return NewV1Client(cfg, o.v1CookieJar), nil + + switch apiVersion { + case "v1": + return NewV1Client(cfg, o.v1CookieJar), nil + case "v2": + return NewV2Client(cfg), nil + default: + // Unreachable: ValidateVersion returned nil, so apiVersion is "v1" or "v2". + return nil, fmt.Errorf("unsupported --api-version %q", apiVersion) + } } // validate checks the required Config fields. ClientVersion is optional diff --git a/downloadclient/downloadclient_test.go b/downloadclient/downloadclient_test.go index 8e3c1fe2..dfb7a368 100644 --- a/downloadclient/downloadclient_test.go +++ b/downloadclient/downloadclient_test.go @@ -14,10 +14,12 @@ func TestNew_V1(t *testing.T) { assert.True(t, ok) } -func TestNew_V2NotYetImplemented(t *testing.T) { - _, err := New(Config{BaseURL: "http://x", Token: "t"}, "v2") - require.Error(t, err) - assert.Contains(t, err.Error(), "not yet implemented") +func TestNew_V2(t *testing.T) { + client, err := New(Config{BaseURL: "http://x", Token: "t"}, "v2") + require.NoError(t, err) + require.NotNil(t, client) + _, ok := client.(*V2Client) + assert.True(t, ok, "expected *V2Client, got %T", client) } func TestNew_UnknownVersion(t *testing.T) { diff --git a/downloadclient/v2.go b/downloadclient/v2.go new file mode 100644 index 00000000..582e86ca --- /dev/null +++ b/downloadclient/v2.go @@ -0,0 +1,103 @@ +package downloadclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" +) + +// maxErrorBodyBytes is the maximum number of bytes from a server error +// response that getJSON includes in its formatted error. Bodies past this +// are truncated; #678 caps the read itself with io.LimitReader on the +// response body to bound memory before truncation. +const maxErrorBodyBytes = 200 + +// V2Client talks to the v2 SDA download API +// (GET /datasets, /datasets/{id}/files, /files/{id}, etc.). +// Methods fill in across #675, #676, #677. Until then, unimplemented +// methods return a clear "not implemented until #N" error. +type V2Client struct { + cfg Config + http *http.Client +} + +// NewV2Client constructs a V2Client. HTTP client is a plain net/http.Client +// (no cookie jar — v2 is stateless bearer-token auth, no Location-based +// redirects requiring cookies). +func NewV2Client(cfg Config) *V2Client { + return &V2Client{ + cfg: cfg, + http: &http.Client{}, + } +} + +// ListDatasets implements Client. Single-page only here; pagination via the +// paginate[T] helper arrives in #676. Returning an explicit error when +// nextPageToken != null prevents silently truncating results. +func (c *V2Client) ListDatasets(ctx context.Context) ([]string, error) { + endpoint, err := url.JoinPath(c.cfg.BaseURL, "datasets") + if err != nil { + return nil, fmt.Errorf("invalid base URL: %w", err) + } + body, err := c.getJSON(ctx, endpoint) + if err != nil { + return nil, err + } + defer body.Close() //nolint:errcheck + + var resp datasetListResponse + if err := json.NewDecoder(body).Decode(&resp); err != nil { + return nil, fmt.Errorf("failed to decode /datasets response: %w", err) + } + if resp.NextPageToken != nil && *resp.NextPageToken != "" { + return nil, errors.New("pagination not yet implemented (coming in #676)") + } + + return resp.Datasets, nil +} + +// ListFiles implements Client. Not implemented until #676. +func (c *V2Client) ListFiles(_ context.Context, _ string, _ ListFilesOptions) ([]File, error) { + return nil, errors.New("V2Client.ListFiles not implemented until #676") +} + +// DatasetInfo implements Client. Not implemented until #676. +func (c *V2Client) DatasetInfo(_ context.Context, _ string) (DatasetInfo, error) { + return DatasetInfo{}, errors.New("V2Client.DatasetInfo not implemented until #676") +} + +// getJSON performs an authenticated GET returning the response body. +// #678 replaces error wrapping with RFC 9457 Problem Details parsing. +func (c *V2Client) getJSON(ctx context.Context, reqURL string) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.cfg.Token) + req.Header.Set("Accept", "application/json") + if c.cfg.ClientVersion != "" { + req.Header.Set("User-Agent", "sda-cli/"+c.cfg.ClientVersion) + req.Header.Set("SDA-Client-Version", c.cfg.ClientVersion) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("http request: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + body := string(b) + if len(body) > maxErrorBodyBytes { + body = body[:maxErrorBodyBytes] + } + + return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, body) + } + + return resp.Body, nil +} diff --git a/downloadclient/v2_test.go b/downloadclient/v2_test.go new file mode 100644 index 00000000..246df160 --- /dev/null +++ b/downloadclient/v2_test.go @@ -0,0 +1,47 @@ +package downloadclient + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestV2Client_ListDatasets_SinglePage(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/datasets", r.URL.Path) + assert.Equal(t, "Bearer v2-token", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"datasets":["EGAD00000000001","EGAD00000000002"],"nextPageToken":null}`) + })) + defer ts.Close() + + c := NewV2Client(Config{BaseURL: ts.URL, Token: "v2-token"}) + c.http = ts.Client() + + got, err := c.ListDatasets(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{"EGAD00000000001", "EGAD00000000002"}, got) +} + +func TestV2Client_ListDatasets_MultiPageNotYetImplemented(t *testing.T) { + // Minimal ListDatasets returns the first page only. If nextPageToken + // is non-null, we explicitly warn by returning an error so #676 can + // flip this into a real paginate call without silent truncation. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"datasets":["EGAD001"],"nextPageToken":"ptk_second"}`) + })) + defer ts.Close() + + c := NewV2Client(Config{BaseURL: ts.URL, Token: "t"}) + c.http = ts.Client() + + _, err := c.ListDatasets(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "pagination not yet implemented") +} diff --git a/downloadclient/v2_types.go b/downloadclient/v2_types.go new file mode 100644 index 00000000..4307555a --- /dev/null +++ b/downloadclient/v2_types.go @@ -0,0 +1,70 @@ +package downloadclient + +// v2File is the v2 server's FileInfo wire shape. Differs from downloadclient.File: +// - Scalar DecryptedFileSize → DecryptedSize (int64 here vs int in v1) +// - DecryptedFileChecksum/Type → Checksums array +// - New DownloadURL (server-provided; clients must not construct /files/{id}) +// - Drops v1-only fields (DisplayFileName, etc.) +// +// Conversion to the shared File type happens at the V2Client boundary. +type v2File struct { + FileID string `json:"fileId"` + FilePath string `json:"filePath"` + Size int64 `json:"size"` + DecryptedSize int64 `json:"decryptedSize"` + Checksums []Checksum `json:"checksums"` + DownloadURL string `json:"downloadUrl"` +} + +// datasetListResponse is the v2 response for GET /datasets. +// nextPageToken is nullable per swagger — use a pointer. +type datasetListResponse struct { + Datasets []string `json:"datasets"` + NextPageToken *string `json:"nextPageToken"` +} + +// fileListResponse is the v2 response for GET /datasets/{id}/files. +type fileListResponse struct { + Files []v2File `json:"files"` + NextPageToken *string `json:"nextPageToken"` +} + +// datasetInfoResponse is the v2 wire response for GET /datasets/{id}. +// Currently a mirror of the public downloadclient.DatasetInfo, but kept as a +// separate wire type so v2-only schema drift (added/renamed fields, extra +// metadata) can be absorbed here without rippling into the shared +// downloadclient surface. The file count uses json:"files" per swagger (not +// "fileCount" as the Go field name might suggest). The swagger schema +// also lists "date" as required; we intentionally don't decode it since +// the CLI doesn't surface it today — add it here first when that changes. +type datasetInfoResponse struct { + DatasetID string `json:"datasetId"` + FileCount int `json:"files"` + Size int64 `json:"size"` +} + +// toFile converts a v2File into the shared downloadclient.File. +// Maps the Checksums array to the legacy scalar fields: +// prefer sha256 if present, else first entry. +// DisplayFileName is not populated (v2 doesn't return it). +func (f v2File) toFile() File { + out := File{ + FileID: f.FileID, + FilePath: f.FilePath, + DecryptedFileSize: f.DecryptedSize, + } + for _, c := range f.Checksums { + if c.Type == "sha256" { + out.DecryptedFileChecksum = c.Checksum + out.DecryptedFileChecksumType = c.Type + + return out + } + } + if len(f.Checksums) > 0 { + out.DecryptedFileChecksum = f.Checksums[0].Checksum + out.DecryptedFileChecksumType = f.Checksums[0].Type + } + + return out +} diff --git a/list/list.go b/list/list.go index f8e6f95f..243806ce 100644 --- a/list/list.go +++ b/list/list.go @@ -120,7 +120,13 @@ func datasetFiles(token string, url string, dataset string, bytesFormat bool) er files, err := client.ListFiles(context.Background(), dataset, downloadclient.ListFilesOptions{}) if err != nil { - return err + // Wrap transport / parse / HTTP errors with the legacy "failed to get + // files, reason: ..." prefix that callers of the previous + // download.GetFilesInfo shim expected before downloadclient was + // introduced. Configuration errors (empty/unparseable BaseURL, empty + // Token) are caught earlier by downloadclient.New() and never reach + // this branch. + return fmt.Errorf("failed to get files, reason: %v", err) } fileIDWidth, sizeWidth := 20, 10 // Set minimum column widths, so that header matches the rest of the table @@ -157,16 +163,32 @@ func Datasets(url string, token string) error { ctx := context.Background() datasets, err := client.ListDatasets(ctx) if err != nil { - return err + // Wrap with the legacy "failed to get datasets, reason: ..." prefix + // that the pre-downloadclient download.GetDatasets shim used to emit. + // Configuration errors are caught earlier by downloadclient.New(). + return fmt.Errorf("failed to get datasets, reason: %v", err) + } + + // v2 has a dedicated DatasetInfo endpoint; until #676 wires it up, print + // just the dataset IDs. v1 has no DatasetInfo endpoint, so the v1 branch + // falls back to calling ListFiles per dataset to compute file count and + // size; that per-dataset enrichment returns for v2 in #676. + if apiVersionFlag == "v2" { + fileIDWidth := 40 + fmt.Printf("%-*s\n", fileIDWidth, "DatasetID") + for _, dataset := range datasets { + fmt.Println(dataset) + } + + return nil } - // NOTE: v1 has no DatasetInfo endpoint, so we call ListFiles per dataset - // to compute file count and size. #676 of issue #663 switches v2 to - // downloadclient.Client.DatasetInfo. for _, dataset := range datasets { files, err := client.ListFiles(ctx, dataset, downloadclient.ListFilesOptions{}) if err != nil { - return err + // URL was already validated by ListDatasets above, so any failure + // here is transport/parse/HTTP and takes the legacy wrap prefix. + return fmt.Errorf("failed to get files, reason: %v", err) } fileIDWidth := 40 // fileIdwith=40 ensures header matches rest of the table fmt.Printf("%-*s \t %s \t %s\n", fileIDWidth, "DatasetID", "Files", "Size") diff --git a/list/list_test.go b/list/list_test.go index a5bfbb22..3904895f 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -242,13 +242,81 @@ func (s *ListTestSuite) TestListDatasetsNoUrl() { assert.EqualError(s.T(), err, "Config.BaseURL is required") } -func (s *ListTestSuite) TestList_APIVersionV2_NotYetImplemented() { +func (s *ListTestSuite) TestListDatasets_WrapsTransportError() { + // v1 list --datasets surfaces HTTP / transport failures with the legacy + // "failed to get datasets, reason: ..." prefix that callers of the + // pre-downloadclient download.GetDatasets shim used to emit. + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer failing.Close() + + listCmd.Flag("datasets").Value.Set("true") + listCmd.Flag("url").Value.Set(failing.URL) + err := listCmd.Execute() + require.Error(s.T(), err) + assert.Contains(s.T(), err.Error(), "failed to get datasets, reason:") +} + +func (s *ListTestSuite) TestListDataset_WrapsTransportError() { + // v1 list --dataset surfaces HTTP / transport failures with the legacy + // "failed to get files, reason: ..." prefix (same contract as + // download.GetFilesInfo emitted before downloadclient was introduced). + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer failing.Close() + + listCmd.Flag("dataset").Value.Set("TES01") + listCmd.Flag("url").Value.Set(failing.URL) + err := listCmd.Execute() + require.Error(s.T(), err) + assert.Contains(s.T(), err.Error(), "failed to get files, reason:") +} + +func (s *ListTestSuite) TestList_APIVersionV2_ListDatasets() { + // Under #675, `list --datasets --api-version v2` prints just the dataset + // IDs returned by v2's /datasets endpoint; #676 reintroduces the + // per-dataset file count + size enrichment via DatasetInfo. + v2Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/datasets" { + w.WriteHeader(http.StatusNotFound) + + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"datasets":["EGAD00000000001","EGAD00000000002"],"nextPageToken":null}`) + })) + defer v2Server.Close() + + rescueStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + listCmd.Flag("datasets").Value.Set("true") + listCmd.Flag("url").Value.Set(v2Server.URL) + listCmd.Flag("api-version").Value.Set("v2") + err := listCmd.Execute() + require.NoError(s.T(), err) + + _ = w.Close() + os.Stdout = rescueStdout + listOutput, _ := io.ReadAll(r) + _ = r.Close() + assert.Contains(s.T(), string(listOutput), "EGAD00000000001") + assert.Contains(s.T(), string(listOutput), "EGAD00000000002") +} + +func (s *ListTestSuite) TestList_APIVersionV2_StubsNotImplemented() { + // v2 factory now returns a real V2Client (#675). For `--dataset `, + // list calls client.ListFiles, which for v2 is stubbed until #676 and + // returns a "not implemented until #676" error. + listCmd.Flag("dataset").Value.Set("TES01") listCmd.Flag("url").Value.Set(s.downloadMockHTTPServer.URL) listCmd.Flag("api-version").Value.Set("v2") err := listCmd.Execute() require.Error(s.T(), err) - assert.Contains(s.T(), err.Error(), "not yet implemented") + assert.Contains(s.T(), err.Error(), "not implemented until #676") } func (s *ListTestSuite) generateDummyToken() string {