PatCredentialController.java

1
package edu.ucsb.cs.scaffold.controller;
2
3
import edu.ucsb.cs.scaffold.entity.PatCredential;
4
import edu.ucsb.cs.scaffold.enums.PatPlatform;
5
import edu.ucsb.cs.scaffold.errors.EntityNotFoundException;
6
import edu.ucsb.cs.scaffold.repository.PatCredentialRepository;
7
import edu.ucsb.cs.scaffold.services.PatEncryptionService;
8
import io.swagger.v3.oas.annotations.Operation;
9
import io.swagger.v3.oas.annotations.Parameter;
10
import io.swagger.v3.oas.annotations.tags.Tag;
11
import java.time.LocalDate;
12
import java.util.Map;
13
import java.util.regex.Pattern;
14
import lombok.extern.slf4j.Slf4j;
15
import org.springframework.beans.factory.annotation.Autowired;
16
import org.springframework.format.annotation.DateTimeFormat;
17
import org.springframework.http.HttpStatus;
18
import org.springframework.security.access.prepost.PreAuthorize;
19
import org.springframework.web.bind.annotation.ExceptionHandler;
20
import org.springframework.web.bind.annotation.GetMapping;
21
import org.springframework.web.bind.annotation.PostMapping;
22
import org.springframework.web.bind.annotation.RequestMapping;
23
import org.springframework.web.bind.annotation.RequestParam;
24
import org.springframework.web.bind.annotation.ResponseStatus;
25
import org.springframework.web.bind.annotation.RestController;
26
27
/**
28
 * Lets each admin or instructor store one personal access token (PAT) per platform: a GitHub PAT at
29
 * /api/pat/github and a PrairieLearn PAT at /api/pat/pl. Tokens are encrypted before they are saved
30
 * and are write-only: no endpoint ever returns one, only metadata (last four characters, expiration
31
 * date). To replace a lost or expired token, the user simply POSTs a new one. See
32
 * docs/Github_PAT.md and docs/PrairieLearn_PAT.md for how to create suitable tokens.
33
 */
