|
19 | 19 | import java.util.UUID; |
20 | 20 |
|
21 | 21 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
| 22 | +import static org.assertj.core.api.Assertions.assertThat; |
22 | 23 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
23 | 24 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
24 | 25 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
@@ -58,13 +59,17 @@ static class ScriptedSource implements DocumentSource { |
58 | 59 | List<SourceFile> files = new ArrayList<>(); |
59 | 60 | String ref = "abc1234"; |
60 | 61 | RuntimeException failure; |
| 62 | + /** What the caller asked to read, so a test can assert on it. */ |
| 63 | + String requestedRevision; |
61 | 64 |
|
62 | 65 | @Override |
63 | | - public SourceSnapshot fetch(SyncConfiguration configuration, String accessToken) { |
| 66 | + public SourceSnapshot fetch( |
| 67 | + SyncConfiguration configuration, String accessToken, String revision) { |
| 68 | + this.requestedRevision = revision; |
64 | 69 | if (failure != null) { |
65 | 70 | throw failure; |
66 | 71 | } |
67 | | - return new SourceSnapshot(ref, List.copyOf(files)); |
| 72 | + return new SourceSnapshot(revision == null ? ref : revision, List.copyOf(files)); |
68 | 73 | } |
69 | 74 | } |
70 | 75 |
|
@@ -457,6 +462,111 @@ void appliesAPushDeliveredWithAValidSignature() throws Exception { |
457 | 462 | .andExpect(status().isOk()); |
458 | 463 | } |
459 | 464 |
|
| 465 | + /** A signed delivery, as a git host sends one. */ |
| 466 | + private org.springframework.test.web.servlet.ResultActions deliver( |
| 467 | + UUID webhookId, String secret, String payload) throws Exception { |
| 468 | + return mockMvc.perform(post("/api/public/sync/{id}", webhookId) |
| 469 | + .contentType("application/json") |
| 470 | + .header("X-Hub-Signature-256", |
| 471 | + "sha256=" + WebhookSignature.hex(payload.getBytes(), secret)) |
| 472 | + .content(payload)); |
| 473 | + } |
| 474 | + |
| 475 | + /** |
| 476 | + * The defect this exists to prevent: a host serves the branch archive from a |
| 477 | + * cache, and a webhook arrives the instant the push lands. Reading the branch |
| 478 | + * can return the tree from before the push that triggered it, leaving the |
| 479 | + * workspace one commit behind and looking healthy — because every push applies |
| 480 | + * the previous one. Naming the commit is immune to that. |
| 481 | + */ |
| 482 | + @Test |
| 483 | + void readsTheCommitTheDeliveryNamedRatherThanTheBranch() throws Exception { |
| 484 | + TestUser owner = registerUser("owner@acme.test", "Owner"); |
| 485 | + UUID ws = createWorkspace(owner, "Platform", "platform"); |
| 486 | + configure(owner, ws, "ARCHIVE"); |
| 487 | + String secret = generateSecret(owner, ws); |
| 488 | + UUID webhookId = webhookIdOf(owner, ws); |
| 489 | + |
| 490 | + source.files = List.of(new SourceFile("docs/pushed.md", "# Pushed")); |
| 491 | + |
| 492 | + deliver(webhookId, secret, |
| 493 | + "{\"ref\":\"refs/heads/main\",\"after\":\"9f8e7d6c5b4a3f2e1d0c\"}") |
| 494 | + .andExpect(status().isOk()) |
| 495 | + .andExpect(jsonPath("$.created").value(1)); |
| 496 | + |
| 497 | + assertThat(source.requestedRevision).isEqualTo("9f8e7d6c5b4a3f2e1d0c"); |
| 498 | + } |
| 499 | + |
| 500 | + /** And the commit is what history says the change came from. */ |
| 501 | + @Test |
| 502 | + void recordsTheCommitAgainstTheSync() throws Exception { |
| 503 | + TestUser owner = registerUser("owner@acme.test", "Owner"); |
| 504 | + UUID ws = createWorkspace(owner, "Platform", "platform"); |
| 505 | + configure(owner, ws, "ARCHIVE"); |
| 506 | + String secret = generateSecret(owner, ws); |
| 507 | + UUID webhookId = webhookIdOf(owner, ws); |
| 508 | + |
| 509 | + source.files = List.of(new SourceFile("docs/pushed.md", "# Pushed")); |
| 510 | + deliver(webhookId, secret, "{\"ref\":\"refs/heads/main\",\"after\":\"deadbeef\"}"); |
| 511 | + |
| 512 | + // The whole record, not just the commit. A webhook sync used to apply its |
| 513 | + // documents and record none of this, because the configuration it wrote to |
| 514 | + // had come from another transaction and was no longer being tracked — so the |
| 515 | + // sync worked and the settings screen said it had never run. |
| 516 | + mockMvc.perform(authed(get("/api/workspaces/{w}/sync", ws), owner)) |
| 517 | + .andExpect(jsonPath("$.lastRef").value("deadbeef")) |
| 518 | + .andExpect(jsonPath("$.lastStatus").value("OK")) |
| 519 | + .andExpect(jsonPath("$.lastAttemptedAt").isNotEmpty()) |
| 520 | + .andExpect(jsonPath("$.lastCreated").value(1)); |
| 521 | + } |
| 522 | + |
| 523 | + /** |
| 524 | + * A repository is a working space. Without this, pushing a draft branch would |
| 525 | + * sync the configured one — work nobody asked for, attributed to a push that |
| 526 | + * did not contain it. |
| 527 | + */ |
| 528 | + @Test |
| 529 | + void ignoresAPushToABranchTheWorkspaceDoesNotFollow() throws Exception { |
| 530 | + TestUser owner = registerUser("owner@acme.test", "Owner"); |
| 531 | + UUID ws = createWorkspace(owner, "Platform", "platform"); |
| 532 | + configure(owner, ws, "ARCHIVE"); |
| 533 | + String secret = generateSecret(owner, ws); |
| 534 | + UUID webhookId = webhookIdOf(owner, ws); |
| 535 | + |
| 536 | + source.files = List.of(new SourceFile("docs/pushed.md", "# Pushed")); |
| 537 | + |
| 538 | + deliver(webhookId, secret, |
| 539 | + "{\"ref\":\"refs/heads/draft\",\"after\":\"abc123\"}") |
| 540 | + .andExpect(status().isOk()) |
| 541 | + // Not a failure: the delivery worked exactly as intended, and a red |
| 542 | + // status here would be a repository with nothing wrong with it. |
| 543 | + .andExpect(jsonPath("$.status").value("OK")) |
| 544 | + .andExpect(jsonPath("$.created").value(0)) |
| 545 | + .andExpect(jsonPath("$.message").value( |
| 546 | + org.hamcrest.Matchers.containsString("Ignored a push to draft"))); |
| 547 | + |
| 548 | + mockMvc.perform(authed(get("/api/workspaces/{w}/documents/by-slug/{s}", ws, "pushed"), owner)) |
| 549 | + .andExpect(status().isNotFound()); |
| 550 | + } |
| 551 | + |
| 552 | + /** A ping, or a host that sends something else: sync the branch, as before. */ |
| 553 | + @Test |
| 554 | + void fallsBackToTheBranchWhenTheDeliveryNamesNoCommit() throws Exception { |
| 555 | + TestUser owner = registerUser("owner@acme.test", "Owner"); |
| 556 | + UUID ws = createWorkspace(owner, "Platform", "platform"); |
| 557 | + configure(owner, ws, "ARCHIVE"); |
| 558 | + String secret = generateSecret(owner, ws); |
| 559 | + UUID webhookId = webhookIdOf(owner, ws); |
| 560 | + |
| 561 | + source.files = List.of(new SourceFile("docs/pushed.md", "# Pushed")); |
| 562 | + |
| 563 | + deliver(webhookId, secret, "{\"zen\":\"Design for failure.\"}") |
| 564 | + .andExpect(status().isOk()) |
| 565 | + .andExpect(jsonPath("$.created").value(1)); |
| 566 | + |
| 567 | + assertThat(source.requestedRevision).isNull(); |
| 568 | + } |
| 569 | + |
460 | 570 | /** The security boundary of the whole feature. */ |
461 | 571 | @Test |
462 | 572 | void refusesADeliveryWithTheWrongSignature() throws Exception { |
|
0 commit comments