ApiCourseKeyService.java

1
package edu.ucsb.cs156.frontiers.services;
2
3
import edu.ucsb.cs156.frontiers.entities.ApiCourseKey;
4
import edu.ucsb.cs156.frontiers.entities.Course;
5
import edu.ucsb.cs156.frontiers.entities.User;
6
import edu.ucsb.cs156.frontiers.errors.EntityNotFoundException;
7
import edu.ucsb.cs156.frontiers.repositories.ApiCourseKeyRepository;
8
import edu.ucsb.cs156.frontiers.repositories.CourseRepository;
9
import jakarta.servlet.http.HttpServletRequest;
10
import java.nio.charset.StandardCharsets;
11
import java.security.MessageDigest;
12
import java.security.NoSuchAlgorithmException;
13
import java.security.SecureRandom;
14
import java.time.ZonedDateTime;
15
import java.util.Base64;
16
import java.util.List;
17
import java.util.Optional;
18
import org.springframework.stereotype.Service;
19
import org.springframework.web.context.request.RequestAttributes;
20
import org.springframework.web.context.request.RequestContextHolder;
21
import org.springframework.web.context.request.ServletRequestAttributes;
22
23
@Service
24
public class ApiCourseKeyService {
25
  public enum ExpirationChoice {
26
    DAYS_90,
27
    MONTHS_6
28
  }
29
30
  public static final String API_KEY_HEADER = "X-API-KEY";
31
32
  public record GeneratedApiCourseKey(String rawKey, ApiCourseKey apiCourseKey) {}
33
34
  private final ApiCourseKeyRepository apiCourseKeyRepository;
35
  private final CourseRepository courseRepository;
36
  private final SecureRandom secureRandom = new SecureRandom();
37
38
  public ApiCourseKeyService(
39
      ApiCourseKeyRepository apiCourseKeyRepository, CourseRepository courseRepository) {
40
    this.apiCourseKeyRepository = apiCourseKeyRepository;
41
    this.courseRepository = courseRepository;
42
  }
43
44
  public GeneratedApiCourseKey createKey(
45
      Long courseId, User creator, ExpirationChoice expirationChoice) {
46
    Course course =
47
        courseRepository
48
            .findById(courseId)
49 1 1. lambda$createKey$0 : replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::lambda$createKey$0 → KILLED
            .orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));
50
51
    String rawKey = generateRawKey();
52
    String salt = randomHex(16);
53
    ZonedDateTime now = ZonedDateTime.now();
54
    ZonedDateTime expiresAt =
55 1 1. createKey : negated conditional → KILLED
        expirationChoice == ExpirationChoice.MONTHS_6 ? now.plusMonths(6) : now.plusDays(90);
56
57
    ApiCourseKey apiCourseKey =
58
        ApiCourseKey.builder()
59
            .course(course)
60
            .createdBy(creator)
61
            .keyHash(hashWithSalt(rawKey, salt))
62
            .salt(salt)
63 1 1. createKey : Replaced integer subtraction with addition → KILLED
            .keySuffix(rawKey.substring(Math.max(0, rawKey.length() - 6)))
64
            .createdAt(now)
65
            .expiresAt(expiresAt)
66
            .build();
67
68
    ApiCourseKey saved = apiCourseKeyRepository.save(apiCourseKey);
69 1 1. createKey : replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::createKey → KILLED
    return new GeneratedApiCourseKey(rawKey, saved);
70
  }
71
72
  public List<ApiCourseKey> listActiveKeys(Long courseId) {
73 1 1. listActiveKeys : replaced return value with Collections.emptyList for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::listActiveKeys → KILLED
    return apiCourseKeyRepository
74
        .findByCourseIdAndRevokedFalseAndExpiresAtAfterOrderByCreatedAtDesc(
75
            courseId, ZonedDateTime.now());
76
  }
77
78
  public ApiCourseKey revokeKey(Long courseId, Long apiKeyId) {
79
    ApiCourseKey key =
80
        apiCourseKeyRepository
81
            .findByIdAndCourseId(apiKeyId, courseId)
82 1 1. lambda$revokeKey$1 : replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::lambda$revokeKey$1 → KILLED
            .orElseThrow(() -> new EntityNotFoundException(ApiCourseKey.class, apiKeyId));
83 1 1. revokeKey : removed call to edu/ucsb/cs156/frontiers/entities/ApiCourseKey::setRevoked → KILLED
    key.setRevoked(true);
84 1 1. revokeKey : replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::revokeKey → KILLED
    return apiCourseKeyRepository.save(key);
85
  }
