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
16 changes: 16 additions & 0 deletions src/components/message/ErrorReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { FullWidthWrapper } from 'src/app-components/FullWidthWrapper/FullWidthW
import classes from 'src/components/message/ErrorReport.module.css';
import { useAllAttachments } from 'src/features/attachments/hooks';
import { FileScanResults } from 'src/features/attachments/types';
import { InstantiationClosedText } from 'src/features/instantiate/containers/InstantiationClosedError';
import { isInstantiationClosedError, useInstantiationClosedReason } from 'src/features/instantiate/instantiationClosed';
import {
InstantiationValidation,
isInstantiationValidationResult,
Expand All @@ -22,6 +24,7 @@ import { HttpStatusCodes } from 'src/utils/network/networking';
import { splitDashedKey } from 'src/utils/splitDashedKey';
import { useGetUniqueKeyFromObject } from 'src/utils/useGetKeyFromObject';
import type { UploadedAttachment } from 'src/features/attachments';
import type { InstantiationClosedReason } from 'src/features/instantiate/instantiationClosed';
import type { AnyValidation, BaseValidation, NodeRefValidation } from 'src/features/validation';

export interface IErrorReportProps extends PropsWithChildren {
Expand Down Expand Up @@ -142,11 +145,24 @@ export function ErrorReportList({ formErrors, taskErrors }: ErrorReportListProps
);
}

export function ErrorListForClosedInstantiation({ reason }: { reason: InstantiationClosedReason }) {
return (
<ErrorReportListItem>
<InstantiationClosedText reason={reason} />
</ErrorReportListItem>
);
}

/**
* @see InstantiateContainer Contains somewhat similar logic, but for a full-screen error page.
*/
export function ErrorListFromInstantiation({ error }: { error: unknown }) {
const selectedParty = useSelectedParty();
const closedReason = useInstantiationClosedReason();

if (isInstantiationClosedError(error)) {
return <ErrorListForClosedInstantiation reason={closedReason ?? { reason: 'closed' }} />;
}

if (isAxiosError(error) && error.response?.status === HttpStatusCodes.Forbidden) {
if (isInstantiationValidationResult(error.response.data)) {
Expand Down
3 changes: 3 additions & 0 deletions src/features/applicationMetadata/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export interface IncomingApplicationMetadata {
altinnNugetVersion?: string;
logo?: ILogoOptions;
messageBoxConfig?: MessageBoxConfig;
validFrom?: string | null;
validTo?: string | null;
disallowUserInstantiation?: boolean;
}

type HideAlwaysSettings = {
Expand Down
53 changes: 53 additions & 0 deletions src/features/instantiate/containers/InstantiateContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';

import { jest } from '@jest/globals';
import { screen, waitFor } from '@testing-library/react';

import { getIncomingApplicationMetadataMock } from 'src/__mocks__/getApplicationMetadataMock';
import { InstantiateContainer } from 'src/features/instantiate/containers/InstantiateContainer';
import { fetchApplicationMetadata } from 'src/queries/queries';
import { renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders';

describe('InstantiateContainer', () => {
it('should show that the service cannot be started when instantiation responds with 410', async () => {
jest.spyOn(window, 'logError').mockImplementation(() => {});
const { mutations } = await renderWithoutInstanceAndLayout({
renderer: () => <InstantiateContainer />,
waitUntilLoaded: false,
});

await waitFor(() => expect(mutations.doInstantiate.mock).toHaveBeenCalledTimes(1));
mutations.doInstantiate.reject(Object.assign(new Error('Gone'), { isAxiosError: true, response: { status: 410 } }));

expect(await screen.findByText('Du kan ikke starte denne tjenesten')).toBeInTheDocument();
expect(screen.getByTestId('StatusCode')).toHaveTextContent('Feil 410');
expect(screen.queryByText(/du mangler rettigheter/i)).not.toBeInTheDocument();
});

it('should not try to instantiate when validTo has passed', async () => {
jest
.mocked(fetchApplicationMetadata)
.mockImplementationOnce(async () => getIncomingApplicationMetadataMock({ validTo: '2020-01-01T00:00:00Z' }));
const { mutations } = await renderWithoutInstanceAndLayout({
renderer: () => <InstantiateContainer />,
waitUntilLoaded: false,
});

expect(await screen.findByText('Du kan ikke starte denne tjenesten')).toBeInTheDocument();
expect(mutations.doInstantiate.mock).not.toHaveBeenCalled();
});

it('should not try to instantiate before validFrom, and say when the service opens', async () => {
jest
.mocked(fetchApplicationMetadata)
.mockImplementationOnce(async () => getIncomingApplicationMetadataMock({ validFrom: '2099-01-01T10:00:00Z' }));
const { mutations } = await renderWithoutInstanceAndLayout({
renderer: () => <InstantiateContainer />,
waitUntilLoaded: false,
});

expect(await screen.findByText('Denne tjenesten har ikke åpnet ennå')).toBeInTheDocument();
expect(screen.getByText(/Tjenesten åpner 01\.01\.2099/)).toBeInTheDocument();
expect(mutations.doInstantiate.mock).not.toHaveBeenCalled();
});
});
15 changes: 13 additions & 2 deletions src/features/instantiate/containers/InstantiateContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import React, { useEffect } from 'react';

import { Loader } from 'src/core/loading/Loader';
import { InstantiateValidationError } from 'src/features/instantiate/containers/InstantiateValidationError';
import { InstantiationClosedError } from 'src/features/instantiate/containers/InstantiationClosedError';
import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError';
import { UnknownError } from 'src/features/instantiate/containers/UnknownError';
import { isInstantiationClosedError, useInstantiationClosedReason } from 'src/features/instantiate/instantiationClosed';
import { isInstantiationValidationResult } from 'src/features/instantiate/InstantiationValidation';
import { useInstantiation } from 'src/features/instantiate/useInstantiation';
import { useSelectedParty } from 'src/features/party/PartiesProvider';
Expand All @@ -17,13 +19,18 @@ export const InstantiateContainer = () => {
changeBodyBackground(AltinnPalette.greyLight);
const party = useSelectedParty();
const instantiation = useInstantiation();
const closedReason = useInstantiationClosedReason();

useEffect(() => {
const shouldCreateInstance = !!party;
const shouldCreateInstance = !!party && !closedReason;
if (shouldCreateInstance) {
instantiation.instantiate(party.partyId);
}
}, [instantiation, party]);
}, [instantiation, party, closedReason]);

if (closedReason) {
return <InstantiationClosedError reason={closedReason} />;
}

// A 403 with RequiredAuthenticationLevel triggers a step-up redirect (see useInstantiation.onError). Block further
// rendering with a loader until the browser navigates away, so we never flash the "missing roles" error page. After a
Expand All @@ -33,6 +40,10 @@ export const InstantiateContainer = () => {
return <Loader reason='authentication-redirect' />;
}

if (isInstantiationClosedError(instantiation.error)) {
return <InstantiationClosedError reason={{ reason: 'closed' }} />;
}

if (isAxiosError(instantiation.error) && instantiation.error.response?.status === HttpStatusCodes.Forbidden) {
if (isInstantiationValidationResult(instantiation.error.response?.data)) {
return <InstantiateValidationError validationResult={instantiation.error.response.data} />;
Expand Down
46 changes: 46 additions & 0 deletions src/features/instantiate/containers/InstantiationClosedError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';

import { InstantiationErrorPage } from 'src/features/instantiate/containers/InstantiationErrorPage';
import { Lang } from 'src/features/language/Lang';
import { useCurrentLanguage } from 'src/features/language/LanguageProvider';
import { useLanguage } from 'src/features/language/useLanguage';
import { formatDateLocale } from 'src/utils/dateUtils';
import { HttpStatusCodes } from 'src/utils/network/networking';
import type { InstantiationClosedReason } from 'src/features/instantiate/instantiationClosed';

/**
* Explains why the service cannot be started, as a text fragment for use in error reports and on the error page.
*/
export function InstantiationClosedText({ reason }: { reason: InstantiationClosedReason }) {
const language = useCurrentLanguage();

if (reason.reason === 'notYetOpen') {
return (
<Lang
id='instantiate.not_yet_open_error_text'
params={[formatDateLocale(language, reason.opensAt, 'dd.MM.yyyy HH:mm')]}
/>
);
}

return <Lang id='instantiate.closed_error_text' />;
}

export function InstantiationClosedError({ reason }: { reason: InstantiationClosedReason }) {
const { langAsString } = useLanguage();

return (
<InstantiationErrorPage
title={
<Lang
id={
reason.reason === 'notYetOpen' ? 'instantiate.not_yet_open_error_title' : 'instantiate.closed_error_title'
}
/>
}
content={<InstantiationClosedText reason={reason} />}
showContactInfo={true}
statusCode={`${langAsString('party_selection.error_caption_prefix')} ${HttpStatusCodes.Gone}`}
/>
);
}
52 changes: 52 additions & 0 deletions src/features/instantiate/instantiationClosed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getInstantiationClosedReason } from 'src/features/instantiate/instantiationClosed';

describe('getInstantiationClosedReason', () => {
const now = new Date('2026-10-21T12:00:00Z');
const tt02Host = 'ttd.apps.tt02.altinn.no';
const prodHost = 'ttd.apps.altinn.no';

it.each([
[undefined, undefined],
[null, undefined],
['2026-10-21T11:59:59Z', 'closed'],
['2026-10-21T12:00:01Z', undefined],
['2026-10-21T11:59:59.1504337Z', 'closed'],
['2026-10-21T13:59:59+02:00', 'closed'],
['2026-10-21T14:00:01+02:00', undefined],
['2026-10-21T11:59:59', 'closed'],
['2026-10-21T12:00:01', undefined],
['2026-10-21', 'closed'],
['2026-10-22', undefined],
['not a date', undefined],
])('validTo %s gives %s', (validTo, expected) => {
expect(getInstantiationClosedReason({ validTo }, now, tt02Host)?.reason).toBe(expected);
});

it.each([
[undefined, undefined],
[null, undefined],
['2026-10-21T12:00:01Z', 'notYetOpen'],
['2026-10-21T11:59:59Z', undefined],
['2026-10-21T14:00:01+02:00', 'notYetOpen'],
['2026-10-21T12:00:01', 'notYetOpen'],
['2026-10-22', 'notYetOpen'],
['2026-10-21', undefined],
['not a date', undefined],
])('validFrom %s gives %s', (validFrom, expected) => {
expect(getInstantiationClosedReason({ validFrom }, now, tt02Host)?.reason).toBe(expected);
});

it('reports when a not yet open service opens', () => {
const result = getInstantiationClosedReason({ validFrom: '2026-10-22T08:00:00Z' }, now, tt02Host);
expect(result).toEqual({ reason: 'notYetOpen', opensAt: new Date('2026-10-22T08:00:00Z') });
});

it.each([
[prodHost, true, 'closed'],
[prodHost, false, undefined],
[tt02Host, true, undefined],
['local.altinn.cloud:8000', true, undefined],
])('on %s with disallowUserInstantiation %s gives %s', (host, disallowUserInstantiation, expected) => {
expect(getInstantiationClosedReason({ disallowUserInstantiation }, now, host)?.reason).toBe(expected);
});
});
65 changes: 65 additions & 0 deletions src/features/instantiate/instantiationClosed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider';
import { isAxiosError } from 'src/utils/isAxiosError';
import { HttpStatusCodes } from 'src/utils/network/networking';
import { isProductionHost } from 'src/utils/urls/urlHelper';
import type { IncomingApplicationMetadata } from 'src/features/applicationMetadata/types';

type InstantiationPreconditions = Pick<
IncomingApplicationMetadata,
'validFrom' | 'validTo' | 'disallowUserInstantiation'
>;

export type InstantiationClosedReason = { reason: 'notYetOpen'; opensAt: Date } | { reason: 'closed' };

// The app backend reads a validity date without an offset as UTC, while JavaScript reads a date-time without an
// offset as local time.
const hasTimeWithoutOffset = /T[^Z+-]*$/i;

function parseValidityDate(value: string): Date {
return new Date(hasTimeWithoutOffset.test(value) ? `${value}Z` : value);
}

/**
* Mirrors the checks the app backend does before creating an instance, so that we can tell the user why the service
* cannot be started instead of sending a request we know will be rejected. The backend responds with 410 Gone in
* every case. Service owners are exempt from disallowUserInstantiation, but they do not use the app frontend.
*
* An unparsable validity date is left for the backend to judge.
*/
export function getInstantiationClosedReason(
{ validFrom, validTo, disallowUserInstantiation }: InstantiationPreconditions,
now: Date,
host: string,
): InstantiationClosedReason | undefined {
if (validFrom) {
const opensAt = parseValidityDate(validFrom);
if (opensAt.getTime() > now.getTime()) {
return { reason: 'notYetOpen', opensAt };
}
}

if (validTo && parseValidityDate(validTo).getTime() < now.getTime()) {
return { reason: 'closed' };
}

// The app backend only enforces disallowUserInstantiation in production, so that apps can still be tried out in
// test environments. Mirror that here, or the frontend would block instantiation where the backend allows it.
if (disallowUserInstantiation && isProductionHost(host)) {
return { reason: 'closed' };
}

return undefined;
}

/**
* The app backend responds with 410 Gone when it refuses to create an instance because the service is closed, see
* getInstantiationClosedReason for the conditions.
*/
export function isInstantiationClosedError(error: unknown): boolean {
return isAxiosError(error) && error.response?.status === HttpStatusCodes.Gone;
}

export function useInstantiationClosedReason(): InstantiationClosedReason | undefined {
const applicationMetadata = useApplicationMetadata();
return getInstantiationClosedReason(applicationMetadata, new Date(), window.location.host);
}
45 changes: 43 additions & 2 deletions src/features/instantiate/selection/InstanceSelection.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React from 'react';

import { screen, within } from '@testing-library/react';
import { screen, waitFor, within } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';

import { getIncomingApplicationMetadataMock } from 'src/__mocks__/getApplicationMetadataMock';
import { InstanceSelectionWrapper } from 'src/features/instantiate/selection/InstanceSelection';
import { fetchApplicationMetadata } from 'src/queries/queries';
import { mockMediaQuery } from 'src/test/mockMediaQuery';
import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders';
import { renderWithInstanceAndLayout, renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders';
import type { ISimpleInstance } from 'src/types';

const mockActiveInstances: ISimpleInstance[] = [
Expand Down Expand Up @@ -75,6 +77,45 @@ describe('InstanceSelection', () => {
expect(mutations.doInstantiate.mock).toHaveBeenCalledTimes(1);
});

it('should explain that new instances cannot be started when instantiation responds with 410', async () => {
jest.spyOn(window, 'logError').mockImplementation(() => {});
const { mutations } = await renderWithoutInstanceAndLayout({
renderer: () => <InstanceSelectionWrapper />,
queries: { fetchActiveInstances: () => Promise.resolve(mockActiveInstances) },
});
await userEvent.click(screen.getByText(/start på nytt/i));
mutations.doInstantiate.reject(Object.assign(new Error('Gone'), { isAxiosError: true, response: { status: 410 } }));

await waitFor(() =>
expect(screen.getByText(/det er ikke mulig å starte nye skjema i denne tjenesten/i)).toBeInTheDocument(),
);
expect(screen.queryByText(/ikke har rettigheter til å starte denne tjenesten/i)).not.toBeInTheDocument();
});

it('should list active instances but not offer to start a new one when validTo has passed', async () => {
jest
.mocked(fetchApplicationMetadata)
.mockImplementationOnce(async () => getIncomingApplicationMetadataMock({ validTo: '2020-01-01T00:00:00Z' }));
const { mutations } = await render();

expect(await screen.findByText(mockActiveInstances[0].lastChangedBy)).toBeInTheDocument();
expect(screen.getByText(/det er ikke mulig å starte nye skjema i denne tjenesten/i)).toBeInTheDocument();
expect(screen.queryByText(/start på nytt/i)).not.toBeInTheDocument();
expect(mutations.doInstantiate.mock).not.toHaveBeenCalled();
});

it('should list active instances but not offer to start a new one before validFrom', async () => {
jest
.mocked(fetchApplicationMetadata)
.mockImplementationOnce(async () => getIncomingApplicationMetadataMock({ validFrom: '2099-01-01T10:00:00Z' }));
const { mutations } = await render();

expect(await screen.findByText(mockActiveInstances[0].lastChangedBy)).toBeInTheDocument();
expect(screen.getByText(/Tjenesten åpner 01\.01\.2099/)).toBeInTheDocument();
expect(screen.queryByText(/start på nytt/i)).not.toBeInTheDocument();
expect(mutations.doInstantiate.mock).not.toHaveBeenCalled();
});

it('should trigger openInstance on editButton click', async () => {
const { mutations, routerRef } = await render();
const row = screen.getByRole('row', {
Expand Down
Loading
Loading