Skip to content

Commit b52aff7

Browse files
committed
Implement pipeline unlock API (#4237)
``` curl http://localhost:8153/go/api/pipelines/up42/unlock \ -X POST \ -H 'Accept: application/vnd.go.cd.v1+json' \ -H 'X-GoCD-Confirm: true' ``` The response will contain a JSON string with a `message` key, and status code will be: - 200 OK on success - 404 if pipeline is not found - 409 Conflict in case of errors with unlocking pipeline
1 parent 42bcf97 commit b52aff7

File tree

31 files changed

+1713
-101
lines changed

31 files changed

+1713
-101
lines changed

api/api-backups-v1/src/main/java/com/thoughtworks/go/apiv1/admin/backups/BackupsControllerDelegate.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,21 @@
2424
import com.thoughtworks.go.apiv1.admin.backups.representers.BackupRepresenter;
2525
import com.thoughtworks.go.i18n.Localizer;
2626
import com.thoughtworks.go.server.domain.ServerBackup;
27+
import com.thoughtworks.go.server.security.HeaderConstraint;
2728
import com.thoughtworks.go.server.service.BackupService;
2829
import com.thoughtworks.go.server.service.result.HttpLocalizedOperationResult;
2930
import com.thoughtworks.go.spark.RequestContext;
3031
import com.thoughtworks.go.spark.Routes;
32+
import com.thoughtworks.go.util.SystemEnvironment;
3133
import spark.Request;
3234
import spark.Response;
3335

36+
import static com.thoughtworks.go.api.util.HaltApiResponses.haltBecauseConfirmHeaderMissing;
3437
import static spark.Spark.*;
3538

3639
public class BackupsControllerDelegate extends ApiController {
40+
private static final HeaderConstraint HEADER_CONSTRAINT = new HeaderConstraint(new SystemEnvironment());
41+
3742
private final ApiAuthenticationHelper apiAuthenticationHelper;
3843
private final BackupService backupService;
3944
private final Localizer localizer;
@@ -56,9 +61,6 @@ public void setupRoutes() {
5661
before("", mimeType, this::setContentType);
5762
before("/*", mimeType, this::setContentType);
5863

59-
before("", this::verifyContentType);
60-
before("/*", this::verifyContentType);
61-
6264
before("", this::verifyConfirmHeader);
6365
before("/*", this::verifyConfirmHeader);
6466

@@ -77,4 +79,10 @@ public Object create(Request request, Response response) {
7779
}
7880
return renderHTTPOperationResult(result, response, localizer);
7981
}
82+
83+
private void verifyConfirmHeader(Request request, Response response) {
84+
if (!HEADER_CONSTRAINT.isSatisfied(request.raw())) {
85+
throw haltBecauseConfirmHeaderMissing();
86+
}
87+
}
8088
}

api/api-backups-v1/src/test/groovy/com/thoughtworks/go/apiv1/admin/backups/BackupsControllerDelegateTest.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ class BackupsControllerDelegateTest implements ControllerTrait<BackupsController
140140
void 'bails if confirm header is set to non true value'() {
141141
enableSecurity()
142142
loginAsAdmin()
143-
postWithApiHeader(controller.controllerBasePath(), [confirm: 'foo'])
143+
postWithApiHeader(controller.controllerBasePath(), [confirm: 'foo'], null)
144144
assertThatResponse()
145145
.isBadRequest()
146146
.hasContentType(controller.mimeType)

api/api-base/src/main/java/com/thoughtworks/go/api/ApiController.java

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@
1616

1717
package com.thoughtworks.go.api;
1818

19+
import com.google.gson.reflect.TypeToken;
1920
import com.thoughtworks.go.api.util.GsonTransformer;
2021
import com.thoughtworks.go.api.util.MessageJson;
21-
import com.thoughtworks.go.server.security.HeaderConstraint;
2222
import com.thoughtworks.go.spark.SparkController;
23-
import com.thoughtworks.go.util.SystemEnvironment;
2423
import spark.Request;
2524
import spark.Response;
2625

2726
import javax.activation.MimeType;
2827
import javax.activation.MimeTypeParseException;
28+
import java.io.IOException;
2929
import java.util.*;
3030

3131
import static com.thoughtworks.go.api.util.HaltApiResponses.haltBecauseConfirmHeaderMissing;
@@ -34,7 +34,6 @@
3434

3535
public abstract class ApiController implements ControllerMethods, SparkController {
3636
private static final Set<String> UPDATE_HTTP_METHODS = new HashSet<>(Arrays.asList("PUT", "POST", "PATCH"));
37-
private static final HeaderConstraint HEADER_CONSTRAINT = new HeaderConstraint(new SystemEnvironment());
3837

3938
protected final ApiVersion apiVersion;
4039
protected final String mimeType;
@@ -53,23 +52,19 @@ protected String messageJson(Exception ex) {
5352
return MessageJson.create(ex.getMessage());
5453
}
5554

56-
protected void verifyConfirmHeader(Request request, Response response) {
57-
if (!HEADER_CONSTRAINT.isSatisfied(request.raw())) {
58-
throw haltBecauseConfirmHeaderMissing();
59-
}
60-
}
61-
62-
protected void verifyContentType(Request request, Response response) {
55+
protected void verifyContentType(Request request, Response response) throws IOException {
6356
if (!UPDATE_HTTP_METHODS.contains(request.requestMethod().toUpperCase())) {
6457
return;
6558
}
6659

67-
if (request.contentLength() >= 1 && !isJsonContentType(request)) {
68-
throw haltBecauseJsonContentTypeExpected();
69-
}
60+
boolean requestHasBody = request.contentLength() >= 1 || request.raw().getInputStream().available() >= 1 || "chunked".equalsIgnoreCase(request.headers("Transfer-Encoding"));
7061

71-
if ("chunked".equalsIgnoreCase(request.headers("Transfer-Encoding")) && !isJsonContentType(request)) {
72-
throw haltBecauseJsonContentTypeExpected();
62+
if (requestHasBody) {
63+
if (!isJsonContentType(request)) {
64+
throw haltBecauseJsonContentTypeExpected();
65+
}
66+
} else if (request.headers().stream().noneMatch(headerName -> headerName.toLowerCase().equals("x-gocd-confirm"))) {
67+
throw haltBecauseConfirmHeaderMissing();
7368
}
7469
}
7570

@@ -89,13 +84,13 @@ public String getMimeType() {
8984
return mimeType;
9085
}
9186

92-
protected Map readRequestBodyAsJSON(Request req) {
93-
Map map = GsonTransformer.getInstance().fromJson(req.body(), Map.class);
87+
protected Map<String, Object> readRequestBodyAsJSON(Request req) {
88+
Map<String, Object> map = GsonTransformer.getInstance().fromJson(req.body(), new TypeToken<Map<String, Object>>() {
89+
}.getType());
9490
if (map == null) {
9591
return Collections.emptyMap();
9692
}
9793
return map;
9894
}
9995

100-
10196
}

api/api-base/src/main/java/com/thoughtworks/go/api/ControllerMethods.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import com.thoughtworks.go.api.util.MessageJson;
2121
import com.thoughtworks.go.i18n.Localizer;
2222
import com.thoughtworks.go.server.service.result.HttpLocalizedOperationResult;
23+
import com.thoughtworks.go.server.service.result.HttpOperationResult;
2324
import org.springframework.http.HttpStatus;
2425
import spark.Request;
2526
import spark.Response;
@@ -80,4 +81,9 @@ default Map<String, Object> renderHTTPOperationResult(HttpLocalizedOperationResu
8081
return Collections.singletonMap("message", result.message(localizer));
8182
}
8283

84+
default Map<String, Object> renderHTTPOperationResult(HttpOperationResult result, Response response) {
85+
response.status(result.httpCode());
86+
return Collections.singletonMap("message", result.message());
87+
}
88+
8389
}

api/api-base/src/main/java/com/thoughtworks/go/api/util/GsonTransformer.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.thoughtworks.go.api.representers.JsonReader;
2222
import spark.ResponseTransformer;
2323

24+
import java.lang.reflect.Type;
2425
import java.util.Date;
2526
import java.util.Map;
2627
import java.util.TimeZone;
@@ -66,6 +67,10 @@ public <T> T fromJson(String string, Class<T> classOfT) {
6667
return GSON.fromJson(string, classOfT);
6768
}
6869

70+
public <T> T fromJson(String string, Type classOfT) {
71+
return GSON.fromJson(string, classOfT);
72+
}
73+
6974
public static GsonTransformer getInstance() {
7075
return SingletonHolder.INSTANCE;
7176
}

api/api-base/src/test/groovy/com/thoughtworks/go/api/ApiControllerTest.groovy

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,20 @@
1717
package com.thoughtworks.go.api
1818

1919
import com.thoughtworks.go.api.mocks.MockHttpServletResponseAssert
20+
import com.thoughtworks.go.api.util.HaltApiMessages
2021
import com.thoughtworks.go.api.util.MessageJson
2122
import com.thoughtworks.go.spark.HttpRequestBuilder
2223
import com.thoughtworks.go.spark.mocks.MockHttpServletRequest
2324
import com.thoughtworks.go.spark.mocks.MockHttpServletResponse
25+
import com.thoughtworks.go.spark.util.SecureRandom
2426
import org.junit.jupiter.api.BeforeEach
2527
import org.junit.jupiter.api.Nested
2628
import org.junit.jupiter.api.Test
2729
import spark.HaltException
2830
import spark.Request
2931
import spark.RequestResponseFactory
3032

33+
import static com.thoughtworks.go.api.util.HaltApiMessages.jsonContentTypeExpected
3134
import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode
3235

3336
class ApiControllerTest {
@@ -63,24 +66,45 @@ class ApiControllerTest {
6366
RequestResponseFactory.create(response)
6467
)
6568
}).as("${method} should not blow up").doesNotThrowAnyException()
66-
MockHttpServletResponseAssert.assertThat(response).isOk().as("${method} response should be ok")
69+
MockHttpServletResponseAssert.assertThat(response)
70+
.as("${method} response should be ok")
71+
.isOk()
6772
}
6873
}
6974

7075
@Test
71-
void 'should not blow up if empty put/post/patch requests do not have a content type'() {
76+
void 'should not blow up if empty put/post/patch requests do not have a content type and contain an confirm header'() {
7277
['put', 'post', 'patch'].each { method ->
7378
def response = new MockHttpServletResponse()
7479
assertThatCode({
7580
baseController.verifyContentType(
76-
RequestResponseFactory.create(HttpRequestBuilder."${method.toUpperCase()}"().build()) as Request,
81+
RequestResponseFactory.create(HttpRequestBuilder."${method.toUpperCase()}"().withHeaders(['X-GoCD-Confirm': SecureRandom.hex()]).build()) as Request,
7782
RequestResponseFactory.create(response)
7883
)
79-
}).as("${method} should not blow up with empty body").doesNotThrowAnyException()
84+
})
85+
.as("${method} should not blow up with empty body and confirm header present")
86+
.doesNotThrowAnyException()
8087
MockHttpServletResponseAssert.assertThat(response).isOk().as("${method} response should be ok with empty body")
8188
}
8289
}
8390

