Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="SelectedPhotoAccess" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- Required to read the current Wi-Fi SSID for session attributes -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both permissions go into every build, including servers that can never use the feature.

The app uses react-native-permissions in 18 places, but location isn't one of them. Also, Desktop shipped enableSessionAttributes in settings. Mobile has no way to opt out.

Is this working as expected or should it be scoped?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine - this just means users will see the prompt and they can feel free to deny the permission.

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
Comment thread
larkox marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ private static void createClientOptions() {

WritableMap requestAdapterConfiguration = Arguments.createMap();
requestAdapterConfiguration.putString("bearerAuthTokenResponseHeader", "token");
requestAdapterConfiguration.putBoolean("enableSessionAttributes", true);
Comment thread
larkox marked this conversation as resolved.
clientOptions.putMap("requestAdapterConfiguration", requestAdapterConfiguration);

WritableMap sessionConfiguration = Arguments.createMap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.modules.network.OkHttpClientProvider
import com.mattermost.networkclient.RCTOkHttpClientFactory
import com.mattermost.networkclient.sessionattributes.SessionAttributes
import com.mattermost.rnshare.ShareWorker
import com.mattermost.rnshare.helpers.RealPathUtil
import com.mattermost.turbolog.TurboLog
import com.mattermost.turbolog.ConfigureOptions
Expand Down Expand Up @@ -66,6 +68,11 @@ class MainApplication : Application(), ReactApplication, INotificationsApplicati
OkHttpClientProvider.setOkHttpClientFactory(RCTOkHttpClientFactory())
ExpoImageOkHttpClientGlideModule.okHttpClient = RCTOkHttpClientFactory().createNewNetworkModuleClient()

// ShareWorker can run without React Native loaded, so ApiClientModuleImpl may never
// initialize the session attributes engine.
SessionAttributes.init(this)
ShareWorker.getSessionAttributesHeader = SessionAttributes::getOutboundHeader

loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
Expand Down
51 changes: 51 additions & 0 deletions app/actions/remote/session_attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import NetworkManager from '@managers/network_manager';

import {fetchSessionAttributesManifest} from './session_attributes';

jest.mock('@managers/network_manager');
jest.mock('@utils/log');

const serverUrl = 'https://chat.example.com';
const manifest: SAField[] = [
{name: 'os_platform', type: 'string', ttl_seconds: 0, grace_period_seconds: 0},
];

describe('fetchSessionAttributesManifest', () => {
const mockClient = {getSessionAttributesManifest: jest.fn()};

beforeEach(() => {
jest.clearAllMocks();
(NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient);
});

it('should return the manifest from the client', async () => {
mockClient.getSessionAttributesManifest.mockResolvedValue(manifest);

const result = await fetchSessionAttributesManifest(serverUrl);

expect(result).toEqual({manifest});
});

it('should return the error when the client request fails', async () => {
const error = new Error('request failed');
mockClient.getSessionAttributesManifest.mockRejectedValue(error);

const result = await fetchSessionAttributesManifest(serverUrl);

expect(result).toEqual({error});
});

it('should return the error when the client cannot be resolved', async () => {
const error = new Error('no client');
(NetworkManager.getClient as jest.Mock).mockImplementation(() => {
throw error;
});

const result = await fetchSessionAttributesManifest(serverUrl);

expect(result).toEqual({error});
});
});
17 changes: 17 additions & 0 deletions app/actions/remote/session_attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';

export const fetchSessionAttributesManifest = async (serverUrl: string): Promise<{manifest?: SAField[]; error?: unknown}> => {
try {
const client = NetworkManager.getClient(serverUrl);
const manifest = await client.getSessionAttributesManifest();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so it seems we are fetching the manifest every time.

a few questions:

  1. What happens if this fails?
  2. Would it be worse not to have an up to date manifest or a stale one?
  3. I don't see this changing a lot, have we considered fetching the delta in subsequent calls instead of the entire manifest every time?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. If we have no manifest at all, the session attributes will not send and the user will be denied access to whatever resource the policy is gating on. We should try to avoid this failing.
  2. It could be - depends on if policies are set up to gate on attributes that the manifest is missing. If the manifest contains new attributes, that's probably worse. If it's removing ones, that's likely not an issue. The worst case is not having a manifest.
  3. The thing is the manifest likely won't change very often at all. I believe the changes I made recently use the changed properties from the websocket event now no? Or did I make a mistake?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 is still a valid concern. The manifest is a list of SAField. How big is that list supposed to be?

If it is small, we can ignore it, but if it is potentially big, we don't want to send 100 registries on every call to this endpoint. We can call client.getSessionAttributesManifest(lastTimeIChecked), and get only the list of "what is new, what has changed, and what has been deleted". That way, most calls to this endpoint will just bring an empty list. That is less time on the wire, less data, and less effort for mobile database.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is anymore, I made a change such that we aren't re-fetching the manifest every time when the property fields update, we are only checking what is coming from the websocket event and upserting to our store.

The only time this runs is on reconnect, and while I suppose we could optimize this to be more efficient, we don't have a large list of attributes here (currently it's 19), and it would require us to completely rearchitect how the endpoint works (which means changes to server, desktop, all which are mostly finalized).

To me, this seems like an improvement we could do later on, but likely isn't necessary right now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as you strongly believe that "doing it later" is not going to bring any headaches, I am ok with it. But I leave you here some things to consider before making that decision:

  • Mobile has to support older versions, so not taking a stab at this now, may imply maintaining two different logic branches later
  • With the DDIL effort, one of the big bottle necks is initial load. And that includes the reconnect logic.
  • Making any change now should be simpler (no breaking change because there is nothing to break) than later, when the product is out in the wild.

If considering all this you still believe we can go ahead with things as they are, I am ok with it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That change later is 100% needed, the changes in no re-fetching anytime are good, but I can see this being a headache in the future for not addressing now for whatever valid reasons we may think of

return {manifest};
} catch (error) {
logDebug('error on fetchSessionAttributesManifest', getFullErrorMessage(error));
return {error};
}
};
11 changes: 10 additions & 1 deletion app/actions/websocket/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@ import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRole
import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system';
import * as teams from './teams';
import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads';
import {handleUserUpdatedEvent, handleUserTypingEvent, handleStatusChangedEvent, handleCustomProfileAttributesValuesUpdatedEvent, handleCustomProfileAttributesFieldUpdatedEvent, handleCustomProfileAttributesFieldDeletedEvent} from './users';
import {
handleCustomProfileAttributesFieldDeletedEvent,
handleCustomProfileAttributesFieldUpdatedEvent,
handleCustomProfileAttributesValuesUpdatedEvent,
handleSessionAttributesPropertyFieldEvent,
handleStatusChangedEvent,
handleUserTypingEvent,
handleUserUpdatedEvent,
} from './users';

