| 1 | package edu.ucsb.cs156.frontiers.controllers; | |
| 2 | ||
| 3 | import com.fasterxml.jackson.core.JsonProcessingException; | |
| 4 | import com.fasterxml.jackson.databind.JsonNode; | |
| 5 | import edu.ucsb.cs156.frontiers.entities.Course; | |
| 6 | import edu.ucsb.cs156.frontiers.entities.CourseStaff; | |
| 7 | import edu.ucsb.cs156.frontiers.entities.RosterStudent; | |
| 8 | import edu.ucsb.cs156.frontiers.enums.OrgStatus; | |
| 9 | import edu.ucsb.cs156.frontiers.repositories.CourseRepository; | |
| 10 | import edu.ucsb.cs156.frontiers.repositories.CourseStaffRepository; | |
| 11 | import edu.ucsb.cs156.frontiers.repositories.RosterStudentRepository; | |
| 12 | import edu.ucsb.cs156.frontiers.utilities.WebhookSecurityUtils; | |
| 13 | import io.swagger.v3.oas.annotations.tags.Tag; | |
| 14 | import java.security.InvalidKeyException; | |
| 15 | import java.security.NoSuchAlgorithmException; | |
| 16 | import java.util.Optional; | |
| 17 | import lombok.extern.slf4j.Slf4j; | |
| 18 | import org.springframework.beans.factory.annotation.Value; | |
| 19 | import org.springframework.http.ResponseEntity; | |
| 20 | import org.springframework.web.bind.annotation.PostMapping; | |
| 21 | import org.springframework.web.bind.annotation.RequestBody; | |
| 22 | import org.springframework.web.bind.annotation.RequestHeader; | |
| 23 | import org.springframework.web.bind.annotation.RequestMapping; | |
| 24 | import org.springframework.web.bind.annotation.RestController; | |
| 25 | ||
| 26 | @Tag(name = "Webhooks Controller") | |
| 27 | @RestController | |
| 28 | @RequestMapping("/api/webhooks") | |
| 29 | @Slf4j | |
| 30 | public class WebhookController { | |
| 31 | ||
| 32 | private final CourseRepository courseRepository; | |
| 33 | private final RosterStudentRepository rosterStudentRepository; | |
| 34 | private final CourseStaffRepository courseStaffRepository; | |
| 35 | ||
| 36 | @Value("${app.webhook.secret}") | |
| 37 | private String webhookSecret; | |
| 38 | ||
| 39 | public WebhookController( | |
| 40 | CourseRepository courseRepository, | |
| 41 | RosterStudentRepository rosterStudentRepository, | |
| 42 | CourseStaffRepository courseStaffRepository) { | |
| 43 | this.courseRepository = courseRepository; | |
| 44 | this.rosterStudentRepository = rosterStudentRepository; | |
| 45 | this.courseStaffRepository = courseStaffRepository; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Accepts webhooks from GitHub, currently to update the membership status of a RosterStudent. | |
| 50 | * | |
| 51 | * @param jsonBody body of the webhook. The description of the currently used webhook is available | |
| 52 | * in docs/webhooks.md | |
| 53 | * @param signature the GitHub webhook signature header for security validation | |
| 54 | * @return either the word success so GitHub will not flag the webhook as a failure, or the | |
| 55 | * updated RosterStudent | |
| 56 | */ | |
| 57 | @PostMapping("/github") | |
| 58 | public ResponseEntity<String> createGitHubWebhook( | |
| 59 | @RequestBody String requestBody, | |
| 60 | @RequestHeader(value = "X-Hub-Signature-256", required = false) String signature) | |
| 61 | throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeyException { | |
| 62 | ||
| 63 | // Validate webhook signature | |
| 64 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!WebhookSecurityUtils.validateGitHubSignature(requestBody, signature, webhookSecret)) { |
| 65 | log.error("Webhook signature validation failed"); | |
| 66 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.status(401).body("Unauthorized: Invalid signature"); |
| 67 | } | |
| 68 | ||
| 69 | // Parse JSON after signature validation | |
| 70 | JsonNode jsonBody; | |
| 71 | try { | |
| 72 | jsonBody = new com.fasterxml.jackson.databind.ObjectMapper().readTree(requestBody); | |
| 73 | } catch (JsonProcessingException e) { | |
| 74 | log.error("Failed to parse webhook JSON body", e); | |
| 75 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.badRequest().body("Invalid JSON"); |
| 76 | } | |
| 77 | ||
| 78 | log.info("Received GitHub webhook: {}", jsonBody.toString()); | |
| 79 | ||
| 80 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!jsonBody.has("action")) { |
| 81 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
| 82 | } | |
| 83 | ||
| 84 | String action = jsonBody.get("action").asText(); | |
| 85 | log.info("Webhook action: {}", action); | |
| 86 | ||
| 87 | // Handle GitHub App uninstall (installation deleted) | |
| 88 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (action.equals("deleted")) { |
| 89 |
2
1. createGitHubWebhook : negated conditional → KILLED 2. createGitHubWebhook : negated conditional → KILLED |
if (!jsonBody.has("installation") || !jsonBody.get("installation").has("id")) { |
| 90 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
| 91 | } | |
| 92 | String installationIdForUninstall = jsonBody.get("installation").get("id").asText(); | |
| 93 | log.info("Processing uninstall for Installation ID: {}", installationIdForUninstall); | |
| 94 | Optional<Course> courseForUninstall = | |
| 95 | courseRepository.findByInstallationId(installationIdForUninstall); | |
| 96 | log.info("Course found for uninstall: {}", courseForUninstall.isPresent()); | |
| 97 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (courseForUninstall.isPresent()) { |
| 98 | Course c = courseForUninstall.get(); | |
| 99 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/Course::setInstallationId → KILLED |
c.setInstallationId(null); |
| 100 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/Course::setOrgName → KILLED |
c.setOrgName(null); |
| 101 | courseRepository.save(c); | |
| 102 | } else { | |
| 103 | log.warn( | |
| 104 | "No course found with installation ID for uninstall: {}", installationIdForUninstall); | |
| 105 | } | |
| 106 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
| 107 | } | |
| 108 | ||
| 109 | // Early return if not an action we care about | |
| 110 |
2
1. createGitHubWebhook : negated conditional → KILLED 2. createGitHubWebhook : negated conditional → KILLED |
if (!action.equals("member_added") && !action.equals("member_invited")) { |
| 111 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
| 112 | } | |
| 113 | ||
| 114 | // Extract GitHub login based on payload structure | |
| 115 | String githubLogin = null; | |
| 116 | String installationId = null; | |
| 117 | OrgStatus role = null; | |
| 118 | ||
| 119 | // For member_added events, the structure is different | |
| 120 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (action.equals("member_added")) { |
| 121 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!jsonBody.has("membership") |
| 122 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("membership").has("user") |
| 123 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("membership").has("role") |
| 124 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("membership").get("user").has("login") |
| 125 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.has("installation") |
| 126 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("installation").has("id")) { |
| 127 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
| 128 | } | |
| 129 | ||
| 130 | githubLogin = jsonBody.get("membership").get("user").get("login").asText(); | |
| 131 | installationId = jsonBody.get("installation").get("id").asText(); | |
| 132 | String textRole = jsonBody.get("membership").get("role").asText(); | |
| 133 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (textRole.equals("admin")) { |
| 134 | role = OrgStatus.OWNER; | |
| 135 | } else { | |
| 136 | role = OrgStatus.MEMBER; | |
| 137 | } | |
| 138 | } | |
| 139 | // For member_invited events, use the original structure | |
| 140 | else { // must be "member_invited" based on earlier check | |
| 141 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!jsonBody.has("user") |
| 142 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("user").has("login") |
| 143 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.has("installation") |
| 144 |
1
1. createGitHubWebhook : negated conditional → KILLED |
|| !jsonBody.get("installation").has("id")) { |
| 145 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
| 146 | } | |
| 147 | ||
| 148 | githubLogin = jsonBody.get("user").get("login").asText(); | |
| 149 | installationId = jsonBody.get("installation").get("id").asText(); | |
| 150 | } | |
| 151 | ||
| 152 | log.info("GitHub login: {}, Installation ID: {}", githubLogin, installationId); | |
| 153 | ||
| 154 | Optional<Course> course = courseRepository.findByInstallationId(installationId); | |
| 155 | log.info("Course found: {}", course.isPresent()); | |
| 156 | ||
| 157 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (!course.isPresent()) { |
| 158 | log.warn("No course found with installation ID: {}", installationId); | |
| 159 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
| 160 | } | |
| 161 | ||
| 162 | Optional<RosterStudent> student = | |
| 163 | rosterStudentRepository.findByCourseAndGithubLogin(course.get(), githubLogin); | |
| 164 | Optional<CourseStaff> staff = | |
| 165 | courseStaffRepository.findByCourseAndGithubLogin(course.get(), githubLogin); | |
| 166 | log.info("Student found: {}", student.isPresent()); | |
| 167 | log.info("Staff found: {}", staff.isPresent()); | |
| 168 | ||
| 169 |
2
1. createGitHubWebhook : negated conditional → KILLED 2. createGitHubWebhook : negated conditional → KILLED |
if (!student.isPresent() && !staff.isPresent()) { |
| 170 | log.warn( | |
| 171 | "No student or staff found with GitHub login: {} in course: {}", | |
| 172 | githubLogin, | |
| 173 | course.get().getCourseName()); | |
| 174 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body("success"); |
| 175 | } | |
| 176 | StringBuilder response = new StringBuilder(); | |
| 177 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (student.isPresent()) { |
| 178 | RosterStudent updatedStudent = student.get(); | |
| 179 | log.info("Current student org status: {}", updatedStudent.getOrgStatus()); | |
| 180 | ||
| 181 | // Update status based on action | |
| 182 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (action.equals("member_added")) { |
| 183 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/RosterStudent::setOrgStatus → KILLED |
updatedStudent.setOrgStatus(role); |
| 184 | log.info("Setting status to {}", role.toString()); | |
| 185 | } else { // must be "member_invited" based on earlier check | |
| 186 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/RosterStudent::setOrgStatus → KILLED |
updatedStudent.setOrgStatus(OrgStatus.INVITED); |
| 187 | log.info("Setting status to INVITED"); | |
| 188 | } | |
| 189 | ||
| 190 | rosterStudentRepository.save(updatedStudent); | |
| 191 | log.info("Student saved with new org status: {}", updatedStudent.getOrgStatus()); | |
| 192 | response.append(updatedStudent); | |
| 193 | } | |
| 194 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (staff.isPresent()) { |
| 195 | CourseStaff updatedStaff = staff.get(); | |
| 196 | log.info("Current course staff member org status: {}", updatedStaff.getOrgStatus()); | |
| 197 | ||
| 198 | // Update status based on action | |
| 199 |
1
1. createGitHubWebhook : negated conditional → KILLED |
if (action.equals("member_added")) { |
| 200 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/CourseStaff::setOrgStatus → KILLED |
updatedStaff.setOrgStatus(role); |
| 201 | log.info("Setting status to {}", role.toString()); | |
| 202 | } else { // must be "member_invited" based on earlier check | |
| 203 |
1
1. createGitHubWebhook : removed call to edu/ucsb/cs156/frontiers/entities/CourseStaff::setOrgStatus → KILLED |
updatedStaff.setOrgStatus(OrgStatus.INVITED); |
| 204 | log.info("Setting status to INVITED"); | |
| 205 | } | |
| 206 | ||
| 207 | courseStaffRepository.save(updatedStaff); | |
| 208 | log.info("Course staff member saved with new org status: {}", updatedStaff.getOrgStatus()); | |
| 209 | response.append(updatedStaff); | |
| 210 | } | |
| 211 |
1
1. createGitHubWebhook : replaced return value with null for edu/ucsb/cs156/frontiers/controllers/WebhookController::createGitHubWebhook → KILLED |
return ResponseEntity.ok().body(response.toString()); |
| 212 | } | |
| 213 | } | |
Mutations | ||
| 64 |
1.1 |
|
| 66 |
1.1 |
|
| 75 |
1.1 |
|
| 80 |
1.1 |
|
| 81 |
1.1 |
|
| 88 |
1.1 |
|
| 89 |
1.1 2.2 |
|
| 90 |
1.1 |
|
| 97 |
1.1 |
|
| 99 |
1.1 |
|
| 100 |
1.1 |
|
| 106 |
1.1 |
|
| 110 |
1.1 2.2 |
|
| 111 |
1.1 |
|
| 120 |
1.1 |
|
| 121 |
1.1 |
|
| 122 |
1.1 |
|
| 123 |
1.1 |
|
| 124 |
1.1 |
|
| 125 |
1.1 |
|
| 126 |
1.1 |
|
| 127 |
1.1 |
|
| 133 |
1.1 |
|
| 141 |
1.1 |
|
| 142 |
1.1 |
|
| 143 |
1.1 |
|
| 144 |
1.1 |
|
| 145 |
1.1 |
|
| 157 |
1.1 |
|
| 159 |
1.1 |
|
| 169 |
1.1 2.2 |
|
| 174 |
1.1 |
|
| 177 |
1.1 |
|
| 182 |
1.1 |
|
| 183 |
1.1 |
|
| 186 |
1.1 |
|
| 194 |
1.1 |
|
| 199 |
1.1 |
|
| 200 |
1.1 |
|
| 203 |
1.1 |
|
| 211 |
1.1 |