Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ TimeProvider timeProvider
) : IStudioOidcUsernameProvider
{
private const string GiteaLookupQuery = """
SELECT u.lower_name
SELECT u.name
FROM external_login_user elu
JOIN "user" u ON elu.user_id = u.id
WHERE elu.external_id = @sub
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Controllers;
using Altinn.Studio.Designer.Services.Implementation;
using Altinn.Studio.Designer.Services.Interfaces;
using Altinn.Studio.Designer.Services.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;

namespace Designer.Tests.Services;

public class StudioctlAuthServiceTests
{
[Fact]
public async Task Authorize_WithCanonicalAuthenticatedUsername_RedirectsToCanonicalSettingsOwner()
{
StudioctlAuthService service = CreateService();
var controller = new StudioctlAuthController(service)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "Jondyr")], "test")),
},
},
};

IActionResult result = await controller.Authorize(
"http://127.0.0.1:12345/callback",
"state",
"code-challenge",
"studioctl prod",
CancellationToken.None
);

var redirect = Assert.IsType<RedirectResult>(result);
Assert.StartsWith("/settings/Jondyr/studioctl-auth?requestId=", redirect.Url);
}

[Fact]
public async Task CreateAuthorizationRequestAsync_WithCanonicalUsername_PreservesCasingInConfirmationUrl()
{
StudioctlAuthService service = CreateService();
var request = new StudioctlAuthorizeRequest(
"http://127.0.0.1:12345/callback",
"state",
"code-challenge",
"studioctl prod"
);

StudioctlAuthResult<string> result = await service.CreateAuthorizationRequestAsync(
"Jondyr",
request,
CancellationToken.None
);

Assert.Equal(StudioctlAuthStatus.Success, result.Status);
Assert.StartsWith("/settings/Jondyr/studioctl-auth?requestId=", result.Value);
}

[Fact]
public async Task CreateAuthorizationRequestAsync_WithLowercaseExistingUsername_PreservesCasingInConfirmationUrl()
{
StudioctlAuthService service = CreateService();
var request = new StudioctlAuthorizeRequest(
"http://127.0.0.1:12345/callback",
"state",
"code-challenge",
"studioctl prod"
);

StudioctlAuthResult<string> result = await service.CreateAuthorizationRequestAsync(
"jondyr",
request,
CancellationToken.None
);

Assert.Equal(StudioctlAuthStatus.Success, result.Status);
Assert.StartsWith("/settings/jondyr/studioctl-auth?requestId=", result.Value);
}

private static StudioctlAuthService CreateService()
{
var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
var apiKeyService = new Mock<IApiKeyService>();
return new StudioctlAuthService(apiKeyService.Object, cache, TimeProvider.System);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ describe('OwnerIndexRedirect', () => {
expect(screen.getByText('User page')).toBeInTheDocument();
});

it('redirects to the api-keys page when owner matches the logged-in user with different casing', () => {
renderOwnerIndexRedirect('/TestUser');
expect(screen.getByText('User page')).toBeInTheDocument();
});

it('renders the no-org-selected message when owner matches the logged-in user and studioOidc is disabled', () => {
mockEnvironment.environment = { featureFlags: { studioOidc: false } };
mockUseFeatureFlag.mockReturnValue(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FeatureFlag, useFeatureFlag } from '@studio/feature-flags';
import { StudioCenter, StudioPageSpinner } from '@studio/components';
import { StudioPageError } from 'app-shared/components';
import { useTranslation } from 'react-i18next';
import { StringUtils } from '@studio/pure-functions';

export const OwnerIndexRedirect = () => {
const { t } = useTranslation();
Expand All @@ -34,7 +35,7 @@ export const OwnerIndexRedirect = () => {
if (!studioOidc && !isAdminEnabled) {
return <NotFound />;
}
if (owner === user.login) {
if (StringUtils.areCaseInsensitiveEqual(owner, user.login)) {
return studioOidc ? <Navigate to={UserRoutePaths.ApiKeys} replace /> : <NoOrgSelected />;
}
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ describe('StudioctlAuth', () => {
).toBeInTheDocument();
});

it('renders the authorization details when the route owner matches the logged-in user with different casing', async () => {
renderStudioctlAuth({
initialEntries: [`/Test-User/studioctl-auth?requestId=${requestId}`],
});

expect(
await screen.findByRole('heading', { name: textMock('settings.studioctl_auth.heading') }),
).toBeInTheDocument();
expect(screen.getByText(authRequest.clientName)).toBeInTheDocument();
});

it('confirms and redirects to the callback URL', async () => {
const user = userEvent.setup();
const confirmStudioctlAuthRequest = jest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { StudioPageError } from 'app-shared/components';
import { useUserQuery } from 'app-shared/hooks/queries';
import { useEnvironmentConfig } from 'app-shared/contexts/EnvironmentConfigContext';
import { StringUtils } from '@studio/pure-functions';
import { NotFound } from '../../../../components/NotFound/NotFound';
import { useRequiredRoutePathsParams } from '../../../../hooks/useRequiredRoutePathsParams';
import { useStudioctlAuthRequestQuery } from '../../hooks/queries/useStudioctlAuthRequestQuery';
Expand All @@ -26,8 +27,9 @@ export const StudioctlAuth = (): React.ReactElement => {
const requestId = searchParams.get('requestId');
const { environment, isPending: isEnvironmentPending } = useEnvironmentConfig();
const { data: user, isPending: isUserPending, isError: isUserError } = useUserQuery();
const ownerMatchesUser = StringUtils.areCaseInsensitiveEqual(owner, user?.login ?? '');
const canLoadRequest =
Boolean(environment?.featureFlags?.studioOidc) && owner === user?.login && Boolean(requestId);
Boolean(environment?.featureFlags?.studioOidc) && ownerMatchesUser && Boolean(requestId);
const {
data: request,
isPending: isRequestPending,
Expand All @@ -50,7 +52,7 @@ export const StudioctlAuth = (): React.ReactElement => {
return <StudioPageError />;
}

if (!environment?.featureFlags?.studioOidc || owner !== user?.login || !requestId) {
if (!environment?.featureFlags?.studioOidc || !ownerMatchesUser || !requestId) {
return <NotFound />;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { textMock } from '@studio/testing/mocks/i18nMock';
import { user as userMock } from 'app-shared/mocks/mocks';
import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
import { QueryKey } from 'app-shared/types/QueryKey';
import type { User } from 'app-shared/types/Repository';

jest.mock('../../features/orgs/layout/PageLayout', () => ({
PageLayout: () => <div>PageLayout</div>,
Expand Down Expand Up @@ -36,16 +37,18 @@ type RenderOptions = {
initialEntries?: string[];
queries?: Record<string, unknown>;
seedCurrentUser?: boolean;
currentUser?: User | null;
};

const renderOrgPageLayout = ({
initialEntries = ['/ttd/bot-accounts'],
queries = {},
seedCurrentUser = true,
currentUser = orgUser,
}: RenderOptions = {}) => {
const queryClient = createQueryClientMock();
if (seedCurrentUser) {
queryClient.setQueryData([QueryKey.CurrentUser], orgUser);
queryClient.setQueryData([QueryKey.CurrentUser], currentUser);
}
return renderWithProviders(<RoutedOrgPageLayout />, { initialEntries, queryClient, queries });
};
Expand All @@ -70,6 +73,18 @@ describe('OrgPageLayout', () => {
).toBeInTheDocument();
});

it('renders the not-found page when owner matches the logged-in user login with different casing', () => {
renderOrgPageLayout({ initialEntries: ['/TestUser/bot-accounts'] });
expect(
screen.getByRole('heading', { name: textMock('not_found_page.heading') }),
).toBeInTheDocument();
});

it('renders PageLayout when user data resolves without a user', () => {
renderOrgPageLayout({ currentUser: null });
expect(screen.getByText('PageLayout')).toBeInTheDocument();
});

it('renders the loading spinner while user data is still loading', () => {
renderOrgPageLayout({
initialEntries: ['/ttd/bot-accounts'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useRequiredRoutePathsParams } from 'settings/hooks/useRequiredRoutePathsParams';
import { useEnvironmentConfig } from 'app-shared/contexts/EnvironmentConfigContext';
import { FeatureFlag, useFeatureFlag } from '@studio/feature-flags';
import { StringUtils } from '@studio/pure-functions';

export const OrgPageLayout = () => {
const { t } = useTranslation();
Expand All @@ -28,7 +29,7 @@ export const OrgPageLayout = () => {
return <StudioPageError />;
}

if (org === user?.login) {
if (StringUtils.areCaseInsensitiveEqual(org, user?.login ?? '')) {
return <NotFound />;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { textMock } from '@studio/testing/mocks/i18nMock';
import { user as userMock } from 'app-shared/mocks/mocks';
import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
import { QueryKey } from 'app-shared/types/QueryKey';
import type { User } from 'app-shared/types/Repository';

jest.mock('../../features/user/layout/PageLayout', () => ({
PageLayout: () => <div>PageLayout</div>,
Expand Down Expand Up @@ -36,16 +37,18 @@ type RenderOptions = {
initialEntries?: string[];
queries?: Record<string, unknown>;
seedCurrentUser?: boolean;
currentUser?: User | null;
};

const renderUserPageLayout = ({
initialEntries = ['/testuser/profile'],
queries = {},
seedCurrentUser = true,
currentUser = loggedInUser,
}: RenderOptions = {}) => {
const queryClient = createQueryClientMock();
if (seedCurrentUser) {
queryClient.setQueryData([QueryKey.CurrentUser], loggedInUser);
queryClient.setQueryData([QueryKey.CurrentUser], currentUser);
}
return renderWithProviders(<RoutedUserPageLayout />, { initialEntries, queryClient, queries });
};
Expand All @@ -63,13 +66,25 @@ describe('UserPageLayout', () => {
expect(screen.getByText('PageLayout')).toBeInTheDocument();
});

it('renders PageLayout when owner matches the logged-in user with different casing', () => {
renderUserPageLayout({ initialEntries: ['/TestUser/profile'] });
expect(screen.getByText('PageLayout')).toBeInTheDocument();
});

it('renders the not-found page when owner does not match the logged-in user', () => {
renderUserPageLayout({ initialEntries: ['/ttd/profile'] });
expect(
screen.getByRole('heading', { name: textMock('not_found_page.heading') }),
).toBeInTheDocument();
});

it('renders the not-found page when user data resolves without a user', () => {
renderUserPageLayout({ currentUser: null });
expect(
screen.getByRole('heading', { name: textMock('not_found_page.heading') }),
).toBeInTheDocument();
});

it('renders the loading spinner while user data is still loading', () => {
renderUserPageLayout({
initialEntries: ['/testuser/profile'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useRequiredRoutePathsParams } from 'settings/hooks/useRequiredRoutePathsParams';
import { useEnvironmentConfig } from 'app-shared/contexts/EnvironmentConfigContext';
import { FeatureFlag, useFeatureFlag } from '@studio/feature-flags';
import { StringUtils } from '@studio/pure-functions';

export const UserPageLayout = () => {
const { t } = useTranslation();
Expand All @@ -28,7 +29,7 @@ export const UserPageLayout = () => {
return <StudioPageError />;
}

if (owner !== user?.login) {
if (!StringUtils.areCaseInsensitiveEqual(owner, user?.login ?? '')) {
return <NotFound />;
}

Expand Down
Loading