diff --git a/server/index.js b/server/index.js index bcca96d..fc926cc 100644 --- a/server/index.js +++ b/server/index.js @@ -184,6 +184,7 @@ app.all('/api/*', API.checkAuth); // Protected routes below app.get('/api/domains', API.getDomains); app.get('/api/domains/:uuid', API.getDomains); +app.get('/api/domains/:uuid/access_events', API.getDomainAccessEvents); app.post('/api/domains/:uuid/registry_lock', API.setDomainRegistryLock); app.delete('/api/domains/:uuid/registry_lock', API.deleteDomainRegistryLock); app.get('/api/contacts/:uuid/do_need_update_contacts', API.doNeedUpdateContacts); diff --git a/server/index.test.js b/server/index.test.js index 793d526..f52d771 100644 --- a/server/index.test.js +++ b/server/index.test.js @@ -79,4 +79,23 @@ describe('server/index smoke', () => { it('exports server instance', () => { expect(serverModule.default).toBeDefined(); }); + + it('registers the access_events route below the checkAuth session gate', async () => { + // `fs` is mocked in this file; read the real source via the un-mocked module. + const fs = await vi.importActual('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const dir = path.dirname(fileURLToPath(import.meta.url)); + const source = fs.readFileSync(path.join(dir, 'index.js'), 'utf8'); + + const gateIndex = source.indexOf("app.all('/api/*', API.checkAuth)"); + const routeIndex = source.indexOf( + "app.get('/api/domains/:uuid/access_events', API.getDomainAccessEvents)" + ); + + expect(gateIndex).toBeGreaterThan(-1); + expect(routeIndex).toBeGreaterThan(-1); + // The route must be declared AFTER the gate so it inherits the session-auth check. + expect(routeIndex).toBeGreaterThan(gateIndex); + }); }); diff --git a/server/routes/apiRoute.js b/server/routes/apiRoute.js index e2aae8a..e566a95 100644 --- a/server/routes/apiRoute.js +++ b/server/routes/apiRoute.js @@ -159,6 +159,18 @@ export default { ); }, + getDomainAccessEvents: async ({ params, session }, res) => { + // Forwards the registrant's existing SESSION Bearer (via the API(session) factory) to the + // registry's per-domain access-events endpoint. Attaches NO client-supplied registrant id — + // ownership is re-derived independently by the registry (defence in depth). handleResponse is + // reused unchanged; it does NOT log the success body, and no token is logged here (N2). + const { uuid } = params; + return handleResponse( + () => API(session).get(`/api/v1/registrant/domains/${uuid}/access_events`), + res + ); + }, + getMenu: async (req, res) => { const { type } = req.params; try { diff --git a/server/routes/apiRoute.test.js b/server/routes/apiRoute.test.js index 1aa06a4..539ef34 100644 --- a/server/routes/apiRoute.test.js +++ b/server/routes/apiRoute.test.js @@ -298,6 +298,78 @@ describe('server/routes/apiRoute', () => { expect(r.__ctx.statusCode).toBe(401); }); + it('getDomainAccessEvents forwards the session Bearer to the per-domain registry endpoint', async () => { + const req = { + ...createSession(), + params: { uuid: 'test-uuid' }, + }; + const r = createRes(); + mockAxiosInstance.get.mockResolvedValue({ status: 200, data: [] }); + + await API.getDomainAccessEvents(req, r); + + // Calls exactly the per-domain registry endpoint, no query string / client identity appended. + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + '/api/v1/registrant/domains/test-uuid/access_events' + ); + + // The API(session) axios factory was created with the session Bearer in its Authorization header. + const createArgs = mockAxiosCreate.mock.calls.map((call) => call[0]); + const withBearer = createArgs.find( + (cfg) => cfg?.headers?.Authorization === 'Bearer test-token' + ); + expect(withBearer).toBeTruthy(); + + // No client-supplied registrant/contact identity is attached to the request. + const getArg = mockAxiosInstance.get.mock.calls[0][1]; + expect(getArg).toBeUndefined(); + }); + + it('getDomainAccessEvents returns the registry body verbatim (three-field events)', async () => { + const events = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'law_enforcement', + }, + ]; + const req = { + ...createSession(), + params: { uuid: 'test-uuid' }, + }; + const r = createRes(); + mockAxiosInstance.get.mockResolvedValue({ status: 200, data: events }); + + await API.getDomainAccessEvents(req, r); + + expect(r.__ctx.statusCode).toBe(200); + expect(r.__ctx.body).toEqual(events); + }); + + it('getDomainAccessEvents does not log the response body or the token', async () => { + const { logError, logWarn, logInfo } = await import('../utils/logger.js'); + const events = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'law_enforcement', + }, + ]; + const req = { + ...createSession(), + params: { uuid: 'test-uuid' }, + }; + const r = createRes(); + mockAxiosInstance.get.mockResolvedValue({ status: 200, data: events }); + + await API.getDomainAccessEvents(req, r); + + // Success path logs nothing at all (no body, no token). + expect(logError).not.toHaveBeenCalled(); + expect(logWarn).not.toHaveBeenCalled(); + expect(logInfo).not.toHaveBeenCalled(); + }); + it('handleResponse handles timeout error', async () => { const req = { ...createSession(), diff --git a/src/components/DomainAccessEvents/DomainAccessEvents.jsx b/src/components/DomainAccessEvents/DomainAccessEvents.jsx new file mode 100644 index 0000000..f70ed50 --- /dev/null +++ b/src/components/DomainAccessEvents/DomainAccessEvents.jsx @@ -0,0 +1,150 @@ +import { FormattedMessage } from 'react-intl'; +import { Button, Container, Icon, Label, Loader, Message, Popup, Table } from 'semantic-ui-react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; + +// The privilege categories the registry can return (RdapPrivilegeGrant::CATEGORIES). The colour is +// a scanning aid only, mirroring how domain statuses are dotted elsewhere on this page. A category +// the portal does not know yet still renders — raw value, neutral dot — so a registry-side addition +// shows up as data rather than disappearing. +const CATEGORY_COLORS = { + cert: 'teal', + eis_internal: 'grey', + police: 'blue', + ria: 'violet', +}; + +const isKnownCategory = (category) => + Object.prototype.hasOwnProperty.call(CATEGORY_COLORS, category); + +// The endpoint sends ISO-8601; every other date in the portal reads DD.MM.Y HH:mm. An unparseable +// value falls back to the raw string rather than rendering "Invalid date". +const formatAccessedAt = (value) => { + const parsed = moment(value); + return parsed.isValid() ? parsed.format('DD.MM.Y HH:mm') : value; +}; + +/** + * "Who accessed my data" — the authority accesses the registry discloses to this domain's + * registrant (RDAP spec 13, Surface B). + * + * Presentational only: the caller owns fetching and decides whether the panel is shown at all. + * `events` stays undefined until a fetch succeeds, which is what keeps the four states apart — + * an in-flight or failed load must never render as "no authority accessed your data". + */ +const DomainAccessEvents = ({ error = false, events, isLoading = false, onRetry, uiElemSize }) => { + const hasEvents = Array.isArray(events) && events.length > 0; + const isEmpty = !error && Array.isArray(events) && events.length === 0; + + return ( +
+ +
+

