pryv 3.12.1 → 3.14.2
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.
- package/README.md +42 -8
- package/package.json +1 -1
- package/src/Auth/AuthController.js +366 -13
- package/src/Auth/AuthStates.js +4 -2
- package/src/Auth/LoginMessages.js +70 -1
- package/src/Auth/ProfileStore.js +137 -0
- package/src/Auth/index.js +5 -2
- package/src/Browser/LoginButton.js +509 -33
- package/src/Connection.js +19 -13
- package/src/Service.js +49 -16
- package/src/ServiceAssets.js +3 -0
- package/src/SharedSecrets.js +4 -1
- package/src/index.d.ts +172 -11
- package/src/lib/handoff.js +139 -0
- package/src/lib/pollUrls.js +71 -0
- package/src/utils.js +7 -1
- package/test/AuthController.test.js +321 -5
- package/test/Connection.apiOneError.test.js +105 -0
- package/test/LoginButton.test.js +573 -10
- package/test/LoginButton.urlCleanup.test.js +453 -14
- package/test/LoginMessages.test.js +8 -0
- package/test/ProfileStore.test.js +88 -0
- package/test/Service.accessRequest.test.js +4 -4
- package/test/Service.accessRequestHandoff.test.js +153 -0
- package/test/Service.errorBodies.test.js +82 -0
- package/test/Service.mfa.test.js +4 -4
- package/test/Service.pollUrlByKey.test.js +114 -0
- package/test/browser-index.html +1 -0
package/src/utils.js
CHANGED
|
@@ -303,7 +303,13 @@ const utils = module.exports = {
|
|
|
303
303
|
url.replace(QUERY_REGEXP,
|
|
304
304
|
// @ts-ignore - replace callback is used for side effects
|
|
305
305
|
function (m, key, value) {
|
|
306
|
-
|
|
306
|
+
// a malformed escape (`%` not followed by hex) is kept as is rather
|
|
307
|
+
// than throwing: any page URL reaches this parser
|
|
308
|
+
try {
|
|
309
|
+
vars[key] = decodeURIComponent(value);
|
|
310
|
+
} catch (e) {
|
|
311
|
+
vars[key] = value;
|
|
312
|
+
}
|
|
307
313
|
});
|
|
308
314
|
return vars;
|
|
309
315
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const AuthController = require('../src/Auth/AuthController');
|
|
8
8
|
const AuthStates = require('../src/Auth/AuthStates');
|
|
9
9
|
const Service = require('../src/Service');
|
|
10
|
+
const PryvError = require('../src/lib/PryvError');
|
|
10
11
|
|
|
11
12
|
describe('[ACNX] AuthController', function () {
|
|
12
13
|
this.timeout(15000);
|
|
@@ -59,6 +60,20 @@ describe('[ACNX] AuthController', function () {
|
|
|
59
60
|
authRequest: { requestingAppId: 'test-app' }
|
|
60
61
|
}, service)).to.throw('Missing settings.authRequest.requestedPermissions');
|
|
61
62
|
});
|
|
63
|
+
|
|
64
|
+
it('[ACVE] defaults the request to shared-secret credential hand-off', function () {
|
|
65
|
+
const settings = { authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } };
|
|
66
|
+
// eslint-disable-next-line no-new
|
|
67
|
+
new AuthController(settings, service);
|
|
68
|
+
expect(settings.authRequest.credentialHandoff).to.equal('shared-secret');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('[ACVF] credentialHandoff:\'inline\' opts out and sends no field', function () {
|
|
72
|
+
const settings = { authRequest: { requestingAppId: 'test-app', requestedPermissions: [], credentialHandoff: 'inline' } };
|
|
73
|
+
// eslint-disable-next-line no-new
|
|
74
|
+
new AuthController(settings, service);
|
|
75
|
+
expect(settings.authRequest).to.not.have.property('credentialHandoff');
|
|
76
|
+
});
|
|
62
77
|
});
|
|
63
78
|
|
|
64
79
|
describe('[ACLX] Listeners', function () {
|
|
@@ -133,8 +148,176 @@ describe('[ACNX] AuthController', function () {
|
|
|
133
148
|
});
|
|
134
149
|
});
|
|
135
150
|
|
|
136
|
-
describe('[
|
|
137
|
-
|
|
151
|
+
describe('[ACUX] account app URL and signOut', function () {
|
|
152
|
+
function controller (extraSettings) {
|
|
153
|
+
return new AuthController(Object.assign({
|
|
154
|
+
authRequest: { requestingAppId: 'test-app', requestedPermissions: [] }
|
|
155
|
+
}, extraSettings), service);
|
|
156
|
+
}
|
|
157
|
+
const profile = (base) => base + '/account/profile?pryvServiceInfoUrl=' + encodeURIComponent(testData.serviceInfoUrl);
|
|
158
|
+
|
|
159
|
+
it('[ACUA] resolves settings.accountUrl, then the service account, then the auth page URL', async function () {
|
|
160
|
+
const auth = controller({ accountUrl: 'https://own.example.com/' });
|
|
161
|
+
await auth.init();
|
|
162
|
+
auth.serviceInfo = Object.assign({}, auth.serviceInfo, { account: 'https://account.example.com' });
|
|
163
|
+
auth._authUrl = 'https://ui.example.com/auth?key=abc';
|
|
164
|
+
expect(auth.accountUrl()).to.equal(profile('https://own.example.com'));
|
|
165
|
+
delete auth.settings.accountUrl;
|
|
166
|
+
expect(auth.accountUrl()).to.equal(profile('https://account.example.com'));
|
|
167
|
+
delete auth.serviceInfo.account;
|
|
168
|
+
expect(auth.accountUrl()).to.equal(profile('https://ui.example.com'));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('[ACUB] is null when nothing names the account app', async function () {
|
|
172
|
+
const auth = controller();
|
|
173
|
+
await auth.init();
|
|
174
|
+
auth.serviceInfo = Object.assign({}, auth.serviceInfo);
|
|
175
|
+
delete auth.serviceInfo.account;
|
|
176
|
+
expect(auth.accountUrl()).to.equal(null);
|
|
177
|
+
for (const authUrl of ['https://ui.example.com/access/login?key=x', 'not a url', 'https://ui.example.com/authorize']) {
|
|
178
|
+
auth._authUrl = authUrl;
|
|
179
|
+
expect(auth.accountUrl(), authUrl).to.equal(null);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('[ACUC] openAccountApp opens the profile page in a new tab without an opener', async function () {
|
|
184
|
+
const auth = controller({ accountUrl: 'https://own.example.com' });
|
|
185
|
+
await auth.init();
|
|
186
|
+
const opened = [];
|
|
187
|
+
const original = window.open;
|
|
188
|
+
window.open = (...args) => { opened.push(args); return null; };
|
|
189
|
+
try {
|
|
190
|
+
expect(auth.openAccountApp()).to.equal(profile('https://own.example.com'));
|
|
191
|
+
} finally { window.open = original; }
|
|
192
|
+
expect(opened).to.deep.equal([[profile('https://own.example.com'), '_blank', 'noopener']]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('[ACUD] signOut emits SIGNOUT once, clears the stored credentials and re-initializes', async function () {
|
|
196
|
+
const states = [];
|
|
197
|
+
let deleted = 0;
|
|
198
|
+
const button = {
|
|
199
|
+
getAuthorizationData: () => null,
|
|
200
|
+
deleteAuthorizationData: async () => { deleted++; },
|
|
201
|
+
onStateChange: async () => {},
|
|
202
|
+
onClick: () => {}
|
|
203
|
+
};
|
|
204
|
+
const auth = new AuthController({
|
|
205
|
+
authRequest: { requestingAppId: 'test-app', requestedPermissions: [] },
|
|
206
|
+
onStateChange: (s) => states.push(s.status)
|
|
207
|
+
}, service, button);
|
|
208
|
+
await auth.init();
|
|
209
|
+
auth._state = { status: AuthStates.AUTHORIZED, username: 'u' };
|
|
210
|
+
states.length = 0;
|
|
211
|
+
await auth.signOut();
|
|
212
|
+
expect(states.filter((s) => s === AuthStates.SIGNOUT)).to.have.lengthOf(1);
|
|
213
|
+
expect(deleted).to.equal(1);
|
|
214
|
+
expect(auth.state.status).to.equal(AuthStates.INITIALIZED);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('[ACUF] the auth page URL of a sign-in locates the account app, and survives a reload', async function () {
|
|
218
|
+
const utils = require('../src/utils');
|
|
219
|
+
const { fetchPost, fetchGet } = utils;
|
|
220
|
+
utils.fetchPost = async () => ({ response: { ok: true }, body: { status: AuthStates.NEED_SIGNIN, key: 'k1', poll: 'https://core.example.com/reg/access/k1', poll_rate_ms: 1, authUrl: 'https://ui.example.com/auth?key=k1' } });
|
|
221
|
+
utils.fetchGet = async () => ({ response: { status: 200 }, body: { status: AuthStates.AUTHORIZED, username: 'u', token: 't', apiEndpoint: 'https://t@u.example.com/' } });
|
|
222
|
+
let saved = null;
|
|
223
|
+
const button = {
|
|
224
|
+
getAuthorizationData: () => saved,
|
|
225
|
+
saveAuthorizationData: (d) => { saved = d; },
|
|
226
|
+
onStateChange: async (state) => {
|
|
227
|
+
if (state.status === AuthStates.AUTHORIZED) saved = { apiEndpoint: state.apiEndpoint, username: state.username, authUrl: auth._authUrl };
|
|
228
|
+
},
|
|
229
|
+
onClick: () => {}
|
|
230
|
+
};
|
|
231
|
+
const auth = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
|
|
232
|
+
try {
|
|
233
|
+
await auth.init();
|
|
234
|
+
auth.serviceInfo = Object.assign({}, auth.serviceInfo, { account: undefined });
|
|
235
|
+
await auth.startAuthRequest();
|
|
236
|
+
} finally { utils.fetchPost = fetchPost; utils.fetchGet = fetchGet; }
|
|
237
|
+
expect(auth.accountUrl()).to.equal(profile('https://ui.example.com'));
|
|
238
|
+
|
|
239
|
+
// a new page load: only the stored data is known
|
|
240
|
+
const reloaded = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
|
|
241
|
+
await reloaded.init();
|
|
242
|
+
reloaded.serviceInfo = Object.assign({}, reloaded.serviceInfo, { account: undefined });
|
|
243
|
+
expect(reloaded.state.status).to.equal(AuthStates.AUTHORIZED);
|
|
244
|
+
expect(reloaded.accountUrl()).to.equal(profile('https://ui.example.com'));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('[ACAT] autologin skips a stored sign-in without a token (legacy and multi-account shapes)', async function () {
|
|
248
|
+
const cases = [
|
|
249
|
+
[{ username: 'u', apiEndpoint: 'https://u.example.com/' }, AuthStates.INITIALIZED],
|
|
250
|
+
[{ username: 'u', apiEndpoint: 'https://t@u.example.com/' }, AuthStates.AUTHORIZED],
|
|
251
|
+
[{ username: 'u', apiEndpoint: 'https://u.example.com/', profiles: [{ username: 'u', apiEndpoint: 'https://u.example.com/' }] }, AuthStates.INITIALIZED],
|
|
252
|
+
[{ username: 'u', apiEndpoint: 'https://t@u.example.com/', profiles: [{ username: 'u', apiEndpoint: 'https://t@u.example.com/' }] }, AuthStates.AUTHORIZED]
|
|
253
|
+
];
|
|
254
|
+
for (const [stored, expected] of cases) {
|
|
255
|
+
const button = {
|
|
256
|
+
getAuthorizationData: () => stored,
|
|
257
|
+
onStateChange: async () => {},
|
|
258
|
+
onClick: () => {}
|
|
259
|
+
};
|
|
260
|
+
const auth = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
|
|
261
|
+
await auth.init();
|
|
262
|
+
expect(auth.state.status, JSON.stringify(stored)).to.equal(expected);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('[ACAU] autologin forgets the stored sign-ins without a token and keeps the usable ones', async function () {
|
|
267
|
+
let saved = {
|
|
268
|
+
username: 'u',
|
|
269
|
+
apiEndpoint: 'https://t@u.example.com/',
|
|
270
|
+
profiles: [
|
|
271
|
+
{ username: 'u', apiEndpoint: 'https://t@u.example.com/' },
|
|
272
|
+
{ username: 'stale', apiEndpoint: 'https://stale.example.com/' }
|
|
273
|
+
]
|
|
274
|
+
};
|
|
275
|
+
let deleted = 0;
|
|
276
|
+
const button = {
|
|
277
|
+
getAuthorizationData: () => saved,
|
|
278
|
+
saveAuthorizationData: (d) => { saved = d; },
|
|
279
|
+
deleteAuthorizationData: () => { deleted++; saved = null; },
|
|
280
|
+
onStateChange: async () => {},
|
|
281
|
+
onClick: () => {}
|
|
282
|
+
};
|
|
283
|
+
const auth = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
|
|
284
|
+
await auth.init();
|
|
285
|
+
expect(auth.state.status).to.equal(AuthStates.AUTHORIZED);
|
|
286
|
+
expect(saved.profiles.map((p) => p.username)).to.deep.equal(['u']);
|
|
287
|
+
|
|
288
|
+
// only token-less sign-ins stored: all forgotten, not signed in
|
|
289
|
+
saved = { username: 'stale', apiEndpoint: 'https://stale.example.com/' };
|
|
290
|
+
const auth2 = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
|
|
291
|
+
await auth2.init();
|
|
292
|
+
expect(auth2.state.status).to.equal(AuthStates.INITIALIZED);
|
|
293
|
+
expect(deleted).to.equal(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('[ACUE] a button with showMenu gets the click instead of SIGNOUT, unless it declines', async function () {
|
|
297
|
+
for (const [answer, expectSignout] of [[true, false], [undefined, false], [false, true]]) {
|
|
298
|
+
const states = [];
|
|
299
|
+
let menus = 0;
|
|
300
|
+
const button = {
|
|
301
|
+
getAuthorizationData: () => null,
|
|
302
|
+
onStateChange: async () => {},
|
|
303
|
+
onClick: () => {},
|
|
304
|
+
showMenu: () => { menus++; return answer; }
|
|
305
|
+
};
|
|
306
|
+
const auth = new AuthController({
|
|
307
|
+
authRequest: { requestingAppId: 'test-app', requestedPermissions: [] },
|
|
308
|
+
onStateChange: (s) => states.push(s.status)
|
|
309
|
+
}, service, button);
|
|
310
|
+
await auth.init();
|
|
311
|
+
auth._state = { status: AuthStates.AUTHORIZED };
|
|
312
|
+
await auth.handleClick();
|
|
313
|
+
expect(menus).to.equal(1);
|
|
314
|
+
expect(states.includes(AuthStates.SIGNOUT), 'showMenu returned ' + answer).to.equal(expectSignout);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('[ASRX] stopAuthRequest', function () {
|
|
320
|
+
it('[ASRA] sets error state with message', async function () {
|
|
138
321
|
const auth = new AuthController({
|
|
139
322
|
authRequest: {
|
|
140
323
|
requestingAppId: 'test-app',
|
|
@@ -150,8 +333,8 @@ describe('[ACNX] AuthController', function () {
|
|
|
150
333
|
});
|
|
151
334
|
});
|
|
152
335
|
|
|
153
|
-
describe('[
|
|
154
|
-
it('[
|
|
336
|
+
describe('[ARUX] returnURL', function () {
|
|
337
|
+
it('[ARUA] throws on invalid returnURL trailer', function () {
|
|
155
338
|
const auth = new AuthController({
|
|
156
339
|
authRequest: {
|
|
157
340
|
requestingAppId: 'test-app',
|
|
@@ -162,7 +345,7 @@ describe('[ACNX] AuthController', function () {
|
|
|
162
345
|
expect(() => auth.getReturnURL('http://example.com')).to.throw('Last character');
|
|
163
346
|
});
|
|
164
347
|
|
|
165
|
-
it('[
|
|
348
|
+
it('[ARUB] handles null/undefined returnURL', function () {
|
|
166
349
|
const auth = new AuthController({
|
|
167
350
|
authRequest: {
|
|
168
351
|
requestingAppId: 'test-app',
|
|
@@ -174,6 +357,26 @@ describe('[ACNX] AuthController', function () {
|
|
|
174
357
|
const result = auth.getReturnURL(undefined, 'http://test.com', { userAgent: 'Safari' });
|
|
175
358
|
expect(result).to.equal(false);
|
|
176
359
|
});
|
|
360
|
+
|
|
361
|
+
it('[ACNV] auto mode reads the browser navigator when no mock is given', function () {
|
|
362
|
+
const auth = new AuthController({
|
|
363
|
+
authRequest: {
|
|
364
|
+
requestingAppId: 'test-app',
|
|
365
|
+
requestedPermissions: []
|
|
366
|
+
}
|
|
367
|
+
}, service);
|
|
368
|
+
const saved = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
|
369
|
+
const iPhone = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148';
|
|
370
|
+
try {
|
|
371
|
+
Object.defineProperty(globalThis, 'navigator', { value: { userAgent: iPhone }, configurable: true, writable: true });
|
|
372
|
+
expect(auth.getReturnURL('auto#', 'http://test.com/app')).to.equal('http://test.com/app#');
|
|
373
|
+
Object.defineProperty(globalThis, 'navigator', { value: { userAgent: 'Safari' }, configurable: true, writable: true });
|
|
374
|
+
expect(auth.getReturnURL('auto#', 'http://test.com/app')).to.equal(false);
|
|
375
|
+
} finally {
|
|
376
|
+
if (saved) Object.defineProperty(globalThis, 'navigator', saved);
|
|
377
|
+
else delete globalThis.navigator;
|
|
378
|
+
}
|
|
379
|
+
});
|
|
177
380
|
});
|
|
178
381
|
|
|
179
382
|
describe('[AIST] State', function () {
|
|
@@ -264,4 +467,117 @@ describe('[ACNX] AuthController', function () {
|
|
|
264
467
|
expect(last.message).to.equal('boom');
|
|
265
468
|
});
|
|
266
469
|
});
|
|
470
|
+
|
|
471
|
+
describe('[ACGX] credential hand-off polling', function () {
|
|
472
|
+
let realFetch;
|
|
473
|
+
let handoffKeyCounter = 0;
|
|
474
|
+
|
|
475
|
+
/** Stub the whole flow: access POST -> NEED_SIGNIN, poll GET -> pollBody,
|
|
476
|
+
* shared-secrets/retrieve -> retrieveResponse. */
|
|
477
|
+
function stubFlow (pollBody, retrieveResponse) {
|
|
478
|
+
realFetch = global.fetch;
|
|
479
|
+
global.fetch = async function (url, options) {
|
|
480
|
+
const u = String(url);
|
|
481
|
+
if (u.includes('shared-secrets/retrieve')) {
|
|
482
|
+
return {
|
|
483
|
+
ok: retrieveResponse.ok !== false,
|
|
484
|
+
status: retrieveResponse.status || 200,
|
|
485
|
+
json: async () => retrieveResponse.body
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
if (options && options.method === 'POST' && u.includes('/access')) {
|
|
489
|
+
return {
|
|
490
|
+
ok: true,
|
|
491
|
+
status: 201,
|
|
492
|
+
json: async () => ({
|
|
493
|
+
status: 'NEED_SIGNIN',
|
|
494
|
+
key: 'ho-key-' + (++handoffKeyCounter),
|
|
495
|
+
poll: 'https://reg.test.local/access/pk' + handoffKeyCounter,
|
|
496
|
+
poll_rate_ms: 5
|
|
497
|
+
})
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (u.includes('/access/')) { // the poll GET
|
|
501
|
+
return { ok: true, status: 200, json: async () => pollBody };
|
|
502
|
+
}
|
|
503
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
afterEach(function () {
|
|
508
|
+
if (realFetch) global.fetch = realFetch;
|
|
509
|
+
realFetch = null;
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
function makeAuth () {
|
|
513
|
+
const auth = new AuthController({
|
|
514
|
+
authRequest: { requestingAppId: 'test-app', requestedPermissions: [] }
|
|
515
|
+
}, service);
|
|
516
|
+
// Skip init() (asset loading): set the service info the flow needs.
|
|
517
|
+
auth.serviceInfo = { access: 'https://reg.test.local/access', register: 'https://reg.test.local/' };
|
|
518
|
+
return auth;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
it('[ACGA] redeems a hand-off ACCEPTED body and rewrites the internal state to the legacy shape (token, no handoff)', async function () {
|
|
522
|
+
stubFlow(
|
|
523
|
+
{ status: 'ACCEPTED', username: 'eve', apiEndpoint: 'https://eve.test.local/', handoff: { type: 'shared-secret', key: 'evt.' + 'e'.repeat(40) } },
|
|
524
|
+
{ body: { secret: { username: 'eve', token: 'tok-eve', apiEndpoint: 'https://eve.test.local/' } } }
|
|
525
|
+
);
|
|
526
|
+
const auth = makeAuth();
|
|
527
|
+
await auth.startAuthRequest();
|
|
528
|
+
expect(auth.state.status).to.equal(AuthStates.AUTHORIZED);
|
|
529
|
+
expect(auth.state.token).to.equal('tok-eve');
|
|
530
|
+
expect(auth.state.apiEndpoint).to.contain('tok-eve@');
|
|
531
|
+
expect(auth.state.apiEndpoint).to.contain('eve.test.local');
|
|
532
|
+
// The one-time key must not survive into the state the cookie/listener see.
|
|
533
|
+
expect(auth.state.handoff).to.equal(undefined);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('[ACGB] a failed hand-off retrieve puts the controller in ERROR, not a broken AUTHORIZED', async function () {
|
|
537
|
+
stubFlow(
|
|
538
|
+
{ status: 'ACCEPTED', username: 'frank', apiEndpoint: 'https://frank.test.local/', handoff: { type: 'shared-secret', key: 'evt.' + 'f'.repeat(40) } },
|
|
539
|
+
{ ok: false, status: 403, body: { error: { id: 'forbidden', message: 'used', data: { id: 'shared-secret-unavailable' } } } }
|
|
540
|
+
);
|
|
541
|
+
const auth = makeAuth();
|
|
542
|
+
await auth.startAuthRequest();
|
|
543
|
+
expect(auth.state.status).to.equal(AuthStates.ERROR);
|
|
544
|
+
expect(auth.state.token).to.equal(undefined);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
describe('[ACQX] a refused access request', function () {
|
|
549
|
+
let realFetch;
|
|
550
|
+
|
|
551
|
+
afterEach(function () {
|
|
552
|
+
if (realFetch) global.fetch = realFetch;
|
|
553
|
+
realFetch = null;
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('[ACQ1] the error carries the server message, id and status; the answer stays out of the message', async function () {
|
|
557
|
+
const SECRET = 'S3CRET-stream-id';
|
|
558
|
+
const body = { error: { id: 'invalid-parameters-format', message: 'The parameters are not valid.', data: { requestedPermissions: [{ streamId: SECRET }] } } };
|
|
559
|
+
realFetch = global.fetch;
|
|
560
|
+
global.fetch = async function () {
|
|
561
|
+
return { ok: false, status: 400, json: async () => body };
|
|
562
|
+
};
|
|
563
|
+
const auth = new AuthController({
|
|
564
|
+
authRequest: { requestingAppId: 'test-app', requestedPermissions: [{ streamId: SECRET, level: 'read' }] }
|
|
565
|
+
}, service);
|
|
566
|
+
auth.serviceInfo = { access: 'https://reg.test.local/access', register: 'https://reg.test.local/' };
|
|
567
|
+
let error = null;
|
|
568
|
+
try {
|
|
569
|
+
await auth.startAuthRequest();
|
|
570
|
+
} catch (e) {
|
|
571
|
+
error = e;
|
|
572
|
+
}
|
|
573
|
+
expect(error).to.be.instanceOf(PryvError);
|
|
574
|
+
expect(error.message).to.equal('The parameters are not valid.');
|
|
575
|
+
expect(error.message).to.not.include(SECRET);
|
|
576
|
+
expect(error.id).to.equal('invalid-parameters-format');
|
|
577
|
+
expect(error.status).to.equal(400);
|
|
578
|
+
expect(error.response.body).to.deep.equal(body);
|
|
579
|
+
expect(auth.state.status).to.equal(AuthStates.ERROR);
|
|
580
|
+
expect(auth.state.error).to.equal(error);
|
|
581
|
+
});
|
|
582
|
+
});
|
|
267
583
|
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, expect, pryv */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* [CAEX] `apiOne()` errors never carry the call's params in their message.
|
|
9
|
+
*
|
|
10
|
+
* Params can hold passwords and tokens, and error messages end up in logs and
|
|
11
|
+
* on screen. The message names the method and the server's error; the full
|
|
12
|
+
* error (or result) stays on `innerObject`. `api()` is stubbed: no server.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SECRET = 'pa55-S3CRET-value';
|
|
16
|
+
|
|
17
|
+
function connWithResult (result) {
|
|
18
|
+
const conn = new pryv.Connection('https://sometoken@alice.example.test/');
|
|
19
|
+
conn.api = async () => result;
|
|
20
|
+
return conn;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function caught (promise) {
|
|
24
|
+
try {
|
|
25
|
+
await promise;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return e;
|
|
28
|
+
}
|
|
29
|
+
throw new Error('expected a rejection');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('[CAEX] apiOne() error message', function () {
|
|
33
|
+
it('[CAEA] an error answer: method and server error in the message, params left out', async () => {
|
|
34
|
+
const serverError = { id: 'invalid-credentials', message: 'The given username/password pair is invalid.' };
|
|
35
|
+
const conn = connWithResult([{ error: serverError }]);
|
|
36
|
+
const e = await caught(conn.apiOne('auth.login', { username: 'alice', password: SECRET }));
|
|
37
|
+
expect(e).to.be.instanceOf(pryv.PryvError);
|
|
38
|
+
expect(e.message).to.include('"auth.login"');
|
|
39
|
+
expect(e.message).to.include('invalid-credentials');
|
|
40
|
+
expect(e.message).to.include('The given username/password pair is invalid.');
|
|
41
|
+
expect(e.message).to.not.include(SECRET);
|
|
42
|
+
expect(e.message).to.not.include('alice');
|
|
43
|
+
expect(e.innerObject).to.equal(serverError);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('[CAEB] expected key missing: named in the message, result data left out', async () => {
|
|
47
|
+
const result = [{ access: { token: SECRET } }];
|
|
48
|
+
const conn = connWithResult(result);
|
|
49
|
+
const e = await caught(conn.apiOne('accesses.create', { name: 'x', token: SECRET }, 'accessX'));
|
|
50
|
+
expect(e).to.be.instanceOf(pryv.PryvError);
|
|
51
|
+
expect(e.message).to.include('"accesses.create"');
|
|
52
|
+
expect(e.message).to.include('"accessX" missing in result');
|
|
53
|
+
expect(e.message).to.not.include(SECRET);
|
|
54
|
+
expect(e.innerObject).to.equal(result);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('[CAEC] no result at all', async () => {
|
|
58
|
+
const conn = connWithResult([]);
|
|
59
|
+
const e = await caught(conn.apiOne('events.get', { token: SECRET }));
|
|
60
|
+
expect(e).to.be.instanceOf(pryv.PryvError);
|
|
61
|
+
expect(e.message).to.include('"events.get"');
|
|
62
|
+
expect(e.message).to.include('no result');
|
|
63
|
+
expect(e.message).to.not.include(SECRET);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('[CAED] an error answer without id or message', async () => {
|
|
67
|
+
const serverError = {};
|
|
68
|
+
const conn = connWithResult([{ error: serverError }]);
|
|
69
|
+
const e = await caught(conn.apiOne('events.get', { token: SECRET }));
|
|
70
|
+
expect(e.message).to.include('"events.get"');
|
|
71
|
+
expect(e.message).to.include('error answer');
|
|
72
|
+
expect(e.message).to.not.include('undefined');
|
|
73
|
+
expect(e.innerObject).to.equal(serverError);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* [CAEY] A batch answer that breaks the protocol: the answer rides on
|
|
79
|
+
* `innerObject`, never in the message (results can hold tokens).
|
|
80
|
+
*/
|
|
81
|
+
describe('[CAEY] api() protocol-violation errors', function () {
|
|
82
|
+
function connWithAnswer (answer) {
|
|
83
|
+
const conn = new pryv.Connection('https://sometoken@alice.example.test/');
|
|
84
|
+
conn.post = async () => answer;
|
|
85
|
+
return conn;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
it('[CAEE] an answer without results', async () => {
|
|
89
|
+
const answer = { error: { id: 'x', message: 'y', data: { token: SECRET } } };
|
|
90
|
+
const e = await caught(connWithAnswer(answer).api([{ method: 'events.get', params: {} }]));
|
|
91
|
+
expect(e).to.be.instanceOf(pryv.PryvError);
|
|
92
|
+
expect(e.message).to.include('not an Array');
|
|
93
|
+
expect(e.message).to.not.include(SECRET);
|
|
94
|
+
expect(e.innerObject).to.equal(answer);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('[CAEF] results that do not match the calls', async () => {
|
|
98
|
+
const answer = { results: [{ access: { token: SECRET } }, {}] };
|
|
99
|
+
const e = await caught(connWithAnswer(answer).api([{ method: 'accesses.create', params: {} }]));
|
|
100
|
+
expect(e).to.be.instanceOf(pryv.PryvError);
|
|
101
|
+
expect(e.message).to.include('does not match');
|
|
102
|
+
expect(e.message).to.not.include(SECRET);
|
|
103
|
+
expect(e.innerObject).to.equal(answer);
|
|
104
|
+
});
|
|
105
|
+
});
|