diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index bfa1a670..550c2e26 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -136,6 +137,30 @@ class NamespaceInvitationServiceTest { .isInstanceOf(DomainNotFoundException.class); } + @Test + void nonActiveUserCannotPreviewOrAcceptAndDoesNotConsumeInvitation() { + NamespaceInvitationService.Created created = service.create("team", "owner"); + ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); + verify(invitations).save(saved.capture()); + NamespaceInvitation invitation = saved.getValue(); + when(invitations.findByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(invitation)); + when(invitations.findNamespaceIdByTokenHash(invitation.getTokenHash())).thenReturn(Optional.of(7L)); + when(invitations.findByNamespaceId(7L)).thenReturn(Optional.of(invitation)); + UserAccount pending = mock(UserAccount.class); + when(pending.getStatus()).thenReturn(UserStatus.PENDING); + when(users.findById("guest")).thenReturn(Optional.of(pending)); + when(users.findLockedById("guest")).thenReturn(Optional.of(pending)); + clearInvocations(invitations, members, audit); + + assertThatThrownBy(() -> service.preview(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.accept(created.token(), "guest")) + .isInstanceOf(DomainNotFoundException.class); + verify(members, never()).save(any()); + verify(invitations, never()).save(any()); + assertThat(invitation.getConsumedAt()).isNull(); + } + @Test void existingMemberCanOpenCurrentConsumedInvitationWithoutReconsuming() { NamespaceInvitationService.Created created = service.create("team", "owner"); diff --git a/web/src/app/router.test.ts b/web/src/app/router.test.ts index f8a95847..50c879cb 100644 --- a/web/src/app/router.test.ts +++ b/web/src/app/router.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { isRedirect } from '@tanstack/react-router' // The router module captures window.location.search at module load time. // We test the exported ORIGINAL_URL_SEARCH constant and the buildReturnTo @@ -51,4 +52,23 @@ describe('router', () => { const childPaths = children.map((route) => route.fullPath ?? route.path) expect(childPaths).toContain('/device') }) + + it('returns unauthenticated invitation visitors to /join even with a query and deployment base path', async () => { + const children = (router.routeTree.children ?? []) as Array<{ + fullPath?: string + options?: { beforeLoad?: (context: { location: { pathname: string; searchStr: string; hash: string } }) => Promise } + }> + const join = children.find((route) => route.fullPath === '/join') + expect(join?.options?.beforeLoad).toBeDefined() + + await expect(join!.options!.beforeLoad!({ + location: { pathname: '/skillhub/join', searchStr: '?x=1', hash: '#invitation-token' }, + })).rejects.toSatisfy((error: unknown) => { + expect(isRedirect(error)).toBe(true) + if (!isRedirect(error)) return false + expect(error.options.to).toBe('/login') + expect(error.options.search).toEqual({ returnTo: '/join' }) + return true + }) + }) }) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 6a706d03..55e332ea 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -236,6 +236,7 @@ const rootRoute = createRootRoute({ }) const requireAuth = createRequireAuth(getCurrentUser) +const requireJoinAuth = createRequireAuth(getCurrentUser, '/join') const redirectAuthenticated = createRedirectAuthenticated(getCurrentUser) const landingRoute = createRoute({ @@ -329,7 +330,7 @@ const namespaceRoute = createRoute({ const joinRoute = createRoute({ getParentRoute: () => rootRoute, path: 'join', - beforeLoad: requireAuth, + beforeLoad: requireJoinAuth, component: JoinPage, }) diff --git a/web/src/shared/lib/auth-route.ts b/web/src/shared/lib/auth-route.ts index ada9d746..6988445f 100644 --- a/web/src/shared/lib/auth-route.ts +++ b/web/src/shared/lib/auth-route.ts @@ -53,13 +53,13 @@ export function createRedirectAuthenticated(getCurrentUser: () => Promise Promise) { +export function createRequireAuth(getCurrentUser: () => Promise, returnTo?: string) { return async function requireAuth({ location }: { location: RouteLocationLike }) { const user = await getCurrentUser() if (!user) { throw redirect({ to: '/login', - search: { returnTo: buildReturnTo(location) }, + search: { returnTo: returnTo ?? buildReturnTo(location) }, }) } return { user } diff --git a/web/src/shared/lib/namespace-invitation.test.ts b/web/src/shared/lib/namespace-invitation.test.ts index 0b389281..09aa5b05 100644 --- a/web/src/shared/lib/namespace-invitation.test.ts +++ b/web/src/shared/lib/namespace-invitation.test.ts @@ -14,7 +14,8 @@ describe('namespace invitation URL handoff', () => { window.history.replaceState(null, '', `/join?from=mail#${token}`) captureInvitationFragment() expect(storedInvitationToken()).toBe(token) - expect(window.location.href).toContain('/join?from=mail') + expect(window.location.pathname).toBe('/join') + expect(window.location.search).toBe('?from=mail') expect(window.location.hash).toBe('') })