86
87
  public boolean authenticateFromRequestForCourse(Long courseId) {
88
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
89 1 1. authenticateFromRequestForCourse : negated conditional → KILLED
    if (!(requestAttributes instanceof ServletRequestAttributes servletRequestAttributes)) {
90 1 1. authenticateFromRequestForCourse : replaced boolean return with true for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::authenticateFromRequestForCourse → KILLED
      return false;
91
    }
92
    HttpServletRequest request = servletRequestAttributes.getRequest();
93
    String rawKey = request.getHeader(API_KEY_HEADER);
94 2 1. authenticateFromRequestForCourse : replaced boolean return with true for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::authenticateFromRequestForCourse → KILLED
2. authenticateFromRequestForCourse : replaced boolean return with false for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::authenticateFromRequestForCourse → KILLED
    return authenticateRawKeyForCourse(rawKey, courseId).isPresent();
95
  }
96
97
  public Optional<ApiCourseKey> authenticateRawKeyForCourse(String rawKey, Long courseId) {
98 2 1. authenticateRawKeyForCourse : negated conditional → KILLED
2. authenticateRawKeyForCourse : negated conditional → KILLED
    if (rawKey == null || rawKey.isBlank()) {
99
      return Optional.empty();
100
    }
101
102
    List<ApiCourseKey> activeKeys = listActiveKeys(courseId);
103
    for (ApiCourseKey key : activeKeys) {
104 1 1. authenticateRawKeyForCourse : negated conditional → KILLED
      if (hashMatches(rawKey, key)) {
105 2 1. authenticateRawKeyForCourse : Replaced long addition with subtraction → KILLED
2. authenticateRawKeyForCourse : removed call to edu/ucsb/cs156/frontiers/entities/ApiCourseKey::setUsageCount → KILLED
        key.setUsageCount(key.getUsageCount() + 1);
106 1 1. authenticateRawKeyForCourse : removed call to edu/ucsb/cs156/frontiers/entities/ApiCourseKey::setLastUsedAt → KILLED
        key.setLastUsedAt(ZonedDateTime.now());
107 1 1. authenticateRawKeyForCourse : replaced return value with Optional.empty for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::authenticateRawKeyForCourse → KILLED
        return Optional.of(apiCourseKeyRepository.save(key));
108
      }
109
    }
110
    return Optional.empty();
111
  }
112
113
  private String generateRawKey() {
114
    byte[] raw = new byte[32];
115 1 1. generateRawKey : removed call to java/security/SecureRandom::nextBytes → KILLED
    secureRandom.nextBytes(raw);
116 1 1. generateRawKey : replaced return value with "" for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::generateRawKey → KILLED
    return "frt_" + Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
117
  }
118
119
  private String randomHex(int bytes) {
120
    byte[] raw = new byte[bytes];
121 1 1. randomHex : removed call to java/security/SecureRandom::nextBytes → KILLED
    secureRandom.nextBytes(raw);
122
    StringBuilder sb = new StringBuilder();
123
    for (byte b : raw) {
124
      sb.append(String.format("%02x", b));
125
    }
126 1 1. randomHex : replaced return value with "" for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::randomHex → KILLED
    return sb.toString();
127
  }
128
129
  private String hashWithSalt(String rawKey, String salt) {
130 1 1. hashWithSalt : replaced return value with "" for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashWithSalt → KILLED
    return Base64.getEncoder().encodeToString(hashBytes(rawKey, salt));
131
  }
132
133
  private byte[] hashBytes(String rawKey, String salt) {
134
    try {
135
      MessageDigest digest = MessageDigest.getInstance("SHA-256");
136 1 1. hashBytes : replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashBytes → KILLED
      return digest.digest((salt + rawKey).getBytes(StandardCharsets.UTF_8));
137
    } catch (NoSuchAlgorithmException e) {
138
      throw new IllegalStateException("SHA-256 unavailable", e);
139
    }
140
  }
141
142
  private boolean hashMatches(String rawKey, ApiCourseKey key) {
143
    byte[] candidate = hashBytes(rawKey, key.getSalt());
144
    try {
145
      byte[] expected = Base64.getDecoder().decode(key.getKeyHash());
146 2 1. hashMatches : replaced boolean return with false for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashMatches → KILLED
2. hashMatches : replaced boolean return with true for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashMatches → KILLED
      return MessageDigest.isEqual(candidate, expected);
147
    } catch (IllegalArgumentException e) {
148 1 1. hashMatches : replaced boolean return with true for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashMatches → KILLED
      return false;
149
    }
150
  }
151
}

Mutations

49