91+
@Test
92+
void 'should blow up if empty put/post/patch requests do not have a content type or a confirm header'() {
93+
['put', 'post', 'patch'].each { method ->
94+
def response = new MockHttpServletResponse()
95+
assertThatCode({
96+
baseController.verifyContentType(
97+
RequestResponseFactory.create(HttpRequestBuilder."${method.toUpperCase()}"().build()) as Request,
98+
RequestResponseFactory.create(response)
99+
)
100+
})
101+
.as("${method} should blow up")
102+
.isInstanceOf(HaltException)
103+
.hasFieldOrPropertyWithValue("statusCode", 400)
104+
.hasFieldOrPropertyWithValue("body", MessageJson.create(HaltApiMessages.confirmHeaderMissing()))
105+
}
106+
}
107+
84108
@Test
85109
void 'should not blow up if non-chunked put/post/patch requests has a body with json content type'() {
86110
['put', 'post', 'patch'].each { method ->
@@ -114,14 +138,14 @@ class ApiControllerTest {
114138
RequestResponseFactory.create(request),
115139
null
116140
)
117-
}).isInstanceOf(HaltException)
118-
.hasFieldOrPropertyWithValue("statusCode", 415)
119-
.hasFieldOrPropertyWithValue("body", MessageJson.create("You must specify a 'Content-Type' of 'application/json'"))
141+
})
120142
.as("${method} should not blow up")
143+
.isInstanceOf(HaltException)
144+
.hasFieldOrPropertyWithValue("statusCode", 415)
145+
.hasFieldOrPropertyWithValue("body", MessageJson.create(jsonContentTypeExpected()))
121146
}
122147
}
123148

