Skip to content

Commit 910e83b

Browse files
committed
[Modernized my test suite for AuthJwtIntegrationTest.java] to account for the significant architectural refactors that have happened since. Existing tests were mostly fine, needed to update the setup a bit, but changes here mainly involved added tests that should now be there (to reflect Spring Security chain filters, JWT auth filter, and so on).
1 parent eb85e3f commit 910e83b

1 file changed

Lines changed: 189 additions & 25 deletions

File tree

springqpro-backend/src/test/java/com/springqprobackend/springqpro/integration/AuthJwtIntegrationTest.java

Lines changed: 189 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.redis.testcontainers.RedisContainer;
44
import com.springqprobackend.springqpro.security.dto.AuthResponse;
5+
import com.springqprobackend.springqpro.testcontainers.IntegrationTestBase;
56
import io.jsonwebtoken.Jwts;
67
import io.jsonwebtoken.io.Decoders;
78
import io.jsonwebtoken.security.Keys;
@@ -23,6 +24,7 @@
2324
import org.testcontainers.containers.PostgreSQLContainer;
2425
import org.testcontainers.junit.jupiter.Container;
2526
import org.testcontainers.junit.jupiter.Testcontainers;
27+
import org.testcontainers.shaded.org.awaitility.Awaitility;
2628
import org.testcontainers.utility.DockerImageName;
2729

2830
import javax.crypto.SecretKey;
@@ -33,6 +35,24 @@
3335

3436
// THIS IS INTEGRATION TEST FOR THE JWT ASPECT [1] -- for main stuff.
3537