1.1
Location : lambda$createKey$0
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:createKey_throws_when_course_not_found()]
replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::lambda$createKey$0 → KILLED

55

1.1
Location : createKey
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:createKey_uses_six_month_expiration_when_selected()]
negated conditional → KILLED

63

1.1
Location : createKey
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:createKey_uses_six_month_expiration_when_selected()]
Replaced integer subtraction with addition → KILLED

69

1.1
Location : createKey
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:createKey_uses_six_month_expiration_when_selected()]
replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::createKey → KILLED

73

1.1
Location : listActiveKeys
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_updates_usage_for_valid_key()]
replaced return value with Collections.emptyList for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::listActiveKeys → KILLED

82

1.1
Location : lambda$revokeKey$1
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:revokeKey_throws_when_key_missing()]
replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::lambda$revokeKey$1 → KILLED

83

1.1
Location : revokeKey
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:revokeKey_marks_key_revoked()]
removed call to edu/ucsb/cs156/frontiers/entities/ApiCourseKey::setRevoked → KILLED

84

1.1
Location : revokeKey
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:revokeKey_marks_key_revoked()]
replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::revokeKey → KILLED

89

1.1
Location : authenticateFromRequestForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateFromRequestForCourse_returns_false_without_request_context()]
negated conditional → KILLED

90

1.1
Location : authenticateFromRequestForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateFromRequestForCourse_returns_false_without_request_context()]
replaced boolean return with true for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::authenticateFromRequestForCourse → KILLED

94

1.1
Location : authenticateFromRequestForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateFromRequestForCourse_returns_false_when_header_missing()]
replaced boolean return with true for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::authenticateFromRequestForCourse → KILLED

2.2
Location : authenticateFromRequestForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateFromRequestForCourse_authenticates_valid_request_header()]
replaced boolean return with false for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::authenticateFromRequestForCourse → KILLED

98

1.1
Location : authenticateRawKeyForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_returns_empty_for_blank_inputs()]
negated conditional → KILLED

2.2
Location : authenticateRawKeyForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_updates_usage_for_valid_key()]
negated conditional → KILLED

104

1.1
Location : authenticateRawKeyForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_returns_empty_for_non_matching_decodable_hash()]
negated conditional → KILLED

105

1.1
Location : authenticateRawKeyForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_updates_usage_for_valid_key()]
Replaced long addition with subtraction → KILLED

2.2
Location : authenticateRawKeyForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_updates_usage_for_valid_key()]
removed call to edu/ucsb/cs156/frontiers/entities/ApiCourseKey::setUsageCount → KILLED

106

1.1
Location : authenticateRawKeyForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_updates_usage_for_valid_key()]
removed call to edu/ucsb/cs156/frontiers/entities/ApiCourseKey::setLastUsedAt → KILLED

107

1.1
Location : authenticateRawKeyForCourse
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_updates_usage_for_valid_key()]
replaced return value with Optional.empty for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::authenticateRawKeyForCourse → KILLED

115

1.1
Location : generateRawKey
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:createKey_generates_unique_raw_key_and_salt()]
removed call to java/security/SecureRandom::nextBytes → KILLED

116

1.1
Location : generateRawKey
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:createKey_generates_expected_fields()]
replaced return value with "" for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::generateRawKey → KILLED

121

1.1
Location : randomHex
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:createKey_generates_unique_raw_key_and_salt()]
removed call to java/security/SecureRandom::nextBytes → KILLED

126

1.1
Location : randomHex
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:createKey_generates_unique_raw_key_and_salt()]
replaced return value with "" for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::randomHex → KILLED

130

1.1
Location : hashWithSalt
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_updates_usage_for_valid_key()]
replaced return value with "" for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashWithSalt → KILLED

136

1.1
Location : hashBytes
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:createKey_uses_six_month_expiration_when_selected()]
replaced return value with null for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashBytes → KILLED

146

1.1
Location : hashMatches
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_updates_usage_for_valid_key()]
replaced boolean return with false for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashMatches → KILLED

2.2
Location : hashMatches
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_returns_empty_for_non_matching_decodable_hash()]
replaced boolean return with true for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashMatches → KILLED

148

1.1
Location : hashMatches
Killed by : edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs156.frontiers.services.ApiCourseKeyServiceTests]/[method:authenticateRawKeyForCourse_returns_empty_for_invalid_key()]
replaced boolean return with true for edu/ucsb/cs156/frontiers/services/ApiCourseKeyService::hashMatches → KILLED

Active mutators

Tests examined


Report generated by PIT 1.17.0