124-
125149
@Test
126150
void 'should blow up if chunked put/post/patch requests has a body with no content type'() {
127151
['put', 'post', 'patch'].each { method ->
@@ -136,14 +160,14 @@ class ApiControllerTest {
136160
RequestResponseFactory.create(request),
137161
RequestResponseFactory.create(response)
138162
)
139-
}).isInstanceOf(HaltException)
163+
})
164+
.as("${method} should blow up")
165+
.isInstanceOf(HaltException)
140166
.hasFieldOrPropertyWithValue("statusCode", 415)
141-
.hasFieldOrPropertyWithValue("body", MessageJson.create("You must specify a 'Content-Type' of 'application/json'"))
142-
.as("${method} should not blow up")
167+
.hasFieldOrPropertyWithValue("body", MessageJson.create(jsonContentTypeExpected()))
143168
}
144169
}
145170

146-
147171
@Test
148172
void 'should not blow up if chunked put/post/patch requests has a body with json content type'() {
149173
['put', 'post', 'patch'].each { method ->
@@ -160,7 +184,9 @@ class ApiControllerTest {
160184
RequestResponseFactory.create(response)
161185
)
162186
}).as("${method} should not blow up with empty body").doesNotThrowAnyException()
163-
MockHttpServletResponseAssert.assertThat(response).isOk().as("${method} response should be ok with empty body")
187+
MockHttpServletResponseAssert.assertThat(response)
188+
.as("${method} response should be ok with empty body")
189+
.isOk()
164190
}
165191
}
166192
}