38+
/* 2026-01-14-NOTE:+DEBUG:
39+
Comments below are complete gibberish and I have no idea what i was going on about.
40+
These "AuthJwtIntegrationTests" are more "Security contract tests" than anything specific (name could be changed to something else
41+
like AuthJwtRedisIntegrationTests or just AuthIntegrationTests, maybe I should do that?).
42+
- Basically test security around /graphql, /auth, and /api (boundary behavior, anonymous vs authenticated).
43+
44+
New tests that were added on this day as part of modernizing this test suite to be up-to-date:
45+
- GraphQL access w/o Authorization header
46+
- REST access w/o Authorization header
47+
- Malformed JWT rejection (invalid JWT)
48+
- Missing refresh token validation
49+
- Expired JWT rejection even when it exists in Redis
50+
- Refresh token rotation under concurrent reuse (race-condition safety)
51+
- Refresh-status correctness
52+
- User/token binding validation
53+
^ these all go from ~test 6 onwards.
54+
*/
55+
3656
// 2025-11-26-NOTE: Remember, efficient setup of my Integration Tests are not high priority while I rush to project MVP completion. I can return to this later!
3757
/* 2025-11-26-NOTE(S):
3858
- @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) to load the FULL Spring context like it's a real server.
@@ -44,39 +64,16 @@ the dummy HTTP client for testing (use it to hit the HTTP endpoints, send header
4464
This basically allows you to control the order of tests which is useful when your tests build upon the same DB. (This can
4565
be a nice alternative to what I was doing prior, which was flushing the DataBase between tests; this is probably better).
4666
*/
47-
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
4867
@AutoConfigureWebTestClient
4968
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
50-
@Testcontainers
51-
@ActiveProfiles("test")
52-
public class AuthJwtIntegrationTest {
69+
public class AuthJwtIntegrationTest extends IntegrationTestBase {
5370
@Autowired
5471
private WebTestClient webTestClient;
5572
@Autowired
5673
private StringRedisTemplate redis;
5774
@Value("${jwt.secret}")
5875
private String jwtSecret; // will be used for test that forges an expired JWT.
5976

60-
/* 2025-11-26-NOTE:[BELOW] I AM RUSHING TO MVP COMPLETION, I HAVE GONE SEVERELY OVERTIME WITH THIS PROJECT AND I WANT TO
61-
WRAP IT UP AS SOON AS POSSIBLE READY FOR PRODUCTION AND DISPLAY FOR RECRUITERS AND READY FOR MY RESUME! THUS, MY
62-
INTEGRATION TESTS ARE A HOT MESS. I WILL COME BACK IN THE NEAR-FUTURE TO REFACTOR AND TIDY THEM. THE TWO CONTAINERS
63-
BELOW SHOULD 100% BE MODULARIZED SOMEWHERE -- BUT I AM ON A TIME CRUNCH AND I CAN'T BE ASKED (RIGHT NOW): */
64-
// DEBUG: BELOW! TEMPORARY - FIX PROPERLY LATER!
65-
@ServiceConnection
66-
protected static final PostgreSQLContainer<?> POSTGRES =
67-
new PostgreSQLContainer<>("postgres:18")
68-
.withDatabaseName("springqpro")
69-
.withUsername("springqpro")
70-
.withPassword("springqpro")
71-
.withReuse(true);
72-
@Container
73-
@ServiceConnection
74-
static final RedisContainer REDIS = new RedisContainer(DockerImageName.parse("redis:7.2"));
75-
// DEBUG: ABOVE! TEMPORARY - FIX PROPERLY LATER!
76-
/* 2025-11-26-NOTE: AS NOTED IN A COMMENT ABOVE THE CLASS, MY TEST STARTS THE FULL SPRING CONTEXT. BUT DON'T FORGET
77-
THAT I NEED TO SPIN UP THE REDIS AND POSTGRES CONTAINERS MYSELF!
78-
NOTE: ALSO, THE @Testcontainers annotation at the top is needed too for this stuff. */
79-
8077
// HELPER METHODS (pretty self-explanatory):
8178
// [1] - Register Attempt:
8279
private void register(String email, String password) {
@@ -269,6 +266,173 @@ void expiredJwtToken_shouldBeRejected_ForGraphQL() {
269266
.contentType(MediaType.APPLICATION_JSON)
270267
.bodyValue(Map.of("query", query))
271268
.exchange()
272-
.expectStatus().isForbidden();
269+
.expectStatus().is4xxClientError();
270+
}
271+
272+
@Test
273+
@Order(6)
274+
void graphqlAccess_shouldFail_withoutAuthorizationHeader() {
275+
String query = """
276+
query {
277+
tasks {
278+
id
279+
}
280+
}
281+
""";
282+
webTestClient.post()
283+
.uri("/graphql")
284+
.contentType(MediaType.APPLICATION_JSON)
285+
.bodyValue(Map.of("query", query))
286+
.exchange()
287+
.expectStatus().is4xxClientError();
288+
}
289+
290+
@Test
291+
@Order(7)
292+
void restAccess_shouldFail_withoutAuthorizationHeader() {
293+
webTestClient.get()
294+
.uri("/api/tasks")
295+
.exchange()
296+
.expectStatus().is4xxClientError();
297+
}
298+
299+
@Test
300+
@Order(8)
301+
void malformedJwt_shouldBeRejected() {
302+
String malformedToken = "this.aint.no.jwt.man";
303+
String query = """
304+
query {
305+
tasks {
306+
id
307+
}
308+
}
309+
""";
310+
webTestClient.post()
311+
.uri("/graphql")
312+
.header(HttpHeaders.AUTHORIZATION, "Bearer " + malformedToken)
313+
.contentType(MediaType.APPLICATION_JSON)
314+
.bodyValue(Map.of("query", query))
315+
.exchange()
316+
.expectStatus().is4xxClientError();
317+
}
318+
319+
@Test
320+
@Order(9)
321+
void refresh_shouldFail_whenRefreshTokenMissing() {
322+
webTestClient.post()
323+
.uri("/auth/refresh")
324+
.contentType(MediaType.APPLICATION_JSON)
325+
.bodyValue(Map.of()) // empty body
326+
.exchange()
327+
.expectStatus().isBadRequest()
328+
.expectBody()
329+
.jsonPath("$.error").exists();
330+
}
331+
332+
// This one's a bit more complex, but basically to show that Redis validity =/= JWT validity ("just because it's there, doesn't mean anything").
333+
@Test
334+
@Order(10)
335+
void refresh_shouldFail_whenJwtExpired_butRedisTokenExists() {
336+
String email = "expired_refresh@example.com";
337+
338+
// Build expired refresh token manually
339+
byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
340+
SecretKey key = Keys.hmacShaKeyFor(keyBytes);
341+
Instant now = Instant.now();
342+
Date issuedAt = Date.from(now.minusSeconds(7200));
343+
Date expiredAt = Date.from(now.minusSeconds(3600));
344+
345+
String expiredRefresh = Jwts.builder()
346+
.subject(email)
347+
.claim("type", "refresh")
348+
.issuedAt(issuedAt)
349+
.expiration(expiredAt)
350+
.signWith(key)
351+
.compact();
352+
353+
// Force-store expired token in Redis
354+
redis.opsForValue().set(expiredRefresh, email);
355+
356+
webTestClient.post()
357+
.uri("/auth/refresh")
358+
.contentType(MediaType.APPLICATION_JSON)
359+
.bodyValue(Map.of("refreshToken", expiredRefresh))
360+
.exchange()
361+
.expectStatus().isUnauthorized()
362+
.expectBody()
363+
.jsonPath("$.error").exists();
364+
}
365+
366+
/* This is actually complex - testing to make sure that a refresh token can only be used once, and
367+
doing so under a race condition where two threads attempt to refresh in proximity: */
368+
@Test
369+
@Order(11)
370+
void refreshToken_shouldOnlyBeUsableOnce_underConcurrency() {
371+
String email = "race@example.com";
372+
String password = "password";
373+
374+
register(email, password);
375+
AuthResponse auth = login(email, password);
376+
String refresh = auth.refreshToken();
377+
378+
Runnable refreshCall = () -> webTestClient.post()
379+
.uri("/auth/refresh")
380+
.contentType(MediaType.APPLICATION_JSON)
381+
.bodyValue(Map.of("refreshToken", refresh))
382+
.exchange();
383+
384+
Thread t1 = new Thread(refreshCall);
385+
Thread t2 = new Thread(refreshCall);
386+
387+
t1.start();
388+
t2.start();
389+
390+
Awaitility.await().untilAsserted(() ->
391+
assertThat(redis.opsForValue().get(refresh)).isNull()
392+
);
393+
}
394+
395+
// Checking to see if the refresh token is still valid for the currently authenticated user:
396+
@Test
397+
@Order(12)
398+
void refreshStatus_shouldReflectRedisState() {
399+
String email = "status@example.com";
400+
String password = "password";
401+
402+
register(email, password);
403+
AuthResponse auth = login(email, password);
404+
405+
webTestClient.get()
406+
.uri(uriBuilder -> uriBuilder
407+
.path("/auth/refresh-status")
408+
.queryParam("refreshToken", auth.refreshToken())
409+
.build())
410+
.header(HttpHeaders.AUTHORIZATION, "Bearer " + auth.accessToken())
411+
.exchange()
412+
.expectStatus().isOk()
413+
.expectBody()
414+
.jsonPath("$.active").isEqualTo(true);
415+
}
416+
417+
// Basically testing for token substitution attacks here (making sure that you can't use another user's access token w/ your refresh):
418+
@Test
419+
@Order(13)
420+
void refreshStatus_shouldFail_whenAccessTokenDoesNotMatchRefreshTokenOwner() {
421+
register("user1@test.com", "pw");
422+
register("user2@test.com", "pw");
423+
424+
AuthResponse u1 = login("user1@test.com", "pw");
425+
AuthResponse u2 = login("user2@test.com", "pw");
426+
427+
webTestClient.get()
428+
.uri(uriBuilder -> uriBuilder
429+
.path("/auth/refresh-status")
430+
.queryParam("refreshToken", u1.refreshToken())
431+
.build())
432+
.header(HttpHeaders.AUTHORIZATION, "Bearer " + u2.accessToken())
433+
.exchange()
434+
.expectStatus().isOk()
435+
.expectBody()
436+
.jsonPath("$.active").isEqualTo(false);
273437
}
274438
}

0 commit comments

Comments
 (0)