Skip to content
76 changes: 76 additions & 0 deletions .github/workflows/integration-v2.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions docs/v2-dev-stack-pin.md
Original file line number Diff line number Diff line change
@@ -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 <old>..<new> -- sda/cmd/download/ dev-tools/download-v2-dev/`
2. Run the pinned commit locally: `git checkout <new> && 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.
9 changes: 6 additions & 3 deletions download/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down
37 changes: 37 additions & 0 deletions download/integration_v2_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
28 changes: 17 additions & 11 deletions downloadclient/downloadclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>" on v2
}

// Client is the SDA download API abstraction for list-family operations.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 6 additions & 4 deletions downloadclient/downloadclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
103 changes: 103 additions & 0 deletions downloadclient/v2.go
Original file line number Diff line number Diff line change
@@ -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{},
Comment thread
nanjiangshu marked this conversation as resolved.
}
}

// 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
}
47 changes: 47 additions & 0 deletions downloadclient/v2_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading