diff --git a/package.json b/package.json index 4a6bbf5a..26d6b061 100644 --- a/package.json +++ b/package.json @@ -290,7 +290,7 @@ "\\.[jt]sx?$": "babel-jest" }, "transformIgnorePatterns": [ - "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)" + "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|@discord|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)" ], "modulePathIgnorePatterns": [ "__tests__/.*/__mocks__", diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts new file mode 100644 index 00000000..b1886ff8 --- /dev/null +++ b/src/state/session/__tests__/session-test.ts @@ -0,0 +1,1643 @@ +import {BskyAgent} from '@atproto/api' +import {describe, expect, it, jest} from '@jest/globals' + +import {agentToSessionAccountOrThrow} from '../agent' +import {Action, getInitialState, reducer, State} from '../reducer' + +jest.mock('jwt-decode', () => ({ + jwtDecode(_token: string) { + return {} + }, +})) + +describe('session', () => { + it('can log in and out', () => { + let state = getInitialState([]) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": false, + } + `) + + const agent = new BskyAgent({service: 'https://alice.com'}) + agent.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent, + newAccount: agentToSessionAccountOrThrow(agent), + }, + ]) + expect(state.currentAgentState.did).toBe('alice-did') + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].did).toBe('alice-did') + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-1", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + + state = run(state, [ + { + type: 'logged-out', + }, + ]) + // Should keep the account but clear out the tokens. + expect(state.currentAgentState.did).toBe(undefined) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].did).toBe('alice-did') + expect(state.accounts[0].accessJwt).toBe(undefined) + expect(state.accounts[0].refreshJwt).toBe(undefined) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": undefined, + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": undefined, + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": true, + } + `) + }) + + it('switches to the latest account, stores all of them', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + // Switch to Alice. + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].did).toBe('alice-did') + expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentAgentState.agent).toBe(agent1) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-1", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + + const agent2 = new BskyAgent({service: 'https://bob.com'}) + agent2.session = { + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + } + state = run(state, [ + { + // Switch to Bob. + type: 'switched-to-account', + newAgent: agent2, + newAccount: agentToSessionAccountOrThrow(agent2), + }, + ]) + expect(state.accounts.length).toBe(2) + // Bob should float upwards. + expect(state.accounts[0].did).toBe('bob-did') + expect(state.accounts[1].did).toBe('alice-did') + expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentAgentState.agent).toBe(agent2) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "bob-access-jwt-1", + "deactivated": false, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob.test", + "pdsUrl": undefined, + "refreshJwt": "bob-refresh-jwt-1", + "service": "https://bob.com/", + }, + { + "accessJwt": "alice-access-jwt-1", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://bob.com/", + }, + "did": "bob-did", + }, + "needsPersist": true, + } + `) + + const agent3 = new BskyAgent({service: 'https://alice.com'}) + agent3.session = { + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + } + state = run(state, [ + { + // Switch back to Alice. + type: 'switched-to-account', + newAgent: agent3, + newAccount: agentToSessionAccountOrThrow(agent3), + }, + ]) + expect(state.accounts.length).toBe(2) + // Alice should float upwards. + expect(state.accounts[0].did).toBe('alice-did') + expect(state.accounts[0].handle).toBe('alice-updated.test') + expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentAgentState.agent).toBe(agent3) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-2", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice-updated.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-2", + "service": "https://alice.com/", + }, + { + "accessJwt": "bob-access-jwt-1", + "deactivated": false, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob.test", + "pdsUrl": undefined, + "refreshJwt": "bob-refresh-jwt-1", + "service": "https://bob.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + + const agent4 = new BskyAgent({service: 'https://jay.com'}) + agent4.session = { + did: 'jay-did', + handle: 'jay.test', + accessJwt: 'jay-access-jwt-1', + refreshJwt: 'jay-refresh-jwt-1', + } + state = run(state, [ + { + // Switch to Jay. + type: 'switched-to-account', + newAgent: agent4, + newAccount: agentToSessionAccountOrThrow(agent4), + }, + ]) + expect(state.accounts.length).toBe(3) + expect(state.accounts[0].did).toBe('jay-did') + expect(state.currentAgentState.did).toBe('jay-did') + expect(state.currentAgentState.agent).toBe(agent4) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "jay-access-jwt-1", + "deactivated": false, + "did": "jay-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "jay.test", + "pdsUrl": undefined, + "refreshJwt": "jay-refresh-jwt-1", + "service": "https://jay.com/", + }, + { + "accessJwt": "alice-access-jwt-2", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice-updated.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-2", + "service": "https://alice.com/", + }, + { + "accessJwt": "bob-access-jwt-1", + "deactivated": false, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob.test", + "pdsUrl": undefined, + "refreshJwt": "bob-refresh-jwt-1", + "service": "https://bob.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://jay.com/", + }, + "did": "jay-did", + }, + "needsPersist": true, + } + `) + + state = run(state, [ + { + // Log everyone out. + type: 'logged-out', + }, + ]) + expect(state.accounts.length).toBe(3) + expect(state.currentAgentState.did).toBe(undefined) + // All tokens should be gone. + expect(state.accounts[0].accessJwt).toBe(undefined) + expect(state.accounts[0].refreshJwt).toBe(undefined) + expect(state.accounts[1].accessJwt).toBe(undefined) + expect(state.accounts[1].refreshJwt).toBe(undefined) + expect(state.accounts[2].accessJwt).toBe(undefined) + expect(state.accounts[2].refreshJwt).toBe(undefined) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": undefined, + "deactivated": false, + "did": "jay-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "jay.test", + "pdsUrl": undefined, + "refreshJwt": undefined, + "service": "https://jay.com/", + }, + { + "accessJwt": undefined, + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice-updated.test", + "pdsUrl": undefined, + "refreshJwt": undefined, + "service": "https://alice.com/", + }, + { + "accessJwt": undefined, + "deactivated": false, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob.test", + "pdsUrl": undefined, + "refreshJwt": undefined, + "service": "https://bob.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": true, + } + `) + }) + + it('can log back in after logging out', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') + expect(state.currentAgentState.did).toBe('alice-did') + + state = run(state, [ + { + type: 'logged-out', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe(undefined) + expect(state.accounts[0].refreshJwt).toBe(undefined) + expect(state.currentAgentState.did).toBe(undefined) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": undefined, + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": undefined, + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": true, + } + `) + + const agent2 = new BskyAgent({service: 'https://alice.com'}) + agent2.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent2, + newAccount: agentToSessionAccountOrThrow(agent2), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2') + expect(state.currentAgentState.did).toBe('alice-did') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-2", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-2", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + }) + + it('can remove active account', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') + expect(state.currentAgentState.did).toBe('alice-did') + + state = run(state, [ + { + type: 'removed-account', + accountDid: 'alice-did', + }, + ]) + expect(state.accounts.length).toBe(0) + expect(state.currentAgentState.did).toBe(undefined) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": true, + } + `) + }) + + it('can remove inactive account', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + const agent2 = new BskyAgent({service: 'https://bob.com'}) + agent2.session = { + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + { + type: 'switched-to-account', + newAgent: agent2, + newAccount: agentToSessionAccountOrThrow(agent2), + }, + ]) + expect(state.accounts.length).toBe(2) + expect(state.currentAgentState.did).toBe('bob-did') + + state = run(state, [ + { + type: 'removed-account', + accountDid: 'alice-did', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.currentAgentState.did).toBe('bob-did') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "bob-access-jwt-1", + "deactivated": false, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob.test", + "pdsUrl": undefined, + "refreshJwt": "bob-refresh-jwt-1", + "service": "https://bob.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://bob.com/", + }, + "did": "bob-did", + }, + "needsPersist": true, + } + `) + + state = run(state, [ + { + type: 'removed-account', + accountDid: 'bob-did', + }, + ]) + expect(state.accounts.length).toBe(0) + expect(state.currentAgentState.did).toBe(undefined) + }) + + it('updates stored account with refreshed tokens', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.currentAgentState.did).toBe('alice-did') + + agent1.session = { + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + email: 'alice@foo.bar', + emailAuthFactor: false, + emailConfirmed: false, + } + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: agentToSessionAccountOrThrow(agent1), + sessionEvent: 'update', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].email).toBe('alice@foo.bar') + expect(state.accounts[0].handle).toBe('alice-updated.test') + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2') + expect(state.currentAgentState.did).toBe('alice-did') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-2", + "deactivated": false, + "did": "alice-did", + "email": "alice@foo.bar", + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice-updated.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-2", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + + agent1.session = { + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-3', + refreshJwt: 'alice-refresh-jwt-3', + email: 'alice@foo.baz', + emailAuthFactor: true, + emailConfirmed: true, + } + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: agentToSessionAccountOrThrow(agent1), + sessionEvent: 'update', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].email).toBe('alice@foo.baz') + expect(state.accounts[0].handle).toBe('alice-updated.test') + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-3') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-3') + expect(state.currentAgentState.did).toBe('alice-did') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-3", + "deactivated": false, + "did": "alice-did", + "email": "alice@foo.baz", + "emailAuthFactor": true, + "emailConfirmed": true, + "handle": "alice-updated.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-3", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + + agent1.session = { + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-4', + refreshJwt: 'alice-refresh-jwt-4', + email: 'alice@foo.baz', + emailAuthFactor: false, + emailConfirmed: false, + } + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: agentToSessionAccountOrThrow(agent1), + sessionEvent: 'update', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].email).toBe('alice@foo.baz') + expect(state.accounts[0].handle).toBe('alice-updated.test') + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-4') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-4') + expect(state.currentAgentState.did).toBe('alice-did') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-4", + "deactivated": false, + "did": "alice-did", + "email": "alice@foo.baz", + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice-updated.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-4", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + }) + + it('bails out of update on identical objects', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.currentAgentState.did).toBe('alice-did') + + agent1.session = { + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + } + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: agentToSessionAccountOrThrow(agent1), + sessionEvent: 'update', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') + + const lastState = state + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: agentToSessionAccountOrThrow(agent1), + sessionEvent: 'update', + }, + ]) + expect(lastState === state).toBe(true) + + agent1.session = { + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-3', + refreshJwt: 'alice-refresh-jwt-3', + } + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: agentToSessionAccountOrThrow(agent1), + sessionEvent: 'update', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-3') + }) + + it('ignores updates from a stale agent', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + + const agent2 = new BskyAgent({service: 'https://bob.com'}) + agent2.session = { + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + } + + state = run(state, [ + { + // Switch to Alice. + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + { + // Switch to Bob. + type: 'switched-to-account', + newAgent: agent2, + newAccount: agentToSessionAccountOrThrow(agent2), + }, + ]) + expect(state.accounts.length).toBe(2) + expect(state.currentAgentState.did).toBe('bob-did') + + agent1.session = { + did: 'alice-did', + handle: 'alice-updated.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + email: 'alice@foo.bar', + emailAuthFactor: false, + emailConfirmed: false, + } + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: agentToSessionAccountOrThrow(agent1), + sessionEvent: 'update', + }, + ]) + expect(state.accounts.length).toBe(2) + expect(state.accounts[1].did).toBe('alice-did') + // Should retain the old values because Alice is not active. + expect(state.accounts[1].handle).toBe('alice.test') + expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1') + expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "bob-access-jwt-1", + "deactivated": false, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob.test", + "pdsUrl": undefined, + "refreshJwt": "bob-refresh-jwt-1", + "service": "https://bob.com/", + }, + { + "accessJwt": "alice-access-jwt-1", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://bob.com/", + }, + "did": "bob-did", + }, + "needsPersist": true, + } + `) + + agent2.session = { + did: 'bob-did', + handle: 'bob-updated.test', + accessJwt: 'bob-access-jwt-2', + refreshJwt: 'bob-refresh-jwt-2', + } + state = run(state, [ + { + // Update Bob. + type: 'received-agent-event', + accountDid: 'bob-did', + agent: agent2, + refreshedAccount: agentToSessionAccountOrThrow(agent2), + sessionEvent: 'update', + }, + ]) + expect(state.accounts.length).toBe(2) + expect(state.accounts[0].did).toBe('bob-did') + // Should update the values because Bob is active. + expect(state.accounts[0].handle).toBe('bob-updated.test') + expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-2') + expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-2') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "bob-access-jwt-2", + "deactivated": false, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob-updated.test", + "pdsUrl": undefined, + "refreshJwt": "bob-refresh-jwt-2", + "service": "https://bob.com/", + }, + { + "accessJwt": "alice-access-jwt-1", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://bob.com/", + }, + "did": "bob-did", + }, + "needsPersist": true, + } + `) + + // Ignore other events for inactive agent too. + const lastState = state + agent1.session = undefined + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: undefined, + sessionEvent: 'network-error', + }, + ]) + expect(lastState === state).toBe(true) + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: undefined, + sessionEvent: 'expired', + }, + ]) + expect(lastState === state).toBe(true) + }) + + it('ignores updates from a removed agent', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + + const agent2 = new BskyAgent({service: 'https://bob.com'}) + agent2.session = { + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + } + + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + { + type: 'switched-to-account', + newAgent: agent2, + newAccount: agentToSessionAccountOrThrow(agent2), + }, + { + type: 'removed-account', + accountDid: 'alice-did', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.currentAgentState.did).toBe('bob-did') + + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-2', + refreshJwt: 'alice-refresh-jwt-2', + } + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: agentToSessionAccountOrThrow(agent1), + sessionEvent: 'update', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].did).toBe('bob-did') + expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1') + expect(state.currentAgentState.did).toBe('bob-did') + }) + + it('does soft logout on network error', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + // Switch to Alice. + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.currentAgentState.did).toBe('alice-did') + + agent1.session = undefined + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: undefined, + sessionEvent: 'network-error', + }, + ]) + expect(state.accounts.length).toBe(1) + // Network error should reset current user but not reset the tokens. + // TODO: We might want to remove or change this behavior? + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') + expect(state.currentAgentState.did).toBe(undefined) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-1", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": true, + } + `) + }) + + it('resets tokens on expired event', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') + expect(state.currentAgentState.did).toBe('alice-did') + + agent1.session = undefined + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: undefined, + sessionEvent: 'expired', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe(undefined) + expect(state.accounts[0].refreshJwt).toBe(undefined) + expect(state.currentAgentState.did).toBe(undefined) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": undefined, + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": undefined, + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": true, + } + `) + }) + + it('resets tokens on created-failed event', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') + expect(state.currentAgentState.did).toBe('alice-did') + + agent1.session = undefined + state = run(state, [ + { + type: 'received-agent-event', + accountDid: 'alice-did', + agent: agent1, + refreshedAccount: undefined, + sessionEvent: 'create-failed', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe(undefined) + expect(state.accounts[0].refreshJwt).toBe(undefined) + expect(state.currentAgentState.did).toBe(undefined) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": undefined, + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": undefined, + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": true, + } + `) + }) + + it('updates current account', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') + expect(state.currentAgentState.did).toBe('alice-did') + + state = run(state, [ + { + type: 'updated-current-account', + updatedFields: { + email: 'alice@foo.bar', + emailConfirmed: false, + }, + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].email).toBe('alice@foo.bar') + expect(state.accounts[0].emailConfirmed).toBe(false) + expect(state.currentAgentState.did).toBe('alice-did') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-1", + "deactivated": false, + "did": "alice-did", + "email": "alice@foo.bar", + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + + state = run(state, [ + { + type: 'updated-current-account', + updatedFields: { + handle: 'alice-updated.test', + }, + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].handle).toBe('alice-updated.test') + expect(state.currentAgentState.did).toBe('alice-did') + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-1", + "deactivated": false, + "did": "alice-did", + "email": "alice@foo.bar", + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice-updated.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + + const agent2 = new BskyAgent({service: 'https://bob.com'}) + agent2.session = { + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + } + state = run(state, [ + { + // Switch to Bob. + type: 'switched-to-account', + newAgent: agent2, + newAccount: agentToSessionAccountOrThrow(agent2), + }, + { + // Update Bob. + type: 'updated-current-account', + updatedFields: { + handle: 'bob-updated.test', + }, + }, + { + // Switch back to Alice. + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + { + // Update Alice. + type: 'updated-current-account', + updatedFields: { + handle: 'alice-updated-2.test', + }, + }, + ]) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "alice-access-jwt-1", + "deactivated": false, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice-updated-2.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + }, + { + "accessJwt": "bob-access-jwt-1", + "deactivated": false, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob-updated.test", + "pdsUrl": undefined, + "refreshJwt": "bob-refresh-jwt-1", + "service": "https://bob.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://alice.com/", + }, + "did": "alice-did", + }, + "needsPersist": true, + } + `) + }) + + it('replaces local accounts with synced accounts', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.session = { + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + const agent2 = new BskyAgent({service: 'https://bob.com'}) + agent2.session = { + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + { + type: 'switched-to-account', + newAgent: agent2, + newAccount: agentToSessionAccountOrThrow(agent2), + }, + ]) + expect(state.accounts.length).toBe(2) + expect(state.currentAgentState.did).toBe('bob-did') + + const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'}) + anotherTabAgent1.session = { + did: 'jay-did', + handle: 'jay.test', + accessJwt: 'jay-access-jwt-1', + refreshJwt: 'jay-refresh-jwt-1', + } + const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'}) + anotherTabAgent2.session = { + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-2', + refreshJwt: 'bob-refresh-jwt-2', + } + state = run(state, [ + { + type: 'synced-accounts', + syncedAccounts: [ + agentToSessionAccountOrThrow(anotherTabAgent1), + agentToSessionAccountOrThrow(anotherTabAgent2), + ], + syncedCurrentDid: 'bob-did', + }, + ]) + expect(state.accounts.length).toBe(2) + expect(state.accounts[0].did).toBe('jay-did') + expect(state.accounts[1].did).toBe('bob-did') + expect(state.accounts[1].accessJwt).toBe('bob-access-jwt-2') + // Keep Bob logged in. + // (We patch up agent.session outside the reducer for this to work.) + expect(state.currentAgentState.did).toBe('bob-did') + expect(state.needsPersist).toBe(false) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "jay-access-jwt-1", + "deactivated": false, + "did": "jay-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "jay.test", + "pdsUrl": undefined, + "refreshJwt": "jay-refresh-jwt-1", + "service": "https://jay.com/", + }, + { + "accessJwt": "bob-access-jwt-2", + "deactivated": false, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob.test", + "pdsUrl": undefined, + "refreshJwt": "bob-refresh-jwt-2", + "service": "https://alice.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://bob.com/", + }, + "did": "bob-did", + }, + "needsPersist": false, + } + `) + + const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'}) + anotherTabAgent3.session = { + did: 'clarence-did', + handle: 'clarence.test', + accessJwt: 'clarence-access-jwt-2', + refreshJwt: 'clarence-refresh-jwt-2', + } + state = run(state, [ + { + type: 'synced-accounts', + syncedAccounts: [agentToSessionAccountOrThrow(anotherTabAgent3)], + syncedCurrentDid: 'clarence-did', + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].did).toBe('clarence-did') + // Log out because we have no matching user. + // (In practice, we'll resume this session outside the reducer.) + expect(state.currentAgentState.did).toBe(undefined) + expect(state.needsPersist).toBe(false) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": "clarence-access-jwt-2", + "deactivated": false, + "did": "clarence-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "clarence.test", + "pdsUrl": undefined, + "refreshJwt": "clarence-refresh-jwt-2", + "service": "https://clarence.com/", + }, + ], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": false, + } + `) + }) +}) + +function run(initialState: State, actions: Action[]): State { + let state = initialState + for (let action of actions) { + state = reducer(state, action) + } + return state +} + +function printState(state: State) { + return { + accounts: state.accounts, + currentAgentState: { + agent: {service: state.currentAgentState.agent.service}, + did: state.currentAgentState.did, + }, + needsPersist: state.needsPersist, + } +} diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index e873f620..a14cd8e1 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -6,6 +6,7 @@ import {SessionAccount} from './types' // A hack so that the reducer can't read anything from the agent. // From the reducer's point of view, it should be a completely opaque object. type OpaqueBskyAgent = { + readonly service: URL readonly api: unknown readonly app: unknown readonly com: unknown