Skip to content

Commit eff600f

Browse files
test(workspace): add unit tests for linked accounts service methods
Add 8 tests covering getLinkedAccounts and unlinkAccount: - Connected + unconnected providers returned with correct usernames - Link-only and disabled IdPs filtered from results - Alias fallback when displayName is null - Keycloak exceptions wrapped in 502 BAD_GATEWAY - Last-provider guard returns 409 CONFLICT - Non-existent alias returns 404 NOT_FOUND - Successful unlink calls removeFederatedIdentity AccountServiceTest: 14 → 22 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent abf0dcd commit eff600f

1 file changed

Lines changed: 210 additions & 0 deletions

File tree

server/application-server/src/test/java/de/tum/in/www1/hephaestus/account/AccountServiceTest.java

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,20 @@
1111
import de.tum.in.www1.hephaestus.gitprovider.user.User;
1212
import de.tum.in.www1.hephaestus.integrations.posthog.PosthogClient;
1313
import de.tum.in.www1.hephaestus.testconfig.BaseUnitTest;
14+
import jakarta.ws.rs.NotFoundException;
15+
import java.util.List;
1416
import java.util.Optional;
1517
import org.junit.jupiter.api.BeforeEach;
1618
import org.junit.jupiter.api.DisplayName;
1719
import org.junit.jupiter.api.Nested;
1820
import org.junit.jupiter.api.Test;
1921
import org.keycloak.admin.client.Keycloak;
22+
import org.keycloak.admin.client.resource.IdentityProvidersResource;
23+
import org.keycloak.admin.client.resource.RealmResource;
24+
import org.keycloak.admin.client.resource.UserResource;
25+
import org.keycloak.admin.client.resource.UsersResource;
26+
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
27+
import org.keycloak.representations.idm.IdentityProviderRepresentation;
2028
import org.mockito.ArgumentCaptor;
2129
import org.mockito.Mock;
2230

