Skip to content

Commit d1c4382

Browse files
fix(core): report every refusing AuthorizationStatus, not just Invalid (#159)
The OCPP 1.6 AuthorizationStatus enumeration (edition 2, section 7.2) has five values and only Accepted permits charging. Blocked, Expired and ConcurrentTx are refusals: the trace shows the same shape as an Invalid refusal, an Authorize answered with a status that denies charging and then no transaction, and the driver's session is over either way. The rule fired on Invalid alone, so a blocked or expired token produced a clean report, and silence from a detector reads as "this is not the problem", which is worse than having no rule. All four refusals now report under FAILED_AUTHORIZATION with the status named in the description. One code rather than four: the operator-facing question is the same in every case, the specific status is on the failure object for anyone who wants to branch on it, and every code is part of the published surface and of the contract-v1 corpus, so codes are worth adding only when a consumer would act differently. ConcurrentTx is included even though section 7.2 marks it as relevant to StartTransaction.req, since seeing it on an Authorize response is irregular but still a refusal. The suggested steps gain a line for that case. Adds a refused-authorization scenario covering the three newly reported statuses, which brings the corpus to 18; counts updated in the registry test, the external fixture test, and both READMEs. No existing scenario's detected code set changes, so the conformance contract is unchanged. Left for its own issue: the rule only inspects Authorize responses, while StartTransaction.conf and StopTransaction.conf also carry idTagInfo, and section 4.8 has the Central System verify the identifier again on StartTransaction, so a session can start and then be deauthorized.
1 parent d687e53 commit d1c4382

10 files changed

Lines changed: 228 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ocpp-debugkit/toolkit': patch
3+
---
4+
5+
Report every refusing `AuthorizationStatus` in `FAILED_AUTHORIZATION`, not just `Invalid` (#156). The OCPP 1.6 enumeration (edition 2, section 7.2) has five values and only `Accepted` permits charging, so `Blocked`, `Expired` and `ConcurrentTx` end a driver's session exactly as `Invalid` does. The rule fired on `Invalid` alone, which meant a blocked or expired token produced a clean report, and silence from a detector reads as "this is not the problem". All four refusals now report under the existing code, with the status named in the description, and the suggested steps mention the `ConcurrentTx` case. Adds a `refused-authorization` scenario covering the three newly reported statuses, bringing the corpus to 18.

‎CURRENT_STATE.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,28 @@ these fixes introduce is required across detection.
100100
- Studio carries an independent copy of this matrix and needs the same
101101
transcription to stay equivalent under `contract-v1`
102102

103+
### FAILED_AUTHORIZATION refusal statuses (Issue #156)
104+
105+
- ✅ Rule 1 now reports every refusing value of the OCPP 1.6
106+
`AuthorizationStatus` enumeration (edition 2, section 7.2): `Blocked`,
107+
`Expired`, `Invalid` and `ConcurrentTx`. It previously fired on `Invalid`
108+
alone, so a blocked or expired token produced a clean report
109+
- ✅ One code rather than four: the operator-facing question is the same in every
110+
case, the status is named in the description, and severity stays `warning`.
111+
Adding codes grows the published `FailureCode` union and the `contract-v1`
112+
surface, so it is worth doing only when a consumer would act differently
113+
- ✅ New `refused-authorization` scenario (18 in the corpus) covering the three
114+
newly reported statuses; counts updated in the registry test, the external
115+
fixture test, and both READMEs
116+
- ✅ No existing scenario's detected code set changes, so the conformance
117+
contract is unchanged
118+
- Out of scope, worth its own issue: the rule only inspects `Authorize`
119+
responses, while `StartTransaction.conf` and `StopTransaction.conf` also carry
120+
`idTagInfo` (section 4.8 re-verifies the identifier on `StartTransaction`, so a
121+
session can start and then be deauthorized)
122+
- Studio's independent copy of this rule needs the same widening to stay
123+
equivalent under `contract-v1`
124+
103125
### GitHub Infrastructure
104126

105127
- ✅ GitHub milestones created (M0, M0.5, v0.1.0, v0.2.0, v0.3.0, v1.0.0)

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ validate behavior against known scenarios.
2222
firmware update failures, suspicious session duration, slow CSMS responses,
2323
heartbeat interval violations, meter value anomalies, unresponsive CSMS, and
2424
repeated boot notifications.
25-
- **Scenario Evaluator** — 17 predefined scenarios with expected failure
25+
- **Scenario Evaluator** — 18 predefined scenarios with expected failure
2626
outcomes for testing the analysis engine. Supports external scenario files.
2727
- **Replay Engine** — Deterministic, pure replay engine with step forward/back,
2828
jump-to-event, and configurable playback speed.

‎packages/toolkit/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ report generation, React components, and CLI.
2020
firmware update failures, suspicious session duration, slow CSMS responses,
2121
heartbeat interval violations, meter value anomalies, unresponsive CSMS, and
2222
repeated boot notifications.
23-
- **Scenario Evaluator** — 17 predefined scenarios with expected failure
23+
- **Scenario Evaluator** — 18 predefined scenarios with expected failure
2424
outcomes for testing the analysis engine. Supports external scenario files.
2525
- **Replay Engine** — Deterministic, pure replay engine with step forward/back,
2626
jump-to-event, and configurable playback speed. No timers or I/O.

‎packages/toolkit/src/core/detection.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,41 @@ describe('detectFailures', () => {
126126

127127
expect(failures.filter((f) => f.code === 'FAILED_AUTHORIZATION')).toHaveLength(2);
128128
});
129+
130+
// OCPP 1.6 edition 2, section 7.2: AuthorizationStatus has five values and
131+
// only Accepted permits charging. The rule used to fire on Invalid alone.
132+
const authorizeWithStatus = (status: string): Event[] => [
133+
makeEvent('evt-0001', 'msg-001', 'Call', 'Authorize', { idTag: 'SYNTHETIC-TAG-001' }, 1000),
134+
makeEvent(
135+
'evt-0002',
136+
'msg-001',
137+
'CallResult',
138+
null,
139+
{ idTagInfo: { status } },
140+
1500,
141+
'CSMS_TO_CS',
142+
),
143+
];
144+
145+
it.each(['Invalid', 'Blocked', 'Expired', 'ConcurrentTx'])(
146+
'detects %s as a refused authorization',
147+
(status) => {
148+
const events = authorizeWithStatus(status);
149+
const failures = detectFailures(events, buildSessionTimeline(events)).filter(
150+
(f) => f.code === 'FAILED_AUTHORIZATION',
151+
);
152+
expect(failures).toHaveLength(1);
153+
expect(failures[0]?.severity).toBe('warning');
154+
expect(failures[0]?.description).toContain(`"${status}"`);
155+
expect(failures[0]?.eventIds).toEqual(['evt-0001', 'evt-0002']);
156+
},
157+
);
158+
159+
it('does not flag an unrecognized idTagInfo status', () => {
160+
const events = authorizeWithStatus('NotAnAuthorizationStatus');
161+
const failures = detectFailures(events, buildSessionTimeline(events));
162+
expect(failures.some((f) => f.code === 'FAILED_AUTHORIZATION')).toBe(false);
163+
});
129164
});
130165

