diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 21cff1c1f..49da24351 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -176,6 +176,11 @@ class Feed { } async markAsSeen(itemOrItems: FeedItemOrItems) { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Feed] Skipping markAsSeen - user not authenticated"); + return { entries: [] }; + } + const now = new Date().toISOString(); this.optimisticallyPerformStatusUpdate( itemOrItems, @@ -188,6 +193,11 @@ class Feed { } async markAllAsSeen() { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Feed] Skipping markAllAsSeen - user not authenticated"); + return { entries: [] }; + } + // To mark all of the messages as seen we: // 1. Optimistically update *everything* we have in the store // 2. We decrement the `unseen_count` to zero optimistically @@ -230,6 +240,11 @@ class Feed { } async markAsUnseen(itemOrItems: FeedItemOrItems) { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Feed] Skipping markAsUnseen - user not authenticated"); + return { entries: [] }; + } + this.optimisticallyPerformStatusUpdate( itemOrItems, "unseen", @@ -241,6 +256,11 @@ class Feed { } async markAsRead(itemOrItems: FeedItemOrItems) { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Feed] Skipping markAsRead - user not authenticated"); + return { entries: [] }; + } + const now = new Date().toISOString(); this.optimisticallyPerformStatusUpdate( itemOrItems, @@ -253,6 +273,11 @@ class Feed { } async markAllAsRead() { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Feed] Skipping markAllAsRead - user not authenticated"); + return { entries: [] }; + } + // To mark all of the messages as read we: // 1. Optimistically update *everything* we have in the store // 2. We decrement the `unread_count` to zero optimistically @@ -295,6 +320,11 @@ class Feed { } async markAsUnread(itemOrItems: FeedItemOrItems) { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Feed] Skipping markAsUnread - user not authenticated"); + return { entries: [] }; + } + this.optimisticallyPerformStatusUpdate( itemOrItems, "unread", @@ -309,6 +339,13 @@ class Feed { itemOrItems: FeedItemOrItems, metadata?: Record, ) { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Feed] Skipping markAsInteracted - user not authenticated", + ); + return { entries: [] }; + } + const now = new Date().toISOString(); this.optimisticallyPerformStatusUpdate( itemOrItems, @@ -332,6 +369,11 @@ class Feed { TODO: how do we handle rollbacks? */ async markAsArchived(itemOrItems: FeedItemOrItems) { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Feed] Skipping markAsArchived - user not authenticated"); + return { entries: [] }; + } + const state = this.store.getState(); const shouldOptimisticallyRemoveItems = @@ -403,6 +445,13 @@ class Feed { } async markAllAsArchived() { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Feed] Skipping markAllAsArchived - user not authenticated", + ); + return { entries: [] }; + } + // Note: there is the potential for a race condition here because the bulk // update is an async method, so if a new message comes in during this window before // the update has been processed we'll effectively reset the `unseen_count` to be what it was. @@ -430,6 +479,13 @@ class Feed { } async markAllReadAsArchived() { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Feed] Skipping markAllReadAsArchived - user not authenticated", + ); + return { entries: [] }; + } + // Note: there is the potential for a race condition here because the bulk // update is an async method, so if a new message comes in during this window before // the update has been processed we'll effectively reset the `unseen_count` to be what it was. @@ -472,6 +528,13 @@ class Feed { } async markAsUnarchived(itemOrItems: FeedItemOrItems) { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Feed] Skipping markAsUnarchived - user not authenticated", + ); + return { entries: [] }; + } + const state = this.store.getState(); const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; @@ -518,6 +581,11 @@ class Feed { /* Fetches the feed content, appending it to the store */ async fetch(options: FetchFeedOptions = {}) { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Feed] Skipping fetch - user not authenticated"); + return; + } + const { networkStatus, ...state } = this.store.getState(); // If the user is not authenticated, then do nothing @@ -608,6 +676,11 @@ class Feed { } async fetchNextPage(options: FetchFeedOptions = {}) { + if (!this.knock.isAuthenticated()) { + this.knock.log("[Feed] Skipping fetchNextPage - user not authenticated"); + return; + } + // Attempts to fetch the next page of results (if we have any) const { pageInfo } = this.store.getState(); diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index 9c089521f..86bbc5db8 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -350,7 +350,14 @@ export class KnockGuideClient { async fetch(opts?: { filters?: QueryFilterParams }) { this.knock.log("[Guide] .fetch"); - this.knock.failIfNotAuthenticated(); + + if (!this.knock.isAuthenticated()) { + this.knock.log("[Guide] Skipping fetch - user not authenticated"); + return { + status: "error" as const, + error: new Error("Not authenticated"), + }; + } const queryParams = this.buildQueryParams(opts?.filters); const queryKey = this.formatQueryKey(queryParams); @@ -400,7 +407,12 @@ export class KnockGuideClient { subscribe() { if (!this.socket) return; - this.knock.failIfNotAuthenticated(); + + if (!this.knock.isAuthenticated()) { + this.knock.log("[Guide] Skipping subscribe - user not authenticated"); + return; + } + this.knock.log("[Guide] Subscribing to real time updates"); // Ensure a live socket connection if not yet connected. @@ -846,6 +858,11 @@ export class KnockGuideClient { async markAsSeen(guide: GuideData, step: GuideStepData) { if (step.message.seen_at) return; + if (!this.knock.isAuthenticated()) { + this.knock.log("[Guide] Skipping markAsSeen - user not authenticated"); + return; + } + this.knock.log( `[Guide] Marking as seen (Guide key: ${guide.key}, Step ref:${step.ref})`, ); @@ -874,6 +891,13 @@ export class KnockGuideClient { step: GuideStepData, metadata?: GenericData, ) { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Guide] Skipping markAsInteracted - user not authenticated", + ); + return; + } + this.knock.log( `[Guide] Marking as interacted (Guide key: ${guide.key}; Step ref:${step.ref})`, ); @@ -901,6 +925,13 @@ export class KnockGuideClient { async markAsArchived(guide: GuideData, step: GuideStepData) { if (step.message.archived_at) return; + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Guide] Skipping markAsArchived - user not authenticated", + ); + return; + } + this.knock.log( `[Guide] Marking as archived (Guide key: ${guide.key}, Step ref:${step.ref})`, ); diff --git a/packages/client/src/clients/messages/index.ts b/packages/client/src/clients/messages/index.ts index cf3701812..2956908cc 100644 --- a/packages/client/src/clients/messages/index.ts +++ b/packages/client/src/clients/messages/index.ts @@ -30,6 +30,13 @@ class MessageClient { status: MessageEngagementStatus, options?: UpdateMessageStatusOptions, ): Promise { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Messages] Skipping updateStatus - user not authenticated", + ); + throw new Error("Not authenticated"); + } + // Metadata is only required for the "interacted" status const payload = status === "interacted" && options @@ -49,6 +56,13 @@ class MessageClient { messageId: string, status: Exclude, ): Promise { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Messages] Skipping removeStatus - user not authenticated", + ); + throw new Error("Not authenticated"); + } + const result = await this.knock.client().makeRequest({ method: "DELETE", url: `/v1/messages/${messageId}/${status}`, @@ -62,6 +76,13 @@ class MessageClient { status: MessageEngagementStatus | "unseen" | "unread" | "unarchived", options?: UpdateMessageStatusOptions, ): Promise { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Messages] Skipping batchUpdateStatuses - user not authenticated", + ); + return []; + } + // Metadata is only required for the "interacted" status const additionalPayload = status === "interacted" && options ? { metadata: options.metadata } : {}; @@ -80,6 +101,13 @@ class MessageClient { status, options, }: BulkUpdateMessagesInChannelProperties): Promise { + if (!this.knock.isAuthenticated()) { + this.knock.log( + "[Messages] Skipping bulkUpdateAllStatusesInChannel - user not authenticated", + ); + throw new Error("Not authenticated"); + } + const result = await this.knock.client().makeRequest({ method: "POST", url: `/v1/channels/${channelId}/messages/bulk/${status}`, diff --git a/packages/client/src/clients/ms-teams/index.ts b/packages/client/src/clients/ms-teams/index.ts index e0da5a5c4..974d124cd 100644 --- a/packages/client/src/clients/ms-teams/index.ts +++ b/packages/client/src/clients/ms-teams/index.ts @@ -18,6 +18,13 @@ class MsTeamsClient { } async authCheck({ tenant: tenantId, knockChannelId }: AuthCheckInput) { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MS Teams] Skipping authCheck - user not authenticated", + ); + return { status: "not_connected" }; + } + const result = await this.instance.client().makeRequest({ method: "GET", url: `/v1/providers/ms-teams/${knockChannelId}/auth_check`, @@ -36,6 +43,13 @@ class MsTeamsClient { async getTeams( input: GetMsTeamsTeamsInput, ): Promise { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MS Teams] Skipping getTeams - user not authenticated", + ); + return { ms_teams_teams: [], skip_token: null }; + } + const { knockChannelId, tenant: tenantId } = input; const queryOptions = input.queryOptions || {}; @@ -62,6 +76,13 @@ class MsTeamsClient { async getChannels( input: GetMsTeamsChannelsInput, ): Promise { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MS Teams] Skipping getChannels - user not authenticated", + ); + return { ms_teams_channels: [] }; + } + const { knockChannelId, teamId, tenant: tenantId } = input; const queryOptions = input.queryOptions || {}; @@ -88,6 +109,13 @@ class MsTeamsClient { tenant: tenantId, knockChannelId, }: RevokeAccessTokenInput) { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[MS Teams] Skipping revokeAccessToken - user not authenticated", + ); + return { status: "not_connected" }; + } + const result = await this.instance.client().makeRequest({ method: "PUT", url: `/v1/providers/ms-teams/${knockChannelId}/revoke_access`, diff --git a/packages/client/src/clients/slack/index.ts b/packages/client/src/clients/slack/index.ts index 1ad308db7..8c92c0715 100644 --- a/packages/client/src/clients/slack/index.ts +++ b/packages/client/src/clients/slack/index.ts @@ -13,6 +13,11 @@ class SlackClient { } async authCheck({ tenant, knockChannelId }: AuthCheckInput) { + if (!this.instance.isAuthenticated()) { + this.instance.log("[Slack] Skipping authCheck - user not authenticated"); + return { status: "not_connected" }; + } + const result = await this.instance.client().makeRequest({ method: "GET", url: `/v1/providers/slack/${knockChannelId}/auth_check`, @@ -31,6 +36,13 @@ class SlackClient { async getChannels( input: GetSlackChannelsInput, ): Promise { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[Slack] Skipping getChannels - user not authenticated", + ); + return { slack_channels: [], next_cursor: null }; + } + const { knockChannelId, tenant } = input; const queryOptions = input.queryOptions || {}; @@ -57,6 +69,13 @@ class SlackClient { } async revokeAccessToken({ tenant, knockChannelId }: RevokeAccessTokenInput) { + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[Slack] Skipping revokeAccessToken - user not authenticated", + ); + return { status: "not_connected" }; + } + const result = await this.instance.client().makeRequest({ method: "PUT", url: `/v1/providers/slack/${knockChannelId}/revoke_access`, diff --git a/packages/client/src/clients/users/index.ts b/packages/client/src/clients/users/index.ts index 9b6d9ca0d..7851b105c 100644 --- a/packages/client/src/clients/users/index.ts +++ b/packages/client/src/clients/users/index.ts @@ -89,7 +89,12 @@ class UserClient { } async getChannelData(params: GetChannelDataInput) { - this.instance.failIfNotAuthenticated(); + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[User] Skipping getChannelData - user not authenticated", + ); + throw new Error("Not authenticated"); + } const result = await this.instance.client().makeRequest({ method: "GET", @@ -103,7 +108,12 @@ class UserClient { channelId, channelData, }: SetChannelDataInput) { - this.instance.failIfNotAuthenticated(); + if (!this.instance.isAuthenticated()) { + this.instance.log( + "[User] Skipping setChannelData - user not authenticated", + ); + throw new Error("Not authenticated"); + } const result = await this.instance.client().makeRequest({ method: "PUT", diff --git a/packages/client/src/knock.ts b/packages/client/src/knock.ts index 7fb4fdb32..2d176a11c 100644 --- a/packages/client/src/knock.ts +++ b/packages/client/src/knock.ts @@ -165,6 +165,25 @@ class Knock { return checkUserToken ? !!(this.userId && this.userToken) : !!this.userId; } + /* + Resets the authentication state and tears down any active connections. + This is useful when you want to clear the current user session without destroying the Knock instance. + */ + resetAuthentication() { + this.log("Resetting authentication state"); + + // Teardown any active feeds and connections + this.feeds.teardownInstances(); + this.teardown(); + + // Clear authentication state + this.userId = undefined; + this.userToken = undefined; + + // Reinitialize the API client without credentials + this.apiClient = this.createApiClient(); + } + // Used to teardown any connected instances teardown() { if (this.tokenExpirationTimer) { diff --git a/packages/client/test/clients/feed/feed.test.ts b/packages/client/test/clients/feed/feed.test.ts index f616fe8de..9c8002a3a 100644 --- a/packages/client/test/clients/feed/feed.test.ts +++ b/packages/client/test/clients/feed/feed.test.ts @@ -21,6 +21,16 @@ describe("Feed", () => { }; }; + const getUnauthenticatedTestSetup = () => { + const { knock, mockApiClient } = createMockKnock(); + // Don't authenticate - leave knock unauthenticated + return { + knock, + mockApiClient, + cleanup: () => vi.clearAllMocks(), + }; + }; + describe("Basic Feed Tests", () => { test("can create a feed client", () => { const { knock, cleanup } = getTestSetup(); @@ -1582,4 +1592,230 @@ describe("Feed", () => { } }); }); + + describe("Authentication Guards", () => { + test("fetch skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedTestSetup(); + + try { + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + + const logSpy = vi.spyOn(knock, "log"); + + await feed.fetch(); + + expect(logSpy).toHaveBeenCalledWith( + "[Feed] Skipping fetch - user not authenticated", + ); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("markAsSeen skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedTestSetup(); + + try { + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + const feedItem = createUnreadFeedItem(); + const logSpy = vi.spyOn(knock, "log"); + + const result = await feed.markAsSeen(feedItem); + + expect(logSpy).toHaveBeenCalledWith( + "[Feed] Skipping markAsSeen - user not authenticated", + ); + expect(result).toEqual({ entries: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("markAllAsSeen skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedTestSetup(); + + try { + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + const logSpy = vi.spyOn(knock, "log"); + + const result = await feed.markAllAsSeen(); + + expect(logSpy).toHaveBeenCalledWith( + "[Feed] Skipping markAllAsSeen - user not authenticated", + ); + expect(result).toEqual({ entries: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("markAsRead skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedTestSetup(); + + try { + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + const feedItem = createUnreadFeedItem(); + const logSpy = vi.spyOn(knock, "log"); + + const result = await feed.markAsRead(feedItem); + + expect(logSpy).toHaveBeenCalledWith( + "[Feed] Skipping markAsRead - user not authenticated", + ); + expect(result).toEqual({ entries: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("markAllAsRead skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedTestSetup(); + + try { + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + const logSpy = vi.spyOn(knock, "log"); + + const result = await feed.markAllAsRead(); + + expect(logSpy).toHaveBeenCalledWith( + "[Feed] Skipping markAllAsRead - user not authenticated", + ); + expect(result).toEqual({ entries: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("markAsArchived skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedTestSetup(); + + try { + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + const feedItem = createUnreadFeedItem(); + const logSpy = vi.spyOn(knock, "log"); + + const result = await feed.markAsArchived(feedItem); + + expect(logSpy).toHaveBeenCalledWith( + "[Feed] Skipping markAsArchived - user not authenticated", + ); + expect(result).toEqual({ entries: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("markAllAsArchived skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedTestSetup(); + + try { + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + const logSpy = vi.spyOn(knock, "log"); + + const result = await feed.markAllAsArchived(); + + expect(logSpy).toHaveBeenCalledWith( + "[Feed] Skipping markAllAsArchived - user not authenticated", + ); + expect(result).toEqual({ entries: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("markAsInteracted skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedTestSetup(); + + try { + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + const feedItem = createUnreadFeedItem(); + const logSpy = vi.spyOn(knock, "log"); + + const result = await feed.markAsInteracted(feedItem); + + expect(logSpy).toHaveBeenCalledWith( + "[Feed] Skipping markAsInteracted - user not authenticated", + ); + expect(result).toEqual({ entries: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("listenForUpdates skips websocket connection when not authenticated", () => { + const { knock, cleanup } = getUnauthenticatedTestSetup(); + + try { + const mockSocketManager = { + join: vi.fn(), + } as unknown as FeedSocketManager; + + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + mockSocketManager, + ); + + const logSpy = vi.spyOn(knock, "log"); + + feed.listenForUpdates(); + + expect(logSpy).toHaveBeenCalledWith( + "[Feed] User is not authenticated, skipping listening for updates", + ); + expect(mockSocketManager.join).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + }); }); diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index 999c93846..d542bae00 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -271,7 +271,7 @@ describe("KnockGuideClient", () => { await client.fetch(); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockKnock.user.getGuides).toHaveBeenCalledWith( channelId, expect.objectContaining({ @@ -288,7 +288,7 @@ describe("KnockGuideClient", () => { const client = new KnockGuideClient(mockKnock, channelId); await client.fetch(); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockStore.setState).toHaveBeenCalledWith(expect.any(Function)); // Get the last setState call and execute its function @@ -343,7 +343,7 @@ describe("KnockGuideClient", () => { ); client.subscribe(); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockSocket.channel).toHaveBeenCalledWith( `guides:${channelId}`, expect.objectContaining({ @@ -3050,7 +3050,7 @@ describe("KnockGuideClient", () => { await client.fetch({ filters: { type: "tooltip" } }); - expect(mockKnock.failIfNotAuthenticated).toHaveBeenCalled(); + expect(mockKnock.isAuthenticated).toHaveBeenCalled(); expect(mockKnock.user.getGuides).toHaveBeenCalledWith( channelId, expect.objectContaining({ @@ -3059,4 +3059,142 @@ describe("KnockGuideClient", () => { ); }); }); + + describe("Authentication Guards", () => { + test("fetch skips API call when not authenticated", async () => { + const unauthKnock = { + ...mockKnock, + isAuthenticated: vi.fn(() => false), + }; + + const client = new KnockGuideClient(unauthKnock, channelId); + const logSpy = vi.spyOn(unauthKnock, "log"); + + const result = await client.fetch(); + + expect(logSpy).toHaveBeenCalledWith( + "[Guide] Skipping fetch - user not authenticated", + ); + expect(result).toEqual({ + status: "error", + error: new Error("Not authenticated"), + }); + expect(mockKnock.user.getGuides).not.toHaveBeenCalled(); + }); + + test("subscribe skips websocket when not authenticated", () => { + const testSocket = { + channel: vi.fn(), + connect: vi.fn(), + isConnected: vi.fn(() => false), + }; + + const unauthKnock = { + ...mockKnock, + isAuthenticated: vi.fn(() => false), + client: () => ({ socket: testSocket }), + }; + + const logSpy = vi.spyOn(unauthKnock, "log"); + const client = new KnockGuideClient(unauthKnock, channelId); + + client.subscribe(); + + // Check that the skip message was logged + expect(logSpy).toHaveBeenCalledWith( + "[Guide] Skipping subscribe - user not authenticated", + ); + expect(testSocket.channel).not.toHaveBeenCalled(); + }); + + test("markAsSeen skips API call when not authenticated", async () => { + const unauthKnock = { + ...mockKnock, + isAuthenticated: vi.fn(() => false), + }; + + const mockGuide = { + key: "test-guide", + steps: [ + { + ref: "step-1", + message: { id: "msg_1", seen_at: null }, + content: {}, + }, + ], + }; + + const client = new KnockGuideClient(unauthKnock, channelId); + const logSpy = vi.spyOn(unauthKnock, "log"); + + const result = await client.markAsSeen(mockGuide, mockGuide.steps[0]); + + expect(logSpy).toHaveBeenCalledWith( + "[Guide] Skipping markAsSeen - user not authenticated", + ); + expect(result).toBeUndefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + + test("markAsInteracted skips API call when not authenticated", async () => { + const unauthKnock = { + ...mockKnock, + isAuthenticated: vi.fn(() => false), + }; + + const mockGuide = { + key: "test-guide", + steps: [ + { + ref: "step-1", + message: { id: "msg_1", interacted_at: null }, + content: {}, + }, + ], + }; + + const client = new KnockGuideClient(unauthKnock, channelId); + const logSpy = vi.spyOn(unauthKnock, "log"); + + const result = await client.markAsInteracted( + mockGuide, + mockGuide.steps[0], + ); + + expect(logSpy).toHaveBeenCalledWith( + "[Guide] Skipping markAsInteracted - user not authenticated", + ); + expect(result).toBeUndefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + + test("markAsArchived skips API call when not authenticated", async () => { + const unauthKnock = { + ...mockKnock, + isAuthenticated: vi.fn(() => false), + }; + + const mockGuide = { + key: "test-guide", + steps: [ + { + ref: "step-1", + message: { id: "msg_1", archived_at: null }, + content: {}, + }, + ], + }; + + const client = new KnockGuideClient(unauthKnock, channelId); + const logSpy = vi.spyOn(unauthKnock, "log"); + + const result = await client.markAsArchived(mockGuide, mockGuide.steps[0]); + + expect(logSpy).toHaveBeenCalledWith( + "[Guide] Skipping markAsArchived - user not authenticated", + ); + expect(result).toBeUndefined(); + expect(mockKnock.user.markGuideStepAs).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/client/test/clients/messages/messages.test.ts b/packages/client/test/clients/messages/messages.test.ts index 23ea85644..3987fc856 100644 --- a/packages/client/test/clients/messages/messages.test.ts +++ b/packages/client/test/clients/messages/messages.test.ts @@ -747,4 +747,98 @@ describe("MessageClient", () => { ); }); }); + + describe("Authentication Guards", () => { + const getUnauthenticatedSetup = () => { + const { knock, mockApiClient } = createMockKnock(); + // Don't authenticate + return { knock, mockApiClient, cleanup: () => vi.clearAllMocks() }; + }; + + test("updateStatus skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new MessageClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + await expect(client.updateStatus("msg_123", "read")).rejects.toThrow( + "Not authenticated", + ); + + expect(logSpy).toHaveBeenCalledWith( + "[Messages] Skipping updateStatus - user not authenticated", + ); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("removeStatus skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new MessageClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + await expect(client.removeStatus("msg_123", "seen")).rejects.toThrow( + "Not authenticated", + ); + + expect(logSpy).toHaveBeenCalledWith( + "[Messages] Skipping removeStatus - user not authenticated", + ); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("batchUpdateStatuses skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new MessageClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + const result = await client.batchUpdateStatuses( + ["msg_1", "msg_2"], + "read", + ); + + expect(logSpy).toHaveBeenCalledWith( + "[Messages] Skipping batchUpdateStatuses - user not authenticated", + ); + expect(result).toEqual([]); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("bulkUpdateAllStatusesInChannel skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new MessageClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + await expect( + client.bulkUpdateAllStatusesInChannel({ + channelId: "channel_123", + status: "read", + options: {}, + }), + ).rejects.toThrow("Not authenticated"); + + expect(logSpy).toHaveBeenCalledWith( + "[Messages] Skipping bulkUpdateAllStatusesInChannel - user not authenticated", + ); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + }); }); diff --git a/packages/client/test/clients/ms-teams/ms-teams.test.ts b/packages/client/test/clients/ms-teams/ms-teams.test.ts index 0aae31f58..b53089596 100644 --- a/packages/client/test/clients/ms-teams/ms-teams.test.ts +++ b/packages/client/test/clients/ms-teams/ms-teams.test.ts @@ -630,4 +630,101 @@ describe("Microsoft Teams Client", () => { } }); }); + + describe("Authentication Guards", () => { + const getUnauthenticatedSetup = () => { + const { knock, mockApiClient } = createMockKnock(); + // Don't authenticate + return { knock, mockApiClient, cleanup: () => vi.clearAllMocks() }; + }; + + test("authCheck skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new MsTeamsClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + const result = await client.authCheck({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(logSpy).toHaveBeenCalledWith( + "[MS Teams] Skipping authCheck - user not authenticated", + ); + expect(result).toEqual({ status: "not_connected" }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("getTeams skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new MsTeamsClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + const result = await client.getTeams({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(logSpy).toHaveBeenCalledWith( + "[MS Teams] Skipping getTeams - user not authenticated", + ); + expect(result).toEqual({ ms_teams_teams: [], skip_token: null }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("getChannels skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new MsTeamsClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + const result = await client.getChannels({ + tenant: "tenant_123", + knockChannelId: "channel_123", + teamId: "team_123", + }); + + expect(logSpy).toHaveBeenCalledWith( + "[MS Teams] Skipping getChannels - user not authenticated", + ); + expect(result).toEqual({ ms_teams_channels: [] }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("revokeAccessToken skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new MsTeamsClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + const result = await client.revokeAccessToken({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(logSpy).toHaveBeenCalledWith( + "[MS Teams] Skipping revokeAccessToken - user not authenticated", + ); + expect(result).toEqual({ status: "not_connected" }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + }); }); diff --git a/packages/client/test/clients/slack/slack.test.ts b/packages/client/test/clients/slack/slack.test.ts index 0c5871c5f..e9b91148f 100644 --- a/packages/client/test/clients/slack/slack.test.ts +++ b/packages/client/test/clients/slack/slack.test.ts @@ -510,4 +510,78 @@ describe("Slack Client", () => { } }); }); + + describe("Authentication Guards", () => { + const getUnauthenticatedSetup = () => { + const { knock, mockApiClient } = createMockKnock(); + // Don't authenticate + return { knock, mockApiClient, cleanup: () => vi.clearAllMocks() }; + }; + + test("authCheck skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new SlackClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + const result = await client.authCheck({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(logSpy).toHaveBeenCalledWith( + "[Slack] Skipping authCheck - user not authenticated", + ); + expect(result).toEqual({ status: "not_connected" }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("getChannels skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new SlackClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + const result = await client.getChannels({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(logSpy).toHaveBeenCalledWith( + "[Slack] Skipping getChannels - user not authenticated", + ); + expect(result).toEqual({ slack_channels: [], next_cursor: null }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + + test("revokeAccessToken skips API call when not authenticated", async () => { + const { knock, mockApiClient, cleanup } = getUnauthenticatedSetup(); + + try { + const client = new SlackClient(knock); + const logSpy = vi.spyOn(knock, "log"); + + const result = await client.revokeAccessToken({ + tenant: "tenant_123", + knockChannelId: "channel_123", + }); + + expect(logSpy).toHaveBeenCalledWith( + "[Slack] Skipping revokeAccessToken - user not authenticated", + ); + expect(result).toEqual({ status: "not_connected" }); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); + }); }); diff --git a/packages/client/test/clients/users/users.test.ts b/packages/client/test/clients/users/users.test.ts index 5d84b1a02..95ae9cb7e 100644 --- a/packages/client/test/clients/users/users.test.ts +++ b/packages/client/test/clients/users/users.test.ts @@ -612,15 +612,11 @@ describe("User Client", () => { await expect( client.getChannelData({ channelId: "test" }), - ).rejects.toThrow( - "Not authenticated. Please call `authenticate` first.", - ); + ).rejects.toThrow("Not authenticated"); await expect( client.setChannelData({ channelId: "test", channelData: {} }), - ).rejects.toThrow( - "Not authenticated. Please call `authenticate` first.", - ); + ).rejects.toThrow("Not authenticated"); }); test("handles channel data operation errors", async () => { diff --git a/packages/client/test/knock.test.ts b/packages/client/test/knock.test.ts index ec9dd5d9b..b53afd36a 100644 --- a/packages/client/test/knock.test.ts +++ b/packages/client/test/knock.test.ts @@ -772,4 +772,58 @@ describe("Knock Client", () => { expect(knock.isAuthenticated(true)).toBe(true); }); }); + + describe("resetAuthentication", () => { + test("clears userId and userToken", () => { + const knock = new Knock("pk_test_12345"); + + knock.authenticate("user_123", "token_456"); + expect(knock.isAuthenticated()).toBe(true); + expect(knock.userId).toBe("user_123"); + expect(knock.userToken).toBe("token_456"); + + knock.resetAuthentication(); + + expect(knock.isAuthenticated()).toBe(false); + expect(knock.userId).toBeUndefined(); + expect(knock.userToken).toBeUndefined(); + }); + + test("tears down feed instances", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const teardownSpy = vi.spyOn(knock.feeds, "teardownInstances"); + + knock.resetAuthentication(); + + expect(teardownSpy).toHaveBeenCalled(); + }); + + test("calls teardown to clean up connections", () => { + const knock = new Knock("pk_test_12345"); + knock.authenticate("user_123", "token_456"); + + const teardownSpy = vi.spyOn(knock, "teardown"); + + knock.resetAuthentication(); + + expect(teardownSpy).toHaveBeenCalled(); + }); + + test("can re-authenticate after reset", () => { + const knock = new Knock("pk_test_12345"); + + knock.authenticate("user_123", "token_456"); + expect(knock.userId).toBe("user_123"); + + knock.resetAuthentication(); + expect(knock.isAuthenticated()).toBe(false); + + knock.authenticate("user_456", "token_789"); + expect(knock.isAuthenticated()).toBe(true); + expect(knock.userId).toBe("user_456"); + expect(knock.userToken).toBe("token_789"); + }); + }); }); diff --git a/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx b/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx index 6aab72054..5589ce250 100644 --- a/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx +++ b/packages/expo/src/modules/push/KnockExpoPushNotificationProvider.tsx @@ -208,6 +208,13 @@ const InternalKnockExpoPushNotificationProvider: React.FC< return; } + if (!knockClient.isAuthenticated()) { + knockClient.log( + "[Knock] Skipping status update - user not authenticated", + ); + return; + } + return knockClient.messages.updateStatus(messageId, status); }, [knockClient], @@ -219,7 +226,7 @@ const InternalKnockExpoPushNotificationProvider: React.FC< customNotificationHandler ?? defaultNotificationHandler, }); - if (autoRegister) { + if (autoRegister && knockClient.isAuthenticated()) { registerForPushNotifications() .then((token) => { if (token) { @@ -241,6 +248,10 @@ const InternalKnockExpoPushNotificationProvider: React.FC< _error, ); }); + } else if (autoRegister && !knockClient.isAuthenticated()) { + knockClient.log( + "[Knock] Skipping auto-register - user not authenticated", + ); } const notificationReceivedSubscription = @@ -277,6 +288,7 @@ const InternalKnockExpoPushNotificationProvider: React.FC< autoRegister, knockExpoChannelId, knockClient, + knockClient.userId, // Track userId to detect authentication state changes ]); return ( diff --git a/packages/react-core/src/modules/core/context/KnockProvider.tsx b/packages/react-core/src/modules/core/context/KnockProvider.tsx index 084475889..2819fae34 100644 --- a/packages/react-core/src/modules/core/context/KnockProvider.tsx +++ b/packages/react-core/src/modules/core/context/KnockProvider.tsx @@ -7,7 +7,6 @@ import * as React from "react"; import { PropsWithChildren } from "react"; import { I18nContent, KnockI18nProvider } from "../../i18n"; -import { useAuthenticatedKnockClient } from "../hooks"; export interface KnockProviderState { knock: Knock; @@ -26,6 +25,13 @@ export type KnockProviderProps = { i18n?: I18nContent; logLevel?: LogLevel; branch?: string; + /** + * Controls whether the KnockProvider should authenticate and initialize the Knock client. + * When set to false, the provider will skip authentication and just render children. + * This is useful for preventing auth errors when user credentials are not yet available. + * @default true + */ + enabled?: boolean; } & ( | { /** @@ -66,6 +72,7 @@ export const KnockProvider: React.FC> = ({ i18n, identificationStrategy, branch, + enabled = true, ...props }) => { const userIdOrUserWithProperties = props?.user || props?.userId; @@ -90,15 +97,46 @@ export const KnockProvider: React.FC> = ({ ], ); - const knock = useAuthenticatedKnockClient( - apiKey ?? "", + // Create Knock client instance (without authentication) + const knockClient = React.useMemo(() => { + return new Knock(apiKey ?? "", { + host: authenticateOptions.host, + logLevel: authenticateOptions.logLevel, + branch: authenticateOptions.branch, + }); + }, [apiKey, authenticateOptions]); + + // Handle authentication state based on enabled prop + React.useEffect(() => { + if (enabled && userIdOrUserWithProperties) { + // When enabled, authenticate (or re-authenticate if user/token changed) + // Note: authenticate() handles re-auth internally if userId/userToken changed + knockClient.authenticate(userIdOrUserWithProperties, userToken, { + onUserTokenExpiring: authenticateOptions.onUserTokenExpiring, + timeBeforeExpirationInMs: authenticateOptions.timeBeforeExpirationInMs, + identificationStrategy: authenticateOptions.identificationStrategy, + }); + } else if (!enabled && knockClient.isAuthenticated()) { + // When disabled, reset authentication if currently authenticated + knockClient.resetAuthentication(); + } + }, [ + enabled, + knockClient, userIdOrUserWithProperties, userToken, authenticateOptions, - ); + ]); + + // Cleanup on unmount + React.useEffect(() => { + return () => { + knockClient.teardown(); + }; + }, [knockClient]); return ( - + {children} ); diff --git a/packages/react-core/test/core/KnockProvider.test.tsx b/packages/react-core/test/core/KnockProvider.test.tsx index 7cfc4376a..48f493356 100644 --- a/packages/react-core/test/core/KnockProvider.test.tsx +++ b/packages/react-core/test/core/KnockProvider.test.tsx @@ -1,9 +1,14 @@ import "@testing-library/jest-dom/vitest"; -import { cleanup, render } from "@testing-library/react"; +import { cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; import { createMockKnock } from "../../../client/test/test-utils/mocks"; -import { KnockProvider, useKnockClient } from "../../src"; +import { + KnockFeedProvider, + KnockProvider, + useKnockClient, + useTranslations, +} from "../../src"; const TEST_BRANCH_SLUG = "lorem-ipsum-dolor-branch"; @@ -29,6 +34,9 @@ mockApiClient.makeRequest.mockImplementation(async ({ method, url, data }) => { afterEach(() => { cleanup(); vi.clearAllMocks(); + // Reset the knock instance state to prevent test pollution + knock.userId = undefined; + knock.userToken = undefined; }); describe("KnockProvider", () => { @@ -282,7 +290,7 @@ describe("KnockProvider", () => { ); }); - test("changing identification strategy from skip to inline enables identification", () => { + test("changing identification strategy from skip to inline enables identification", async () => { const TestConsumer = () => { const knock = useKnockClient(); return
User Id: {knock.userId}
; @@ -321,14 +329,276 @@ describe("KnockProvider", () => { , ); - // Verify identify is now called + // Wait for the effect to complete + await waitFor(() => { + expect(mockApiClient.makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PUT", + url: "/v1/users/test_user_id", + data: { name: "John Doe", email: "john@example.com" }, + }), + ); + }); + }); + }); + + describe("enabled prop", () => { + test("does not double-authenticate on initial mount", () => { + const TestConsumer = () => { + const knock = useKnockClient(); + return
User: {knock.userId}
; + }; + + const authenticateSpy = vi.spyOn(knock, "authenticate"); + + render( + + + , + ); + + // Should only authenticate once, not twice + expect(authenticateSpy).toHaveBeenCalledTimes(1); + }); + + test("renders children without authentication when enabled is false", () => { + const TestChild = () =>
Child content
; + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("child")).toHaveTextContent("Child content"); + // Verify no API calls were made (no authentication) + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); + + test("authenticates normally when enabled is true (default)", () => { + const TestConsumer = () => { + const knock = useKnockClient(); + return
API Key: {knock.apiKey}
; + }; + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("consumer-msg")).toHaveTextContent( + "API Key: test_api_key", + ); + }); + + test("authenticates normally when enabled is explicitly true", () => { + const TestConsumer = () => { + const knock = useKnockClient(); + return
API Key: {knock.apiKey}
; + }; + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId("consumer-msg")).toHaveTextContent( + "API Key: test_api_key", + ); + }); + + test("useKnockClient returns unauthenticated client when enabled is false", () => { + const TestConsumer = () => { + const knock = useKnockClient(); + return ( +
+ API Key: {knock.apiKey}, Authenticated: {String(knock.isAuthenticated())} +
+ ); + }; + + const { getByTestId } = render( + + + , + ); + + // Client should exist but not be authenticated + expect(getByTestId("consumer-msg")).toHaveTextContent("API Key: test_api_key"); + expect(getByTestId("consumer-msg")).toHaveTextContent("Authenticated: false"); + }); + + test("toggling enabled from false to true initializes authentication", async () => { + const authenticateSpy = vi.spyOn(knock, "authenticate"); + + const TestConsumer = () => { + const knock = useKnockClient(); + return ( +
+ Authenticated: {String(knock.isAuthenticated())} +
+ ); + }; + + const { rerender } = render( + + + , + ); + + // Should not have authenticated when disabled + expect(authenticateSpy).not.toHaveBeenCalled(); + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + + // Enable the provider + rerender( + + + , + ); + + // Wait for the effect to complete and authentication to happen + await waitFor(() => { + expect(authenticateSpy).toHaveBeenCalledWith( + { id: "test_user_id", name: "John" }, + undefined, + expect.any(Object), + ); + }); + expect(mockApiClient.makeRequest).toHaveBeenCalledWith( expect.objectContaining({ method: "PUT", url: "/v1/users/test_user_id", - data: { name: "John Doe", email: "john@example.com" }, + data: { name: "John" }, }), ); }); + + test("toggling enabled from true to false calls resetAuthentication", async () => { + const authenticateSpy = vi.spyOn(knock, "authenticate"); + const resetAuthSpy = vi.spyOn(knock, "resetAuthentication"); + + // Manually set authenticated state for this test + knock.userId = "test_user_id"; + + const TestChild = () =>
Content
; + + const { rerender } = render( + + + , + ); + + // Wait for initial authentication + await waitFor(() => { + expect(authenticateSpy).toHaveBeenCalled(); + }); + + expect(authenticateSpy).toHaveBeenCalledTimes(1); + expect(resetAuthSpy).not.toHaveBeenCalled(); + + // Disable the provider + rerender( + + + , + ); + + // Wait for resetAuthentication to be called + await waitFor(() => { + expect(resetAuthSpy).toHaveBeenCalled(); + }); + + expect(resetAuthSpy).toHaveBeenCalledTimes(1); + }); + + test("i18n context is still available when enabled is false", () => { + const TestChild = () => { + const { t, locale } = useTranslations(); + return ( +
+ Locale: {locale}, Translation: {t("archiveNotification")} +
+ ); + }; + + const { getByTestId } = render( + + + , + ); + + // i18n should still work (with default locale and translations) + expect(getByTestId("i18n-test")).toHaveTextContent("Locale: en"); + expect(getByTestId("i18n-test")).toHaveTextContent( + "Translation: Archive this notification", + ); + }); + + test("child providers can render when enabled is false", () => { + const TestChild = () => { + const knock = useKnockClient(); + return ( +
+ Authenticated: {String(knock.isAuthenticated())} +
+ ); + }; + + const { getByTestId } = render( + + + + + , + ); + + // Child provider should render successfully + expect(getByTestId("feed-test")).toHaveTextContent("Authenticated: false"); + // No API requests should be made + expect(mockApiClient.makeRequest).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/react-native/src/modules/push/KnockPushNotificationProvider.tsx b/packages/react-native/src/modules/push/KnockPushNotificationProvider.tsx index 625fd9ffb..a030cfc23 100644 --- a/packages/react-native/src/modules/push/KnockPushNotificationProvider.tsx +++ b/packages/react-native/src/modules/push/KnockPushNotificationProvider.tsx @@ -45,6 +45,13 @@ export const KnockPushNotificationProvider: React.FC< // Acts like an upsert. Inserts or updates const registerPushTokenToChannel = useCallback( async (token: string, channelId: string): Promise => { + if (!knockClient.isAuthenticated()) { + knockClient.log( + "[Knock] Skipping registerPushTokenToChannel - user not authenticated", + ); + return; + } + const newDevice: Device = { token, locale: Intl.DateTimeFormat().resolvedOptions().locale, @@ -81,6 +88,13 @@ export const KnockPushNotificationProvider: React.FC< const unregisterPushTokenFromChannel = useCallback( async (token: string, channelId: string): Promise => { + if (!knockClient.isAuthenticated()) { + knockClient.log( + "[Knock] Skipping unregisterPushTokenFromChannel - user not authenticated", + ); + return; + } + return knockClient.user .getChannelData({ channelId: channelId }) .then((result: ChannelData) => {