pryv 3.13.0 → 3.15.0
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 +10 -1
- package/package.json +1 -1
- package/src/Auth/AuthController.js +33 -8
- package/src/Auth/ProfileStore.js +19 -1
- package/src/Auth/index.js +5 -2
- package/src/Browser/LoginButton.js +127 -17
- package/src/Connection.js +19 -13
- package/src/Service.js +29 -15
- package/src/index.d.ts +76 -7
- package/src/lib/MfaRequiredError.js +2 -0
- package/src/lib/PryvError.js +4 -0
- package/src/lib/pollUrls.js +71 -0
- package/src/utils.js +7 -1
- package/test/AuthController.test.js +111 -5
- package/test/Connection.apiOneError.test.js +105 -0
- package/test/LoginButton.test.js +3 -1
- package/test/LoginButton.urlCleanup.test.js +453 -14
- package/test/PryvError.test.js +10 -0
- package/test/Service.accessRequest.test.js +4 -4
- package/test/Service.accessRequestConsent.test.js +16 -0
- package/test/Service.errorBodies.test.js +82 -0
- package/test/Service.mfa.test.js +17 -4
- package/test/Service.pollUrlByKey.test.js +114 -0
package/src/index.d.ts
CHANGED
|
@@ -160,7 +160,8 @@ declare module 'pryv' {
|
|
|
160
160
|
*/
|
|
161
161
|
export class PryvError extends globalThis.Error {
|
|
162
162
|
constructor(message: string, innerObject?: globalThis.Error | object);
|
|
163
|
-
|
|
163
|
+
/** `'PryvError'`, or the subclass name (`'MfaRequiredError'`, ...). */
|
|
164
|
+
name: string;
|
|
164
165
|
innerObject?: globalThis.Error | object;
|
|
165
166
|
id?: string;
|
|
166
167
|
status?: number;
|
|
@@ -890,11 +891,20 @@ declare module 'pryv' {
|
|
|
890
891
|
pollRateMs: number;
|
|
891
892
|
/** Echoed only by a core that understood `authRequest.consent`. */
|
|
892
893
|
consent?: AuthRequestConsentForm;
|
|
894
|
+
/** Echoed only by a core that understood `authRequest.cmcInvites`. */
|
|
895
|
+
cmcInvites?: AuthCmcInvite[];
|
|
893
896
|
}>;
|
|
897
|
+
/**
|
|
898
|
+
* Poll an access request once, by its full poll URL (recommended) or its
|
|
899
|
+
* `key`. A key started in this process polls the poll URL the server issued
|
|
900
|
+
* for it (the core holding the request); an unknown key polls
|
|
901
|
+
* `access + key`, which may miss the request on a multi-core platform.
|
|
902
|
+
*/
|
|
894
903
|
pollAccessRequest(keyOrPollUrl: string): Promise<any>;
|
|
895
904
|
/**
|
|
896
905
|
* Resolve an auth-flow polling `key` (from {@link Service.startAccessRequest})
|
|
897
|
-
* into a working {@link Connection}. Polls the access request once
|
|
906
|
+
* into a working {@link Connection}. Polls the access request once (as
|
|
907
|
+
* {@link Service.pollAccessRequest} does with a key); throws a
|
|
898
908
|
* {@link PryvError} unless the access is `ACCEPTED`.
|
|
899
909
|
*/
|
|
900
910
|
connectFromKey(key: string): Promise<Connection>;
|
|
@@ -974,6 +984,35 @@ declare module 'pryv' {
|
|
|
974
984
|
>;
|
|
975
985
|
};
|
|
976
986
|
|
|
987
|
+
/**
|
|
988
|
+
* A cross-account messaging invite the user is asked to answer in the same
|
|
989
|
+
* authorisation request (`authRequest.cmcInvites`): the capability URL the
|
|
990
|
+
* app received, whether declining it refuses the whole request, and for
|
|
991
|
+
* which account it is accepted (`'target'`: the account the access is
|
|
992
|
+
* granted for, when the user acts for an account it manages).
|
|
993
|
+
*/
|
|
994
|
+
export type AuthRequestCmcInvite = {
|
|
995
|
+
capabilityUrl: string;
|
|
996
|
+
mandatory?: boolean;
|
|
997
|
+
for?: 'self' | 'target';
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
/** An invite as the core echoes it back, with its defaults filled in. */
|
|
1001
|
+
export type AuthCmcInvite = {
|
|
1002
|
+
capabilityUrl: string;
|
|
1003
|
+
mandatory: boolean;
|
|
1004
|
+
for: 'self' | 'target';
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* What the auth page did with one invite, in the ACCEPTED body (one per
|
|
1009
|
+
* invite, in order). A hint: the requester learns the truth from its inbox.
|
|
1010
|
+
*/
|
|
1011
|
+
export type AuthCmcInviteOutcome =
|
|
1012
|
+
| { acceptEventId: string; dataGrantAccessId?: string; acceptedFor?: 'self' }
|
|
1013
|
+
| { declined: true }
|
|
1014
|
+
| { reason: string };
|
|
1015
|
+
|
|
977
1016
|
export type States =
|
|
978
1017
|
| 'ERROR'
|
|
979
1018
|
| 'LOADING'
|
|
@@ -1017,8 +1056,10 @@ declare module 'pryv' {
|
|
|
1017
1056
|
defaultName: string;
|
|
1018
1057
|
}>;
|
|
1019
1058
|
consent?: AuthRequestConsentForm;
|
|
1059
|
+
/** Present only when the request carried `cmcInvites` the core understood. */
|
|
1060
|
+
cmcInvites?: AuthCmcInvite[];
|
|
1020
1061
|
requestingAppId: string;
|
|
1021
|
-
|
|
1062
|
+
returnURL?: string | null;
|
|
1022
1063
|
serviceInfo?: ServiceInfo;
|
|
1023
1064
|
};
|
|
1024
1065
|
ACCEPTED: {
|
|
@@ -1032,6 +1073,10 @@ declare module 'pryv' {
|
|
|
1032
1073
|
* `connectFromKey`). Absent when the account comes from stored
|
|
1033
1074
|
* credentials: page load, an account switch without sign-in, or the
|
|
1034
1075
|
* return to the previous account after a switch that did not complete.
|
|
1076
|
+
* Also absent on the return from a sign-in by redirection (`returnURL`,
|
|
1077
|
+
* including `'auto#'` on a phone or tablet): that state carries
|
|
1078
|
+
* `apiEndpoint` with its token instead. Handle both:
|
|
1079
|
+
* `state.key ? connectFromKey(state.key) : new Connection(state.apiEndpoint)`.
|
|
1035
1080
|
*/
|
|
1036
1081
|
key?: string;
|
|
1037
1082
|
/** Display hint posted by the auth page for a grant on a controlled account; `accessInfo().delegation` is authoritative. */
|
|
@@ -1043,6 +1088,8 @@ declare module 'pryv' {
|
|
|
1043
1088
|
profile?: AuthProfile;
|
|
1044
1089
|
/** One-time credential hand-off key (shared-secret delivery), in place of `token`. */
|
|
1045
1090
|
handoff?: { type: 'shared-secret'; key: string };
|
|
1091
|
+
/** What the auth page did with each invite of the request (a hint). */
|
|
1092
|
+
cmcInvites?: AuthCmcInviteOutcome[];
|
|
1046
1093
|
};
|
|
1047
1094
|
SIGNOUT: {};
|
|
1048
1095
|
SWITCHING: {
|
|
@@ -1077,6 +1124,7 @@ declare module 'pryv' {
|
|
|
1077
1124
|
requestingAppId: string;
|
|
1078
1125
|
requestedPermissions: AuthRequestedPermission[];
|
|
1079
1126
|
consent?: AuthRequestConsentForm;
|
|
1127
|
+
cmcInvites?: AuthCmcInvite[];
|
|
1080
1128
|
lang?: string;
|
|
1081
1129
|
returnURL?: string;
|
|
1082
1130
|
clientData?: KeyValue;
|
|
@@ -1099,7 +1147,6 @@ declare module 'pryv' {
|
|
|
1099
1147
|
export type AuthSettings = {
|
|
1100
1148
|
spanButtonID?: string;
|
|
1101
1149
|
onStateChange?: (state: StateChange<States>) => void;
|
|
1102
|
-
returnURL?: string;
|
|
1103
1150
|
/**
|
|
1104
1151
|
* Account menu shown when the signed-in button is clicked (default: on).
|
|
1105
1152
|
* `false` restores the plain logout confirmation.
|
|
@@ -1114,7 +1161,29 @@ declare module 'pryv' {
|
|
|
1114
1161
|
languageCode?: string;
|
|
1115
1162
|
requestedPermissions: AuthRequestedPermission[];
|
|
1116
1163
|
consent?: AuthRequestConsent;
|
|
1117
|
-
|
|
1164
|
+
/**
|
|
1165
|
+
* Cross-account messaging invites the user answers in the same
|
|
1166
|
+
* sign-in (1 to 8). The ACCEPTED state carries one outcome per invite;
|
|
1167
|
+
* a declined mandatory invite ends the request REFUSED with
|
|
1168
|
+
* `reasonId: 'REFUSED_MANDATORY_CONSENT'`. An older core ignores the field.
|
|
1169
|
+
*/
|
|
1170
|
+
cmcInvites?: AuthRequestCmcInvite[];
|
|
1171
|
+
/**
|
|
1172
|
+
* Where the sign-in happens: `'auto#'` (default, also when unset or
|
|
1173
|
+
* `false`) opens a popup on desktop and redirects on a phone or tablet;
|
|
1174
|
+
* `'self#'` always redirects and comes back to the current page; a URL
|
|
1175
|
+
* always redirects and comes back to that URL. Must end with `#`, `?`
|
|
1176
|
+
* or `&`.
|
|
1177
|
+
*/
|
|
1178
|
+
returnURL?: string | false;
|
|
1179
|
+
/**
|
|
1180
|
+
* Your own auth page for this request (query parameters allowed, e.g. a
|
|
1181
|
+
* `username` hint or `backUrl` / `backLabel`). The platform honours it
|
|
1182
|
+
* only when it matches one of its `access:trustedAuthUrls` entries and
|
|
1183
|
+
* refuses the request otherwise; unset, the platform's default auth page
|
|
1184
|
+
* is used.
|
|
1185
|
+
*/
|
|
1186
|
+
authUrl?: string;
|
|
1118
1187
|
referer?: string;
|
|
1119
1188
|
clientData?: KeyValue;
|
|
1120
1189
|
deviceName?: string;
|
|
@@ -1248,9 +1317,9 @@ declare module 'pryv' {
|
|
|
1248
1317
|
stopAuthRequest(msg: string): void;
|
|
1249
1318
|
handleClick(): Promise<void>;
|
|
1250
1319
|
getReturnURL(
|
|
1251
|
-
returnURL?: string,
|
|
1320
|
+
returnURL?: string | false,
|
|
1252
1321
|
windowLocationForTest?: string,
|
|
1253
|
-
navigatorForTests?: string,
|
|
1322
|
+
navigatorForTests?: string | Navigator,
|
|
1254
1323
|
): string | boolean;
|
|
1255
1324
|
/** `overrides`: auth request fields for this request only; `previous`: state to return to if an account switch does not complete. */
|
|
1256
1325
|
startAuthRequest(overrides?: Partial<AuthSettings['authRequest']>, previous?: AuthStatePayload): Promise<AuthRequestResponse>;
|
|
@@ -41,6 +41,8 @@ class MfaRequiredError extends PryvError {
|
|
|
41
41
|
this.id = (apiErr && apiErr.id) || 'mfa-required';
|
|
42
42
|
this.status = response && response.status;
|
|
43
43
|
this.response = { body, status: response && response.status };
|
|
44
|
+
// Read by name, but not printed with the error (like `innerObject`).
|
|
45
|
+
Object.defineProperty(this, 'mfaToken', { enumerable: false });
|
|
44
46
|
|
|
45
47
|
if (Error.captureStackTrace) {
|
|
46
48
|
Error.captureStackTrace(this, MfaRequiredError);
|
package/src/lib/PryvError.js
CHANGED
|
@@ -35,6 +35,10 @@ class PryvError extends Error {
|
|
|
35
35
|
this.status = undefined;
|
|
36
36
|
/** @type {{ body: any, status: number }|undefined} Raw response */
|
|
37
37
|
this.response = undefined;
|
|
38
|
+
// `innerObject` and `response` hold raw platform answers: still readable and
|
|
39
|
+
// writable, but not enumerable, so `console.log(err)` does not print them.
|
|
40
|
+
Object.defineProperty(this, 'innerObject', { enumerable: false });
|
|
41
|
+
Object.defineProperty(this, 'response', { enumerable: false });
|
|
38
42
|
|
|
39
43
|
if (Error.captureStackTrace) {
|
|
40
44
|
Error.captureStackTrace(this, PryvError);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The poll URL the server issued for each auth request started in this
|
|
8
|
+
* process, by key.
|
|
9
|
+
*
|
|
10
|
+
* A pending auth request lives on the core that created it, and its poll URL
|
|
11
|
+
* points at that core. The service's `access` URL may reach any core of a
|
|
12
|
+
* multi-core platform, so polling `access + key` can land on a core that does
|
|
13
|
+
* not know the request. Polling by key therefore uses the server-issued URL
|
|
14
|
+
* when this process started the request, and falls back to `access + key`
|
|
15
|
+
* only for a key it never saw.
|
|
16
|
+
*
|
|
17
|
+
* Keys are long random server values, so one process-wide map (like the
|
|
18
|
+
* hand-off cache) serves every Service.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Most entries kept (oldest dropped first), sized for a busy Node service. */
|
|
22
|
+
const MAX_ENTRIES = 1000;
|
|
23
|
+
/** How long an entry is kept: well beyond any auth request's lifetime. */
|
|
24
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/** key -> { pollUrl, expiresAt }, in insertion order */
|
|
27
|
+
const pollUrls = new Map();
|
|
28
|
+
|
|
29
|
+
/** Drop expired entries (called on every remember). */
|
|
30
|
+
function sweep () {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const [key, entry] of pollUrls) {
|
|
33
|
+
if (now > entry.expiresAt) pollUrls.delete(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Remember the poll URL of an auth request (ignored unless both look valid).
|
|
39
|
+
* @param {string} key
|
|
40
|
+
* @param {string} pollUrl
|
|
41
|
+
*/
|
|
42
|
+
function remember (key, pollUrl) {
|
|
43
|
+
if (typeof key !== 'string' || key === '' || typeof pollUrl !== 'string' || !/^https?:\/\//.test(pollUrl)) return;
|
|
44
|
+
sweep();
|
|
45
|
+
pollUrls.delete(key);
|
|
46
|
+
pollUrls.set(key, { pollUrl, expiresAt: Date.now() + TTL_MS });
|
|
47
|
+
while (pollUrls.size > MAX_ENTRIES) pollUrls.delete(pollUrls.keys().next().value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The server-issued poll URL of `key`, or null when this process did not
|
|
52
|
+
* start that request (or it expired).
|
|
53
|
+
* @param {string} key
|
|
54
|
+
* @returns {string|null}
|
|
55
|
+
*/
|
|
56
|
+
function lookup (key) {
|
|
57
|
+
const entry = pollUrls.get(key);
|
|
58
|
+
if (entry == null) return null;
|
|
59
|
+
if (Date.now() > entry.expiresAt) {
|
|
60
|
+
pollUrls.delete(key);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return entry.pollUrl;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Forget every entry. */
|
|
67
|
+
function clear () {
|
|
68
|
+
pollUrls.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { remember, lookup, clear, MAX_ENTRIES, TTL_MS };
|
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);
|
|
@@ -243,6 +244,55 @@ describe('[ACNX] AuthController', function () {
|
|
|
243
244
|
expect(reloaded.accountUrl()).to.equal(profile('https://ui.example.com'));
|
|
244
245
|
});
|
|
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
|
+
|
|
246
296
|
it('[ACUE] a button with showMenu gets the click instead of SIGNOUT, unless it declines', async function () {
|
|
247
297
|
for (const [answer, expectSignout] of [[true, false], [undefined, false], [false, true]]) {
|
|
248
298
|
const states = [];
|
|
@@ -266,8 +316,8 @@ describe('[ACNX] AuthController', function () {
|
|
|
266
316
|
});
|
|
267
317
|
});
|
|
268
318
|
|
|
269
|
-
describe('[
|
|
270
|
-
it('[
|
|
319
|
+
describe('[ASRX] stopAuthRequest', function () {
|
|
320
|
+
it('[ASRA] sets error state with message', async function () {
|
|
271
321
|
const auth = new AuthController({
|
|
272
322
|
authRequest: {
|
|
273
323
|
requestingAppId: 'test-app',
|
|
@@ -283,8 +333,8 @@ describe('[ACNX] AuthController', function () {
|
|
|
283
333
|
});
|
|
284
334
|
});
|
|
285
335
|
|
|
286
|
-
describe('[
|
|
287
|
-
it('[
|
|
336
|
+
describe('[ARUX] returnURL', function () {
|
|
337
|
+
it('[ARUA] throws on invalid returnURL trailer', function () {
|
|
288
338
|
const auth = new AuthController({
|
|
289
339
|
authRequest: {
|
|
290
340
|
requestingAppId: 'test-app',
|
|
@@ -295,7 +345,7 @@ describe('[ACNX] AuthController', function () {
|
|
|
295
345
|
expect(() => auth.getReturnURL('http://example.com')).to.throw('Last character');
|
|
296
346
|
});
|
|
297
347
|
|
|
298
|
-
it('[
|
|
348
|
+
it('[ARUB] handles null/undefined returnURL', function () {
|
|
299
349
|
const auth = new AuthController({
|
|
300
350
|
authRequest: {
|
|
301
351
|
requestingAppId: 'test-app',
|
|
@@ -307,6 +357,26 @@ describe('[ACNX] AuthController', function () {
|
|
|
307
357
|
const result = auth.getReturnURL(undefined, 'http://test.com', { userAgent: 'Safari' });
|
|
308
358
|
expect(result).to.equal(false);
|
|
309
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
|
+
});
|
|
310
380
|
});
|
|
311
381
|
|
|
312
382
|
describe('[AIST] State', function () {
|
|
@@ -474,4 +544,40 @@ describe('[ACNX] AuthController', function () {
|
|
|
474
544
|
expect(auth.state.token).to.equal(undefined);
|
|
475
545
|
});
|
|
476
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
|
+
});
|
|
477
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
|
+
});
|
package/test/LoginButton.test.js
CHANGED
|
@@ -273,7 +273,9 @@ describe('[LBTX] LoginButton', function () {
|
|
|
273
273
|
loginBtn.saveAuthorizationData({ apiEndpoint: 'https://tok@menu-user.example.com/', username: 'menu-user' });
|
|
274
274
|
loginBtn.showMenu();
|
|
275
275
|
dialog().querySelector('.pryv-menu-logout').click();
|
|
276
|
-
|
|
276
|
+
// the re-init after a logout is exposed as `pending`: wait for it, not
|
|
277
|
+
// for a fixed delay (a slow re-init was still LOADING after 50 ms)
|
|
278
|
+
await loginBtn.pending;
|
|
277
279
|
expect(states.filter((s) => s === AuthStates.SIGNOUT)).to.have.lengthOf(1);
|
|
278
280
|
expect(confirms).to.equal(0);
|
|
279
281
|
expect(dialog()).to.equal(null);
|