34
@Tag(name = "PatCredential")
35
@RequestMapping("/api/pat")
36
@RestController
37
@Slf4j
38
public class PatCredentialController extends ApiController {
39
40
  // A GitHub classic PAT: "ghp_" followed by letters and digits. Only classic tokens work here:
41
  // a fine-grained token can only be scoped to repos owned by the user's own account or an org
42
  // the user is a member of, and this app's users are outside collaborators on repos owned by
43
  // another org — so a fine-grained token can never reach them. See docs/PAT-design.md.
44
  private static final Pattern CLASSIC_PAT_PATTERN = Pattern.compile("^ghp_[A-Za-z0-9]{20,244}$");
45
46
  @Autowired private PatCredentialRepository patCredentialRepository;
47
48
  @Autowired private PatEncryptionService patEncryptionService;
49
50
  @Operation(
51
      summary = "Get metadata about the current user's stored GitHub PAT (never the token itself)")
52
  @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
53
  @GetMapping("/github")
54
  public PatCredential getGithubPatCredential() {
55 1 1. getGithubPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::getGithubPatCredential → KILLED
    return getPatCredential(PatPlatform.GITHUB);
56
  }
57
58
  @Operation(
59
      summary =
60
          "Get metadata about the current user's stored PrairieLearn PAT (never the token itself)")
61
  @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
62
  @GetMapping("/pl")
63
  public PatCredential getPlPatCredential() {
64 1 1. getPlPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::getPlPatCredential → KILLED
    return getPatCredential(PatPlatform.PRAIRIELEARN);
65
  }
66
67
  @Operation(summary = "Set (create or replace) the current user's GitHub PAT")
68
  @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
69
  @PostMapping("/github")
70
  public PatCredential postGithubPatCredential(
71
      @Parameter(name = "token", description = "GitHub classic PAT (starts with ghp_)")
72
          @RequestParam
73
          String token,
74
      @Parameter(
75
              name = "expiresAt",
76
              description = "Expiration date of the token in ISO format, e.g. 2026-12-31")
77
          @RequestParam(required = false)
78
          @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
79
          LocalDate expiresAt) {
80
    String trimmedToken = token.strip();
81 1 1. postGithubPatCredential : removed call to edu/ucsb/cs/scaffold/controller/PatCredentialController::validateGithubToken → KILLED
    validateGithubToken(trimmedToken);
82 1 1. postGithubPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::postGithubPatCredential → KILLED
    return savePatCredential(PatPlatform.GITHUB, trimmedToken, expiresAt);
83
  }
84
85
  @Operation(summary = "Set (create or replace) the current user's PrairieLearn PAT")
86
  @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
87
  @PostMapping("/pl")
88
  public PatCredential postPlPatCredential(
89
      @Parameter(name = "token", description = "PrairieLearn personal access token") @RequestParam
90
          String token,
91
      @Parameter(
92
              name = "expiresAt",
93
              description = "Expiration date of the token in ISO format, e.g. 2026-12-31")
94
          @RequestParam(required = false)
95
          @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
96
          LocalDate expiresAt) {
97
    String trimmedToken = token.strip();
98 1 1. postPlPatCredential : negated conditional → KILLED
    if (trimmedToken.isBlank()) {
99
      throw new IllegalArgumentException("token is required");
100
    }
101 1 1. postPlPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::postPlPatCredential → KILLED
    return savePatCredential(PatPlatform.PRAIRIELEARN, trimmedToken, expiresAt);
102
  }
103
104
  private PatCredential getPatCredential(PatPlatform platform) {
105
    long userId = getCurrentUser().getUser().getId();
106 1 1. getPatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::getPatCredential → KILLED
    return patCredentialRepository
107
        .findByUserIdAndPlatform(userId, platform)
108 1 1. lambda$getPatCredential$0 : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::lambda$getPatCredential$0 → KILLED
        .orElseThrow(() -> new EntityNotFoundException(PatCredential.class, userId));
109
  }
110
111
  private PatCredential savePatCredential(
112
      PatPlatform platform, String trimmedToken, LocalDate expiresAt) {
113
    PatEncryptionService.EncryptedPat encrypted = patEncryptionService.encrypt(trimmedToken);
114
115
    long userId = getCurrentUser().getUser().getId();
116
    PatCredential credential =
117
        patCredentialRepository
118
            .findByUserIdAndPlatform(userId, platform)
119 1 1. lambda$savePatCredential$1 : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::lambda$savePatCredential$1 → KILLED
            .orElseGet(() -> PatCredential.builder().userId(userId).platform(platform).build());
120 1 1. savePatCredential : removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setCiphertext → KILLED
    credential.setCiphertext(encrypted.ciphertext());
121 1 1. savePatCredential : removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setKeyVersion → KILLED
    credential.setKeyVersion(encrypted.keyVersion());
122 2 1. savePatCredential : Replaced integer subtraction with addition → KILLED
2. savePatCredential : removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setLastFour → KILLED
    credential.setLastFour(trimmedToken.substring(trimmedToken.length() - 4));
123 1 1. savePatCredential : removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setExpiresAt → KILLED
    credential.setExpiresAt(expiresAt);
124 1 1. savePatCredential : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::savePatCredential → KILLED
    return patCredentialRepository.save(credential);
125
  }
126
127
  private void validateGithubToken(String token) {
128 1 1. validateGithubToken : negated conditional → KILLED
    if (token.isBlank()) {
129
      throw new IllegalArgumentException("token is required");
130
    }
131 1 1. validateGithubToken : negated conditional → KILLED
    if (token.startsWith("github_pat_")) {
132
      throw new IllegalArgumentException(
133
          "fine-grained tokens (github_pat_...) cannot reach this app's repositories; create a classic token (ghp_...) instead — see docs/Github_PAT.md");
134
    }
135 1 1. validateGithubToken : negated conditional → KILLED
    if (!CLASSIC_PAT_PATTERN.matcher(token).matches()) {
136
      throw new IllegalArgumentException(
137
          "token must be a GitHub classic personal access token (starting with ghp_); see docs/Github_PAT.md");
138
    }
139
  }
140
141
  /**
142
   * Thrown by PatEncryptionService when no PAT_ENCRYPTION_KEY is configured — a server-side
143
   * deployment problem, not a client error, so it maps to 503 rather than 400.
144
   */
145
  @ExceptionHandler({IllegalStateException.class})
146
  @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
147
  public Object handleIllegalStateException(Throwable e) {
148 1 1. handleIllegalStateException : replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::handleIllegalStateException → KILLED
    return Map.of(
149
        "type", e.getClass().getSimpleName(),
150
        "message", e.getMessage());
151
  }
152
}

