Skip to content

Commit 7d19608

Browse files
committed
feat(servicenow): register ActionsRegistryService action for incident listing
Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
1 parent c665f7c commit 7d19608

8 files changed

Lines changed: 1336 additions & 2 deletions

File tree

ACTIONS_TODO.md

Lines changed: 287 additions & 0 deletions
Large diffs are not rendered by default.

create-action.md

Lines changed: 567 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@backstage-community/plugin-servicenow-backend': patch
3+
---
4+
5+
Registered action `servicenow:list-incidents` for ServiceNow incident management.
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
17+
import { InputError, NotFoundError } from '@backstage/errors';
18+
import { createListIncidentsAction } from './createListIncidentsAction';
19+
import { ServiceNowClient } from '../service-now-rest/client';
20+
21+
const mockIncidents = [
22+
{
23+
sys_id: 'INC0001',
24+
number: 'INC0001234',
25+
short_description: 'Database connection failure',
26+
description: 'The database connection is failing intermittently.',
27+
sys_created_on: '2024-01-15 10:30:00',
28+
priority: 1,
29+
incident_state: 1,
30+
url: 'https://instance.servicenow.com/nav_to.do?uri=incident.do?sys_id=INC0001',
31+
},
32+
{
33+
sys_id: 'INC0002',
34+
number: 'INC0001235',
35+
short_description: 'API latency spike',
36+
description: 'The API response times are unusually high.',
37+
sys_created_on: '2024-01-16 09:00:00',
38+
priority: 2,
39+
incident_state: 2,
40+
url: 'https://instance.servicenow.com/nav_to.do?uri=incident.do?sys_id=INC0002',
41+
},
42+
];
43+
44+
const mockEntity = {
45+
apiVersion: 'backstage.io/v1alpha1',
46+
kind: 'System',
47+
metadata: {
48+
name: 'my-service',
49+
namespace: 'default',
50+
annotations: { 'servicenow.com/entity-id': 'sn-entity-abc123' },
51+
},
52+
spec: { type: 'service', lifecycle: 'production' },
53+
};
54+
55+
describe('createListIncidentsAction', () => {
56+
let mockActionsRegistry: { register: jest.Mock };
57+
let mockServiceNowClient: jest.Mocked<ServiceNowClient>;
58+
let mockCatalog: { getEntityByRef: jest.Mock };
59+
60+
beforeEach(() => {
61+
mockActionsRegistry = { register: jest.fn() };
62+
63+
mockServiceNowClient = {
64+
fetchIncidents: jest
65+
.fn()
66+
.mockResolvedValue({ items: mockIncidents, totalCount: 2 }),
67+
};
68+
69+
mockCatalog = {
70+
getEntityByRef: jest.fn().mockResolvedValue(mockEntity),
71+
};
72+
73+
createListIncidentsAction({
74+
actionsRegistry: mockActionsRegistry as any,
75+
serviceNowClient: mockServiceNowClient,
76+
catalog: mockCatalog as any,
77+
});
78+
});
79+
80+
it('registers the servicenow:list-incidents action', () => {
81+
expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1);
82+
const reg = mockActionsRegistry.register.mock.calls[0][0];
83+
expect(reg.name).toBe('servicenow:list-incidents');
84+
expect(reg.attributes.readOnly).toBe(true);
85+
expect(reg.attributes.destructive).toBe(false);
86+
expect(reg.attributes.idempotent).toBe(true);
87+
});
88+
89+
it('returns incidents with defaults for limit and offset', async () => {
90+
const reg = mockActionsRegistry.register.mock.calls[0][0];
91+
92+
const result = await reg.action({
93+
input: {},
94+
credentials: mockCredentials.user(),
95+
logger: mockServices.logger.mock(),
96+
});
97+
98+
expect(mockServiceNowClient.fetchIncidents).toHaveBeenCalledWith({
99+
u_backstage_entity_id: undefined,
100+
userEmail: undefined,
101+
state: undefined,
102+
priority: undefined,
103+
search: undefined,
104+
limit: 10,
105+
offset: 0,
106+
order: undefined,
107+
orderBy: undefined,
108+
});
109+
expect(result.output.totalCount).toBe(2);
110+
expect(result.output.incidents).toHaveLength(2);
111+
expect(result.output.incidents[0].sys_id).toBe('INC0001');
112+
expect(result.output.incidents[0].priority).toBe(1);
113+
});
114+
115+
it('resolves entity name to ServiceNow entity ID via annotation', async () => {
116+
const reg = mockActionsRegistry.register.mock.calls[0][0];
117+
const credentials = mockCredentials.user();
118+
119+
const result = await reg.action({
120+
input: { name: 'my-service' },
121+
credentials,
122+
logger: mockServices.logger.mock(),
123+
});
124+
125+
expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(
126+
{ kind: 'System', namespace: 'default', name: 'my-service' },
127+
{ credentials },
128+
);
129+
expect(mockServiceNowClient.fetchIncidents).toHaveBeenCalledWith(
130+
expect.objectContaining({
131+
u_backstage_entity_id: 'sn-entity-abc123',
132+
}),
133+
);
134+
expect(result.output.totalCount).toBe(2);
135+
});
136+
137+
it('uses provided kind and namespace when resolving the entity', async () => {
138+
const reg = mockActionsRegistry.register.mock.calls[0][0];
139+
const credentials = mockCredentials.user();
140+
141+
await reg.action({
142+
input: { name: 'my-service', kind: 'Service', namespace: 'my-ns' },
143+
credentials,
144+
logger: mockServices.logger.mock(),
145+
});
146+
147+
expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(
148+
{ kind: 'Service', namespace: 'my-ns', name: 'my-service' },
149+
{ credentials },
150+
);
151+
});
152+
153+
it('does not call catalog when no name is provided', async () => {
154+
const reg = mockActionsRegistry.register.mock.calls[0][0];
155+
156+
await reg.action({
157+
input: { search: 'database' },
158+
credentials: mockCredentials.user(),
159+
logger: mockServices.logger.mock(),
160+
});
161+
162+
expect(mockCatalog.getEntityByRef).not.toHaveBeenCalled();
163+
expect(mockServiceNowClient.fetchIncidents).toHaveBeenCalledWith(
164+
expect.objectContaining({ u_backstage_entity_id: undefined }),
165+
);
166+
});
167+
168+
it('throws NotFoundError when entity does not exist', async () => {
169+
mockCatalog.getEntityByRef.mockResolvedValue(undefined);
170+
const reg = mockActionsRegistry.register.mock.calls[0][0];
171+
172+
await expect(
173+
reg.action({
174+
input: { name: 'unknown' },
175+
credentials: mockCredentials.user(),
176+
logger: mockServices.logger.mock(),
177+
}),
178+
).rejects.toThrow(NotFoundError);
179+
});
180+
181+
it('throws InputError when entity is missing the servicenow.com/entity-id annotation', async () => {
182+
mockCatalog.getEntityByRef.mockResolvedValue({
183+
...mockEntity,
184+
metadata: { ...mockEntity.metadata, annotations: {} },
185+
});
186+
const reg = mockActionsRegistry.register.mock.calls[0][0];
187+
188+
await expect(
189+
reg.action({
190+
input: { name: 'my-service' },
191+
credentials: mockCredentials.user(),
192+
logger: mockServices.logger.mock(),
193+
}),
194+
).rejects.toThrow(InputError);
195+
});
196+
197+
it('passes all optional filters to fetchIncidents', async () => {
198+
const reg = mockActionsRegistry.register.mock.calls[0][0];
199+
200+
await reg.action({
201+
input: {
202+
state: '1,2',
203+
priority: '1',
204+
search: 'database',
205+
limit: 25,
206+
offset: 50,
207+
order: 'desc',
208+
orderBy: 'sys_created_on',
209+
userEmail: 'alice@example.com',
210+
},
211+
credentials: mockCredentials.user(),
212+
logger: mockServices.logger.mock(),
213+
});
214+
215+
expect(mockServiceNowClient.fetchIncidents).toHaveBeenCalledWith({
216+
u_backstage_entity_id: undefined,
217+
userEmail: 'alice@example.com',
218+
state: '1,2',
219+
priority: '1',
220+
search: 'database',
221+
limit: 25,
222+
offset: 50,
223+
order: 'desc',
224+
orderBy: 'sys_created_on',
225+
});
226+
});
227+
228+
it('returns empty list when no incidents found', async () => {
229+
mockServiceNowClient.fetchIncidents.mockResolvedValue({
230+
items: [],
231+
totalCount: 0,
232+
});
233+
const reg = mockActionsRegistry.register.mock.calls[0][0];
234+
235+
const result = await reg.action({
236+
input: { search: 'no-match' },
237+
credentials: mockCredentials.user(),
238+
logger: mockServices.logger.mock(),
239+
});
240+
241+
expect(result.output.totalCount).toBe(0);
242+
expect(result.output.incidents).toHaveLength(0);
243+
});
244+
});

0 commit comments

Comments
 (0)