From a3092eac71eeb3070de4fb0ab613dc479fd5f50d Mon Sep 17 00:00:00 2001 From: "Andrew D.Laptev" Date: Thu, 20 Aug 2026 16:27:46 +0300 Subject: [PATCH] feat: AccessRules class --- __tests__/accessrules.test.ts | 164 +++++++++++++++++++++++++++++++++ src/accessrules.ts | 167 ++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/onvif.ts | 12 +++ src/service.ts | 1 + 5 files changed, 345 insertions(+) create mode 100644 __tests__/accessrules.test.ts create mode 100644 src/accessrules.ts diff --git a/__tests__/accessrules.test.ts b/__tests__/accessrules.test.ts new file mode 100644 index 00000000..877c9acb --- /dev/null +++ b/__tests__/accessrules.test.ts @@ -0,0 +1,164 @@ +import { Onvif } from '../src'; +import { AccessProfile } from '../src/interfaces/accessrules'; + +const ACCESS_PROFILE_TOKEN_1 = 'AccessProfileToken_1'; +const ACCESS_POINT_TOKEN_1 = 'AccessPointToken_1'; + +let cam: Onvif; +const createdAccessProfileTokens: string[] = []; + +beforeAll(async () => { + cam = new Onvif({ + hostname: '127.0.0.1', + username: 'admin', + password: 'admin', + port: 8000, + }); + await cam.connect(); +}); + +afterEach(async () => { + while (createdAccessProfileTokens.length > 0) { + const token = createdAccessProfileTokens.pop()!; + try { + await cam.accessRules.deleteAccessProfile({ token }); + } catch { + // already deleted + } + } +}); + +describe('AccessRules', () => { + beforeAll(() => { + if (!cam.uri.accessrules) { + throw new Error('AccessRules service is not available on the test device'); + } + }); + + describe('getServiceCapabilities', () => { + it('should return access rules service capabilities as an object', async () => { + const caps = await cam.accessRules.getServiceCapabilities(); + expect(caps).toBeDefined(); + expect(typeof caps).toBe('object'); + expect(Array.isArray(caps)).toBe(false); + }); + + it('should return capability flags from the happytime mock server', async () => { + const caps = await cam.accessRules.getServiceCapabilities(); + expect(caps.maxLimit).toBe(10); + expect(caps.maxAccessProfiles).toBe(10); + expect(caps.maxAccessPoliciesPerAccessProfile).toBe(1); + expect(caps.multipleSchedulesPerAccessPointSupported).toBe(true); + expect(caps.clientSuppliedTokenSupported).toBe(true); + }); + }); + + describe('getAccessProfileInfoList / getAccessProfileInfo', () => { + it('should return access profile info list from the mock server', async () => { + const list = await cam.accessRules.getAccessProfileInfoList(); + expect(list.accessProfileInfo?.length).toBeGreaterThanOrEqual(1); + expect(list.accessProfileInfo?.[0]).toHaveProperty('token'); + expect(list.accessProfileInfo?.[0]).toHaveProperty('name'); + expect(list.accessProfileInfo?.[0].token).toBe(ACCESS_PROFILE_TOKEN_1); + }); + + it('should return access profile info for requested tokens', async () => { + const response = await cam.accessRules.getAccessProfileInfo({ + token: [ACCESS_PROFILE_TOKEN_1], + }); + expect(response.accessProfileInfo?.length).toBe(1); + expect(response.accessProfileInfo?.[0].token).toBe(ACCESS_PROFILE_TOKEN_1); + expect(response.accessProfileInfo?.[0].name).toBe('AccessProfileName_1'); + expect(response.accessProfileInfo?.[0].description).toBe('test'); + }); + }); + + describe('getAccessProfileList / getAccessProfiles', () => { + it('should return access profile list from the mock server', async () => { + const list = await cam.accessRules.getAccessProfileList(); + expect(list.accessProfile?.length).toBeGreaterThanOrEqual(1); + expect(list.accessProfile?.[0].accessPolicy?.length).toBeGreaterThanOrEqual(1); + expect(list.accessProfile?.[0].accessPolicy?.[0].entity).toBe(ACCESS_POINT_TOKEN_1); + }); + + it('should return access profiles for requested tokens', async () => { + const response = await cam.accessRules.getAccessProfiles({ + token: [ACCESS_PROFILE_TOKEN_1], + }); + expect(response.accessProfile?.length).toBe(1); + expect(response.accessProfile?.[0].token).toBe(ACCESS_PROFILE_TOKEN_1); + expect(response.accessProfile?.[0].name).toBe('AccessProfileName_1'); + expect(response.accessProfile?.[0].accessPolicy?.[0].scheduleToken).toBe('test'); + }); + + it('should return empty list for an unknown token', async () => { + const response = await cam.accessRules.getAccessProfiles({ token: ['InvalidToken'] }); + expect(response.accessProfile ?? []).toHaveLength(0); + }); + }); + + describe('createAccessProfile / modifyAccessProfile / deleteAccessProfile', () => { + it('should create, modify, and delete an access profile', async () => { + const accessProfile: AccessProfile = { + token: '', + name: 'TempAccessProfile', + description: 'temp profile', + accessPolicy: [ + { + scheduleToken: 'ScheduleToken_1', + entity: ACCESS_POINT_TOKEN_1, + }, + ], + }; + + const token = await cam.accessRules.createAccessProfile({ accessProfile }); + createdAccessProfileTokens.push(token); + expect(token).toBeDefined(); + + await cam.accessRules.modifyAccessProfile({ + accessProfile: { + ...accessProfile, + token, + name: 'TempAccessProfileModified', + description: 'modified', + }, + }); + + const response = await cam.accessRules.getAccessProfiles({ token: [token] }); + expect(response.accessProfile?.[0].name).toBe('TempAccessProfileModified'); + expect(response.accessProfile?.[0].description).toBe('modified'); + expect(response.accessProfile?.[0].accessPolicy?.[0].entity).toBe(ACCESS_POINT_TOKEN_1); + + await cam.accessRules.deleteAccessProfile({ token }); + createdAccessProfileTokens.pop(); + + const afterDelete = await cam.accessRules.getAccessProfiles({ token: [token] }); + expect(afterDelete.accessProfile ?? []).toHaveLength(0); + }); + }); + + describe('setAccessProfile', () => { + it('should create or replace an access profile with a client-supplied token', async () => { + const token = 'ClientSuppliedAccessProfileToken'; + createdAccessProfileTokens.push(token); + + await cam.accessRules.setAccessProfile({ + accessProfile: { + token, + name: 'SetAccessProfile', + description: 'set via client token', + accessPolicy: [ + { + scheduleToken: 'ScheduleToken_1', + entity: ACCESS_POINT_TOKEN_1, + }, + ], + }, + }); + + const response = await cam.accessRules.getAccessProfiles({ token: [token] }); + expect(response.accessProfile?.[0].token).toBe(token); + expect(response.accessProfile?.[0].name).toBe('SetAccessProfile'); + }); + }); +}); diff --git a/src/accessrules.ts b/src/accessrules.ts new file mode 100644 index 00000000..6f16586a --- /dev/null +++ b/src/accessrules.ts @@ -0,0 +1,167 @@ +/** + * AccessRules ver10 module + * @author Andrew D.Laptev + * @see https://www.onvif.org/ver10/accessrules/wsdl/accessrules.wsdl + */ + +import { Onvif } from './onvif'; +import Service from './service'; +import { + AccessPolicy, + AccessProfile, + AccessProfileInfo, + Capabilities, + CreateAccessProfile, + CreateAccessProfileResponse, + DeleteAccessProfile, + GetAccessProfileInfo, + GetAccessProfileInfoList, + GetAccessProfileInfoListResponse, + GetAccessProfileInfoResponse, + GetAccessProfileList, + GetAccessProfileListResponse, + GetAccessProfiles, + GetAccessProfilesResponse, + ModifyAccessProfile, + SetAccessProfile, +} from './interfaces/accessrules'; + +/** + * AccessRules service + * @example + * ```ts + * const list = await cam.accessRules.getAccessProfileInfoList(); + * const token = list.accessProfileInfo![0].token; + * console.log((await cam.accessRules.getAccessProfiles({ token: [token] })).accessProfile); + * ``` + */ +export default class AccessRules extends Service { + constructor(onvif: Onvif) { + super(onvif, 'accessrules'); + } + + private static accessPolicyToBuild(policy: AccessPolicy) { + return { + ScheduleToken: policy.scheduleToken, + Entity: policy.entity, + ...(policy.entityType !== undefined && { EntityType: policy.entityType }), + ...(policy.extension && { Extension: policy.extension }), + }; + } + + private static accessProfileInfoToBuild(profile: AccessProfileInfo | AccessProfile) { + return { + $: { token: profile.token }, + Name: profile.name, + ...(profile.description && { Description: profile.description }), + }; + } + + private static accessProfileToBuild(profile: AccessProfile) { + return { + ...AccessRules.accessProfileInfoToBuild(profile), + ...(profile.accessPolicy && { + AccessPolicy: profile.accessPolicy.map(AccessRules.accessPolicyToBuild), + }), + ...(profile.extension && { Extension: profile.extension }), + }; + } + + /** + * Returns the capabilities of the access rules service. + */ + async getServiceCapabilities(): Promise { + const response = await this.request({ GetServiceCapabilities: {} }); + return response.getServiceCapabilitiesResponse?.capabilities ?? {}; + } + + /** + * Returns access profile info items for the requested tokens. + * @param options + */ + async getAccessProfileInfo({ token }: GetAccessProfileInfo): Promise { + const response = await this.request({ GetAccessProfileInfo: { Token: token } }, { array: ['accessProfileInfo'] }); + return response.getAccessProfileInfoResponse ?? {}; + } + + /** + * Returns a list of access profile info items. + * @param options + */ + async getAccessProfileInfoList(options: GetAccessProfileInfoList = {}): Promise { + const response = await this.request( + { + GetAccessProfileInfoList: { + ...(options.limit !== undefined && { Limit: options.limit }), + ...(options.startReference && { StartReference: options.startReference }), + }, + }, + { array: ['accessProfileInfo'] }, + ); + return response.getAccessProfileInfoListResponse ?? {}; + } + + /** + * Returns access profile items for the requested tokens. + * @param options + */ + async getAccessProfiles({ token }: GetAccessProfiles): Promise { + const response = await this.request( + { GetAccessProfiles: { Token: token } }, + { array: ['accessProfile', 'accessPolicy'] }, + ); + return response.getAccessProfilesResponse ?? {}; + } + + /** + * Returns a list of access profile items. + * @param options + */ + async getAccessProfileList(options: GetAccessProfileList = {}): Promise { + const response = await this.request( + { + GetAccessProfileList: { + ...(options.limit !== undefined && { Limit: options.limit }), + ...(options.startReference && { StartReference: options.startReference }), + }, + }, + { array: ['accessProfile', 'accessPolicy'] }, + ); + return response.getAccessProfileListResponse ?? {}; + } + + /** + * Creates a new access profile. + * @param options + */ + async createAccessProfile({ accessProfile }: CreateAccessProfile): Promise { + const response = await this.request({ + CreateAccessProfile: { AccessProfile: AccessRules.accessProfileToBuild(accessProfile) }, + }); + return response.createAccessProfileResponse.token; + } + + /** + * Creates or replaces an access profile (requires ClientSuppliedTokenSupported). + * @param options + */ + async setAccessProfile({ accessProfile }: SetAccessProfile): Promise { + await this.request({ SetAccessProfile: { AccessProfile: AccessRules.accessProfileToBuild(accessProfile) } }); + } + + /** + * Modifies an existing access profile. + * @param options + */ + async modifyAccessProfile({ accessProfile }: ModifyAccessProfile): Promise { + await this.request({ ModifyAccessProfile: { AccessProfile: AccessRules.accessProfileToBuild(accessProfile) } }); + } + + /** + * Deletes an access profile. + * @param options + */ + async deleteAccessProfile({ token }: DeleteAccessProfile): Promise { + await this.request({ DeleteAccessProfile: { Token: token } }); + } +} diff --git a/src/index.ts b/src/index.ts index 5e85bc07..c6cd540c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { default as Recording } from './recording'; export { default as DoorControl } from './doorcontrol'; export { default as AccessControl } from './accesscontrol'; export { default as Credential } from './credential'; +export { default as AccessRules } from './accessrules'; export { default as Thermal } from './thermal'; export { default as Analytics } from './analytics'; export { default as DeviceIO } from './deviceio'; diff --git a/src/onvif.ts b/src/onvif.ts index 26c665d8..c1233a78 100644 --- a/src/onvif.ts +++ b/src/onvif.ts @@ -25,6 +25,7 @@ import type Recording from './recording'; import type DoorControl from './doorcontrol'; import type AccessControl from './accesscontrol'; import type Credential from './credential'; +import type AccessRules from './accessrules'; import type Thermal from './thermal'; import type Analytics from './analytics'; import type DeviceIO from './deviceio'; @@ -77,6 +78,7 @@ export interface OnvifServices { doorcontrol?: URL; accesscontrol?: URL; credential?: URL; + accessrules?: URL; thermal?: URL; actionengine?: URL; search?: URL; @@ -345,6 +347,15 @@ export class Onvif extends EventEmitter { * ``` */ public readonly credential: Credential; + /** + * AccessRules namespace for accessrules v1.0 methods + * @example + * ```typescript + * const list = await onvif.accessRules.getAccessProfileInfoList(); + * console.log(list); + * ``` + */ + public readonly accessRules: AccessRules; /** * Thermal namespace for thermal v1.0 methods * @example @@ -495,6 +506,7 @@ export class Onvif extends EventEmitter { this.doorControl = createLazy(this, () => import('./doorcontrol')); this.accessControl = createLazy(this, () => import('./accesscontrol')); this.credential = createLazy(this, () => import('./credential')); + this.accessRules = createLazy(this, () => import('./accessrules')); this.thermal = createLazy(this, () => import('./thermal')); this.analytics = createLazy(this, () => import('./analytics')); this.deviceIO = createLazy(this, () => import('./deviceio')); diff --git a/src/service.ts b/src/service.ts index 6001b38c..6ea1bf76 100644 --- a/src/service.ts +++ b/src/service.ts @@ -24,6 +24,7 @@ const XMLNS: Record = { doorcontrol: 'http://www.onvif.org/ver10/doorcontrol/wsdl', accesscontrol: 'http://www.onvif.org/ver10/accesscontrol/wsdl', credential: 'http://www.onvif.org/ver10/credential/wsdl', + accessrules: 'http://www.onvif.org/ver10/accessrules/wsdl', thermal: 'http://www.onvif.org/ver10/thermal/wsdl', search: 'http://www.onvif.org/ver10/search/wsdl', analyticsdevice: 'http://www.onvif.org/ver10/analyticsdevice/wsdl',