Skip to content
Open
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
1 change: 1 addition & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions server/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
12 changes: 12 additions & 0 deletions server/routes/apiRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions server/routes/apiRoute.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
150 changes: 150 additions & 0 deletions src/components/DomainAccessEvents/DomainAccessEvents.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="page--block domain-access-events">
<Container text>
<header className="page--block--header">
<h2>
<FormattedMessage id="domain.accessEvents.title" />
<Popup basic inverted trigger={<Icon name="question circle" />}>
<FormattedMessage id="domain.accessEvents.tooltip" />
</Popup>
</h2>
{isEmpty ? <FormattedMessage id="domain.accessEvents.empty" tagName="p" /> : null}
</header>
{isLoading && !hasEvents ? (
<div className="domain-access-events--loading">
<Loader active data-test="access-events-loading" inline="centered" />
</div>
) : null}
{error ? (
<Message data-test="access-events-error" negative>
<Message.Content>
<FormattedMessage id="domain.accessEvents.error" tagName="p" />
<Button
data-test="access-events-retry"
onClick={onRetry}
primary
size={uiElemSize}
>
<FormattedMessage id="domain.accessEvents.retry" tagName="span" />
</Button>
</Message.Content>
</Message>
) : null}
{!error && hasEvents ? (
<Table basic="very">
<Table.Header>
<Table.Row>
<Table.HeaderCell>
<FormattedMessage
id="domain.accessEvents.institution"
tagName="strong"
/>
</Table.HeaderCell>
<Table.HeaderCell>
<FormattedMessage
id="domain.accessEvents.category"
tagName="strong"
/>
</Table.HeaderCell>
<Table.HeaderCell>
<FormattedMessage
id="domain.accessEvents.accessedAt"
tagName="strong"
/>
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{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.
<Table.Row
data-test="access-events-row"
key={`${event.accessed_at}-${event.category}-${
event.organization || ''
}-${index}`}
>
<Table.Cell>
{event.organization || (
<FormattedMessage id="domain.accessEvents.institutionUnknown" />
)}
</Table.Cell>
<Table.Cell>
<Label
circular
color={CATEGORY_COLORS[event.category] || 'grey'}
empty
/>{' '}
{isKnownCategory(event.category) ? (
<FormattedMessage
id={`domain.accessEvents.category.${event.category}`}
/>
) : (
event.category
)}
</Table.Cell>
<Table.Cell>{formatAccessedAt(event.accessed_at)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
) : null}
</Container>
</div>
);
};

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;
Loading
Loading