Mutations

55

1.1
Location : getGithubPatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:instructor_can_get_own_github_credential_metadata_but_never_the_ciphertext()]
replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::getGithubPatCredential → KILLED

64

1.1
Location : getPlPatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:instructor_can_get_own_pl_credential_metadata_but_never_the_ciphertext()]
replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::getPlPatCredential → KILLED

81

1.1
Location : postGithubPatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_github_rejects_a_token_that_is_too_short()]
removed call to edu/ucsb/cs/scaffold/controller/PatCredentialController::validateGithubToken → KILLED

82

1.1
Location : postGithubPatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:instructor_can_post_a_new_github_credential()]
replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::postGithubPatCredential → KILLED

98

1.1
Location : postPlPatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_pl_rejects_a_blank_token()]
negated conditional → KILLED

101

1.1
Location : postPlPatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:instructor_can_post_a_new_pl_credential()]
replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::postPlPatCredential → KILLED

106

1.1
Location : getPatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:instructor_can_get_own_github_credential_metadata_but_never_the_ciphertext()]
replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::getPatCredential → KILLED

108

1.1
Location : lambda$getPatCredential$0
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:get_pl_returns_404_when_user_has_no_credential()]
replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::lambda$getPatCredential$0 → KILLED

119

1.1
Location : lambda$savePatCredential$1
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_pl_strips_surrounding_whitespace_from_the_token()]
replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::lambda$savePatCredential$1 → KILLED

120

1.1
Location : savePatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_pl_replaces_the_existing_credential_for_the_user()]
removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setCiphertext → KILLED

121

1.1
Location : savePatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_pl_replaces_the_existing_credential_for_the_user()]
removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setKeyVersion → KILLED

122

1.1
Location : savePatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_pl_strips_surrounding_whitespace_from_the_token()]
Replaced integer subtraction with addition → KILLED

2.2
Location : savePatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:instructor_can_post_a_new_github_credential()]
removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setLastFour → KILLED

123

1.1
Location : savePatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_github_records_the_expiration_date_when_given()]
removed call to edu/ucsb/cs/scaffold/entity/PatCredential::setExpiresAt → KILLED

124

1.1
Location : savePatCredential
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:instructor_can_post_a_new_github_credential()]
replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::savePatCredential → KILLED

128

1.1
Location : validateGithubToken
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_github_rejects_a_fine_grained_token_with_an_explanation()]
negated conditional → KILLED

131

1.1
Location : validateGithubToken
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_github_rejects_a_fine_grained_token_with_an_explanation()]
negated conditional → KILLED

135

1.1
Location : validateGithubToken
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_github_rejects_a_token_that_is_too_short()]
negated conditional → KILLED

148

1.1
Location : handleIllegalStateException
Killed by : edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.controller.PatCredentialControllerTests]/[method:post_github_returns_503_when_encryption_is_not_configured_on_the_server()]
replaced return value with null for edu/ucsb/cs/scaffold/controller/PatCredentialController::handleIllegalStateException → KILLED

Active mutators

Tests examined


Report generated by PIT 1.17.0