ring-client-api 14.0.0-beta.0 → 14.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ import { http, HttpResponse } from 'msw';
2
+ import { setupServer } from 'msw/node';
3
+ import { RingRestClient } from "../rest-client.js";
4
+ import { clearTimeouts, getHardwareId, toBase64 } from "../util.js";
5
+ import { firstValueFrom } from 'rxjs';
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, } from 'vitest';
7
+ let sessionCreatedCount = 0, client;
8
+ const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoFactorAuthCode = '123456', hardwareIdPromise = getHardwareId(), accessToken = 'ey__accees_token', secondAccessToken = 'ey__second_accees_token', refreshToken = 'ey__refresh_token', secondRefreshToken = 'ey__second_refresh_token', thirdRefreshToken = 'ey__third_refresh_token', server = setupServer(http.post('https://oauth.ring.com/oauth/token', async ({ request: req }) => {
9
+ const body = await req.json();
10
+ if (req.headers.get('2fa-support') !== 'true' ||
11
+ req.headers.get('User-Agent') !== 'android:com.ringapp' ||
12
+ req.headers.get('hardware_id') !== (await hardwareIdPromise)) {
13
+ return HttpResponse.json({
14
+ code: 1,
15
+ error: 'Invalid auth headers: ' + JSON.stringify(req.headers, null, 2),
16
+ }, { status: 400 });
17
+ }
18
+ if (body.grant_type === 'refresh_token') {
19
+ if (body.refresh_token === refreshToken) {
20
+ // Valid refresh token
21
+ return HttpResponse.json({
22
+ access_token: accessToken,
23
+ expires_in: 3600,
24
+ refresh_token: secondRefreshToken,
25
+ scope: 'client',
26
+ token_type: 'Bearer',
27
+ }, { status: 200 });
28
+ }
29
+ if (body.refresh_token === secondRefreshToken) {
30
+ // Valid refresh token
31
+ return HttpResponse.json({
32
+ access_token: secondAccessToken,
33
+ expires_in: 3600,
34
+ refresh_token: thirdRefreshToken,
35
+ scope: 'client',
36
+ token_type: 'Bearer',
37
+ }, { status: 200 });
38
+ }
39
+ // Invalid refresh token
40
+ return HttpResponse.json({
41
+ error: 'invalid_grant',
42
+ error_description: 'token is invalid or does not exists',
43
+ }, { status: 401 });
44
+ }
45
+ if (body.grant_type !== 'password' ||
46
+ body.client_id !== 'ring_official_android' ||
47
+ body.scope !== 'client') {
48
+ return HttpResponse.json({
49
+ code: 1,
50
+ error: 'Invalid auth request: ' + JSON.stringify(body),
51
+ }, { status: 400 });
52
+ }
53
+ if (body.username !== email || body.password !== password) {
54
+ // Wrong username or password
55
+ return HttpResponse.json({
56
+ error: 'access_denied',
57
+ error_description: 'invalid user credentials',
58
+ }, { status: 401 });
59
+ }
60
+ if (req.headers.get('2fa-code') &&
61
+ req.headers.get('2fa-code') !== twoFactorAuthCode) {
62
+ // Wrong 2fa code
63
+ return HttpResponse.json({
64
+ err_msg: 'bad request response from dependency service',
65
+ error: 'Verification Code is invalid or expired',
66
+ }, { status: 400 });
67
+ }
68
+ if (req.headers.get('2fa-code') === twoFactorAuthCode) {
69
+ // Successfull login with correct 2fa code
70
+ return HttpResponse.json({
71
+ access_token: accessToken,
72
+ expires_in: 3600,
73
+ refresh_token: refreshToken,
74
+ scope: 'client',
75
+ token_type: 'Bearer',
76
+ responseTimestamp: Date.now(),
77
+ });
78
+ }
79
+ // 2fa code not provided, so return the 2fa prompt
80
+ return HttpResponse.json({
81
+ next_time_in_secs: 60,
82
+ phone,
83
+ tsv_state: 'sms',
84
+ }, { status: 412 });
85
+ }), http.post('https://api.ring.com/clients_api/session', async ({ request: req }) => {
86
+ const authHeader = req.headers.get('Authorization');
87
+ if (authHeader !== `Bearer ${accessToken}` &&
88
+ authHeader !== `Bearer ${secondAccessToken}`) {
89
+ // Invalid access token used
90
+ return HttpResponse.json({}, { status: 401 });
91
+ }
92
+ const body = await req.json();
93
+ if (body.device.hardware_id !== (await getHardwareId()) ||
94
+ body.device.metadata.api_version !== 11 ||
95
+ body.device.metadata.device_model !== 'ring-client-api' ||
96
+ body.device.os !== 'android') {
97
+ return HttpResponse.text('Bad session request: ' + JSON.stringify(body, null, 2), { status: 400 });
98
+ }
99
+ // Fake a response from the session endpoint, incrementing the sessionCreatedCount
100
+ sessionCreatedCount++;
101
+ return HttpResponse.json({
102
+ profile: {
103
+ id: 1234,
104
+ },
105
+ });
106
+ }));
107
+ async function wrapRefreshToken(rt) {
108
+ return toBase64(JSON.stringify({
109
+ rt,
110
+ hid: await hardwareIdPromise,
111
+ }));
112
+ }
113
+ beforeEach(() => {
114
+ sessionCreatedCount = 0;
115
+ });
116
+ beforeAll(() => {
117
+ // Establish requests interception layer before all tests.
118
+ server.listen();
119
+ });
120
+ afterAll(() => {
121
+ // Clean up after all tests are done, preventing this
122
+ // interception layer from affecting irrelevant tests.
123
+ server.close();
124
+ });
125
+ afterEach(() => {
126
+ client.clearTimeouts();
127
+ clearTimeouts();
128
+ });
129
+ describe('getAuth', () => {
130
+ it('should throw and set the 2fa prompt', async () => {
131
+ client = new RingRestClient({
132
+ password,
133
+ email,
134
+ });
135
+ await expect(() => client.getAuth()).rejects.toThrow('Your Ring account is configured to use 2-factor authentication (2fa). See https://github.com/dgreif/ring/wiki/Refresh-Tokens for details.');
136
+ expect(client.promptFor2fa).toEqual(`Please enter the code sent to ${phone} via sms`);
137
+ expect(client.using2fa).toEqual(true);
138
+ });
139
+ it('should accept a 2fa code', async () => {
140
+ client = new RingRestClient({
141
+ password,
142
+ email,
143
+ });
144
+ // ignore the first reject, it's tested above
145
+ await expect(() => client.getAuth()).rejects.toThrow();
146
+ // call getAuth again with the 2fa code, which should succeed
147
+ const auth = await client.getAuth(twoFactorAuthCode);
148
+ expect(auth).toMatchObject({
149
+ access_token: accessToken,
150
+ refresh_token: await wrapRefreshToken(refreshToken),
151
+ });
152
+ expect(client.refreshToken).toEqual(await wrapRefreshToken(refreshToken));
153
+ });
154
+ it('should handle invalid credentials', async () => {
155
+ client = new RingRestClient({
156
+ password: 'incorrect password',
157
+ email,
158
+ });
159
+ await expect(() => client.getAuth()).rejects.toThrow('Failed to fetch oauth token from Ring. Verify that your email and password are correct. (error: access_denied)');
160
+ });
161
+ it('should handle invalid 2fa code', async () => {
162
+ client = new RingRestClient({
163
+ password,
164
+ email,
165
+ });
166
+ // ignore the first reject, it's it('ove
167
+ await expect(() => client.getAuth()).rejects.toThrow();
168
+ // call getAuth again with an invalid 2fa code, which should fail
169
+ await expect(() => client.getAuth('invalid 2fa code')).rejects.toThrow('Verification Code is invalid or expired');
170
+ expect(client.promptFor2fa).toEqual('Invalid 2fa code entered. Please try again.');
171
+ });
172
+ it('should establish a valid auth token with a valid refresh token', async () => {
173
+ client = new RingRestClient({
174
+ refreshToken,
175
+ });
176
+ expect(await client.getCurrentAuth()).toMatchObject({
177
+ access_token: accessToken,
178
+ refresh_token: await wrapRefreshToken(secondRefreshToken),
179
+ });
180
+ expect(client.refreshToken).toEqual(await wrapRefreshToken(secondRefreshToken));
181
+ });
182
+ it('should emit an event when a new refresh token is created', async () => {
183
+ client = new RingRestClient({
184
+ refreshToken,
185
+ });
186
+ const refreshedPromise = firstValueFrom(client.onRefreshTokenUpdated), auth = await client.getAuth();
187
+ expect(auth).toMatchObject({
188
+ access_token: accessToken,
189
+ refresh_token: await wrapRefreshToken(secondRefreshToken),
190
+ });
191
+ expect(await refreshedPromise).toEqual({
192
+ oldRefreshToken: refreshToken,
193
+ newRefreshToken: await wrapRefreshToken(secondRefreshToken),
194
+ });
195
+ });
196
+ });
197
+ describe('fetch', () => {
198
+ let invalidateFirstAccessToken = false;
199
+ beforeEach(() => {
200
+ invalidateFirstAccessToken = false;
201
+ server.use(http.get('https://api.ring.com/clients_api/some_endpoint', ({ request: req }) => {
202
+ const authHeader = req.headers.get('Authorization');
203
+ if (invalidateFirstAccessToken &&
204
+ authHeader === `Bearer ${accessToken}`) {
205
+ // Original access token used, but no longer valid
206
+ return HttpResponse.json({}, { status: 401 });
207
+ }
208
+ if (authHeader !== `Bearer ${accessToken}` &&
209
+ authHeader !== `Bearer ${secondAccessToken}`) {
210
+ // Invalid access token used
211
+ return HttpResponse.json({}, { status: 401 });
212
+ }
213
+ if (sessionCreatedCount === 0) {
214
+ // Session not created yet
215
+ return HttpResponse.json({
216
+ error: 'Session not found for ' + req.headers.get('hardware_id'),
217
+ }, { status: 404 });
218
+ }
219
+ return HttpResponse.json([]);
220
+ }));
221
+ });
222
+ it('should include the auth token as a header', async () => {
223
+ client = new RingRestClient({
224
+ refreshToken,
225
+ });
226
+ const response = await client.request({
227
+ url: 'https://api.ring.com/clients_api/some_endpoint',
228
+ });
229
+ expect(response).toEqual([]);
230
+ });
231
+ it('should fetch a new auth token if the first is no longer valid', async () => {
232
+ client = new RingRestClient({
233
+ refreshToken,
234
+ });
235
+ invalidateFirstAccessToken = true;
236
+ const response = await client.request({
237
+ url: 'https://api.ring.com/clients_api/some_endpoint',
238
+ });
239
+ expect(response).toEqual([]);
240
+ });
241
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { getBatteryLevel, cleanSnapshotUuid } from "../ring-camera.js";
2
+ import { describe, expect, it } from 'vitest';
3
+ describe('Ring Camera', () => {
4
+ describe('battery level', () => {
5
+ it('should handle string battery life', () => {
6
+ expect(getBatteryLevel({
7
+ battery_life: '49',
8
+ })).toEqual(49);
9
+ });
10
+ it('should handle null battery life', () => {
11
+ expect(getBatteryLevel({ battery_life: null })).toEqual(null);
12
+ });
13
+ it('should handle right battery only', () => {
14
+ expect(getBatteryLevel({ battery_life: null, battery_life_2: 24 })).toEqual(24);
15
+ });
16
+ it('should handle left battery only', () => {
17
+ expect(getBatteryLevel({ battery_life: 76, battery_life_2: null })).toEqual(76);
18
+ });
19
+ it('should handle dual batteries', () => {
20
+ expect(getBatteryLevel({ battery_life: '92', battery_life_2: 84 })).toEqual(84);
21
+ expect(getBatteryLevel({ battery_life: '92', battery_life_2: 100 })).toEqual(92);
22
+ });
23
+ });
24
+ describe('cleanSnapshotUuid', () => {
25
+ it('should return the original uuid if it is already clean', () => {
26
+ expect(cleanSnapshotUuid('c2a0a397-3538-422d-bb4e-51837a56b870')).toBe('c2a0a397-3538-422d-bb4e-51837a56b870');
27
+ });
28
+ it('should return remove anything after :', () => {
29
+ expect(cleanSnapshotUuid('c2a0a397-3538-422d-bb4e-51837a56b870:122429140')).toBe('c2a0a397-3538-422d-bb4e-51837a56b870');
30
+ });
31
+ it('should handle falsy values', () => {
32
+ expect(cleanSnapshotUuid(undefined)).toBe(undefined);
33
+ expect(cleanSnapshotUuid(null)).toBe(null);
34
+ });
35
+ });
36
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ring-client-api",
3
- "version": "14.0.0-beta.0",
3
+ "version": "14.0.0-beta.1",
4
4
  "description": "Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting",
5
5
  "main": "lib/index.js",
6
6
  "type": "module",
@@ -23,26 +23,26 @@
23
23
  "colors": "1.4.0",
24
24
  "debug": "^4.4.0",
25
25
  "json-bigint": "^1.0.0",
26
- "rxjs": "^7.8.1",
26
+ "rxjs": "^7.8.2",
27
27
  "socket.io-client": "^2.5.0",
28
28
  "systeminformation": "^5.25.11",
29
- "undici": "^7.2.1",
30
- "uuid": "^11.0.5",
31
- "werift": "0.21.6",
32
- "ws": "^8.18.0"
29
+ "undici": "^7.8.0",
30
+ "uuid": "^11.1.0",
31
+ "werift": "0.22.1",
32
+ "ws": "^8.18.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/debug": "4.1.12",
36
36
  "@types/json-bigint": "^1.0.4",
37
- "@types/node": "22.10.5",
37
+ "@types/node": "22.14.1",
38
38
  "@types/socket.io-client": "1.4.36",
39
39
  "@types/uuid": "10.0.0",
40
- "@types/ws": "^8.5.13",
40
+ "@types/ws": "^8.18.1",
41
41
  "eslint-config-shared": "*",
42
- "msw": "^2.7.0",
42
+ "msw": "^2.7.5",
43
43
  "tsconfig": "*",
44
- "typescript": "5.7.3",
45
- "vitest": "^2.1.8"
44
+ "typescript": "5.8.3",
45
+ "vitest": "^3.1.1"
46
46
  },
47
47
  "engines": {
48
48
  "node": "^18 || ^20 || ^22"