Skip to content

Commit 567fd31

Browse files
author
Mario Ferreira
committed
Sync the commit a webhook delivered, not the branch
Found by watching the real thing: a push to the handbook repository returned 200, and the page did not change. A manual sync a minute later applied exactly one page. The webhook had synced the tree from before the push that triggered it. A host serves the branch archive from a cache, and a webhook arrives the instant the push lands, so `archive/main.zip` can still be the previous tree. The workspace then sits one commit behind and looks perfectly healthy, because every push applies the previous one — which is why nothing caught it until a push was watched end to end. The delivery names the commit, so that is what gets read. Immune to the cache, and more honest anyway: it applies the tree that was pushed rather than whatever the branch points at by the time the download runs. The commit also becomes the ref in the activity log, which used to just repeat the branch name. The delivery also names the branch, so a push to one this workspace does not follow is now ignored rather than triggering a sync of the configured branch. Reported as OK, not FAILED: that delivery worked exactly as intended, and a red status would be a repository with nothing wrong with it. Uncovered a second defect while testing the first. A webhook sync applied its documents and recorded none of the outcome — no status, no message, no commit, no problems — because the controller passed an entity that findForWebhook had loaded in its own read-only transaction, and mutations to a detached entity go nowhere. The manual path was unaffected, since it loads inside its own transaction, so the settings screen was right after Sync now and said "nothing yet" after every webhook. syncFromWebhook now takes the id and loads it itself. Probed by handing it a detached instance again. 506 backend tests.
1 parent ee1fb77 commit 567fd31

11 files changed

Lines changed: 388 additions & 28 deletions

File tree

‎backend/src/main/java/com/devforge/sync/api/SyncController.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ public ResponseEntity<Map<String, Object>> webhook(
160160
return ResponseEntity.notFound().build();
161161
}
162162

163-
SyncOutcome outcome = syncService.syncFromWebhook(webhook.configuration());
163+
SyncOutcome outcome = syncService.syncFromWebhook(webhookId, payload);
164164

165165
// 200 even for a failed sync: the delivery was accepted and understood, and a
166166
// git host retrying because DevForge could not reach the repository would not

