From 268e5cdc146c952c3892b0ac8e37e476a0b40c1f Mon Sep 17 00:00:00 2001 From: Matteo Fiandesio Date: Mon, 5 Oct 2026 16:27:10 +0200 Subject: [PATCH] fix(runs): authenticate 'tw runs star' and 'tw runs unstar' Both commands failed with "Unauthorized" against a live Platform, even though the caller's token was valid and the same request made by hand succeeded. The workflow star endpoints are missing their security requirement in the Platform OpenAPI spec, so the generated SDK passes no auth names for them. ApiClient.invokeAPI skips authentication entirely when the auth name array is null, rather than falling back to the configured scheme, so the request went out with no Authorization header at all. Default the auth names to BearerAuth when an operation declares none. This routes through the SDK's own authentication, reusing the token already set with setBearerToken. In SDK 1.200.0 eleven operations declare no security: the three *WorkflowStar ones, six GA4GH ones, ServiceInfoApi.info and NextflowApi.nextflowVersions. The last two are genuinely public and the GA4GH ones target the same host, so sending the token is harmless; a token is always present by the time the client is built. The existing star tests matched on method and path only, so an unauthenticated request satisfied them. Assert the Authorization header instead. The mock server is shared across the test class without a reset, so the verification allows for the star requests other tests record. This is a workaround, not the cure: the spec is still missing the security block in SDK 1.230.0. Once that is fixed the override becomes a no-op. Co-Authored-By: Claude Opus 5 (1M context) --- .../tower/cli/commands/AbstractApiCmd.java | 14 ++++++++++ .../io/seqera/tower/cli/runs/RunsCmdTest.java | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index 0eef4481..1fccc926 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -18,6 +18,8 @@ import io.seqera.tower.ApiClient; import io.seqera.tower.ApiException; +import io.seqera.tower.ApiResponse; +import io.seqera.tower.Pair; import io.seqera.tower.api.ActionsApi; import io.seqera.tower.api.AvatarsApi; import io.seqera.tower.api.ComputeEnvsApi; @@ -78,6 +80,7 @@ import picocli.CommandLine; import javax.ws.rs.client.Entity; +import javax.ws.rs.core.GenericType; import javax.ws.rs.core.MediaType; import java.io.IOException; import java.net.URLConnection; @@ -318,6 +321,17 @@ public ClientConfig getDefaultClientConfig() { return config; } + @Override + public ApiResponse invokeAPI(String operation, String path, String method, List queryParams, Object body, Map headerParams, Map cookieParams, Map formParams, String accept, String contentType, String[] authNames, GenericType returnType, boolean isBodyNullable) throws ApiException { + // Some operations are missing their security requirement in the OpenAPI spec (e.g. the workflow + // star endpoints), so the generated SDK passes no auth names and skips authentication entirely, + // sending the request unauthenticated. Fall back to the only scheme this client uses. + if (authNames == null || authNames.length == 0) { + authNames = new String[]{"BearerAuth"}; + } + return super.invokeAPI(operation, path, method, queryParams, body, headerParams, cookieParams, formParams, accept, contentType, authNames, returnType, isBodyNullable); + } + @Override protected void applyDebugSetting(ClientConfig clientConfig) { if (app().verbose) { diff --git a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java index 74019b24..906fe00e 100644 --- a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java @@ -198,6 +198,34 @@ void testUnstarNotStarred(MockServerClient mock) { assertEquals(1, out.exitCode); } + @Test + void testStarSendsAuthorization(MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"workflowId\":\"5dAZoXrcmZXRO4\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + exec(mock, "runs", "star", "-i", "5dAZoXrcmZXRO4"); + + mock.verify(request().withMethod("POST").withPath("/workflow/5dAZoXrcmZXRO4/star") + .withHeader("Authorization", "Bearer " + token()), VerificationTimes.atLeast(1)); + } + + @Test + void testUnstarSendsAuthorization(MockServerClient mock) { + mock.when( + request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + exec(mock, "runs", "unstar", "-i", "5dAZoXrcmZXRO4"); + + mock.verify(request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star") + .withHeader("Authorization", "Bearer " + token()), VerificationTimes.atLeast(1)); + } + @Test void testCancelForbidden(MockServerClient mock) { mock.when(