api/api-current-user-v1/src/test/groovy/com/thoughtworks/go/apiv1/currentuser/CurrentUserControllerDelegateTest.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ class CurrentUserControllerDelegateTest implements ControllerTrait<CurrentUserCo
183183

184184
@Override
185185
void makeHttpCall() {
186-
patchWithApiHeader(controller.controllerBasePath(), null as Object)
186+
patchWithApiHeader(controller.controllerBasePath(), [:])
187187
}
188188
}
189189

api/api-dashboard-v2/src/test/groovy/com/thoughtworks/go/apiv2/dashboard/DashboardControllerDelegateTest.groovy

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ import static org.mockito.MockitoAnnotations.initMocks
4141
class DashboardControllerDelegateTest implements SecurityServiceTrait, ControllerTrait<DashboardControllerDelegate> {
4242

4343
@Mock
44-
private GoDashboardService goDashboardService;
44+
private GoDashboardService goDashboardService
4545

4646
@Mock
47-
private PipelineSelectionsService pipelineSelectionsService;
47+
private PipelineSelectionsService pipelineSelectionsService
4848

4949
@BeforeEach
5050
void setup() {

api/api-dashboard-v2/src/test/groovy/com/thoughtworks/go/apiv2/dashboard/representers/PipelineRepresenterTest.groovy

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class PipelineRepresenterTest {
4040
void 'renders pipeline with hal representation'() {
4141
def counter = mock(Counter.class)
4242
when(counter.getNext()).thenReturn(Long.valueOf(1))
43-
def permissions = new Permissions(NoOne.INSTANCE, NoOne.INSTANCE, NoOne.INSTANCE, NoOne.INSTANCE);
43+
def permissions = new Permissions(NoOne.INSTANCE, NoOne.INSTANCE, NoOne.INSTANCE, NoOne.INSTANCE)
4444
def pipeline = new GoDashboardPipeline(pipeline_model('pipeline_name', 'pipeline_label'), permissions, "grp", counter)
4545
def json = PipelineRepresenter.toJSON(pipeline, new TestRequestContext(), new Username(new CaseInsensitiveString(SecureRandom.hex())))
4646
assertThatJson(json).isEqualTo([
@@ -86,7 +86,7 @@ class PipelineRepresenterTest {
8686

8787
actualJson.remove("_links")
8888
actualJson.remove("_embedded")
89-
def expectedJson = pipelines_hash();
89+
def expectedJson = pipelines_hash()
9090
expectedJson.can_operate = true
9191
assertThatJson(actualJson).isEqualTo(expectedJson)
9292
}

api/api-dashboard-v2/src/test/groovy/com/thoughtworks/go/apiv2/dashboard/representers/StageRepresenterTest.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class StageRepresenterTest {
5959

6060
@Test
6161
void 'renders stages without previous stage with hal representation'() {
62-
def stageInstance = new StageInstanceModel('stage2', '2', StageResult. Cancelled, new StageIdentifier('pipeline-name', 23, 'stage', '2'));
62+
def stageInstance = new StageInstanceModel('stage2', '2', StageResult. Cancelled, new StageIdentifier('pipeline-name', 23, 'stage', '2'))
6363
def json = StageRepresenter.toJSON(stageInstance, new TestRequestContext(), 'pipeline-name', '23')
6464

6565
def expectedJson = [

0 commit comments

Comments
 (0)