‎backend/src/main/java/com/devforge/sync/application/DocumentSource.java‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ public interface DocumentSource {
1717

1818
/**
1919
* @param accessToken decrypted, or null for a public repository
20+
* @param revision the exact revision to read, or null for the configured
21+
* branch. A webhook names the commit it just delivered,
22+
* which a branch name cannot be relied on to resolve to —
23+
* see {@link PushEvent}.
2024
* @throws SourceUnavailableException when the repository cannot be read; the
2125
* message is shown to the operator, so it must say something useful
2226
*/
23-
SourceSnapshot fetch(SyncConfiguration configuration, String accessToken);
27+
SourceSnapshot fetch(SyncConfiguration configuration, String accessToken, String revision);
2428
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package com.devforge.sync.application;
2+
3+
import tools.jackson.core.JacksonException;
4+
import tools.jackson.databind.JsonNode;
5+
import tools.jackson.databind.json.JsonMapper;
6+
7+
import java.nio.charset.StandardCharsets;
8+
import java.util.Optional;
9+
10+
/**
11+
* What a git host said it just pushed.
12+
*
13+
* <p>Read for one reason: <strong>the commit</strong>. Syncing the branch name
14+
* instead looks equivalent and is not — a host serves {@code archive/main.zip}
15+
* from a cache, and a webhook arrives at the instant the push lands, so the
16+
* download can return the tree from <em>before</em> the push that triggered it.
17+
* The workspace then sits one commit behind and looks perfectly healthy, because
18+
* every push applies the previous one.
19+
*
20+
* <p>Naming the commit is immune to that, and is the more honest thing to do
21+
* anyway: it applies the tree that was actually pushed rather than whatever the
22+
* branch happens to point at by the time the download runs.
23+
*
24+
* <p>The branch matters too. Without it, a push to any branch syncs the configured
25+
* one, so a draft branch triggers work that has nothing to do with it.
26+
*
27+
* <p>GitHub, GitLab, Gitea and Forgejo all send {@code ref} and {@code after} at
28+
* the top level, so one shape covers every host DevForge claims to support.
29+
*
30+
* @param ref the full ref that moved, such as {@code refs/heads/main}
31+
* @param commitId the ref's new tip
32+
*/
33+
public record PushEvent(String ref, String commitId) {
34+
35+
private static final JsonMapper MAPPER = JsonMapper.builder().build();
36+
37+
/** All zeros is how a host says a branch was deleted; there is no tree to fetch. */
38+
private static final String DELETED = "0000000000000000000000000000000000000000";
39+
40+
/**
41+
* @return empty for a body that is not a push — a ping, a pull request, a
42+
* branch deletion, or anything unparseable. The caller falls back to
43+
* syncing the configured branch, which is what happened before any of
44+
* this was read and is never worse than refusing.
45+
*/
46+
public static Optional<PushEvent> parse(byte[] payload) {
47+
if (payload == null || payload.length == 0) {
48+
return Optional.empty();
49+
}
50+
51+
JsonNode root;
52+
try {
53+
root = MAPPER.readTree(new String(payload, StandardCharsets.UTF_8));
54+
} catch (JacksonException notJson) {
55+
return Optional.empty();
56+
}
57+
58+
if (root == null || !root.isObject()) {
59+
return Optional.empty();
60+
}
61+
62+
String ref = text(root, "ref");
63+
String after = text(root, "after");
64+
// GitLab sends `checkout_sha` alongside `after`; they agree on a push, and
65+
// this is only a fallback for a host that omits one of them.
66+
String commit = after != null ? after : text(root, "checkout_sha");
67+
68+
if (ref == null || commit == null || commit.equals(DELETED)) {
69+
return Optional.empty();
70+
}
71+
return Optional.of(new PushEvent(ref, commit));
72+
}
73+
74+
/** Whether this push moved the branch a workspace follows. */
75+
public boolean movedBranch(String branch) {
76+
return branch != null
77+
&& (ref.equals("refs/heads/" + branch) || ref.equals(branch));
78+
}
79+
80+
/** The branch's short name, for saying which one was ignored. */
81+
public String branchName() {
82+
return ref.startsWith("refs/heads/") ? ref.substring("refs/heads/".length()) : ref;
83+
}
84+
85+
private static String text(JsonNode root, String field) {
86+
JsonNode value = root.get(field);
87+
if (value == null || !value.isTextual() || value.asString().isBlank()) {
88+
return null;
89+
}
90+
return value.asString();
91+
}
92+
}

‎backend/src/main/java/com/devforge/sync/application/SyncOutcome.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,16 @@ public record SyncOutcome(
2525
public static SyncOutcome failed(String message) {
2626
return new SyncOutcome(SyncStatus.FAILED, null, 0, 0, 0, 0, List.of(), message);
2727
}
28+
29+
/**
30+
* Nothing was attempted, and nothing was wrong.
31+
*
32+
* <p>Reported as OK rather than FAILED: a push to a branch this workspace does
33+
* not follow is a delivery working exactly as intended, and recording it as a
34+
* failure would leave the settings screen showing a red status for a repository
35+
* with nothing wrong with it.
36+
*/
37+
public static SyncOutcome ignored(String message) {
38+
return new SyncOutcome(SyncStatus.OK, null, 0, 0, 0, 0, List.of(), message);
39+
}
2840
}

‎backend/src/main/java/com/devforge/sync/application/SyncRunner.java‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ public SyncRunner(
7070
*/
7171
@Transactional
7272
public SyncOutcome run(SyncConfiguration configuration, UUID actorId) {
73+
return run(configuration, actorId, null);
74+
}
75+
76+
/**
77+
* @param revision the exact revision to apply, or null for the configured
78+
* branch. A webhook names the commit it delivered, which is
79+
* both more accurate and immune to a host serving a cached
80+
* archive of the branch — see {@link PushEvent}.
81+
*/
82+
public SyncOutcome run(SyncConfiguration configuration, UUID actorId, String revision) {
7383
configuration.recordAttempt();
7484

7585
if (!configuration.isConfigured()) {
@@ -89,7 +99,7 @@ public SyncOutcome run(SyncConfiguration configuration, UUID actorId) {
8999
+ "DEVFORGE_JWT_SECRET was changed. Enter it again to reconnect."),
90100
actorId);
91101
}
92-
snapshot = source.fetch(configuration, token);
102+
snapshot = source.fetch(configuration, token, revision);
93103
} catch (SourceUnavailableException e) {
94104
log.warn("Sync fetch failed for workspace {}: {}",
95105
configuration.getWorkspaceId(), e.getMessage());

‎backend/src/main/java/com/devforge/sync/application/SyncService.java‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,38 @@ public SyncSettingsResponse syncNow(UUID workspaceId, UUID userId) {
205205
/**
206206
* Runs a sync in response to a verified webhook.
207207
*
208+
* <p>Takes the id and loads the configuration again rather than accepting the
209+
* one {@link #findForWebhook} handed the controller. That instance came from a
210+
* different transaction and is detached, so everything a run records on it — the
211+
* status, the message, the commit, the problems — would be written to an object
212+
* JPA is no longer tracking and silently dropped. The documents were still
213+
* applied, which is what made it hard to notice: the sync worked, and the
214+
* settings screen said it had never run.
215+
*
208216
* <p>No workspace permission is checked, and none could be: a git host has no
209217
* DevForge account. The signature is what authorises the call, which is why
210218
* {@link #findForWebhook} refuses to hand back a configuration without one.
211219
*/
212220
@Transactional
213-
public SyncOutcome syncFromWebhook(SyncConfiguration configuration) {
214-
return runner.run(configuration, null);
221+
public SyncOutcome syncFromWebhook(UUID webhookId, byte[] payload) {
222+
SyncConfiguration configuration = repository.findByWebhookId(webhookId)
223+
.filter(SyncConfiguration::isEnabled)
224+
.orElseThrow(() -> new ResourceNotFoundException("Sync configuration", webhookId));
225+
226+
Optional<PushEvent> push = PushEvent.parse(payload);
227+
228+
// A push to another branch is not this workspace's business. Without this
229+
// check any branch would trigger a sync of the configured one, so pushing a
230+
// draft would apply main — work nobody asked for, attributed to a push that
231+
// did not contain it.
232+
if (push.isPresent() && !push.get().movedBranch(configuration.getBranch())) {
233+
return SyncOutcome.ignored(
234+
"Ignored a push to %s; this workspace follows %s."
235+
.formatted(push.get().branchName(), configuration.getBranch()));
236+
}
237+
238+
// The commit, not the branch. See PushEvent for why the difference matters.
239+
return runner.run(configuration, null, push.map(PushEvent::commitId).orElse(null));
215240
}
216241

217242
/**

‎backend/src/main/java/com/devforge/sync/infrastructure/ArchiveDocumentSource.java‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ public ArchiveDocumentSource() {
6565
}
6666

6767
@Override
68-
public SourceSnapshot fetch(SyncConfiguration configuration, String accessToken) {
69-
URI archive = archiveUri(configuration);
68+
public SourceSnapshot fetch(SyncConfiguration configuration, String accessToken, String revision) {
69+
String ref = revision == null || revision.isBlank() ? configuration.getBranch() : revision;
70+
URI archive = archiveUri(configuration, ref);
7071

7172
HttpRequest.Builder request = HttpRequest.newBuilder(archive)
7273
.timeout(Duration.ofSeconds(60))
@@ -94,8 +95,8 @@ public SourceSnapshot fetch(SyncConfiguration configuration, String accessToken)
9495

9596
if (response.statusCode() == 404) {
9697
throw new SourceUnavailableException(
97-
("Nothing found at that repository and branch. Check the URL and that "
98-
+ "branch '%s' exists.").formatted(configuration.getBranch()));
98+
("Nothing found at that repository and revision. Check the URL and that "
99+
+ "'%s' exists.").formatted(ref));
99100
}
100101
if (response.statusCode() == 401 || response.statusCode() == 403) {
101102
throw new SourceUnavailableException(
@@ -110,7 +111,9 @@ public SourceSnapshot fetch(SyncConfiguration configuration, String accessToken)
110111
.formatted(response.statusCode()));
111112
}
112113

113-
return new SourceSnapshot(configuration.getBranch(), read(response.body()));
114+
// The snapshot records what was actually read, so the activity log says
115+
// which commit a change came from rather than repeating the branch name.
116+
return new SourceSnapshot(ref, read(response.body()));
114117
}
115118

116119
/**
@@ -135,7 +138,7 @@ private static boolean looksLikeZip(byte[] bytes) {
135138
* A trailing {@code .git} is stripped because that is how a clone URL is usually
136139
* copied, and it is not part of the web path.
137140
*/
138-
private static URI archiveUri(SyncConfiguration configuration) {
141+
private static URI archiveUri(SyncConfiguration configuration, String ref) {
139142
String base = configuration.getRepositoryUrl().trim()
140143
.replaceAll("/+$", "")
141144
.replaceAll("\\.git$", "");
@@ -149,7 +152,6 @@ private static URI archiveUri(SyncConfiguration configuration) {
149152
// The ref goes in a path segment, so anything that could break out of it
150153
// is rejected rather than escaped — a branch name is not a place for
151154
// path traversal.
152-
String ref = configuration.getBranch();
153155
if (ref.contains("..") || ref.startsWith("/")) {
154156
throw new SourceUnavailableException("That branch name is not usable in a URL.");
155157
}

‎backend/src/test/java/com/devforge/sync/api/GitSyncIntegrationTest.java‎

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.UUID;
2020

2121
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
22+
import static org.assertj.core.api.Assertions.assertThat;
2223
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
2324
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
2425
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@@ -58,13 +59,17 @@ static class ScriptedSource implements DocumentSource {
5859
List<SourceFile> files = new ArrayList<>();
5960
String ref = "abc1234";
6061
RuntimeException failure;
62+
/** What the caller asked to read, so a test can assert on it. */
63+
String requestedRevision;
6164

6265
@Override
63-
public SourceSnapshot fetch(SyncConfiguration configuration, String accessToken) {
66+
public SourceSnapshot fetch(
67+
SyncConfiguration configuration, String accessToken, String revision) {
68+
this.requestedRevision = revision;
6469
if (failure != null) {
6570
throw failure;
6671
}
67-
return new SourceSnapshot(ref, List.copyOf(files));
72+
return new SourceSnapshot(revision == null ? ref : revision, List.copyOf(files));
6873
}
6974
}
7075

@@ -457,6 +462,111 @@ void appliesAPushDeliveredWithAValidSignature() throws Exception {
457462
.andExpect(status().isOk());
458463
}
459464

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+
460570
/** The security boundary of the whole feature. */
461571
@Test
462572
void refusesADeliveryWithTheWrongSignature() throws Exception {

0 commit comments

Comments
 (0)