131166
describe('CONNECTOR_FAULT', () => {

‎packages/toolkit/src/core/detection.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* 16 detection rules (v0.1 + v0.2 + v0.3):
55
*
66
* v0.1:
7-
* 1. FAILED_AUTHORIZATION — Authorize response with idTagInfo.status = "Invalid"
7+
* 1. FAILED_AUTHORIZATION — Authorize response with a refusing idTagInfo.status
88
* 2. CONNECTOR_FAULT — StatusNotification with status = "Faulted" during active session
99
* 3. STATION_OFFLINE_DURING_SESSION — session has StartTransaction but no StopTransaction
1010
*
@@ -39,6 +39,7 @@ const SUGGESTED_STEPS: Record<FailureCode, string[]> = {
3939
'Verify the idTag is valid and not expired',
4040
'Check the CSMS local authorization list',
4141
'Ensure the idTag is not blocked or deactivated',
42+
'For ConcurrentTx, check whether the idTag is still open on another transaction',
4243
'Review the Authorize response payload for rejection reason',
4344
],
4445
CONNECTOR_FAULT: [
@@ -201,9 +202,20 @@ function getStatusNotificationErrorCode(event: Event): string | null {
201202
// Detection rules
202203
// ---------------------------------------------------------------------------
203204

205+
/**
206+
* The refusing values of the OCPP 1.6 `AuthorizationStatus` enumeration (edition
207+
* 2, section 7.2). The enumeration has five values and only `Accepted` permits
208+
* charging: `Blocked` and `Expired` are refusals of a known identifier,
209+
* `Invalid` means the identifier is unknown, and `ConcurrentTx` means it is
210+
* already in another transaction. Section 7.2 marks `ConcurrentTx` as only
211+
* relevant to `StartTransaction.req`, so seeing it on an `Authorize` response is
212+
* itself irregular, but it is still a refusal.
213+
*/
214+
const AUTHORIZATION_REFUSAL_STATUSES = new Set(['Blocked', 'Expired', 'Invalid', 'ConcurrentTx']);
215+
204216
/**
205217
* Rule 1: FAILED_AUTHORIZATION
206-
* Detects Authorize responses where idTagInfo.status is "Invalid".
218+
* Detects Authorize responses carrying an idTagInfo.status that refuses charging.
207219
*/
208220
function detectFailedAuthorization(events: Event[]): Failure[] {
209221
const failures: Failure[] = [];
@@ -222,10 +234,10 @@ function detectFailedAuthorization(events: Event[]): Failure[] {
222234
if (!matchingCall) continue;
223235

224236
const status = getAuthorizeStatus(event);
225-
if (status === 'Invalid') {
237+
if (status !== null && AUTHORIZATION_REFUSAL_STATUSES.has(status)) {
226238
failures.push({
227239
code: 'FAILED_AUTHORIZATION',
228-
description: `Authorization rejected: idTag returned "Invalid" status (messageId: ${event.messageId})`,
240+
description: `Authorization rejected: idTag returned "${status}" status (messageId: ${event.messageId})`,
229241
severity: SEVERITY.FAILED_AUTHORIZATION,
230242
eventIds: [matchingCall.id, event.id],
231243
suggestedSteps: SUGGESTED_STEPS.FAILED_AUTHORIZATION,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
export default {
2+
name: 'refused-authorization',
3+
description:
4+
'Authorization refused three times with the non-Invalid statuses of OCPP 1.6 section 7.2: Blocked, Expired, ConcurrentTx. StartTransaction is not attempted. Expects FAILED_AUTHORIZATION failure.',
5+
trace: {
6+
traceId: 'scenario-refused-authorization',
7+
metadata: {
8+
stationId: 'CS-SYNTHETIC-017',
9+
ocppVersion: '1.6',
10+
source: 'synthetic-scenario',
11+
description:
12+
'Station boots, connector prepares, then three idTags are refused in turn: one Blocked, one Expired, one ConcurrentTx. Section 7.2 marks ConcurrentTx as relevant to StartTransaction, so an Authorize response carrying it is irregular; it is included because the rule treats every non-Accepted status as a refusal wherever it appears. The connector returns to Available without a transaction.',
13+
},
14+
events: [
15+
{
16+
timestamp: '2026-01-15T09:00:00.000Z',
17+
direction: 'CS_TO_CSMS',
18+
message: [
19+
2,
20+
'msg-001',
21+
'BootNotification',
22+
{
23+
chargePointVendor: 'SyntheticVendor',
24+
chargePointModel: 'SM-100',
25+
chargePointSerialNumber: 'CS-SYNTHETIC-017',
26+
firmwareVersion: '1.0.0',
27+
},
28+
],
29+
},
30+
{
31+
timestamp: '2026-01-15T09:00:00.500Z',
32+
direction: 'CSMS_TO_CS',
33+
message: [
34+
3,
35+
'msg-001',
36+
{
37+
currentTime: '2026-01-15T09:00:00.500Z',
38+
interval: 300,
39+
status: 'Accepted',
40+
},
41+
],
42+
},
43+
{
44+
timestamp: '2026-01-15T09:00:05.000Z',
45+
direction: 'CS_TO_CSMS',
46+
message: [
47+
2,
48+
'msg-002',
49+
'StatusNotification',
50+
{ connectorId: 0, status: 'Available', errorCode: 'NoError' },
51+
],
52+
},
53+
{
54+
timestamp: '2026-01-15T09:00:05.500Z',
55+
direction: 'CSMS_TO_CS',
56+
message: [3, 'msg-002', {}],
57+
},
58+
{
59+
timestamp: '2026-01-15T09:00:10.000Z',
60+
direction: 'CS_TO_CSMS',
61+
message: [
62+
2,
63+
'msg-003',
64+
'StatusNotification',
65+
{ connectorId: 1, status: 'Preparing', errorCode: 'NoError' },
66+
],
67+
},
68+
{
69+
timestamp: '2026-01-15T09:00:10.500Z',
70+
direction: 'CSMS_TO_CS',
71+
message: [3, 'msg-003', {}],
72+
},
73+
{
74+
timestamp: '2026-01-15T09:00:20.000Z',
75+
direction: 'CS_TO_CSMS',
76+
message: [2, 'msg-004', 'Authorize', { idTag: 'SYNTHETIC-TAG-201' }],
77+
},
78+
{
79+
timestamp: '2026-01-15T09:00:20.500Z',
80+
direction: 'CSMS_TO_CS',
81+
message: [3, 'msg-004', { idTagInfo: { status: 'Blocked' } }],
82+
},
83+
{
84+
timestamp: '2026-01-15T09:00:35.000Z',
85+
direction: 'CS_TO_CSMS',
86+
message: [2, 'msg-005', 'Authorize', { idTag: 'SYNTHETIC-TAG-202' }],
87+
},
88+
{
89+
timestamp: '2026-01-15T09:00:35.500Z',
90+
direction: 'CSMS_TO_CS',
91+
message: [3, 'msg-005', { idTagInfo: { status: 'Expired' } }],
92+
},
93+
{
94+
timestamp: '2026-01-15T09:00:50.000Z',
95+
direction: 'CS_TO_CSMS',
96+
message: [2, 'msg-006', 'Authorize', { idTag: 'SYNTHETIC-TAG-203' }],
97+
},
98+
{
99+
timestamp: '2026-01-15T09:00:50.500Z',
100+
direction: 'CSMS_TO_CS',
101+
message: [3, 'msg-006', { idTagInfo: { status: 'ConcurrentTx' } }],
102+
},
103+
{
104+
timestamp: '2026-01-15T09:01:05.000Z',
105+
direction: 'CS_TO_CSMS',
106+
message: [
107+
2,
108+
'msg-007',
109+
'StatusNotification',
110+
{ connectorId: 1, status: 'Available', errorCode: 'NoError' },
111+
],
112+
},
113+
{
114+
timestamp: '2026-01-15T09:01:05.500Z',
115+
direction: 'CSMS_TO_CS',
116+
message: [3, 'msg-007', {}],
117+
},
118+
],
119+
},
120+
expectedFailures: ['FAILED_AUTHORIZATION'],
121+
assertions: [{ type: 'failure_count', params: { code: 'FAILED_AUTHORIZATION', min: 3 } }],
122+
};

‎packages/toolkit/src/scenarios/index.test.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
unresponsiveCsmsScenario,
2121
firmwareUpdateSuccessScenario,
2222
firmwareUpdateFailureScenario,
23+
refusedAuthorizationScenario,
2324
} from './index.js';
2425
import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.js';
2526

@@ -28,8 +29,8 @@ import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.
2829
// ---------------------------------------------------------------------------
2930

3031
describe('scenario registry', () => {
31-
it('exports exactly 17 scenarios', () => {
32-
expect(scenarios).toHaveLength(17);
32+
it('exports exactly 18 scenarios', () => {
33+
expect(scenarios).toHaveLength(18);
3334
});
3435

3536
it('exports scenario names in order', () => {
@@ -51,6 +52,7 @@ describe('scenario registry', () => {
5152
'unresponsive-csms',
5253
'firmware-update-success',
5354
'firmware-update-failure',
55+
'refused-authorization',
5456
]);
5557
});
5658

@@ -213,6 +215,21 @@ describe('scenario engine integration', () => {
213215
const failures = detectFailures(result.events, sessions);
214216
expect(failures.some((f) => f.code === 'FIRMWARE_UPDATE_FAILURE')).toBe(true);
215217
});
218+
219+
it('refused-authorization: detects FAILED_AUTHORIZATION for each non-Invalid refusal', () => {
220+
const trace = JSON.stringify(refusedAuthorizationScenario.trace);
221+
const result = parseTrace(trace);
222+
const sessions = buildSessionTimeline(result.events);
223+
const failures = detectFailures(result.events, sessions).filter(
224+
(f) => f.code === 'FAILED_AUTHORIZATION',
225+
);
226+
expect(failures).toHaveLength(3);
227+
expect(failures.map((f) => f.description.match(/"(\w+)" status/)?.[1])).toEqual([
228+
'Blocked',
229+
'Expired',
230+
'ConcurrentTx',
231+
]);
232+
});
216233
});
217234

218235
// ---------------------------------------------------------------------------

‎packages/toolkit/src/scenarios/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import heartbeatIrregular from './__scenarios__/heartbeat-irregular.js';
2222
import unresponsiveCsms from './__scenarios__/unresponsive-csms.js';
2323
import firmwareUpdateSuccess from './__scenarios__/firmware-update-success.js';
2424
import firmwareUpdateFailure from './__scenarios__/firmware-update-failure.js';
25+
import refusedAuthorization from './__scenarios__/refused-authorization.js';
2526

2627
// ---------------------------------------------------------------------------
2728
// Scenarios derived from core fixtures
@@ -70,6 +71,7 @@ const heartbeatIrregularScenario: Scenario = heartbeatIrregular as unknown as Sc
7071
const unresponsiveCsmsScenario: Scenario = unresponsiveCsms as unknown as Scenario;
7172
const firmwareUpdateSuccessScenario: Scenario = firmwareUpdateSuccess as unknown as Scenario;
7273
const firmwareUpdateFailureScenario: Scenario = firmwareUpdateFailure as unknown as Scenario;
74+
const refusedAuthorizationScenario: Scenario = refusedAuthorization as unknown as Scenario;
7375

7476
// ---------------------------------------------------------------------------
7577
// Registry
@@ -93,6 +95,7 @@ export const scenarios = [
9395
unresponsiveCsmsScenario,
9496
firmwareUpdateSuccessScenario,
9597
firmwareUpdateFailureScenario,
98+
refusedAuthorizationScenario,
9699
] as const;
97100

98101
export const scenarioNames = [
@@ -113,6 +116,7 @@ export const scenarioNames = [
113116
'unresponsive-csms',
114117
'firmware-update-success',
115118
'firmware-update-failure',
119+
'refused-authorization',
116120
] as const;
117121

118122
export {
@@ -133,6 +137,7 @@ export {
133137
unresponsiveCsmsScenario,
134138
firmwareUpdateSuccessScenario,
135139
firmwareUpdateFailureScenario,
140+
refusedAuthorizationScenario,
136141
};
137142

138143
export { compareScenarioReports } from './compare.js';

‎tests/external-fixture/test.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ const scenarios = await import('@ocpp-debugkit/toolkit/scenarios');
158158

159159
assert(Array.isArray(scenarios.scenarios), 'scenarios is an array');
160160
assert(
161-
scenarios.scenarios.length === 17,
162-
`17 scenarios exported (got ${scenarios.scenarios.length})`,
161+
scenarios.scenarios.length === 18,
162+
`18 scenarios exported (got ${scenarios.scenarios.length})`,
163163
);
164164
assert(typeof scenarios.getScenario === 'function', 'getScenario is a function');
165165
assert(scenarios.getScenario('normal-session') !== undefined, 'normal-session scenario exists');

0 commit comments

Comments
 (0)