diff --git a/src/ingestor.ts b/src/ingestor.ts index 192595a2..30b02017 100644 --- a/src/ingestor.ts +++ b/src/ingestor.ts @@ -142,7 +142,7 @@ export default async function ingestor(req) { body: message.body, discussion: message.discussion || '', choices: message.choices, - privacy: message.privacy || '', + privacy: message.privacy, labels: message.labels || [], start: message.start, end: message.end, @@ -169,7 +169,7 @@ export default async function ingestor(req) { name: message.title, body: message.body, discussion: message.discussion || '', - privacy: message.privacy || '', + privacy: message.privacy, choices: message.choices, labels: message.labels || [], metadata: { diff --git a/src/writer/follow.ts b/src/writer/follow.ts index db109a11..23c0eac3 100644 --- a/src/writer/follow.ts +++ b/src/writer/follow.ts @@ -3,7 +3,12 @@ import db from '../helpers/mysql'; import { DEFAULT_NETWORK_ID, NETWORK_IDS } from '../helpers/utils'; export const getFollowsCount = async (follower: string): Promise => { - const query = `SELECT COUNT(*) AS count FROM follows WHERE follower = ?`; + const query = ` + SELECT COUNT(*) AS count + FROM follows + JOIN spaces ON spaces.id = follows.space + WHERE follower = ? AND spaces.deleted = 0 + `; const [{ count }] = await db.queryAsync(query, [follower]); diff --git a/src/writer/proposal.ts b/src/writer/proposal.ts index b61bc987..43817d90 100644 --- a/src/writer/proposal.ts +++ b/src/writer/proposal.ts @@ -102,7 +102,10 @@ export async function verify(body): Promise { if (msg.payload.type !== space.voting.type) return Promise.reject('invalid voting type'); } - if (space.voting?.privacy !== 'any' && msg.payload.privacy) { + const spacePrivacy = space.voting?.privacy ?? 'any'; + const proposalPrivacy = msg.payload.privacy; + + if (proposalPrivacy !== undefined && spacePrivacy !== 'any' && spacePrivacy !== proposalPrivacy) { return Promise.reject('not allowed to set privacy'); } @@ -210,9 +213,9 @@ export async function action(body, ipfs, receipt, id): Promise { const plugins = JSON.stringify(metadata.plugins || {}); const spaceNetwork = spaceSettings.network; const proposalSnapshot = parseInt(msg.payload.snapshot || '0'); - let privacy = spaceSettings.voting?.privacy || ''; + let privacy = spaceSettings.voting?.privacy ?? 'any'; if (privacy === 'any') { - privacy = msg.payload.privacy; + privacy = msg.payload.privacy ?? ''; } let quorum = spaceSettings.voting?.quorum || 0; diff --git a/src/writer/settings.ts b/src/writer/settings.ts index d6a222c2..88e06553 100644 --- a/src/writer/settings.ts +++ b/src/writer/settings.ts @@ -88,6 +88,10 @@ export async function verify(body): Promise { const isAdmin = admins.includes(body.address.toLowerCase()); const newAdmins = (msg.payload.admins || []).map(admin => admin.toLowerCase()); + if (msg.payload.domain && !space?.turbo && !space?.domain) { + return Promise.reject('domain is a turbo feature only'); + } + const anotherSpaceWithDomain = ( await db.queryAsync('SELECT 1 FROM spaces WHERE domain = ? AND id != ? LIMIT 1', [ msg.payload.domain, diff --git a/src/writer/update-proposal.ts b/src/writer/update-proposal.ts index 8d187786..680119cf 100644 --- a/src/writer/update-proposal.ts +++ b/src/writer/update-proposal.ts @@ -49,7 +49,10 @@ export async function verify(body): Promise { if (proposal.author !== body.address) return Promise.reject('Not the author'); - if (space.voting?.privacy !== 'any' && msg.payload.privacy) { + const spacePrivacy = space.voting?.privacy ?? 'any'; + const proposalPrivacy = msg.payload.privacy; + + if (proposalPrivacy !== undefined && spacePrivacy !== 'any' && spacePrivacy !== proposalPrivacy) { return Promise.reject('not allowed to set privacy'); } @@ -68,9 +71,9 @@ export async function action(body, ipfs): Promise { const metadata = msg.payload.metadata || {}; const plugins = JSON.stringify(metadata.plugins || {}); const spaceSettings = await getSpace(msg.space); - let privacy = spaceSettings.voting?.privacy || ''; + let privacy = spaceSettings.voting?.privacy ?? 'any'; if (privacy === 'any') { - privacy = msg.payload.privacy; + privacy = msg.payload.privacy ?? ''; } const proposal = { diff --git a/test/integration/writer/follows.test.ts b/test/integration/writer/follows.test.ts index 4fecccdd..5c5f6299 100644 --- a/test/integration/writer/follows.test.ts +++ b/test/integration/writer/follows.test.ts @@ -1,6 +1,6 @@ -import { verify, action } from '../../../src/writer/follow'; import { FOLLOWS_LIMIT_PER_USER } from '../../../src/helpers/limits'; import db, { sequencerDB } from '../../../src/helpers/mysql'; +import { action, verify } from '../../../src/writer/follow'; import { spacesSqlFixtures } from '../../fixtures/space'; describe('writer/follow', () => { @@ -9,7 +9,7 @@ describe('writer/follow', () => { afterAll(async () => { await db.queryAsync('DELETE FROM follows'); - await db.queryAsync('DELETE FROM spaces WHERE id = ?', [`${TEST_PREFIX}-${space.id}`]); + await db.queryAsync('DELETE FROM spaces WHERE id LIKE ?', [`${TEST_PREFIX}%`]); await db.endAsync(); await sequencerDB.endAsync(); }); @@ -18,14 +18,22 @@ describe('writer/follow', () => { const followerId = '0x0'; beforeAll(async () => { - let i = 0; + let i = 1; const promises: Promise[] = []; while (i <= FOLLOWS_LIMIT_PER_USER) { + promises.push( + db.queryAsync('INSERT INTO snapshot_sequencer_test.spaces SET ?', { + ...space, + id: `${TEST_PREFIX}${i}.eth`, + deleted: 0, + settings: JSON.stringify(space.settings) + }) + ); promises.push( db.queryAsync( 'INSERT INTO follows SET id = ?, ipfs = ?, follower = ?, space = ?, created = ?', - [i, i, followerId, `test-${i}.eth`, i] + [i, i, followerId, `${TEST_PREFIX}${i}.eth`, i] ) ); @@ -41,6 +49,18 @@ describe('writer/follow', () => { ); }); + it('ignores deleted spaces from the limit', async () => { + await db.queryAsync('UPDATE snapshot_sequencer_test.spaces SET deleted = 1 WHERE id = ?', [ + `${TEST_PREFIX}1.eth` + ]); + + await expect(verify({ from: followerId })).resolves.toEqual(true); + + return db.queryAsync('UPDATE snapshot_sequencer_test.spaces SET deleted = 0 WHERE id = ?', [ + `${TEST_PREFIX}1.eth` + ]); + }); + it('returns true when the user has not reached the limit', () => { return expect(verify({ from: '0x1' })).resolves.toEqual(true); }); @@ -111,7 +131,7 @@ describe('writer/follow', () => { it('should increment the follower count of the space', async () => { await db.queryAsync('INSERT INTO spaces SET ?', { ...space, - id: `${TEST_PREFIX}-${space.id}`, + id: `${TEST_PREFIX}${space.id}`, settings: JSON.stringify(space.settings) }); @@ -119,7 +139,7 @@ describe('writer/follow', () => { const ipfs = '4'; const message = { from: '0x4', - space: `${TEST_PREFIX}-${space.id}`, + space: `${TEST_PREFIX}${space.id}`, timestamp: 1 }; diff --git a/test/unit/writer/proposal.test.ts b/test/unit/writer/proposal.test.ts index d2b5f49c..e36e938a 100644 --- a/test/unit/writer/proposal.test.ts +++ b/test/unit/writer/proposal.test.ts @@ -1,21 +1,21 @@ import omit from 'lodash/omit'; -import * as writer from '../../../src/writer/proposal'; -import input from '../../fixtures/writer-payload/proposal.json'; -import { spacesGetSpaceFixtures } from '../../fixtures/space'; import { + ACTIVE_PROPOSAL_BY_AUTHOR_LIMIT, ECOSYSTEM_SPACE_PROPOSAL_DAY_LIMIT, - FLAGGED_SPACE_PROPOSAL_DAY_LIMIT, - SPACE_PROPOSAL_DAY_LIMIT, - VERIFIED_SPACE_PROPOSAL_DAY_LIMIT, ECOSYSTEM_SPACE_PROPOSAL_MONTH_LIMIT, + FLAGGED_SPACE_PROPOSAL_DAY_LIMIT, FLAGGED_SPACE_PROPOSAL_MONTH_LIMIT, - SPACE_PROPOSAL_MONTH_LIMIT, - VERIFIED_SPACE_PROPOSAL_MONTH_LIMIT, MAINNET_ECOSYSTEM_SPACES, - ACTIVE_PROPOSAL_BY_AUTHOR_LIMIT, + SPACE_PROPOSAL_DAY_LIMIT, + SPACE_PROPOSAL_MONTH_LIMIT, TURBO_SPACE_PROPOSAL_DAY_LIMIT, - TURBO_SPACE_PROPOSAL_MONTH_LIMIT + TURBO_SPACE_PROPOSAL_MONTH_LIMIT, + VERIFIED_SPACE_PROPOSAL_DAY_LIMIT, + VERIFIED_SPACE_PROPOSAL_MONTH_LIMIT } from '../../../src/helpers/limits'; +import * as writer from '../../../src/writer/proposal'; +import { spacesGetSpaceFixtures } from '../../fixtures/space'; +import input from '../../fixtures/writer-payload/proposal.json'; const FLAGGED_ADDRESSES = ['0x0']; @@ -69,6 +69,12 @@ mockGetProposalsCount.mockResolvedValue([ } ]); +function updateInputPayload(input: any, payload: any) { + const msg = JSON.parse(input.msg); + + return { ...input, msg: JSON.stringify({ ...msg, payload: { ...msg.payload, ...payload } }) }; +} + describe('writer/proposal', () => { afterEach(jest.clearAllMocks); @@ -294,6 +300,116 @@ describe('writer/proposal', () => { expect(mockSnapshotUtilsValidate).toHaveBeenCalledTimes(0); }); }); + + describe('when the space is using ANY privacy', () => { + beforeEach(() => { + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + voting: { privacy: 'any' } + }); + }); + + it('accepts a proposal with shutter privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: 'shutter' })) + ).resolves.toBeUndefined(); + }); + + it('accepts a proposal with undefined privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: undefined })) + ).resolves.toBeUndefined(); + }); + + it('accepts a proposal with no privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: '' })) + ).resolves.toBeUndefined(); + }); + }); + + // Fallback as if { privacy: 'any' } + describe('when the space is missing the privacy settings', () => { + beforeEach(() => { + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + voting: undefined + }); + }); + + it('accepts a proposal with shutter privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: 'shutter' })) + ).resolves.toBeUndefined(); + }); + + it('accepts a proposal with undefined privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: undefined })) + ).resolves.toBeUndefined(); + }); + + it('accepts a proposal with no privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: '' })) + ).resolves.toBeUndefined(); + }); + }); + + describe('when the space is using NO privacy', () => { + beforeEach(() => { + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + voting: { privacy: '' } + }); + }); + + it('rejects a proposal with shutter privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: 'shutter' })) + ).rejects.toMatch('not allowed to set privacy'); + }); + + it('accepts a proposal with undefined privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: undefined })) + ).resolves.toBeUndefined(); + }); + + it('accepts a proposal with no privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: '' })) + ).resolves.toBeUndefined(); + }); + }); + + describe('when the space is using SHUTTER privacy', () => { + beforeEach(() => { + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + voting: { privacy: 'shutter' } + }); + }); + + it('accepts a proposal with shutter privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: 'shutter' })) + ).resolves.toBeUndefined(); + }); + + it('accepts a proposal with undefined privacy', () => { + return expect( + writer.verify(updateInputPayload(input, { privacy: undefined })) + ).resolves.toBeUndefined(); + }); + + it('rejects a proposal with privacy empty string', async () => { + expect.assertions(1); + await expect(writer.verify(updateInputPayload(input, { privacy: '' }))).rejects.toMatch( + 'not allowed to set privacy' + ); + }); + }); }); it('rejects if the snapshot is in the future', async () => { diff --git a/test/unit/writer/settings.test.ts b/test/unit/writer/settings.test.ts index 618d5f00..8f1627d3 100644 --- a/test/unit/writer/settings.test.ts +++ b/test/unit/writer/settings.test.ts @@ -1,7 +1,7 @@ +import SpaceSchema from '@snapshot-labs/snapshot.js/src/schemas/space.json'; import { verify } from '../../../src/writer/settings'; import { spacesGetSpaceFixtures } from '../../fixtures/space'; import input from '../../fixtures/writer-payload/space.json'; -import SpaceSchema from '@snapshot-labs/snapshot.js/src/schemas/space.json'; function editedInput(payload = {}) { const result = { ...input, msg: JSON.parse(input.msg) }; @@ -105,6 +105,70 @@ describe('writer/settings', () => { ) ).rejects.toContain('wrong space format'); }); + + describe('when the space has an existing custom domain', () => { + it('accepts a new domain for non-turbo spaces', () => { + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + turbo: false, + domain: 'test.com' + }); + return expect( + verify( + editedInput({ + domain: 'test2.com' + }) + ) + ).resolves.toBeUndefined(); + }); + + it('accepts a new domain for turbo spaces', () => { + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + turbo: true, + domain: 'test.com' + }); + return expect( + verify( + editedInput({ + domain: 'test2.com' + }) + ) + ).resolves.toBeUndefined(); + }); + }); + + describe('when the space does not have an existing custom domain', () => { + it('rejects a new domain for non-turbo spaces', () => { + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + turbo: false, + domain: undefined + }); + return expect( + verify( + editedInput({ + domain: 'test2.com' + }) + ) + ).rejects.toContain('domain is a turbo feature only'); + }); + + it('accepts a new domain for turbo spaces', () => { + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + turbo: true, + domain: undefined + }); + return expect( + verify( + editedInput({ + domain: 'test2.com' + }) + ) + ).resolves.toBeUndefined(); + }); + }); }); describe('on valid data', () => {