Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

e2e protocol ricardo #1820

Merged
merged 4 commits into from
Jan 25, 2024
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
198 changes: 99 additions & 99 deletions frontend/controller/actions/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,111 +330,111 @@ export default (sbp('sbp/selectors/register', {
return index === -1 ? contractSyncPriorityList.length : index
}

// IMPORTANT: we avoid using 'await' on the syncs so that Vue.js can proceed
// loading the website instead of stalling out.
// See the TODO note in startApp (main.js) for why this is not awaited
sbp('chelonia/contract/sync', identityContractID, { force: true })
.catch((err) => {
sbp('okTurtles.events/emit', LOGIN_ERROR, { username, identityContractID, error: err })
const errMessage = err?.message || String(err)
console.error('Error during login contract sync', errMessage)

const promptOptions = {
heading: L('Login error'),
question: L('Do you want to log out? Error details: {err}.', { err: err.message }),
primaryButton: L('No'),
secondaryButton: L('Yes')
}
try {
await sbp('chelonia/contract/sync', identityContractID, { force: true })
} catch (err) {
sbp('okTurtles.events/emit', LOGIN_ERROR, { username, identityContractID, error: err })
const errMessage = err?.message || String(err)
console.error('Error during login contract sync', errMessage)

const promptOptions = {
heading: L('Login error'),
question: L('Do you want to log out? Error details: {err}.', { err: err.message }),
primaryButton: L('No'),
secondaryButton: L('Yes')
}

const result = await sbp('gi.ui/prompt', promptOptions)
if (!result) {
return sbp('gi.actions/identity/logout')
} else {
throw err
}
}

sbp('gi.ui/prompt', promptOptions).then((result) => {
if (!result) {
sbp('gi.actions/identity/logout')
.catch((e) => {
console.error('[gi.actions/identity/login] Error calling logout', e)
})
try {
// $FlowFixMe[incompatible-call]
await Promise.all(Object.entries(contractIDs).sort(([a], [b]) => {
// Sync contracts in order based on type
return getContractSyncPriority(a) - getContractSyncPriority(b)
}).map(([, ids]) => {
return sbp('okTurtles.eventQueue/queueEvent', `login:${identityContractID ?? '(null)'}`, ['chelonia/contract/sync', ids, { force: true }])
}))
} catch (err) {
alert(L('Sync error during login: {msg}', { msg: err?.message || 'unknown error' }))
console.error('Error during contract sync upon login (syncing all contractIDs)', err)
}

try {
// The state above might be null, so we re-grab it
const state = sbp('state/vuex/state')

// The updated list of groups
const groupIds = Object.keys(state[identityContractID].groups)

// contract sync might've triggered an async call to /remove, so
// wait before proceeding
// $FlowFixMe[incompatible-call]
await sbp('chelonia/contract/wait', Array.from(new Set([...groupIds, ...Object.values(contractIDs).flat()])))

// Call 'gi.actions/group/join' on all groups which may need re-joining
await Promise.allSettled(
groupIds.map(groupId => (
// (1) Check whether the contract exists (may have been removed
// after sync)
has(state.contracts, groupId) &&
has(state[identityContractID].groups, groupId) &&
// (2) Check whether the join process is still incomplete
// This needs to be re-checked because it may have changed after
// sync
state[groupId]?.profiles?.[username]?.status !== PROFILE_STATUS.ACTIVE &&
// (3) Call join
sbp('gi.actions/group/join', {
originatingContractID: identityContractID,
originatingContractName: 'gi.contracts/identity',
contractID: groupId,
contractName: 'gi.contracts/group',
reference: state[identityContractID].groups[groupId].hash,
signingKeyId: state[identityContractID].groups[groupId].inviteSecretId,
innerSigningKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'csk'),
encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'cek')
}).catch((e) => {
alert(L('Join group error during login: {msg}', { msg: e?.message || 'unknown error' }))
console.error(`Error during gi.actions/group/join for ${groupId} at login`, e)
})
))
)

// update the 'lastLoggedIn' field in user's group profiles
sbp('state/vuex/getters').groupsByName
.map(entry => entry.contractID)
.forEach(cId => {
// We send this action only for groups we have fully joined (i.e.,
// accepted an invite add added our profile)
if (state[cId]?.profiles?.[username]?.status === PROFILE_STATUS.ACTIVE) {
sbp('gi.actions/group/updateLastLoggedIn', { contractID: cId }).catch((e) => console.error('Error sending updateLastLoggedIn', e))
}
}).catch((e) => {
console.error('Error at gi.ui/prompt', e)
})

throw new Error('Unable to sync identity contract')
}).then(() => {
// $FlowFixMe[incompatible-call]
return Promise.all(Object.entries(contractIDs).sort(([a], [b]) => {
// Sync contracts in order based on type
return getContractSyncPriority(a) - getContractSyncPriority(b)
}).map(([, ids]) => {
return sbp('okTurtles.eventQueue/queueEvent', `login:${identityContractID ?? '(null)'}`, ['chelonia/contract/sync', ids, { force: true }])
}))
.catch((err) => {
console.error('Error during contract sync upon login (syncing all contractIDs)', err)
})
.then(async function () {
// The state above might be null, so we re-grab it
const state = sbp('state/vuex/state')

// The updated list of groups
const groupIds = Object.keys(state[identityContractID].groups)

// contract sync might've triggered an async call to /remove, so
// wait before proceeding
// $FlowFixMe[incompatible-call]
await sbp('chelonia/contract/wait', Array.from(new Set([...groupIds, ...Object.values(contractIDs).flat()])))

// Call 'gi.actions/group/join' on all groups which may need re-joining
await Promise.allSettled(
groupIds.map(groupId => (
// (1) Check whether the contract exists (may have been removed
// after sync)
has(state.contracts, groupId) &&
has(state[identityContractID].groups, groupId) &&
// (2) Check whether the join process is still incomplete
// This needs to be re-checked because it may have changed after
// sync
state[groupId]?.profiles?.[username]?.status !== PROFILE_STATUS.ACTIVE &&
// (3) Call join
sbp('gi.actions/group/join', {
originatingContractID: identityContractID,
originatingContractName: 'gi.contracts/identity',
contractID: groupId,
contractName: 'gi.contracts/group',
reference: state[identityContractID].groups[groupId].hash,
signingKeyId: state[identityContractID].groups[groupId].inviteSecretId,
innerSigningKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'csk'),
encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'cek')
}).catch((e) => {
console.error(`Error during gi.actions/group/join for ${groupId} at login`, e)
})
))
)

// update the 'lastLoggedIn' field in user's group profiles
sbp('state/vuex/getters').groupsByName
.map(entry => entry.contractID)
.forEach(cId => {
// We send this action only for groups we have fully joined (i.e.,
// accepted an invite add added our profile)
if (state[cId]?.profiles?.[username]?.status === PROFILE_STATUS.ACTIVE) {
sbp('gi.actions/group/updateLastLoggedIn', { contractID: cId }).catch((e) => console.error('Error sending updateLastLoggedIn', e))
}
})

// NOTE: users could notice that they leave the group by someone
// else when they log in
if (!state.currentGroupId) {
const gId = Object.keys(state.contracts)
.find(cID => has(state[identityContractID].groups, cID))

if (gId) {
sbp('gi.actions/group/switch', gId)
}
}
}).finally(() => {
sbp('okTurtles.events/emit', LOGIN, { username, identityContractID })
})
}).catch((err) => {
console.error('Error during identity contract sync upon login', err)
})
// NOTE: users could notice that they leave the group by someone
// else when they log in
if (!state.currentGroupId) {
const gId = Object.keys(state.contracts)
.find(cID => has(state[identityContractID].groups, cID))

if (gId) {
sbp('gi.actions/group/switch', gId)
}
}
} catch (e) {
alert(L('Error during login: {msg}', { msg: e?.message || 'unknown error' }))
console.error('[gi.actions/identity/login] Error re-joining groups after login', e)
} finally {
sbp('okTurtles.events/emit', LOGIN, { username, identityContractID })
}

return identityContractID
} catch (e) {
console.error('gi.actions/identity/login failed!', e)
Expand Down
3 changes: 2 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ <h1>Outdated browser</h1>
:class="appClasses"
:data-sync="ephemeral.syncs"
:data-logged-in="ephemeral.finishedLogin"
:data-ready="ephemeral.ready"
>
<app-styles></app-styles>
<banner-general ref="bannerGeneral"></banner-general>
<navigation v-if="showNav" class="l-navigation"></navigation>
<router-view class="l-page"></router-view>
<router-view v-if="ephemeral.ready" class="l-page"></router-view>
<modal class="l-modal"></modal>
<background-sounds></background-sounds>
<!-- we no longer use cypress-bypass-ui -->
Expand Down
40 changes: 18 additions & 22 deletions frontend/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,28 +232,6 @@ async function startApp () {
}
}))
await sbp('translations/init', navigator.language)
// NOTE: important to do this before setting up Vue.js because a lot of that relies
// on the router stuff which has guards that expect the contracts to be loaded
const identityContractID = await sbp('gi.db/settings/load', SETTING_CURRENT_USER)
try {
if (identityContractID) {
sbp('okTurtles.events/on', CONTRACT_IS_SYNCING, initialSyncFn)
// TODO: 'gi.actions/identity/login' will return before the contract
// state is synced
// This is to continue with the following block that is setting up
// Vue.js, but as a result the login events listened for below may
// have already fired. Instead, this block should be moved below the
// Vue.js set up, and the router be updated to handle not having a login
// state initially.
await sbp('gi.actions/identity/login', { identityContractID })
}
} catch (e) {
console.error(`caught ${e.name} while logging in: ${e.message}`, e)
await sbp('gi.actions/identity/logout')
console.warn(`It looks like the local user '${identityContractID}' does not exist anymore on the server 😱 If this is unexpected, contact us at https://gitter.im/okTurtles/group-income`)
// TODO: handle this better
await sbp('gi.db/settings/delete', identityContractID)
}
} catch (e) {
const errMsg = `Fatal error while initializing Group Income: ${e.name} - ${e.message}\n\nPlease report this bug here: ${ALLOWED_URLS.ISSUE_PAGE}`
console.error(errMsg, e)
Expand Down Expand Up @@ -380,6 +358,24 @@ async function startApp () {

sbp('okTurtles.events/emit', THEME_CHANGE, this.$store.state.settings.themeColor)
this.setBadgeOnTab()

// Now that the app is ready, we proceed to call /login (which will restore
// the user's session, if they are already logged in)
// Since this is asynchronous, we must check this.ephemeral.finishedLogin
// to ensure that we don't override user interactions that have already
// happened (an example where things can happen this quickly is in the
// tests).
sbp('gi.db/settings/load', SETTING_CURRENT_USER).then(identityContractID => {
if (!identityContractID || this.ephemeral.finishedLogin === 'yes') return
return sbp('gi.actions/identity/login', { identityContractID }).catch((e) => {
console.error(`[main] caught ${e?.name} while logging in: ${e?.message || e}`, e)
console.warn(`It looks like the local user '${identityContractID}' does not exist anymore on the server 😱 If this is unexpected, contact us at https://gitter.im/okTurtles/group-income`)
})
}).catch(e => {
console.error(`[main] caught ${e?.name} while fetching settings or handling a login error: ${e?.message || e}`, e)
}).finally(() => {
Vue.set(this.ephemeral, 'ready', true)
})
},
computed: {
...mapGetters(['groupsByName', 'ourUnreadMessages', 'totalUnreadNotificationCount']),
Expand Down
31 changes: 0 additions & 31 deletions frontend/views/containers/access/LoginForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import sbp from '@sbp/sbp'
import { validationMixin } from 'vuelidate'
import { required } from 'vuelidate/lib/validators'
import { L } from '@common/common.js'
import { LOGIN, LOGIN_ERROR } from '~/frontend/utils/events.js'
import BannerScoped from '@components/banners/BannerScoped.vue'
import ButtonSubmit from '@components/ButtonSubmit.vue'
import PasswordForm from '@containers/access/PasswordForm.vue'
Expand Down Expand Up @@ -88,40 +87,10 @@ export default ({

const username = this.form.username

let outerResolve, outerReject
// Promise to return once login finished, so that the form doesn't show
// as completed.
// See the TODO note in startApp (main.js) for how to do this more
// cleanly by having 'gi.actions/identity/login' await on the call
// to 'chelonia/contract/sync' that happens after logging in.
const finishedLoggingIn = new Promise((resolve, reject) => {
outerResolve = resolve
outerReject = reject
})

// 'gi.actions/identity/login' syncs the identity contract without
// awaiting on it, which can cause issues because this.postSubmit()
// can get called before the state for the identity contract is complete.
// To avoid these issues, we set up an event handler (on LOGIN) to call
// this.postSubmit() once the identity contract has finished syncing
// If an error occurred during login, we set up an event handler (on
// LOGIN_ERROR) to remove the login event handler.
const loginEventHandler = () => {
sbp('okTurtles.events/off', LOGIN_ERROR, loginErrorEventHandler)
outerResolve()
}
const loginErrorEventHandler = ({ error }) => {
sbp('okTurtles.events/off', LOGIN, loginEventHandler)
outerReject(error)
}
sbp('okTurtles.events/once', LOGIN, loginEventHandler)
sbp('okTurtles.events/once', LOGIN_ERROR, loginErrorEventHandler)

await sbp('gi.actions/identity/login', {
username,
passwordFn: wrapValueInFunction(this.form.password)
})
await finishedLoggingIn
await this.postSubmit()
this.$emit('submit-succeeded')

Expand Down
1 change: 1 addition & 0 deletions frontend/views/containers/chatroom/ChatMain.vue
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ export default ({
}
}).catch((e) => {
console.error(`Error while publishing message for ${contractID}`, e)
alert(e?.message || e)
})
},
async scrollToMessage (messageHash, effect = true) {
Expand Down
6 changes: 6 additions & 0 deletions test/cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ Cypress.Commands.add('giSignup', (username, {
const email = `${username}@email.com`

if (bypassUI) {
// Wait for the app to be ready
cy.getByDT('app').should('have.attr', 'data-ready', 'true')

cy.window().its('sbp').then(async sbp => {
await sbp('gi.actions/identity/signupAndLogin', { username, email, passwordFn: () => password })
await sbp('controller/router').push({ path: '/' }).catch(e => {})
Expand Down Expand Up @@ -202,6 +205,9 @@ Cypress.Commands.add('giLogin', (username, {
firstLoginAfterJoinGroup = false
} = {}) => {
if (bypassUI) {
// Wait for the app to be ready
cy.getByDT('app').should('have.attr', 'data-ready', 'true')

cy.window().its('sbp').then(sbp => {
return new Promise(resolve => {
const ourUsername = sbp('state/vuex/getters').ourUsername
Expand Down