export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMessage) {
switch (msg.event) {
Expand Down Expand Up @@ -317,6 +325,7 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
case WebsocketEvents.PROPERTY_FIELD_CREATED:
case WebsocketEvents.PROPERTY_FIELD_UPDATED:
handlePropertyFieldCreatedOrUpdated(serverUrl, msg);
handleSessionAttributesPropertyFieldEvent(serverUrl, msg);
Comment thread
larkox marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
break;

case WebsocketEvents.PROPERTY_FIELD_DELETED:
Expand Down
9 changes: 9 additions & 0 deletions app/actions/websocket/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {loadConfigAndCalls} from '@calls/actions/calls';
import {isSupportedServerCalls} from '@calls/utils';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import SessionAttributesManager from '@managers/session_attributes_manager';
import {handlePlaybookReconnect} from '@playbooks/actions/websocket/reconnect';
import {getActiveServerUrl} from '@queries/app/servers';
import {getLastPostInThread} from '@queries/servers/post';
Expand Down Expand Up @@ -52,6 +53,12 @@ jest.mock('@utils/helpers', () => ({
}));

jest.mock('@playbooks/actions/websocket/reconnect');
jest.mock('@managers/session_attributes_manager', () => ({
__esModule: true,
default: {
refreshManifest: jest.fn().mockResolvedValue(undefined),
},
}));

describe('WebSocket Index Actions', () => {
const serverUrl = 'baseHandler.test.com';
Expand Down Expand Up @@ -107,6 +114,7 @@ describe('WebSocket Index Actions', () => {
expect(loadConfigAndCalls).toHaveBeenCalled();
expect(deferredAppEntryActions).toHaveBeenCalled();
expect(handlePlaybookReconnect).toHaveBeenCalledWith(serverUrl);
expect(SessionAttributesManager.refreshManifest).toHaveBeenCalledWith(serverUrl);
});

it('should handle error when server database not found', async () => {
Expand Down Expand Up @@ -163,6 +171,7 @@ describe('WebSocket Index Actions', () => {
expect(expiredBoRPostCleanup).toHaveBeenCalled();
expect(AppsManager.refreshAppBindings).toHaveBeenCalled();
expect(handlePlaybookReconnect).toHaveBeenCalledWith(serverUrl);
expect(SessionAttributesManager.refreshManifest).toHaveBeenCalledWith(serverUrl);
});

it('should fetch posts for channel screen', async () => {
Expand Down
3 changes: 3 additions & 0 deletions app/actions/websocket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {isSupportedServerCalls} from '@calls/utils';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import SessionAttributesManager from '@managers/session_attributes_manager';
import {handlePlaybookReconnect} from '@playbooks/actions/websocket/reconnect';
import {getActiveServerUrl} from '@queries/app/servers';
import {getLastPostInThread} from '@queries/servers/post';
Expand Down Expand Up @@ -64,6 +65,8 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel
const {database} = operator;

try {
await SessionAttributesManager.refreshManifest(serverUrl);
Comment thread
devinbinnie marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point is too early.. the refreshManifest function reads from config and license, but that is fetched during entry if I'm not mistaken.

On top of that, the await here is blocking the rest of the calls, I guess that is by design so that subsequent calls have the attributes attached in the headers? Which brings me to another question but I guess is related to the feature in itself, does every request needs to include the headers or is there something in the session that already has the attributes set for this session and only updates when something in the client changes ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So to answer the question about the feature, yes every authenticated request should have session attributes, and this is by design. We could feasibility use session attributes to write policies that gate data access at any API endpoint, so if the values are missing and not cached on the server at the right time, the user could get denied access to something they should have.

Right now, we are only using these for permission policies which is pretty limited, but we should be able to use these for anything we want going forward.

Tech spec here if you'd like to see: https://mattermost.atlassian.net/wiki/spaces/ICT/pages/4559732738/WIP+Session+Attributes+v1.0+MVF?atl_p=eyJpIjoiM2Q1Mjc3ZDYtZWIxYy1hNWQyLWQ3YTItZTI5Y2Y5Y2RhMDEzIiwidCI6InRvcExlZnRWaWV3Q29udGFpbmVyIiwic291cmNlIjoiZW1haWwiLCJlIjoiY2Mtbm90aWZpY2F0aW9uc19iYXRjaF91cGRhdGUifQ

Comment thread
larkox marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconnect waits on a telemetry call before fetching any messages.

When the websocket reconnects, the very first thing we do is wait for the session attributes manifest. Only after that finishes do we call entry() and start pulling channels and posts.

So the order is: ask the server about session attributes → then go get the user's messages.

If that manifest endpoint is slow, the user just stares at a stale channel while we're waiting on a call that has nothing to do with showing them their messages.

Should we fire it without awaiting, or move it after the entry sync?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason it needs to be synchronous is so that theoretically, any endpoint we hit will make sure that the user has up-to-date session attributes before evaluating whether they have access to resources. This will become very important when we start using ABAC to control if a user can see a channel or not (with session attributes).


const lastFullSync = await getLastFullSync(database);
const now = Date.now();

Expand Down
44 changes: 44 additions & 0 deletions app/actions/websocket/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {reconcilePersistenceFlag} from '@actions/local/ephemeral_mode/wipe';
import {storeConfig} from '@actions/local/systems';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import SessionAttributesManager from '@managers/session_attributes_manager';
import {getConfig, getLicense} from '@queries/servers/system';

import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system';
Expand All @@ -16,6 +17,13 @@ jest.mock('@actions/local/channel');
jest.mock('@actions/local/ephemeral_mode/wipe');
jest.mock('@actions/local/systems');
jest.mock('@database/manager');
jest.mock('@managers/session_attributes_manager', () => ({
__esModule: true,
default: {
refreshManifest: jest.fn().mockResolvedValue(undefined),
removeServer: jest.fn(),
},
}));
jest.mock('@queries/servers/system');

describe('WebSocket System Actions', () => {
Expand Down Expand Up @@ -156,6 +164,42 @@ describe('WebSocket System Actions', () => {
expect(updateDmGmDisplayName).toHaveBeenCalledWith(serverUrl);
});

it('should re-init session attributes when feature flag is enabled', async () => {
jest.mocked(getConfig).mockResolvedValue({
FeatureFlagSessionAttributes: 'false',
} as ClientConfig);

const msg = {
data: {
config: {
FeatureFlagSessionAttributes: 'true',
},
},
} as WebSocketMessage;

await handleConfigChangedEvent(serverUrl, msg);

expect(SessionAttributesManager.refreshManifest).toHaveBeenCalledWith(serverUrl);
});

it('should stop sending session attributes when feature flag is disabled', async () => {
jest.mocked(getConfig).mockResolvedValue({
FeatureFlagSessionAttributes: 'true',
} as ClientConfig);

const msg = {
data: {
config: {
FeatureFlagSessionAttributes: 'false',
},
},
} as WebSocketMessage;

await handleConfigChangedEvent(serverUrl, msg);

expect(SessionAttributesManager.removeServer).toHaveBeenCalledWith(serverUrl);
});

it('should reconcile the persistence flag against the new config so a live MEM-cleanup change clears zero-persistence', async () => {
const mockConfig = {
MobileEphemeralModeEnabled: 'true',
Expand Down
22 changes: 22 additions & 0 deletions app/actions/websocket/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {reconcilePersistenceFlag} from '@actions/local/ephemeral_mode/wipe';
import {storeConfig} from '@actions/local/systems';
import {fetchCategories} from '@actions/remote/category';
import {applyPersistenceModeChange} from '@actions/remote/refresh';
import {License} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import SessionAttributesManager from '@managers/session_attributes_manager';
import {getConfig, getCurrentTeamId, getLicense} from '@queries/servers/system';
import EphemeralStore from '@store/ephemeral_store';
import {getFullErrorMessage} from '@utils/errors';
Expand All @@ -26,6 +28,16 @@ export async function handleLicenseChangedEvent(serverUrl: string, msg: WebSocke
if (license?.LockTeammateNameDisplay && (prevLicense?.LockTeammateNameDisplay !== license.LockTeammateNameDisplay)) {
updateDmGmDisplayName(serverUrl);
}

const prevSessionAttributes = prevLicense?.SkuShortName === License.SKU_SHORT_NAME.EnterpriseAdvanced;
const newSessionAttributes = license?.SkuShortName === License.SKU_SHORT_NAME.EnterpriseAdvanced;
if (newSessionAttributes !== prevSessionAttributes) {
if (newSessionAttributes) {
await SessionAttributesManager.refreshManifest(serverUrl);
} else {
SessionAttributesManager.removeServer(serverUrl);
}
}
} catch {
// do nothing
}
Expand Down Expand Up @@ -60,6 +72,16 @@ export async function handleConfigChangedEvent(serverUrl: string, msg: WebSocket
logError('handleConfigChangedEvent', getFullErrorMessage(modeChangeError));
}
}

const prevSessionAttributes = prevConfig?.FeatureFlagSessionAttributes === 'true';
const newSessionAttributes = config?.FeatureFlagSessionAttributes === 'true';
if (newSessionAttributes !== prevSessionAttributes) {
if (newSessionAttributes) {
await SessionAttributesManager.refreshManifest(serverUrl);
} else {
SessionAttributesManager.removeServer(serverUrl);
}
}
} catch {
// do nothing
}
Expand Down
62 changes: 62 additions & 0 deletions app/actions/websocket/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {setCurrentUserStatus} from '@actions/local/user';
import {fetchMe, fetchUsersByIds} from '@actions/remote/user';
import {Events} from '@constants';
import DatabaseManager from '@database/manager';
import SessionAttributesManager from '@managers/session_attributes_manager';
import WebsocketManager from '@managers/websocket_manager';
import {queryChannelsByTypes, queryUserChannelsByTypes} from '@queries/servers/channel';
import {deleteCustomProfileAttributesByFieldId} from '@queries/servers/custom_profile';
Expand All @@ -25,6 +26,7 @@ import {
handleCustomProfileAttributesValuesUpdatedEvent,
handleCustomProfileAttributesFieldUpdatedEvent,
handleCustomProfileAttributesFieldDeletedEvent,
handleSessionAttributesPropertyFieldEvent,
} from './users';

import type ServerDataOperator from '@database/operator/server_data_operator';
Expand All @@ -35,6 +37,14 @@ jest.mock('@actions/remote/user');
jest.mock('@database/manager');
jest.mock('@helpers/api/preference');
jest.mock('@managers/websocket_manager');
jest.mock('@managers/session_attributes_manager', () => ({
__esModule: true,
default: {
refreshManifest: jest.fn().mockResolvedValue(undefined),
upsertManifestField: jest.fn(),
removeManifestField: jest.fn(),
},
}));
jest.mock('@queries/servers/channel');
jest.mock('@queries/servers/custom_profile');
jest.mock('@queries/servers/preference');
Expand Down Expand Up @@ -532,4 +542,56 @@ describe('WebSocket Users Actions', () => {
expect(deleteCustomProfileAttributesByFieldId).toHaveBeenCalled();
});
});

describe('handleSessionAttributesPropertyFieldEvent', () => {
const buildField = (attrs: Record<string, unknown> = {}) => JSON.stringify({
name: 'os_version',
type: 'text',
attrs: {enabled: true, platforms: ['desktop', 'mobile'], ttl_seconds: 60, grace_period_seconds: 30, ...attrs},
});

const buildMessage = (property_field?: string, objectType: string | undefined = 'session') => ({
event: 'property_field_updated',
data: {object_type: objectType, property_field},
}) as WebSocketMessage;

it('should upsert an enabled mobile field from the websocket payload', () => {
handleSessionAttributesPropertyFieldEvent(serverUrl, buildMessage(buildField()));

expect(SessionAttributesManager.upsertManifestField).toHaveBeenCalledWith(serverUrl, {
name: 'os_version',
type: 'text',
ttl_seconds: 60,
grace_period_seconds: 30,
});
expect(SessionAttributesManager.removeManifestField).not.toHaveBeenCalled();
});

it('should remove a disabled field', () => {
handleSessionAttributesPropertyFieldEvent(serverUrl, buildMessage(buildField({enabled: false})));

expect(SessionAttributesManager.removeManifestField).toHaveBeenCalledWith(serverUrl, 'os_version');
expect(SessionAttributesManager.upsertManifestField).not.toHaveBeenCalled();
});

it('should remove a field not targeted at mobile', () => {
handleSessionAttributesPropertyFieldEvent(serverUrl, buildMessage(buildField({platforms: ['desktop']})));

expect(SessionAttributesManager.removeManifestField).toHaveBeenCalledWith(serverUrl, 'os_version');
});

it('should ignore non-session object types', () => {
handleSessionAttributesPropertyFieldEvent(serverUrl, buildMessage(buildField(), 'channel'));

expect(SessionAttributesManager.upsertManifestField).not.toHaveBeenCalled();
expect(SessionAttributesManager.removeManifestField).not.toHaveBeenCalled();
});

it('should ignore events without a property field payload', () => {
handleSessionAttributesPropertyFieldEvent(serverUrl, buildMessage(undefined));

expect(SessionAttributesManager.upsertManifestField).not.toHaveBeenCalled();
expect(SessionAttributesManager.removeManifestField).not.toHaveBeenCalled();
});
});
});
Loading
Loading