@@ -275,4 +283,206 @@ void roundTripConsistency() {
275283
assertThat(fetched.aiReviewEnabled()).isFalse();
276284
}
277285
}
286+
287+
// ── getLinkedAccounts ───────────────────────────────────────────────────
288+
289+
@Nested
290+
@DisplayName("getLinkedAccounts")
291+
class GetLinkedAccounts {
292+
293+
@Mock
294+
private RealmResource realmResource;
295+
296+
@Mock
297+
private IdentityProvidersResource identityProvidersResource;
298+
299+
@Mock
300+
private UsersResource usersResource;
301+
302+
@Mock
303+
private UserResource userResource;
304+
305+
private void setupKeycloakMocks() {
306+
when(keycloak.realm("hephaestus")).thenReturn(realmResource);
307+
when(realmResource.identityProviders()).thenReturn(identityProvidersResource);
308+
when(realmResource.users()).thenReturn(usersResource);
309+
when(usersResource.get(KEYCLOAK_USER_ID)).thenReturn(userResource);
310+
}
311+
312+
private IdentityProviderRepresentation idp(
313+
String alias,
314+
String displayName,
315+
boolean enabled,
316+
boolean linkOnly
317+
) {
318+
IdentityProviderRepresentation idp = new IdentityProviderRepresentation();
319+
idp.setAlias(alias);
320+
idp.setDisplayName(displayName);
321+
idp.setEnabled(enabled);
322+
idp.setLinkOnly(linkOnly);
323+
return idp;
324+
}
325+
326+
private FederatedIdentityRepresentation fedIdentity(String provider, String username) {
327+
FederatedIdentityRepresentation fi = new FederatedIdentityRepresentation();
328+
fi.setIdentityProvider(provider);
329+
fi.setUserName(username);
330+
return fi;
331+
}
332+
333+
@Test
334+
@DisplayName("returns connected and unconnected providers")
335+
void returnsConnectedAndUnconnectedProviders() {
336+
setupKeycloakMocks();
337+
when(identityProvidersResource.findAll()).thenReturn(
338+
List.of(idp("github", "GitHub", true, false), idp("gitlab", "GitLab", true, false))
339+
);
340+
when(userResource.getFederatedIdentity()).thenReturn(List.of(fedIdentity("github", "octocat")));
341+
342+
List<LinkedAccountDTO> result = accountService.getLinkedAccounts(KEYCLOAK_USER_ID);
343+
344+
assertThat(result).hasSize(2);
345+
LinkedAccountDTO github = result
346+
.stream()
347+
.filter(a -> a.providerAlias().equals("github"))
348+
.findFirst()
349+
.orElseThrow();
350+
assertThat(github.connected()).isTrue();
351+
assertThat(github.linkedUsername()).isEqualTo("octocat");
352+
353+
LinkedAccountDTO gitlab = result
354+
.stream()
355+
.filter(a -> a.providerAlias().equals("gitlab"))
356+
.findFirst()
357+
.orElseThrow();
358+
assertThat(gitlab.connected()).isFalse();
359+
assertThat(gitlab.linkedUsername()).isNull();
360+
}
361+
362+
@Test
363+
@DisplayName("filters out link-only and disabled providers")
364+
void filtersLinkOnlyAndDisabledProviders() {
365+
setupKeycloakMocks();
366+
when(identityProvidersResource.findAll()).thenReturn(
367+
List.of(
368+
idp("github", "GitHub", true, false),
369+
idp("saml", "SAML SSO", true, true),
370+
idp("legacy", "Legacy", false, false)
371+
)
372+
);
373+
when(userResource.getFederatedIdentity()).thenReturn(List.of());
374+
375+
List<LinkedAccountDTO> result = accountService.getLinkedAccounts(KEYCLOAK_USER_ID);
376+
377+
assertThat(result).hasSize(1);
378+
assertThat(result.get(0).providerAlias()).isEqualTo("github");
379+
}
380+
381+
@Test
382+
@DisplayName("falls back to alias when displayName is null")
383+
void fallsBackToAliasWhenDisplayNameNull() {
384+
setupKeycloakMocks();
385+
when(identityProvidersResource.findAll()).thenReturn(List.of(idp("github", null, true, false)));
386+
when(userResource.getFederatedIdentity()).thenReturn(List.of());
387+
388+
List<LinkedAccountDTO> result = accountService.getLinkedAccounts(KEYCLOAK_USER_ID);
389+
390+
assertThat(result).hasSize(1);
391+
assertThat(result.get(0).providerName()).isEqualTo("github");
392+
}
393+
394+
@Test
395+
@DisplayName("wraps Keycloak exception in BAD_GATEWAY")
396+
void wrapsKeycloakExceptionInBadGateway() {
397+
when(keycloak.realm("hephaestus")).thenThrow(new NotFoundException("realm not found"));
398+
399+
assertThatThrownBy(() -> accountService.getLinkedAccounts(KEYCLOAK_USER_ID))
400+
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class)
401+
.satisfies(ex -> {
402+
var rse = (org.springframework.web.server.ResponseStatusException) ex;
403+
assertThat(rse.getStatusCode().value()).isEqualTo(502);
404+
});
405+
}
406+
}
407+
408+
// ── unlinkAccount ───────────────────────────────────────────────────────
409+
410+
@Nested
411+
@DisplayName("unlinkAccount")
412+
class UnlinkAccount {
413+
414+
@Mock
415+
private RealmResource realmResource;
416+
417+
@Mock
418+
private UsersResource usersResource;
419+
420+
@Mock
421+
private UserResource userResource;
422+
423+
private void setupKeycloakMocks() {
424+
when(keycloak.realm("hephaestus")).thenReturn(realmResource);
425+
when(realmResource.users()).thenReturn(usersResource);
426+
when(usersResource.get(KEYCLOAK_USER_ID)).thenReturn(userResource);
427+
}
428+
429+
private FederatedIdentityRepresentation fedIdentity(String provider) {
430+
FederatedIdentityRepresentation fi = new FederatedIdentityRepresentation();
431+
fi.setIdentityProvider(provider);
432+
fi.setUserName(provider + "-user");
433+
return fi;
434+
}
435+
436+
@Test
437+
@DisplayName("successfully unlinks when multiple providers exist")
438+
void successfullyUnlinksWithMultipleProviders() {
439+
setupKeycloakMocks();
440+
when(userResource.getFederatedIdentity()).thenReturn(List.of(fedIdentity("github"), fedIdentity("gitlab")));
441+
442+
accountService.unlinkAccount(KEYCLOAK_USER_ID, "github");
443+
444+
verify(userResource).removeFederatedIdentity("github");
445+
}
446+
447+
@Test
448+
@DisplayName("throws CONFLICT when unlinking last provider")
449+
void throwsConflictWhenUnlinkingLastProvider() {
450+
setupKeycloakMocks();
451+
when(userResource.getFederatedIdentity()).thenReturn(List.of(fedIdentity("github")));
452+
453+
assertThatThrownBy(() -> accountService.unlinkAccount(KEYCLOAK_USER_ID, "github"))
454+
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class)
455+
.satisfies(ex -> {
456+
var rse = (org.springframework.web.server.ResponseStatusException) ex;
457+
assertThat(rse.getStatusCode().value()).isEqualTo(409);
458+
});
459+
}
460+
461+
@Test
462+
@DisplayName("throws NOT_FOUND when provider alias not linked")
463+
void throwsNotFoundWhenProviderNotLinked() {
464+
setupKeycloakMocks();
465+
when(userResource.getFederatedIdentity()).thenReturn(List.of(fedIdentity("github"), fedIdentity("gitlab")));
466+
467+
assertThatThrownBy(() -> accountService.unlinkAccount(KEYCLOAK_USER_ID, "nonexistent"))
468+
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class)
469+
.satisfies(ex -> {
470+
var rse = (org.springframework.web.server.ResponseStatusException) ex;
471+
assertThat(rse.getStatusCode().value()).isEqualTo(404);
472+
});
473+
}
474+
475+
@Test
476+
@DisplayName("wraps Keycloak exception in BAD_GATEWAY")
477+
void wrapsKeycloakExceptionInBadGateway() {
478+
when(keycloak.realm("hephaestus")).thenThrow(new NotFoundException("realm not found"));
479+
480+
assertThatThrownBy(() -> accountService.unlinkAccount(KEYCLOAK_USER_ID, "github"))
481+
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class)
482+
.satisfies(ex -> {
483+
var rse = (org.springframework.web.server.ResponseStatusException) ex;
484+
assertThat(rse.getStatusCode().value()).isEqualTo(502);
485+
});
486+
}
487+
}
278488
}

0 commit comments

Comments
 (0)