diff --git a/ironnode.d.ts b/ironnode.d.ts index 4e7ace1..bf29697 100644 --- a/ironnode.d.ts +++ b/ironnode.d.ts @@ -106,6 +106,23 @@ export interface GroupUserEditResponse { }>; } +/** + * User status as returned by the IronCore Identity server. Typed as `number` + * instead of `0 | 1` because the wire format is a `u8` and future server releases + * may introduce new status values; older clients still need to deserialize + * those responses without a type-system lie. Use the `UserStatus` constants + * (`UserStatus.Disabled`, `UserStatus.Enabled`) for the values you write, + * and treat unknown values as opaque on read. + */ +export type UserStatus = number; +export interface UserUpdateResult { + accountID: string; + segmentID: number; + status: UserStatus; + userMasterPublicKey: PublicKey; + needsRotation: boolean; +} + export interface UserPublicKeyGetResponse { [userID: string]: PublicKey | null; } @@ -153,6 +170,7 @@ export interface User { deleteDevice(id?: number): Promise<{id: number}>; rotateMasterKey(password: string): Promise<{needsRotation: boolean}>; changePassword(currentPassword: string, newPassword: string): Promise; + disableSelf(): Promise; } export interface SDK { @@ -192,4 +210,10 @@ export namespace User { export function verify(jwt: string): Promise; export function create(jwt: string, password: string, options?: UserCreateOptions): Promise; export function generateDeviceKeys(jwt: string, password: string, options?: DeviceCreateOptions): Promise; + export function updateStatus(jwt: string, status: (typeof UserStatus)[keyof typeof UserStatus]): Promise; } + +export const UserStatus: { + Disabled: 0; + Enabled: 1; +}; diff --git a/src/Constants.ts b/src/Constants.ts index defe9a1..f050add 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -28,8 +28,18 @@ export const UserAndGroupTypes = { GROUP: "group", }; +export const UserStatus = { + Disabled: 0, + Enabled: 1, +} as const; + +// Narrow write-side type derived from the `UserStatus` constants. Distinct from the public +// `UserStatus` type in ironnode.d.ts, which is intentionally `number` for forward-compat on reads. +export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus]; + export const ErrorCodes = { INITIALIZE_INVALID_ACCOUNT_ID: 100, + SDK_NOT_INITIALIZED: 101, USER_VERIFY_API_REQUEST_FAILURE: 200, USER_CREATE_REQUEST_FAILURE: 201, USER_PASSCODE_INCORRECT: 203, @@ -43,6 +53,8 @@ export const ErrorCodes = { USER_PRIVATE_KEY_ROTATION_FAILURE: 211, USER_UPDATE_REQUEST_FAILURE: 212, USER_PASSCODE_CHANGE_FAILURE: 213, + USER_UPDATE_STATUS_REQUEST_FAILURE: 214, + JWT_FORMAT_FAILURE: 215, DOCUMENT_LIST_REQUEST_FAILURE: 300, DOCUMENT_GET_REQUEST_FAILURE: 301, DOCUMENT_CREATE_REQUEST_FAILURE: 302, diff --git a/src/api/UserApi.ts b/src/api/UserApi.ts index 202e964..da159c9 100644 --- a/src/api/UserApi.ts +++ b/src/api/UserApi.ts @@ -2,7 +2,7 @@ import {TransformKey} from "@ironcorelabs/recrypt-node-binding"; import Future from "futurejs"; import {DeviceCreateOptions, UserDeviceListResponse} from "../../ironnode"; import {AugmentationFactor, Base64String, MessageSignature, PrivateKey, PublicKey} from "../commonTypes"; -import {ErrorCodes} from "../Constants"; +import {ErrorCodes, UserStatus} from "../Constants"; import {computeEd25519PublicKey} from "../crypto/Recrypt"; import ApiState from "../lib/ApiState"; import SDKError from "../lib/SDKError"; @@ -15,6 +15,13 @@ interface UserUpdateApiResponse { userPrivateKey: PrivateKey; userMasterPublicKey: PublicKey; } +export interface UserUpdateStatusApiResponse { + id: string; + status: number; + segmentId: number; + userMasterPublicKey: PublicKey; + needsRotation: boolean; +} interface ApiUserResponse extends UserUpdateApiResponse { segmentId: number; needsRotation: boolean; @@ -221,6 +228,34 @@ const userUpdateEncryptedPrivateKey = (sign: MessageSignature, accountId: string errorCode: ErrorCodes.USER_UPDATE_REQUEST_FAILURE, }); +const userUpdateInternal = (auth: MessageSignature | string, accountId: string, status: number) => { + const authHeader = typeof auth === "string" ? `jwt ${auth}` : ApiRequest.getAuthHeader(auth); + return { + url: `users/${encodeURIComponent(accountId)}`, + options: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + body: JSON.stringify({status}), + }, + errorCode: ErrorCodes.USER_UPDATE_STATUS_REQUEST_FAILURE, + }; +}; + +/** + * Update the status of the current user using the SDK's device auth. Can only be used + * to disable a user. Re-enabling a user must be done with userUpdateStatusByJwt. + */ +const userDisableSelf = (sign: MessageSignature, accountId: string) => userUpdateInternal(sign, accountId, UserStatus.Disabled); + +/** + * Update a user's status using JWT auth. Used to enable or disable a user by + * an admin holding a valid JWT for that user. + */ +const userUpdateStatusByJwt = (jwt: string, accountId: string, status: number) => userUpdateInternal(jwt, accountId, status); + export default { /** * API method to get the master public key for the user who the SDK is acting as. @@ -325,4 +360,22 @@ export default { const {url, options, errorCode} = userDeviceDelete(getSignatureHeader(), accountID, deviceID); return ApiRequest.fetchJSON<{id: number}>(url, errorCode, options); }, + + /** + * Update the status of the current SDK user + */ + callUserDisableSelfApi() { + const {accountID} = ApiState.accountAndSegmentIDs(); + const {url, options, errorCode} = userDisableSelf(getSignatureHeader(), accountID); + return ApiRequest.fetchJSON(url, errorCode, options); + }, + + /** + * Update a user's status using JWT auth. The account ID is derived from + * the JWT's `sub` claim and used in the request URL. + */ + callUserUpdateStatusByJwtApi(jwt: string, accountID: string, status: number) { + const {url, options, errorCode} = userUpdateStatusByJwt(jwt, accountID, status); + return ApiRequest.fetchJSON(url, errorCode, options); + }, }; diff --git a/src/api/tests/UserApi.test.ts b/src/api/tests/UserApi.test.ts index 5ca58f9..054975b 100644 --- a/src/api/tests/UserApi.test.ts +++ b/src/api/tests/UserApi.test.ts @@ -305,6 +305,52 @@ describe("UserApi", () => { }); }); + describe("callUserDisableSelfApi", () => { + test("PUTs to users/{accountId} with status body and no private key", (done) => { + UserApi.callUserDisableSelfApi().engage( + (e) => done.fail(e), + (res: any) => { + expect(res).toEqual({foo: "bar"}); + expect(ApiRequest.fetchJSON).toHaveBeenCalledWith(`users/${TestUtils.testAccountID}`, jasmine.any(Number), jasmine.any(Object)); + const request = (ApiRequest.fetchJSON as jest.Mock).mock.calls[0][2]; + expect(request.method).toEqual("PUT"); + expect(request.headers.Authorization).toMatch(/IronCore\s{1}\d{1}[.][a-zA-Z0-9=\/+]+[.][a-zA-Z0-9=\/+]+/); + const body = JSON.parse(request.body); + expect(body).toEqual({status: 0}); + expect(body).not.toHaveProperty("userPrivateKey"); + done(); + } + ); + }); + }); + + describe("callUserUpdateStatusByJwtApi", () => { + test("PUTs to users/{accountId} with status body and JWT auth", (done) => { + UserApi.callUserUpdateStatusByJwtApi("the.jwt.token", "target-user", 1).engage( + (e) => done.fail(e), + (res: any) => { + expect(res).toEqual({foo: "bar"}); + expect(ApiRequest.fetchJSON).toHaveBeenCalledWith("users/target-user", jasmine.any(Number), jasmine.any(Object)); + const request = (ApiRequest.fetchJSON as jest.Mock).mock.calls[0][2]; + expect(request.method).toEqual("PUT"); + expect(request.headers.Authorization).toEqual("jwt the.jwt.token"); + expect(JSON.parse(request.body)).toEqual({status: 1}); + done(); + } + ); + }); + + test("URL-encodes the account ID", (done) => { + UserApi.callUserUpdateStatusByJwtApi("jwt", "user/with:special#chars", 0).engage( + (e) => done.fail(e), + () => { + expect(ApiRequest.fetchJSON).toHaveBeenCalledWith("users/user%2Fwith%3Aspecial%23chars", jasmine.any(Number), jasmine.any(Object)); + done(); + } + ); + }); + }); + describe("callUserDeviceDeleteApi", () => { test("calls delete API and returns response", (done) => { const deviceDeleteResult = {id: 35352}; diff --git a/src/index.ts b/src/index.ts index b38b4ee..587dc49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ import {DeviceCreateOptions, UserCreateOptions} from "../ironnode"; import {Base64String} from "./commonTypes"; +import {UserStatus} from "./Constants"; import * as Utils from "./lib/Utils"; import * as Initialization from "./sdk/Initialization"; +import * as UserOperations from "./operations/UserOperations"; /** * Initialize the Node SDK by providing account information necessary to run operations from a server side context. Returns a Promise which @@ -54,8 +56,29 @@ export const User = { generateDeviceKeys(jwt: string, password: string, deviceOptions: DeviceCreateOptions = {deviceName: ""}) { return Initialization.generateDevice(jwt, password, deviceOptions).toPromise(); }, + /** + * Enable or disable a user identified by the provided signed JWT. The user ID is read from the JWT's `sub` claim. The server validates + * the JWT signature; clients holding a valid JWT for a user may flip that user's status between `UserStatus.Disabled` and `UserStatus.Enabled`. + * @param {string} jwt Signed JWT for the user whose status is being updated + * @param {UserStatus} status Desired status (`UserStatus.Disabled` or `UserStatus.Enabled`) + */ + updateStatus(jwt: string, status: UserStatus) { + if (typeof jwt !== "string" || !jwt.length) { + throw new Error("Expected a non-empty JWT string."); + } + // Runtime guard for callers using `as any` to bypass the literal-union parameter type. + if (status !== UserStatus.Disabled && status !== UserStatus.Enabled) { + throw new Error(`Invalid user status '${status}'. Expected UserStatus.Disabled (0) or UserStatus.Enabled (1).`); + } + return UserOperations.updateUserStatus(jwt, status).toPromise(); + }, }; +/** + * User status constants. Used with `User.updateStatus` to enable or disable a user. + */ +export {UserStatus}; + /** * List of SDK Error Codes */ diff --git a/src/lib/ApiState.ts b/src/lib/ApiState.ts index 7aee325..6830252 100644 --- a/src/lib/ApiState.ts +++ b/src/lib/ApiState.ts @@ -45,6 +45,23 @@ class ApiState { this.accountEncryptedPublicKeyBytes = key; } + /** + * Wipe the in-memory account context. Used after operations that revoke the current + * device (`disableSelf`, current-device delete). Subsequent SDK calls fail locally + * rather than signing requests with revoked keys. + */ + clearCurrentUser() { + const replacement = new ApiState(); + this.accountID = replacement.accountID; + this.segmentID = replacement.segmentID; + this.currentKeyId = replacement.currentKeyId; + this.accountPublicKeyBytes = replacement.accountPublicKeyBytes; + this.accountEncryptedPublicKeyBytes = replacement.accountEncryptedPublicKeyBytes; + this.publicSigningKey = replacement.publicSigningKey; + this.privateDeviceKey = replacement.privateDeviceKey; + this.privateSigningKey = replacement.privateSigningKey; + } + /** * Return the public key of the account being used. */ diff --git a/src/lib/SDKState.ts b/src/lib/SDKState.ts new file mode 100644 index 0000000..a2ebdf1 --- /dev/null +++ b/src/lib/SDKState.ts @@ -0,0 +1,35 @@ +import {ErrorCodes} from "../Constants"; +import SDKError from "./SDKError"; + +/** + * Public-surface initialization flag. Kept separate from `ApiState` (which stores the + * cryptographic context with definite-assignment types) so the lifecycle gate at the + * SDK boundary doesn't require nullable accessors on every keyed field. Every method + * on the `SDK` object returned from `initialize()` calls `checkSDKInitialized()` at + * the top so post-disable / post-current-device-delete calls fail cleanly here + * instead of signing requests with revoked keys deeper in the stack. + */ +let hasInitializedSDK = false; + +export function setSDKInitialized() { + hasInitializedSDK = true; +} + +export function clearSDKInitialized() { + hasInitializedSDK = false; +} + +export function isSDKInitialized(): boolean { + return hasInitializedSDK; +} + +export function checkSDKInitialized(): void { + if (!hasInitializedSDK) { + throw new SDKError( + new Error( + "SDK is not initialized. Either `initialize()` has not been called, or this session was terminated by `disableSelf()` or by deleting the current device." + ), + ErrorCodes.SDK_NOT_INITIALIZED + ); + } +} diff --git a/src/lib/Utils.ts b/src/lib/Utils.ts index 0f709f7..68e497e 100644 --- a/src/lib/Utils.ts +++ b/src/lib/Utils.ts @@ -1,4 +1,5 @@ import {TransformKey} from "@ironcorelabs/recrypt-node-binding"; +import Future from "futurejs"; import {PublicKey, Base64String, DocumentHeader} from "../commonTypes"; import { AES_IV_LENGTH, @@ -7,8 +8,10 @@ import { HEADER_META_LENGTH_LENGTH, VERSION_HEADER_LENGTH, ALLOWED_ID_CHAR_REGEX, + ErrorCodes, } from "../Constants"; import {DocumentAccessList} from "../../ironnode"; +import SDKError from "./SDKError"; export const Codec = { /** @@ -74,6 +77,33 @@ export function dedupeArray(list: string[], clearEmptyValues: boolean = false) { }); } +/** + * Extract the user/account ID (the `sub` claim) from a signed JWT. Signature + * verification is left to the IronCore backend; this only parses the payload. + * Resolves with the parsed user ID on success, or rejects with an `SDKError` + * (code `JWT_FORMAT_FAILURE`) on failure. + */ +export function getUserIdFromJwt(jwtToken: string): Future { + const fail = (message: string): Future => Future.reject(new SDKError(new Error(message), ErrorCodes.JWT_FORMAT_FAILURE)); + if (typeof jwtToken !== "string" || !jwtToken.length) { + return fail("Invalid JWT provided."); + } + const parts = jwtToken.split("."); + if (parts.length !== 3) { + return fail("Invalid JWT provided."); + } + let payload: {sub?: unknown}; + try { + payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + } catch { + return fail("Invalid JWT provided."); + } + if (typeof payload.sub !== "string" || !payload.sub.length) { + return fail("Invalid JWT provided. Missing 'sub' claim."); + } + return Future.of(payload.sub); +} + /** * Validate that the provided document ID is a string and has a length */ @@ -137,10 +167,16 @@ export function dedupeAccessLists(accessList: DocumentAccessList) { let userAccess: string[] = []; let groupAccess: string[] = []; if (accessList.users && accessList.users.length) { - userAccess = dedupeArray(accessList.users.map(({id}) => id), true); + userAccess = dedupeArray( + accessList.users.map(({id}) => id), + true + ); } if (accessList.groups && accessList.groups.length) { - groupAccess = dedupeArray(accessList.groups.map(({id}) => id), true); + groupAccess = dedupeArray( + accessList.groups.map(({id}) => id), + true + ); } return [userAccess, groupAccess]; } diff --git a/src/lib/tests/SDKState.test.ts b/src/lib/tests/SDKState.test.ts new file mode 100644 index 0000000..ba210ef --- /dev/null +++ b/src/lib/tests/SDKState.test.ts @@ -0,0 +1,42 @@ +import {ErrorCodes} from "../../Constants"; +import SDKError from "../SDKError"; +import * as SDKState from "../SDKState"; + +describe("SDKState", () => { + afterEach(() => { + SDKState.clearSDKInitialized(); + }); + + test("starts in the uninitialized state", () => { + expect(SDKState.isSDKInitialized()).toBe(false); + }); + + test("setSDKInitialized flips the flag to true", () => { + SDKState.setSDKInitialized(); + expect(SDKState.isSDKInitialized()).toBe(true); + }); + + test("clearSDKInitialized flips the flag back to false", () => { + SDKState.setSDKInitialized(); + SDKState.clearSDKInitialized(); + expect(SDKState.isSDKInitialized()).toBe(false); + }); + + describe("checkSDKInitialized", () => { + test("does not throw when initialized", () => { + SDKState.setSDKInitialized(); + expect(() => SDKState.checkSDKInitialized()).not.toThrow(); + }); + + test("throws an SDKError with code SDK_NOT_INITIALIZED when not initialized", () => { + try { + SDKState.checkSDKInitialized(); + fail("Expected checkSDKInitialized to throw"); + } catch (e) { + expect(e).toBeInstanceOf(SDKError); + expect((e as SDKError).code).toEqual(ErrorCodes.SDK_NOT_INITIALIZED); + expect((e as SDKError).message).toMatch(/initialize/); + } + }); + }); +}); diff --git a/src/lib/tests/Utils.test.ts b/src/lib/tests/Utils.test.ts index c7c9257..b2b7199 100644 --- a/src/lib/tests/Utils.test.ts +++ b/src/lib/tests/Utils.test.ts @@ -1,3 +1,5 @@ +import {ErrorCodes} from "../../Constants"; +import SDKError from "../SDKError"; import * as Utils from "../Utils"; import * as TestUtils from "../../tests/TestUtils"; @@ -94,6 +96,55 @@ describe("Utils", () => { }); }); + describe("getUserIdFromJwt", () => { + const makeJwt = (claims: object) => { + const header = Buffer.from(JSON.stringify({alg: "ES256", typ: "JWT"})).toString("base64url"); + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `${header}.${payload}.sig`; + }; + + const expectJwtFormatFailure = (jwt: any, done: jest.DoneCallback, extraCheck?: (e: SDKError) => void) => { + Utils.getUserIdFromJwt(jwt).engage( + (e) => { + expect(e).toBeInstanceOf(SDKError); + expect(e.code).toEqual(ErrorCodes.JWT_FORMAT_FAILURE); + if (extraCheck) extraCheck(e); + done(); + }, + () => done.fail(`Expected JWT_FORMAT_FAILURE for: ${jwt}`) + ); + }; + + test("resolves with the sub claim from a well-formed JWT", (done) => { + Utils.getUserIdFromJwt(makeJwt({sub: "user-1", pid: 1, sid: "seg"})).engage( + (e) => done.fail(e), + (sub) => { + expect(sub).toEqual("user-1"); + done(); + } + ); + }); + + test("rejects with JWT_FORMAT_FAILURE when JWT is empty", (done) => expectJwtFormatFailure("", done)); + test("rejects with JWT_FORMAT_FAILURE when JWT is not a string", (done) => expectJwtFormatFailure(null, done)); + test("rejects with JWT_FORMAT_FAILURE when JWT has fewer than three segments", (done) => expectJwtFormatFailure("only.two", done)); + test("rejects with JWT_FORMAT_FAILURE when JWT has more than three segments", (done) => expectJwtFormatFailure("a.b.c.d", done)); + + test("rejects with JWT_FORMAT_FAILURE when JWT payload is not valid JSON", (done) => { + const header = Buffer.from("{}").toString("base64url"); + const garbage = Buffer.from("not-json").toString("base64url"); + expectJwtFormatFailure(`${header}.${garbage}.sig`, done); + }); + + test("rejects with JWT_FORMAT_FAILURE when sub claim is missing", (done) => { + expectJwtFormatFailure(makeJwt({pid: 1}), done, (e) => { + expect(e.message).toMatch(/'sub' claim/); + }); + }); + + test("rejects with JWT_FORMAT_FAILURE when sub claim is empty", (done) => expectJwtFormatFailure(makeJwt({sub: ""}), done)); + }); + describe("validateID", () => { test("throws when ID is not a string with length", () => { expect(() => Utils.validateID(3 as any)).toThrow(); diff --git a/src/operations/UserOperations.ts b/src/operations/UserOperations.ts index 077568d..4388875 100644 --- a/src/operations/UserOperations.ts +++ b/src/operations/UserOperations.ts @@ -1,21 +1,35 @@ import Future from "futurejs"; -import {UserPublicKeyGetResponse} from "../../ironnode"; -import UserApi from "../api/UserApi"; +import {UserPublicKeyGetResponse, UserUpdateResult} from "../../ironnode"; +import UserApi, {UserUpdateStatusApiResponse} from "../api/UserApi"; import {PublicKey} from "../commonTypes"; +import {UserStatus} from "../Constants"; import ApiState from "../lib/ApiState"; import SDKError from "../lib/SDKError"; +import {clearSDKInitialized} from "../lib/SDKState"; +import {getUserIdFromJwt} from "../lib/Utils"; import * as UserCrypto from "./UserCrypto"; +const toUserUpdateResult = (resp: UserUpdateStatusApiResponse): UserUpdateResult => ({ + accountID: resp.id, + segmentID: resp.segmentId, + status: resp.status, + userMasterPublicKey: resp.userMasterPublicKey, + needsRotation: resp.needsRotation, +}); + /** * Get a list of all groups that the current user is either a member or admin of. */ export function getUserPublicKeys(userList: string[]): Future { return UserApi.callUserKeyListApi(userList).map((keyList) => { //First convert the API public keys response into a key/value format of userID/public key - const publicKeysById = keyList.result.reduce((list, userKey) => { - list[userKey.id] = userKey.userMasterPublicKey; - return list; - }, {} as {[key: string]: PublicKey}); + const publicKeysById = keyList.result.reduce( + (list, userKey) => { + list[userKey.id] = userKey.userMasterPublicKey; + return list; + }, + {} as {[key: string]: PublicKey} + ); //Then iterate through the requested list of IDs to fill in the ones that didn't exist return userList.reduce((fullListResponse: {[key: string]: PublicKey | null}, requestedUserID) => { if (!fullListResponse[requestedUserID]) { @@ -37,7 +51,13 @@ export function getUserDevices() { * Delete device keys from the users account given the ID or omit to delete the device used to make the delete request. */ export function deleteUserDevice(deviceID?: number) { - return UserApi.callUserDeviceDeleteApi(deviceID); + return UserApi.callUserDeviceDeleteApi(deviceID).map((resp) => { + if (deviceID === undefined) { + ApiState.clearCurrentUser(); + clearSDKInitialized(); + } + return resp; + }); } /** @@ -54,6 +74,31 @@ export function rotateMasterKey(password: string): Future { + return UserApi.callUserDisableSelfApi().map((resp) => { + ApiState.clearCurrentUser(); + clearSDKInitialized(); + return toUserUpdateResult(resp); + }); +} + +/** + * Enable or disable a user identified by the provided JWT. The user ID is + * extracted from the JWT's `sub` claim. + */ +export function updateUserStatus(jwt: string, status: UserStatus): Future { + return getUserIdFromJwt(jwt).flatMap((accountID) => UserApi.callUserUpdateStatusByJwtApi(jwt, accountID, status).map(toUserUpdateResult)); +} + /** * Decrypt and reencrypt the users master private key using the provided passwords. Then make a call to the API to store their newly encrypted master private * key and save it to state for future session use. diff --git a/src/operations/tests/UserOperations.test.ts b/src/operations/tests/UserOperations.test.ts index 32f9381..b329a97 100644 --- a/src/operations/tests/UserOperations.test.ts +++ b/src/operations/tests/UserOperations.test.ts @@ -1,6 +1,8 @@ import Future from "futurejs"; import UserApi from "../../api/UserApi"; +import {ErrorCodes} from "../../Constants"; import ApiState from "../../lib/ApiState"; +import * as SDKState from "../../lib/SDKState"; import * as TestUtils from "../../tests/TestUtils"; import * as UserCrypto from "../UserCrypto"; import * as UserOperations from "../UserOperations"; @@ -8,6 +10,11 @@ import * as UserOperations from "../UserOperations"; describe("UserOperations", () => { beforeEach(() => { ApiState.setAccountContext(...TestUtils.getTestApiState()); + SDKState.setSDKInitialized(); + }); + + afterEach(() => { + SDKState.clearSDKInitialized(); }); describe("getUserPublicKeys", () => { @@ -102,6 +109,56 @@ describe("UserOperations", () => { } ); }); + + test("does not clear ApiState when deleting an arbitrary device by ID", (done) => { + // Deleting a different device must not revoke the current session, so the + // local keys remain intact and subsequent SDK calls keep working. + jest.spyOn(UserApi, "callUserDeviceDeleteApi").mockReturnValue(Future.of({id: 352})); + + UserOperations.deleteUserDevice(352).engage( + (e) => done.fail(e), + () => { + expect(ApiState.accountAndSegmentIDs().accountID).toEqual(TestUtils.testAccountID); + expect(ApiState.devicePrivateKey()).toEqual(TestUtils.devicePrivateBytes); + done(); + } + ); + }); + + test("clears ApiState and the init flag when called with no device ID (current-device delete)", (done) => { + // The server revokes the current device's keys, so leaving them in process + // memory would let the next SDK call sign with revoked keys and fail with a + // confusing 401 instead of a clean local error. + expect(ApiState.accountAndSegmentIDs().accountID).toEqual(TestUtils.testAccountID); + expect(SDKState.isSDKInitialized()).toBe(true); + + jest.spyOn(UserApi, "callUserDeviceDeleteApi").mockReturnValue(Future.of({id: 99})); + + UserOperations.deleteUserDevice().engage( + (e) => done.fail(e), + (result: any) => { + expect(result).toEqual({id: 99}); + expect(UserApi.callUserDeviceDeleteApi).toHaveBeenCalledWith(undefined); + expect(ApiState.accountAndSegmentIDs()).toEqual({accountID: undefined, segmentID: undefined}); + expect(ApiState.devicePrivateKey()).toBeUndefined(); + expect(ApiState.signingKeys().privateKey).toBeUndefined(); + expect(SDKState.isSDKInitialized()).toBe(false); + done(); + } + ); + }); + + test("does not clear ApiState when the current-device delete API call fails", (done) => { + jest.spyOn(UserApi, "callUserDeviceDeleteApi").mockReturnValue(Future.reject(new Error("server error")) as any); + + UserOperations.deleteUserDevice().engage( + () => { + expect(ApiState.accountAndSegmentIDs().accountID).toEqual(TestUtils.testAccountID); + done(); + }, + () => done.fail("Expected deleteUserDevice to fail when the API rejects") + ); + }); }); describe("rotateMasterKey", () => { @@ -127,6 +184,128 @@ describe("UserOperations", () => { }); }); + describe("disableSelf", () => { + test("calls status API and maps the response", (done) => { + jest.spyOn(UserApi, "callUserDisableSelfApi").mockReturnValue( + Future.of({ + id: "abc", + status: 0, + segmentId: 42, + userMasterPublicKey: {x: "px", y: "py"}, + needsRotation: false, + }) + ); + + UserOperations.disableSelf().engage( + (e) => done.fail(e), + (res) => { + expect(res).toEqual({ + accountID: "abc", + segmentID: 42, + status: 0, + userMasterPublicKey: {x: "px", y: "py"}, + needsRotation: false, + }); + expect(UserApi.callUserDisableSelfApi).toHaveBeenCalled(); + done(); + } + ); + }); + + test("clears the in-memory account context and init flag after a successful disable", (done) => { + expect(ApiState.accountAndSegmentIDs().accountID).toEqual(TestUtils.testAccountID); + expect(SDKState.isSDKInitialized()).toBe(true); + + jest.spyOn(UserApi, "callUserDisableSelfApi").mockReturnValue( + Future.of({id: "abc", status: 0, segmentId: 42, userMasterPublicKey: {x: "", y: ""}, needsRotation: false}) + ); + + UserOperations.disableSelf().engage( + (e) => done.fail(e), + () => { + expect(ApiState.accountAndSegmentIDs()).toEqual({accountID: undefined, segmentID: undefined}); + expect(ApiState.devicePrivateKey()).toBeUndefined(); + expect(ApiState.signingKeys().privateKey).toBeUndefined(); + expect(ApiState.accountEncryptedPrivateKey()).toBeUndefined(); + expect(ApiState.accountPublicKey()).toBeUndefined(); + expect(SDKState.isSDKInitialized()).toBe(false); + done(); + } + ); + }); + + test("does not clear the in-memory account context when the API call fails", (done) => { + jest.spyOn(UserApi, "callUserDisableSelfApi").mockReturnValue(Future.reject(new Error("server error")) as any); + + UserOperations.disableSelf().engage( + () => { + expect(ApiState.accountAndSegmentIDs().accountID).toEqual(TestUtils.testAccountID); + done(); + }, + () => done.fail("Expected disableSelf to fail when the API rejects") + ); + }); + }); + + describe("updateUserStatus", () => { + const buildJwt = (claims: object) => { + const header = Buffer.from(JSON.stringify({alg: "ES256", typ: "JWT"})).toString("base64url"); + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `${header}.${payload}.sig`; + }; + + test("extracts user ID from JWT sub claim and calls API", (done) => { + const jwt = buildJwt({sub: "user-from-jwt", pid: 1, sid: "seg"}); + jest.spyOn(UserApi, "callUserUpdateStatusByJwtApi").mockReturnValue( + Future.of({ + id: "user-from-jwt", + status: 1, + segmentId: 7, + userMasterPublicKey: {x: "x", y: "y"}, + needsRotation: false, + }) + ); + + UserOperations.updateUserStatus(jwt, 1).engage( + (e) => done.fail(e), + (res) => { + expect(res).toEqual({ + accountID: "user-from-jwt", + segmentID: 7, + status: 1, + userMasterPublicKey: {x: "x", y: "y"}, + needsRotation: false, + }); + expect(UserApi.callUserUpdateStatusByJwtApi).toHaveBeenCalledWith(jwt, "user-from-jwt", 1); + done(); + } + ); + }); + + test("rejects with JWT_FORMAT_FAILURE when JWT cannot be parsed", (done) => { + UserOperations.updateUserStatus("not-a-jwt", 0).engage( + (e) => { + expect(e.message).toMatch(/Invalid JWT/); + expect(e.code).toEqual(ErrorCodes.JWT_FORMAT_FAILURE); + done(); + }, + () => done.fail("Expected updateUserStatus to fail for malformed JWT") + ); + }); + + test("rejects with JWT_FORMAT_FAILURE when JWT is missing sub claim", (done) => { + const jwt = buildJwt({pid: 1}); + UserOperations.updateUserStatus(jwt, 0).engage( + (e) => { + expect(e.message).toMatch(/Invalid JWT/); + expect(e.code).toEqual(ErrorCodes.JWT_FORMAT_FAILURE); + done(); + }, + () => done.fail("Expected updateUserStatus to fail when sub is missing") + ); + }); + }); + describe("changeUsersPassword", () => { test("changes encrypted key and saves it to the API", (done) => { jest.spyOn(UserCrypto, "reencryptUserMasterPrivateKey").mockReturnValue(Future.of(Buffer.from("newKey!"))); diff --git a/src/sdk/DocumentSDK.ts b/src/sdk/DocumentSDK.ts index 297ac1e..7e35acc 100644 --- a/src/sdk/DocumentSDK.ts +++ b/src/sdk/DocumentSDK.ts @@ -1,5 +1,6 @@ import * as crypto from "crypto"; import {DocumentAccessList, DocumentCreateOptions, EncryptedDocumentResponse} from "../../ironnode"; +import {checkSDKInitialized} from "../lib/SDKState"; import * as Utils from "../lib/Utils"; import * as DocumentOperations from "../operations/DocumentOperations"; @@ -30,6 +31,7 @@ function calculateDocumentCreateOptionsDefault(options?: DocumentCreateOptions) * This list will include documents the user authored as well as documents that were granted access to the current user, either by another user or a group. */ export function list() { + checkSDKInitialized(); return DocumentOperations.list().toPromise(); } @@ -38,6 +40,7 @@ export function list() { * @param {string} documentID ID of the document metadata to retrieve */ export function getMetadata(documentID: string) { + checkSDKInitialized(); Utils.validateID(documentID); return DocumentOperations.getMetadata(documentID).toPromise(); } @@ -48,6 +51,7 @@ export function getMetadata(documentID: string) { * @param encryptedDocument Encrypted document content to parse. */ export function getDocumentIDFromBytes(encryptedDocument: Buffer) { + checkSDKInitialized(); Utils.validateEncryptedDocument(encryptedDocument); return DocumentOperations.getDocumentIDFromBytes(encryptedDocument).toPromise(); } @@ -58,6 +62,7 @@ export function getDocumentIDFromBytes(encryptedDocument: Buffer) { * @param inputStream Encrypted document input stream. */ export function getDocumentIDFromStream(inputStream: NodeJS.ReadableStream) { + checkSDKInitialized(); return DocumentOperations.getDocumentIDFromStream(inputStream).toPromise(); } @@ -67,6 +72,7 @@ export function getDocumentIDFromStream(inputStream: NodeJS.ReadableStream) { * @param {Buffer} documentData Document data to decrypt */ export function decryptBytes(documentID: string, encryptedDocument: Buffer) { + checkSDKInitialized(); Utils.validateID(documentID); Utils.validateEncryptedDocument(encryptedDocument); return DocumentOperations.decryptBytes(documentID, encryptedDocument).toPromise(); @@ -105,6 +111,7 @@ export function decryptBytes(documentID: string, encryptedDocument: Buffer) { * groups: Array - List of group IDs to share document with. Each value in the array should be in the form {id: string}. */ export function encryptBytes(documentData: Buffer, options?: DocumentCreateOptions): Promise { + checkSDKInitialized(); Utils.validateDocumentData(documentData); const encryptOptions = calculateDocumentCreateOptionsDefault(options); if (encryptOptions.documentID) { @@ -134,6 +141,7 @@ export function encryptBytes(documentData: Buffer, options?: DocumentCreateOptio * groups: Array - List of group IDs to share document with. Each value in the array should be in the form {id: string}. */ export function encryptStream(inputStream: NodeJS.ReadableStream, outputStream: NodeJS.WritableStream, options?: DocumentCreateOptions) { + checkSDKInitialized(); const encryptOptions = calculateDocumentCreateOptionsDefault(options); if (encryptOptions.documentID) { Utils.validateID(encryptOptions.documentID); @@ -156,6 +164,7 @@ export function encryptStream(inputStream: NodeJS.ReadableStream, outputStream: * @param {Buffer} newDocumentData New content to encrypt for document */ export function updateEncryptedBytes(documentID: string, newDocumentData: Buffer): Promise { + checkSDKInitialized(); Utils.validateID(documentID); Utils.validateDocumentData(newDocumentData); return DocumentOperations.updateDocumentBytes(documentID, newDocumentData).toPromise(); @@ -169,6 +178,7 @@ export function updateEncryptedBytes(documentID: string, newDocumentData: Buffer * @param {WritableStream} outputStream Writable stream to write encrypted file contents to */ export function updateEncryptedStream(documentID: string, inputStream: NodeJS.ReadableStream, outputStream: NodeJS.WritableStream) { + checkSDKInitialized(); Utils.validateID(documentID); return DocumentOperations.updateDocumentStream(documentID, inputStream, outputStream).toPromise(); } @@ -179,6 +189,7 @@ export function updateEncryptedStream(documentID: string, inputStream: NodeJS.Re * @param {string|null} name Name to update. Send in null/empty string to clear a documents name field. */ export function updateName(documentID: string, name: string | null) { + checkSDKInitialized(); Utils.validateID(documentID); return DocumentOperations.updateDocumentName(documentID, name).toPromise(); } @@ -190,6 +201,7 @@ export function updateName(documentID: string, name: string | null) { * @param {DocumentAccessList} accessList List of IDs (user IDs, group IDs) with which to grant document access */ export function grantAccess(documentID: string, grantList: DocumentAccessList) { + checkSDKInitialized(); Utils.validateID(documentID); Utils.validateAccessList(grantList); @@ -204,6 +216,7 @@ export function grantAccess(documentID: string, grantList: DocumentAccessList) { * @param {DocumentAccessList} revokeList List of IDs (user IDs and/or groupIDs) from which to revoke access */ export function revokeAccess(documentID: string, revokeList: DocumentAccessList) { + checkSDKInitialized(); Utils.validateID(documentID); Utils.validateAccessList(revokeList); diff --git a/src/sdk/GroupSDK.ts b/src/sdk/GroupSDK.ts index 45270ea..5f4ba45 100644 --- a/src/sdk/GroupSDK.ts +++ b/src/sdk/GroupSDK.ts @@ -1,4 +1,5 @@ import {GroupCreateOptions, GroupUpdateOptions} from "../../ironnode"; +import {checkSDKInitialized} from "../lib/SDKState"; import * as Utils from "../lib/Utils"; import * as GroupOperations from "../operations/GroupOperations"; @@ -6,6 +7,7 @@ import * as GroupOperations from "../operations/GroupOperations"; * List all groups that the current user is either an admin or member of. */ export function list() { + checkSDKInitialized(); return GroupOperations.list().toPromise(); } @@ -14,6 +16,7 @@ export function list() { * @param {string} groupID ID of group to retrieve */ export function get(groupID: string) { + checkSDKInitialized(); Utils.validateID(groupID); return GroupOperations.get(groupID).toPromise(); } @@ -23,6 +26,7 @@ export function get(groupID: string) { * @param {GroupCreateOptions} options Group creation options */ export function create(options: GroupCreateOptions = {groupName: "", addAsMember: true, needsRotation: false}) { + checkSDKInitialized(); if (options.groupID) { Utils.validateID(options.groupID); } @@ -35,6 +39,7 @@ export function create(options: GroupCreateOptions = {groupName: "", addAsMember * @param {GroupUpdateOptions} options Group update options. */ export function update(groupID: string, options: GroupUpdateOptions) { + checkSDKInitialized(); Utils.validateID(groupID); if (options.groupName === null || (typeof options.groupName === "string" && options.groupName.length)) { return GroupOperations.update(groupID, options.groupName).toPromise(); @@ -49,6 +54,7 @@ export function update(groupID: string, options: GroupUpdateOptions) { * @param {string} groupId ID of the group to rotate */ export function rotatePrivateKey(groupId: string) { + checkSDKInitialized(); Utils.validateID(groupId); return GroupOperations.rotateGroupPrivateKey(groupId).toPromise(); } @@ -59,6 +65,7 @@ export function rotatePrivateKey(groupId: string) { * @param {string[]} userList List of user IDs to add as admins to the group */ export function addAdmins(groupID: string, userList: string[]) { + checkSDKInitialized(); Utils.validateID(groupID); Utils.validateIDList(userList); return GroupOperations.addAdmins(groupID, Utils.dedupeArray(userList, true)).toPromise(); @@ -71,6 +78,7 @@ export function addAdmins(groupID: string, userList: string[]) { * @param {string[]} userList List of users to remove as admins from the group */ export function removeAdmins(groupID: string, userList: string[]) { + checkSDKInitialized(); Utils.validateID(groupID); Utils.validateIDList(userList); return GroupOperations.removeAdmins(groupID, Utils.dedupeArray(userList, true)).toPromise(); @@ -82,6 +90,7 @@ export function removeAdmins(groupID: string, userList: string[]) { * @param {string[]} userList List of user IDs to add as members to the group */ export function addMembers(groupID: string, userList: string[]) { + checkSDKInitialized(); Utils.validateID(groupID); Utils.validateIDList(userList); return GroupOperations.addMembers(groupID, Utils.dedupeArray(userList, true)).toPromise(); @@ -93,6 +102,7 @@ export function addMembers(groupID: string, userList: string[]) { * @param {string[]} userList List of user IDs to remove as members from the group. */ export function removeMembers(groupID: string, userList: string[]) { + checkSDKInitialized(); Utils.validateID(groupID); Utils.validateIDList(userList); return GroupOperations.removeMembers(groupID, Utils.dedupeArray(userList, true)).toPromise(); @@ -103,6 +113,7 @@ export function removeMembers(groupID: string, userList: string[]) { * encrypted to the group to no longer be able to be decrypted. */ export function deleteGroup(groupID: string) { + checkSDKInitialized(); Utils.validateID(groupID); return GroupOperations.deleteGroup(groupID).toPromise(); } diff --git a/src/sdk/Initialization.ts b/src/sdk/Initialization.ts index 1c99797..f761695 100644 --- a/src/sdk/Initialization.ts +++ b/src/sdk/Initialization.ts @@ -7,6 +7,7 @@ import {decryptUserMasterKey, encryptUserMasterKey} from "../crypto/AES"; import * as Recrypt from "../crypto/Recrypt"; import ApiState from "../lib/ApiState"; import SDKError from "../lib/SDKError"; +import {setSDKInitialized} from "../lib/SDKState"; import {Codec} from "../lib/Utils"; import * as DocumentSDK from "./DocumentSDK"; import * as GroupSDK from "./GroupSDK"; @@ -60,7 +61,7 @@ function generateDeviceAndTransformKeys(jwt: string, userMasterKeyPair: KeyPair) } /** - * Initizlize the Node SDK. Retrieves the public key for the provided account ID and sets all other provided data into the API state + * Initialize the Node SDK. Retrieves the public key for the provided account ID and sets all other provided data into the API state * library for future requests. */ export function initialize(accountID: string, segmentID: number, privateDeviceKey: Base64String, privateSigningKey: Base64String): Future { @@ -74,6 +75,7 @@ export function initialize(accountID: string, segmentID: number, privateDeviceKe Codec.Buffer.fromBase64(privateSigningKey), user.currentKeyId ); + setSDKInitialized(); return Future.of({ ...SDK, userContext: { diff --git a/src/sdk/UserSDK.ts b/src/sdk/UserSDK.ts index c70c72e..8f2610e 100644 --- a/src/sdk/UserSDK.ts +++ b/src/sdk/UserSDK.ts @@ -1,9 +1,11 @@ +import {checkSDKInitialized} from "../lib/SDKState"; import * as UserOperations from "../operations/UserOperations"; /** * Get a list of user public keys given a single user or a list of user IDs. */ export function getPublicKey(users: string | string[]) { + checkSDKInitialized(); if (!users || !users.length) { throw new Error("You must provide a user ID or list of users IDs to perform this operation."); } @@ -14,6 +16,7 @@ export function getPublicKey(users: string | string[]) { * Get a list of the current users devices. Returns information about the device ID, name, and created/updated times. */ export function listDevices() { + checkSDKInitialized(); return UserOperations.getUserDevices().toPromise(); } @@ -23,6 +26,7 @@ export function listDevices() { * @param {number} deviceID ID of device to delete. Omit to delete the current device. */ export function deleteDevice(deviceID?: number) { + checkSDKInitialized(); if (deviceID && typeof deviceID !== "number") { throw new Error(`Invalid device ID provided. Expected a number greater than zero but got ${deviceID}`); } @@ -34,6 +38,7 @@ export function deleteDevice(deviceID?: number) { * before performing rotation. */ export function rotateMasterKey(password: string) { + checkSDKInitialized(); return UserOperations.rotateMasterKey(password).toPromise(); } @@ -41,5 +46,16 @@ export function rotateMasterKey(password: string) { * Change the users escrow password. */ export function changePassword(currentPassword: string, newPassword: string) { + checkSDKInitialized(); return UserOperations.changeUsersPassword(currentPassword, newPassword).toPromise(); } + +/** + * Disable the currently authenticated user. The user remains in any groups + * they belonged to but cannot call SDK functions until an admin re-enables them + * via `User.updateStatus`. + */ +export function disableSelf() { + checkSDKInitialized(); + return UserOperations.disableSelf().toPromise(); +} diff --git a/src/sdk/tests/DocumentSDK.test.ts b/src/sdk/tests/DocumentSDK.test.ts index 8044095..fb5887b 100644 --- a/src/sdk/tests/DocumentSDK.test.ts +++ b/src/sdk/tests/DocumentSDK.test.ts @@ -1,8 +1,31 @@ import Future from "futurejs"; +import * as SDKState from "../../lib/SDKState"; import * as DocumentOperations from "../../operations/DocumentOperations"; import * as DocumentSDK from "../DocumentSDK"; describe("DocumentSDK", () => { + beforeEach(() => { + SDKState.setSDKInitialized(); + }); + + afterEach(() => { + SDKState.clearSDKInitialized(); + }); + + describe("SDK initialization gate", () => { + // Iterates over every exported function so new SDK methods are automatically + // covered. If a method is added without a `checkSDKInitialized()` guard, this + // test fails for that method by name. + Object.entries(DocumentSDK) + .filter(([, fn]) => typeof fn === "function") + .forEach(([name, fn]) => { + test(`${name} throws when SDK is not initialized`, () => { + SDKState.clearSDKInitialized(); + expect(() => (fn as (...args: any[]) => unknown)()).toThrow(/initialize/); + }); + }); + }); + describe("list", () => { test("returns Promise invoking document list", (done) => { const spy = jest.spyOn(DocumentOperations, "list"); diff --git a/src/sdk/tests/GroupSDK.test.ts b/src/sdk/tests/GroupSDK.test.ts index 64f3004..cdbfbd5 100644 --- a/src/sdk/tests/GroupSDK.test.ts +++ b/src/sdk/tests/GroupSDK.test.ts @@ -1,8 +1,31 @@ import Future from "futurejs"; +import * as SDKState from "../../lib/SDKState"; import * as GroupOperations from "../../operations/GroupOperations"; import * as GroupSDK from "../GroupSDK"; describe("GroupSDK", () => { + beforeEach(() => { + SDKState.setSDKInitialized(); + }); + + afterEach(() => { + SDKState.clearSDKInitialized(); + }); + + describe("SDK initialization gate", () => { + // Iterates over every exported function so new SDK methods are automatically + // covered. If a method is added without a `checkSDKInitialized()` guard, this + // test fails for that method by name. + Object.entries(GroupSDK) + .filter(([, fn]) => typeof fn === "function") + .forEach(([name, fn]) => { + test(`${name} throws when SDK is not initialized`, () => { + SDKState.clearSDKInitialized(); + expect(() => (fn as (...args: any[]) => unknown)()).toThrow(/initialize/); + }); + }); + }); + describe("list", () => { test("calls list operation", (done) => { const spy = jest.spyOn(GroupOperations, "list"); diff --git a/src/sdk/tests/Initialization.test.ts b/src/sdk/tests/Initialization.test.ts index d4adfdd..bd01b9c 100644 --- a/src/sdk/tests/Initialization.test.ts +++ b/src/sdk/tests/Initialization.test.ts @@ -4,6 +4,7 @@ import {ErrorCodes} from "../../Constants"; import * as AES from "../../crypto/AES"; import * as Recrypt from "../../crypto/Recrypt"; import ApiState from "../../lib/ApiState"; +import * as SDKState from "../../lib/SDKState"; import {Codec} from "../../lib/Utils"; import * as TestUtils from "../../tests/TestUtils"; import * as Initialization from "../Initialization"; @@ -16,6 +17,46 @@ describe("Initialization", () => { stateSpy = jest.spyOn(ApiState, "setAccountContext"); apiSpy = jest.spyOn(UserApi, "getAccountContextPublicKey"); stateSpy.mockImplementation(() => Future.of(undefined)); + SDKState.clearSDKInitialized(); + }); + + afterEach(() => { + SDKState.clearSDKInitialized(); + }); + + test("flips the SDK init flag to true on success", (done) => { + expect(SDKState.isSDKInitialized()).toBe(false); + apiSpy.mockReturnValue( + Future.of({ + id: "user-10", + userMasterPublicKey: Codec.PublicKey.toBase64({x: Buffer.from([1]), y: Buffer.from([2])}), + currentKeyId: 1, + userPrivateKey: Buffer.from([1]).toString("base64"), + needsRotation: false, + groupsNeedingRotation: [], + }) + ); + + Initialization.initialize("user-10", 3, Codec.Buffer.toBase64(Buffer.from([1])), Codec.Buffer.toBase64(Buffer.from([2]))).engage( + (e) => done.fail(e), + () => { + expect(SDKState.isSDKInitialized()).toBe(true); + done(); + } + ); + }); + + test("does not flip the init flag when the API call fails", (done) => { + expect(SDKState.isSDKInitialized()).toBe(false); + apiSpy.mockReturnValue(Future.reject(new Error("server unreachable"))); + + Initialization.initialize("user-10", 3, Codec.Buffer.toBase64(Buffer.from([1])), Codec.Buffer.toBase64(Buffer.from([2]))).engage( + () => { + expect(SDKState.isSDKInitialized()).toBe(false); + done(); + }, + () => done.fail("Expected initialize to fail when the API rejects") + ); }); test("should resolve successfully and set api state context", () => { diff --git a/src/sdk/tests/UserSDK.test.ts b/src/sdk/tests/UserSDK.test.ts index 751728b..6dcc888 100644 --- a/src/sdk/tests/UserSDK.test.ts +++ b/src/sdk/tests/UserSDK.test.ts @@ -1,8 +1,45 @@ import Future from "futurejs"; +import * as SDKState from "../../lib/SDKState"; import * as UserOperations from "../../operations/UserOperations"; import * as UserSDK from "../UserSDK"; describe("UserSDK", () => { + beforeEach(() => { + SDKState.setSDKInitialized(); + }); + + afterEach(() => { + SDKState.clearSDKInitialized(); + }); + + describe("SDK initialization gate", () => { + // Iterates over every exported function so new SDK methods are automatically + // covered. If a method is added without a `checkSDKInitialized()` guard, this + // test fails for that method by name. + Object.entries(UserSDK) + .filter(([, fn]) => typeof fn === "function") + .forEach(([name, fn]) => { + test(`${name} throws when SDK is not initialized`, () => { + SDKState.clearSDKInitialized(); + expect(() => (fn as (...args: any[]) => unknown)()).toThrow(/initialize/); + }); + }); + + test("disableSelf flips the init flag off on success, so subsequent calls fail locally", (done) => { + jest.spyOn(UserOperations, "disableSelf").mockImplementation(() => { + SDKState.clearSDKInitialized(); + return Future.of({} as any); + }); + UserSDK.disableSelf() + .then(() => { + expect(SDKState.isSDKInitialized()).toBe(false); + expect(() => UserSDK.listDevices()).toThrow(/initialize/); + done(); + }) + .catch((e) => done.fail(e)); + }); + }); + describe("getPublicKey", () => { test("fails if no value provided ", () => { expect(() => UserSDK.getPublicKey("")).toThrow(); @@ -79,6 +116,20 @@ describe("UserSDK", () => { }); }); + describe("disableSelf", () => { + test("calls UserOperations.disableSelf and forwards the result", (done) => { + const spy = jest.spyOn(UserOperations, "disableSelf"); + spy.mockReturnValue(Future.of("disabled") as any); + UserSDK.disableSelf() + .then((resp) => { + expect(resp).toEqual("disabled"); + expect(UserOperations.disableSelf).toHaveBeenCalledWith(); + done(); + }) + .catch((e) => done.fail(e)); + }); + }); + describe("changePassword", () => { test("should call into UserOperations", () => { const spy = jest.spyOn(UserOperations, "changeUsersPassword"); diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index a096bee..982c80e 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -1,5 +1,6 @@ import Future from "futurejs"; import * as IronNode from "../index"; +import * as UserOperations from "../operations/UserOperations"; import * as Initialization from "../sdk/Initialization"; describe("IronNode", () => { @@ -66,5 +67,36 @@ describe("IronNode", () => { }) .catch((e) => fail(e)); }); + + describe("updateStatus", () => { + test("rejects an empty JWT", () => { + expect(() => IronNode.User.updateStatus("", 0)).toThrow(); + expect(() => (IronNode.User as any).updateStatus(null, 0)).toThrow(); + }); + + test("rejects an invalid status value", () => { + expect(() => (IronNode.User as any).updateStatus("jwt", 2)).toThrow(); + expect(() => (IronNode.User as any).updateStatus("jwt", "active")).toThrow(); + }); + + test("calls UserOperations.updateUserStatus with provided JWT and status", (done) => { + const spy = jest.spyOn(UserOperations, "updateUserStatus"); + spy.mockReturnValue(Future.of("updated") as any); + IronNode.User.updateStatus("the.jwt.token", IronNode.UserStatus.Enabled) + .then((res) => { + expect(res).toEqual("updated"); + expect(UserOperations.updateUserStatus).toHaveBeenCalledWith("the.jwt.token", 1); + done(); + }) + .catch((e) => done.fail(e)); + }); + }); + }); + + describe("UserStatus", () => { + test("exposes Disabled (0) and Enabled (1) constants", () => { + expect(IronNode.UserStatus.Disabled).toEqual(0); + expect(IronNode.UserStatus.Enabled).toEqual(1); + }); }); });