+ + }> + + +

+ {isEmpty ? : null} +
+ {isLoading && !hasEvents ? ( +
+ +
+ ) : null} + {error ? ( + + + + + + + ) : null} + {!error && hasEvents ? ( + + + + + + + + + + + + + + + + {events.map((event, index) => ( + // The registry logs every request separately (no dedup), so two + // events can be identical in all three fields — the position in the + // server-ordered list is what makes the key unique. + + + {event.organization || ( + + )} + + + + {formatAccessedAt(event.accessed_at)} + + ))} + +
+ ) : null} +
+
+ ); +}; + +DomainAccessEvents.propTypes = { + error: PropTypes.bool, + events: PropTypes.arrayOf( + PropTypes.shape({ + accessed_at: PropTypes.string, + category: PropTypes.string, + organization: PropTypes.string, + }) + ), + isLoading: PropTypes.bool, + onRetry: PropTypes.func.isRequired, + uiElemSize: PropTypes.string, +}; + +export default DomainAccessEvents; diff --git a/src/components/DomainAccessEvents/DomainAccessEvents.test.jsx b/src/components/DomainAccessEvents/DomainAccessEvents.test.jsx new file mode 100644 index 0000000..2203ad1 --- /dev/null +++ b/src/components/DomainAccessEvents/DomainAccessEvents.test.jsx @@ -0,0 +1,144 @@ +import { render, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; +import moment from 'moment'; +import DomainAccessEvents from './DomainAccessEvents'; +import Providers from '../../__mocks__/Providers'; + +const FORMAT = 'DD.MM.Y HH:mm'; + +const createTestStore = () => + configureStore({ + reducer: { + ui: ( + state = { uiElemSize: 'small', lang: 'et', menus: { main: [] }, isMainMenuOpen: false } + ) => state, + }, + }); + +const EMPTY_MESSAGE = 'Ükski asutus ei ole selle domeeni andmeid vaadanud.'; +const ERROR_MESSAGE = 'Vaatamiste ajalugu ei õnnestunud laadida. Palun proovi uuesti.'; + +const events = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'police', + }, + { + accessed_at: '2026-07-09T09:30:00+03:00', + organization: null, + category: 'cert', + }, +]; + +describe('DomainAccessEvents', () => { + let store; + let onRetry; + + const renderPanel = (props = {}) => + render( + + + + ); + + beforeEach(() => { + store = createTestStore(); + onRetry = vi.fn(); + }); + + it('renders translated categories, an institution fallback and portal-formatted timestamps', () => { + const { container } = renderPanel({ events }); + + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + + const rows = Array.from(container.querySelectorAll('[data-test="access-events-row"]')).map( + (row) => Array.from(row.querySelectorAll('td')).map((td) => td.textContent.trim()) + ); + + // The timestamp is rendered in the viewer's own timezone (moment's default), so the + // expectation is derived the same way rather than hardcoded — what is asserted is the + // DD.MM.Y HH:mm shape the rest of the portal uses, not a fixed offset. + expect(rows).toEqual([ + ['Politsei- ja Piirivalveamet', 'Politsei', moment(events[0].accessed_at).format(FORMAT)], + ['Määramata', 'CERT', moment(events[1].accessed_at).format(FORMAT)], + ]); + rows.forEach(([, , accessedAt]) => { + expect(accessedAt).toMatch(/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}$/); + }); + + // the raw category enum must never reach the user + expect(container.textContent).not.toContain('police'); + expect(container.textContent).not.toContain(events[0].accessed_at); + }); + + it('renders an unrecognised category as its raw value instead of dropping the row', () => { + const { container } = renderPanel({ + events: [{ ...events[0], category: 'some_new_category' }], + }); + + expect(container.querySelectorAll('[data-test="access-events-row"]')).toHaveLength(1); + expect(container.textContent).toContain('some_new_category'); + }); + + it('renders only the three disclosed fields', () => { + const { container } = renderPanel({ events }); + + ['accessor_name', 'grant_ref', 'request_id', 'caller_ip', 'result_code'].forEach((field) => { + expect(container.textContent).not.toContain(field); + }); + }); + + it('uses a semantic header with descriptive columns (a11y)', () => { + const { container } = renderPanel({ events }); + + const headerCells = Array.from(container.querySelectorAll('thead th')).map((th) => + th.textContent.trim() + ); + expect(headerCells).toEqual(['Asutus', 'Kategooria', 'Vaatamise aeg']); + }); + + it('shows the empty state only on a successful empty response', () => { + const { container } = renderPanel({ events: [] }); + + expect(container.textContent).toContain(EMPTY_MESSAGE); + expect(container.querySelector('table')).not.toBeInTheDocument(); + }); + + it('shows a loader — never the empty state — while the fetch is in flight', () => { + const { container } = renderPanel({ isLoading: true }); + + expect(container.querySelector('[data-test="access-events-loading"]')).toBeInTheDocument(); + expect(container.textContent).not.toContain(EMPTY_MESSAGE); + }); + + it('shows an error with a working retry — never the empty state — on failure', () => { + const { container } = renderPanel({ error: true }); + + expect(container.querySelector('[data-test="access-events-error"]')).toBeInTheDocument(); + expect(container.textContent).toContain(ERROR_MESSAGE); + expect(container.textContent).not.toContain(EMPTY_MESSAGE); + + fireEvent.click(container.querySelector('[data-test="access-events-retry"]')); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('keeps the empty state away when a refresh fails over a previously empty result', () => { + // The reducer keeps the last successful payload on FAILURE, so an empty array can coexist + // with error: true. That combination must read as "could not load", not as "nobody looked". + const { container } = renderPanel({ error: true, events: [] }); + + expect(container.textContent).toContain(ERROR_MESSAGE); + expect(container.textContent).not.toContain(EMPTY_MESSAGE); + }); + + it('renders nothing but the header before any fetch has happened', () => { + const { container } = renderPanel(); + + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).not.toContain(EMPTY_MESSAGE); + expect(container.textContent).not.toContain(ERROR_MESSAGE); + expect(container.querySelector('table')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/DomainAccessEvents/_DomainAccessEvents.scss b/src/components/DomainAccessEvents/_DomainAccessEvents.scss new file mode 100644 index 0000000..8ea1b91 --- /dev/null +++ b/src/components/DomainAccessEvents/_DomainAccessEvents.scss @@ -0,0 +1,9 @@ +@use "../../global-scss/all" as *; + +.domain-access-events { + &--loading { + // Semantic's inline loader has no intrinsic height; without this the block collapses to the + // header while the fetch is in flight and the page visibly jumps once the table arrives. + padding: 40px 0; + } +} diff --git a/src/components/index.js b/src/components/index.js index f04a4dd..8a0b7c8 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -2,6 +2,7 @@ export { default as ScrollToTop } from './ScrollToTop/ScrollToTop'; export { default as MainLayout } from './common/MainLayout/MainLayout'; export { default as Loading } from './common/Loading/Loading'; export { default as DomainList } from './DomainList/DomainList'; +export { default as DomainAccessEvents } from './DomainAccessEvents/DomainAccessEvents'; export { default as UserData } from './UserData/UserData'; export { default as WhoIsEdit } from './WhoIsEdit/WhoIsEdit'; export { default as MessageModule } from './modules/MessageModule/MessageModule'; diff --git a/src/index.scss b/src/index.scss index 0bec267..af101bc 100644 --- a/src/index.scss +++ b/src/index.scss @@ -15,6 +15,7 @@ @use "./components/common/MainFooter/MainFooter"; @use "./components/common/MainLayout/MainLayout"; @use "./components/DomainList/DomainList"; +@use "./components/DomainAccessEvents/DomainAccessEvents"; @use "./components/UserData/UserData"; // Pages diff --git a/src/pages/DomainPage/DomainPage.jsx b/src/pages/DomainPage/DomainPage.jsx index 915b449..a8e9a78 100644 --- a/src/pages/DomainPage/DomainPage.jsx +++ b/src/pages/DomainPage/DomainPage.jsx @@ -24,6 +24,7 @@ import { PageMessage, MainLayout, WhoIsConfirmDialog, + DomainAccessEvents, } from '../../components'; import domainStatuses from '../../utils/domainStatuses.json'; import { @@ -33,13 +34,16 @@ import { } from '../../redux/reducers/domains'; import { fetchCompanies as fetchCompaniesAction } from '../../redux/reducers/companies'; import { updateContact as updateContactAction } from '../../redux/reducers/contacts'; +import { fetchAccessEvents as fetchAccessEventsAction } from '../../redux/reducers/accessEvents'; import Helpers from '../../utils/helpers'; const DomainPage = ({ + accessEvents, companies, contacts, domains, error, + fetchAccessEvents, fetchCompanies, fetchDomain, isLoading, @@ -52,6 +56,20 @@ const DomainPage = ({ const { id } = useParams(); const domain = domains[id]; const { uiElemSize } = ui; + // Per-domain access-events record: { events, isLoading, error }. `events` stays undefined until a + // fetch succeeds, so we can distinguish "loading / failed" from "loaded and genuinely empty". + const accessEventsRecord = accessEvents[id]; + // The registry scopes access events to the caller's DIRECT registrant contact — the private + // person whose ident matches (Contact.registrant_user_direct_contacts). A tech/admin contact or + // a company representative passes the domain check but gets an empty list, which the panel + // would render as "no authority has accessed this domain's data" — a false negative in a + // feature whose whole point is transparency. So the panel is shown, and fetched, only for the + // direct registrant. When the registry widens own_ids to registrant_user_contacts (company + // representatives; spec 13 grounding §6 open question), widen this check with it. + const registrantContact = contacts[domain?.registrant?.id]; + const isDomainRegistrant = Boolean( + registrantContact?.ident?.type === 'priv' && registrantContact.ident.code === user.ident + ); const [isDirty, setIsDirty] = useState(false); const [isLockable, setIsLockable] = useState(false); @@ -75,6 +93,16 @@ const DomainPage = ({ fetchData(); }, [domain, fetchDomain, isLoading, id, error, companies.isLoading, fetchCompanies]); + useEffect(() => { + // Once the domain is loaded, fetch the list of authorities that have accessed its data. + // Guarded on the per-uuid record so we fetch once per domain: the record exists as soon as + // the request is dispatched (REQUEST/SUCCESS/FAILURE), which prevents a refetch loop while + // `events` is still undefined during loading or after a failure. + if (domain && isDomainRegistrant && accessEventsRecord === undefined) { + fetchAccessEvents(id); + } + }, [domain, isDomainRegistrant, accessEventsRecord, fetchAccessEvents, id]); + useEffect(() => { if (registrantContacts?.ident?.type === 'org') { if (companies.isLoading === null) { @@ -594,6 +622,15 @@ const DomainPage = ({ + {isDomainRegistrant ? ( + fetchAccessEvents(id)} + uiElemSize={uiElemSize} + /> + ) : null} { }; const mapStateToProps = (state) => ({ + accessEvents: state.accessEvents.byUuid, companies: state.companies, contacts: state.contacts.data, error: state.domains.error, @@ -724,6 +762,7 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = (dispatch) => bindActionCreators( { + fetchAccessEvents: fetchAccessEventsAction, fetchCompanies: fetchCompaniesAction, fetchDomain: fetchDomainAction, lockDomain: lockDomainAction, diff --git a/src/pages/DomainPage/DomainPage.test.jsx b/src/pages/DomainPage/DomainPage.test.jsx index ae5e8ca..9f564d8 100644 --- a/src/pages/DomainPage/DomainPage.test.jsx +++ b/src/pages/DomainPage/DomainPage.test.jsx @@ -47,6 +47,15 @@ vi.mock('../../redux/reducers/domains', () => ({ }), })); +// Mock the access-events action (thunk); its dispatch is a no-op action in these tests +const { mockFetchAccessEvents } = vi.hoisted(() => ({ + mockFetchAccessEvents: vi.fn(() => ({ type: 'MOCK_FETCH_ACCESS_EVENTS' })), +})); + +vi.mock('../../redux/reducers/accessEvents', () => ({ + fetchAccessEvents: mockFetchAccessEvents, +})); + const createTestStore = (overrides = {}) => { const baseState = { ui: { @@ -92,6 +101,9 @@ const createTestStore = (overrides = {}) => { isLoading: null, message: null, }, + accessEvents: { + byUuid: {}, + }, }; // Create reducers that handle domain actions @@ -129,6 +141,7 @@ const createTestStore = (overrides = {}) => { }, contacts: (state = { ...baseState.contacts, ...overrides.contacts }) => state, companies: (state = { ...baseState.companies, ...overrides.companies }) => state, + accessEvents: (state = { ...baseState.accessEvents, ...overrides.accessEvents }) => state, }; return configureStore({ @@ -289,4 +302,193 @@ describe('DomainPage', () => { expect(container.textContent).toContain('Admin'); expect(container.textContent).toContain('test@admin.ee'); }); + + describe('access-events panel', () => { + const sampleEvents = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'police', + }, + { + accessed_at: '2026-07-09T09:30:00+03:00', + organization: null, + category: 'cert', + }, + ]; + + it('renders exactly the three returned fields and no withheld data', () => { + const eventsStore = createTestStore({ + accessEvents: { + byUuid: { + [mockDomain.id]: { + events: sampleEvents, + isLoading: false, + error: false, + }, + }, + }, + }); + + const { container } = render( + + + + ); + + // panel title (et translation) + the three fields, one row per event + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).toContain('Politsei- ja Piirivalveamet'); + expect(container.querySelectorAll('[data-test="access-events-row"]')).toHaveLength(2); + + // withheld fields must never appear in the DOM + const withheld = [ + 'accessor_name', + 'grant_ref', + 'request_id', + 'caller_ip', + 'result_code', + ]; + withheld.forEach((field) => { + expect(container.textContent).not.toContain(field); + }); + }); + + it('is hidden — and never fetched — for a viewer who is not the direct registrant', () => { + // The registry scopes events to the caller's own ident-matched registrant contact, so a + // tech/admin contact or a company representative would get an empty list. Rendering the + // panel for them would state "no authority has accessed this domain's data", which we + // cannot actually know. It must not appear at all. + const otherPersonStore = createTestStore({ + contacts: { + data: { + ...Object.fromEntries(contacts.map((contact) => [contact.id, contact])), + [mockDomain.registrant.id]: { + ...contacts.find((c) => c.id === mockDomain.registrant.id), + ident: { code: '00000000000', type: 'priv', country_code: 'EE' }, + }, + }, + message: null, + }, + }); + + const { container } = render( + + + + ); + + expect(container.textContent).not.toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).not.toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' + ); + // no panel means no request either — the endpoint is never called on their behalf + expect(mockFetchAccessEvents).not.toHaveBeenCalled(); + }); + + it('renders the empty-state message ONLY on a successful empty array', () => { + const emptyStore = createTestStore({ + accessEvents: { + byUuid: { + [mockDomain.id]: { + events: [], + isLoading: false, + error: false, + }, + }, + }, + }); + + const { container } = render( + + + + ); + + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' + ); + }); + + it('shows a loading indicator (not the empty message) while the fetch is in flight', () => { + const loadingStore = createTestStore({ + accessEvents: { + byUuid: { + [mockDomain.id]: { + events: undefined, + isLoading: true, + error: false, + }, + }, + }, + }); + + const { container } = render( + + + + ); + + // panel is present, a loading indicator shows... + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect( + container.querySelector('[data-test="access-events-loading"]') + ).toBeInTheDocument(); + // ...and it must NOT falsely claim nobody accessed the data. + expect(container.textContent).not.toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' + ); + }); + + it('shows an error/retry affordance (not the empty message) on fetch failure', () => { + const errorStore = createTestStore({ + accessEvents: { + byUuid: { + [mockDomain.id]: { + events: undefined, + isLoading: false, + error: true, + }, + }, + }, + }); + + const { container } = render( + + + + ); + + // panel + error text + retry button... + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect( + container.querySelector('[data-test="access-events-error"]') + ).toBeInTheDocument(); + expect( + container.querySelector('[data-test="access-events-retry"]') + ).toBeInTheDocument(); + expect(container.textContent).toContain( + 'Vaatamiste ajalugu ei õnnestunud laadida. Palun proovi uuesti.' + ); + // ...and the empty message must NOT appear on a failed load. + expect(container.textContent).not.toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' + ); + }); + + it('does not show the empty message before any fetch record exists (undefined)', () => { + // No byUuid record for this domain at all -> neither empty nor error/loading text. + const { container } = render( + + + + ); + + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).not.toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' + ); + }); + }); }); diff --git a/src/redux/actions/index.js b/src/redux/actions/index.js index b75dd99..b4925e1 100644 --- a/src/redux/actions/index.js +++ b/src/redux/actions/index.js @@ -30,6 +30,11 @@ export const RESPOND_REGISTRANT_CHANGE_REQUEST = 'RESPOND_REGISTRANT_CHANGE_REQU export const RESPOND_REGISTRANT_CHANGE_SUCCESS = 'RESPOND_REGISTRANT_CHANGE_SUCCESS'; export const RESPOND_REGISTRANT_CHANGE_FAILED = 'RESPOND_REGISTRANT_CHANGE_FAILED'; +// Access events (who accessed my domain data) +export const FETCH_ACCESS_EVENTS_REQUEST = 'FETCH_ACCESS_EVENTS_REQUEST'; +export const FETCH_ACCESS_EVENTS_SUCCESS = 'FETCH_ACCESS_EVENTS_SUCCESS'; +export const FETCH_ACCESS_EVENTS_FAILURE = 'FETCH_ACCESS_EVENTS_FAILURE'; + // Contacts export const FETCH_CONTACT_REQUEST = 'FETCH_CONTACT_REQUEST'; export const FETCH_CONTACT_SUCCESS = 'FETCH_CONTACT_SUCCESS'; diff --git a/src/redux/reducers/accessEvents.js b/src/redux/reducers/accessEvents.js new file mode 100644 index 0000000..bb48a81 --- /dev/null +++ b/src/redux/reducers/accessEvents.js @@ -0,0 +1,89 @@ +import api from '../../utils/api'; +import { + FETCH_ACCESS_EVENTS_REQUEST, + FETCH_ACCESS_EVENTS_SUCCESS, + FETCH_ACCESS_EVENTS_FAILURE, + LOGOUT_USER, +} from '../actions'; + +// Access events describe which authorities have accessed a domain's data. The registry returns, per +// event, EXACTLY three fields: { accessed_at, organization, category }. Nothing else is stored or +// surfaced here. +// +// Per-request state (events, isLoading, error) is keyed by domain uuid so that an in-flight or +// failed fetch for one domain can never drive another domain's panel. This matters for a +// transparency feature: a failed load for domain A must not read as "no accesses" on domain B. + +const requestAccessEvents = (uuid) => ({ + payload: { uuid }, + type: FETCH_ACCESS_EVENTS_REQUEST, +}); + +const receiveAccessEvents = (uuid, events) => ({ + payload: { uuid, events }, + type: FETCH_ACCESS_EVENTS_SUCCESS, +}); + +const failAccessEvents = (uuid) => ({ + payload: { uuid }, + type: FETCH_ACCESS_EVENTS_FAILURE, +}); + +const fetchAccessEvents = (uuid) => (dispatch) => { + dispatch(requestAccessEvents(uuid)); + return api + .fetchDomainAccessEvents(uuid) + .then((res) => res.data) + .then((events) => dispatch(receiveAccessEvents(uuid, events))) + .catch(() => dispatch(failAccessEvents(uuid))); +}; + +// byUuid holds one { events, isLoading, error } record per domain uuid. events stays undefined +// until a successful fetch, so the panel can tell "loading / failed" apart from "loaded, empty". +const initialState = { + byUuid: {}, +}; + +const recordFor = (state, uuid) => + state.byUuid[uuid] || { events: undefined, isLoading: false, error: false }; + +const withRecord = (state, uuid, record) => ({ + ...state, + byUuid: { + ...state.byUuid, + [uuid]: record, + }, +}); + +export default function reducer(state = initialState, { payload, type }) { + switch (type) { + case LOGOUT_USER: + return initialState; + + case FETCH_ACCESS_EVENTS_REQUEST: + return withRecord(state, payload.uuid, { + ...recordFor(state, payload.uuid), + isLoading: true, + error: false, + }); + + case FETCH_ACCESS_EVENTS_SUCCESS: + return withRecord(state, payload.uuid, { + events: payload.events, + isLoading: false, + error: false, + }); + + case FETCH_ACCESS_EVENTS_FAILURE: + return withRecord(state, payload.uuid, { + ...recordFor(state, payload.uuid), + isLoading: false, + error: true, + }); + + default: + return state; + } +} + +export { initialState, fetchAccessEvents }; diff --git a/src/redux/reducers/accessEvents.test.js b/src/redux/reducers/accessEvents.test.js new file mode 100644 index 0000000..8364d22 --- /dev/null +++ b/src/redux/reducers/accessEvents.test.js @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import reducer, { initialState, fetchAccessEvents } from './accessEvents'; + +// Mock the api module instead of axios +vi.mock('../../utils/api', () => ({ + default: { + fetchDomainAccessEvents: vi.fn(), + }, +})); + +// Import api after mocking +import api from '../../utils/api'; + +const uuid = 'bd695cc9-1da8-4c39-b7ac-9a2055e0a93e'; +const sampleEvents = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'law_enforcement', + }, + { + accessed_at: '2026-07-09T09:30:00+03:00', + organization: null, + category: 'court', + }, +]; + +describe('Access events actions', () => { + let dispatch; + + beforeEach(() => { + vi.clearAllMocks(); + dispatch = vi.fn((action) => { + if (typeof action === 'function') { + return action(dispatch); + } + return action; + }); + }); + + describe('fetchAccessEvents', () => { + it('dispatches request then success with events keyed by uuid', async () => { + api.fetchDomainAccessEvents.mockResolvedValueOnce({ data: sampleEvents }); + + await fetchAccessEvents(uuid)(dispatch); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'FETCH_ACCESS_EVENTS_REQUEST', + payload: { uuid }, + }); + expect(dispatch).toHaveBeenLastCalledWith({ + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid, events: sampleEvents }, + }); + expect(api.fetchDomainAccessEvents).toHaveBeenCalledWith(uuid); + }); + + it('dispatches request then failure on error', async () => { + api.fetchDomainAccessEvents.mockRejectedValueOnce(new Error('Failed to fetch')); + + await fetchAccessEvents(uuid)(dispatch); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'FETCH_ACCESS_EVENTS_REQUEST', + payload: { uuid }, + }); + expect(dispatch).toHaveBeenLastCalledWith({ + type: 'FETCH_ACCESS_EVENTS_FAILURE', + payload: { uuid }, + }); + }); + }); +}); + +describe('Access events reducer', () => { + const otherUuid = 'aaaaaaaa-1111-2222-3333-444444444444'; + + it('returns initial state', () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + + it('handles FETCH_ACCESS_EVENTS_REQUEST scoped to the uuid', () => { + const state = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_REQUEST', + payload: { uuid }, + }); + expect(state.byUuid[uuid].isLoading).toBe(true); + expect(state.byUuid[uuid].error).toBe(false); + // events stays undefined while loading -> the panel must not read this as "empty" + expect(state.byUuid[uuid].events).toBeUndefined(); + }); + + it('handles FETCH_ACCESS_EVENTS_SUCCESS keyed by uuid', () => { + const state = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid, events: sampleEvents }, + }); + expect(state.byUuid[uuid].events).toEqual(sampleEvents); + expect(state.byUuid[uuid].isLoading).toBe(false); + expect(state.byUuid[uuid].error).toBe(false); + }); + + it('preserves records for other uuids on a new success', () => { + const first = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid: otherUuid, events: [] }, + }); + const second = reducer(first, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid, events: sampleEvents }, + }); + expect(second.byUuid[otherUuid].events).toEqual([]); + expect(second.byUuid[uuid].events).toEqual(sampleEvents); + }); + + it('handles FETCH_ACCESS_EVENTS_FAILURE scoped to the uuid', () => { + const state = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_FAILURE', + payload: { uuid }, + }); + expect(state.byUuid[uuid].isLoading).toBe(false); + expect(state.byUuid[uuid].error).toBe(true); + // events stays undefined on failure -> the panel must not read this as "empty" + expect(state.byUuid[uuid].events).toBeUndefined(); + }); + + it('scopes loading/error per uuid: a failure for A does not touch B (W2)', () => { + // A loads successfully (empty result), then B fails. + const afterA = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid: otherUuid, events: [] }, + }); + const afterBRequest = reducer(afterA, { + type: 'FETCH_ACCESS_EVENTS_REQUEST', + payload: { uuid }, + }); + const afterBFailure = reducer(afterBRequest, { + type: 'FETCH_ACCESS_EVENTS_FAILURE', + payload: { uuid }, + }); + + // B is errored... + expect(afterBFailure.byUuid[uuid].error).toBe(true); + expect(afterBFailure.byUuid[uuid].isLoading).toBe(false); + // ...but A is untouched: still a successful empty load, no error, not loading. + expect(afterBFailure.byUuid[otherUuid].events).toEqual([]); + expect(afterBFailure.byUuid[otherUuid].error).toBe(false); + expect(afterBFailure.byUuid[otherUuid].isLoading).toBe(false); + }); + + it('resets to initial state on LOGOUT_USER', () => { + const populated = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid, events: sampleEvents }, + }); + expect(reducer(populated, { type: 'LOGOUT_USER' })).toEqual(initialState); + }); +}); diff --git a/src/redux/reducers/index.js b/src/redux/reducers/index.js index 6aa4602..4b2f70a 100644 --- a/src/redux/reducers/index.js +++ b/src/redux/reducers/index.js @@ -7,9 +7,11 @@ import companies from './companies'; import contacts from './contacts'; import verification from './verification'; import filters from './filters'; +import accessEvents from './accessEvents'; export default withReduxStateSync( combineReducers({ + accessEvents, companies, contacts, domains, diff --git a/src/translations/accessEvents.i18n.test.js b/src/translations/accessEvents.i18n.test.js new file mode 100644 index 0000000..5c85aa0 --- /dev/null +++ b/src/translations/accessEvents.i18n.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import en from './en.json'; +import et from './et.json'; + +// AC20: every new user-facing string is a React-Intl key under the domain.accessEvents.* namespace, +// present in BOTH en.json and et.json, with matching key sets and no empty values. +describe('domain.accessEvents.* i18n keys', () => { + const namespace = 'domain.accessEvents.'; + const expectedKeys = [ + 'domain.accessEvents.accessedAt', + 'domain.accessEvents.category', + // One label per RdapPrivilegeGrant::CATEGORIES value, so the panel never shows the raw + // enum. A category the registry adds later falls back to its raw value by design. + 'domain.accessEvents.category.cert', + 'domain.accessEvents.category.eis_internal', + 'domain.accessEvents.category.police', + 'domain.accessEvents.category.ria', + 'domain.accessEvents.empty', + 'domain.accessEvents.error', + 'domain.accessEvents.institution', + 'domain.accessEvents.institutionUnknown', + 'domain.accessEvents.retry', + 'domain.accessEvents.title', + 'domain.accessEvents.tooltip', + ].sort(); + + const enKeys = Object.keys(en) + .filter((k) => k.startsWith(namespace)) + .sort(); + const etKeys = Object.keys(et) + .filter((k) => k.startsWith(namespace)) + .sort(); + + it('en.json contains exactly the expected keys', () => { + expect(enKeys).toEqual(expectedKeys); + }); + + it('et.json contains exactly the expected keys', () => { + expect(etKeys).toEqual(expectedKeys); + }); + + it('the two locale key sets match', () => { + expect(enKeys).toEqual(etKeys); + }); + + it('every value is a non-empty string in both locales', () => { + expectedKeys.forEach((key) => { + expect(typeof en[key]).toBe('string'); + expect(en[key].trim().length).toBeGreaterThan(0); + expect(typeof et[key]).toBe('string'); + expect(et[key].trim().length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/translations/en.json b/src/translations/en.json index f26dc55..2a0da4d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -25,6 +25,19 @@ "domain.404.message.content": " ", "domain.404.message.title": "Unfortunately, the domain with this name was not found", "domain.404.title": "Domain not found", + "domain.accessEvents.accessedAt": "Access time", + "domain.accessEvents.category": "Category", + "domain.accessEvents.category.cert": "CERT", + "domain.accessEvents.category.eis_internal": "EIS internal", + "domain.accessEvents.category.police": "Police", + "domain.accessEvents.category.ria": "RIA", + "domain.accessEvents.empty": "No authority has accessed this domain's data.", + "domain.accessEvents.error": "The access history could not be loaded. Please try again.", + "domain.accessEvents.institution": "Institution", + "domain.accessEvents.institutionUnknown": "Not specified", + "domain.accessEvents.retry": "Try again", + "domain.accessEvents.title": "Who accessed my data", + "domain.accessEvents.tooltip": "This list shows which authorities have accessed this domain's registration data. Only the institution, the category of access and the access time are shown. Recent accesses may appear after a short delay.", "domain.admin_contacts": "Admin contacts", "domain.changeContacts": "Change contacts", "domain.contact.admin": "Admin", diff --git a/src/translations/et.json b/src/translations/et.json index 00f9a04..3f689cf 100644 --- a/src/translations/et.json +++ b/src/translations/et.json @@ -25,6 +25,19 @@ "domain.404.message.content": " ", "domain.404.message.title": "Kahjuks sellise nimega domeeni ei leitud", "domain.404.title": "Domeeni ei leitud", + "domain.accessEvents.accessedAt": "Vaatamise aeg", + "domain.accessEvents.category": "Kategooria", + "domain.accessEvents.category.cert": "CERT", + "domain.accessEvents.category.eis_internal": "EIS sisemine", + "domain.accessEvents.category.police": "Politsei", + "domain.accessEvents.category.ria": "RIA", + "domain.accessEvents.empty": "Ükski asutus ei ole selle domeeni andmeid vaadanud.", + "domain.accessEvents.error": "Vaatamiste ajalugu ei õnnestunud laadida. Palun proovi uuesti.", + "domain.accessEvents.institution": "Asutus", + "domain.accessEvents.institutionUnknown": "Määramata", + "domain.accessEvents.retry": "Proovi uuesti", + "domain.accessEvents.title": "Kes on minu andmeid vaadanud", + "domain.accessEvents.tooltip": "See loend näitab, millised asutused on selle domeeni registreerimisandmeid vaadanud. Kuvatakse ainult asutus, vaatamise kategooria ja aeg. Hiljutised vaatamised võivad ilmuda väikese viivitusega.", "domain.admin_contacts": "Halduskontaktid", "domain.changeContacts": "Muuda kontakte", "domain.contact.admin": "Haldus", diff --git a/src/utils/api.js b/src/utils/api.js index 22e4f0b..b4ecb7b 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -87,9 +87,11 @@ export default { updateContact: (uuid, form) => instance.patch(`/api/contacts/${uuid}`, JSON.stringify(form)), - setDomainRegistryLock: (uuid, extensionsProhibited) => + setDomainRegistryLock: (uuid, extensionsProhibited) => instance.post(`/api/domains/${uuid}/registry_lock?extensionsProhibited=${extensionsProhibited}`), + fetchDomainAccessEvents: (uuid) => instance.get(`/api/domains/${uuid}/access_events`), + deleteDomainRegistryLock: (uuid) => instance.delete(`/api/domains/${uuid}/registry_lock`), };