From 1862b6916909195e4c49441d6c5753b93f9028fd Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Thu, 8 Nov 2018 13:29:29 -0500 Subject: [PATCH 01/10] Properly signing, creating, fetching comments. Fix incorrect types. Setup scaffolding for replies. --- backend/grant/comment/models.py | 14 ++++- backend/grant/proposal/views.py | 59 +++++++++++++++----- backend/grant/user/models.py | 3 - backend/grant/utils/auth.py | 3 + backend/migrations/versions/3699cb98fc2a_.py | 30 ++++++++++ frontend/client/api/api.ts | 11 ++++ frontend/client/components/Comment/index.tsx | 6 +- frontend/client/modules/proposals/actions.ts | 39 ++++++------- frontend/types/comment.ts | 5 +- frontend/types/user.ts | 4 +- 10 files changed, 127 insertions(+), 47 deletions(-) create mode 100644 backend/migrations/versions/3699cb98fc2a_.py diff --git a/backend/grant/comment/models.py b/backend/grant/comment/models.py index 564d2c2a..d99e2e6f 100644 --- a/backend/grant/comment/models.py +++ b/backend/grant/comment/models.py @@ -11,12 +11,17 @@ class Comment(db.Model): date_created = db.Column(db.DateTime) content = db.Column(db.Text, nullable=False) + parent_comment_id = db.Column(db.Integer, db.ForeignKey("comment.id"), nullable=True) proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - def __init__(self, proposal_id, user_id, content): + author = db.relationship("User", back_populates="comments") + replies = db.relationship("Comment") + + def __init__(self, proposal_id, user_id, parent_comment_id, content): self.proposal_id = proposal_id self.user_id = user_id + self.parent_comment_id = parent_comment_id self.content = content self.date_created = datetime.datetime.now() @@ -26,16 +31,19 @@ class CommentSchema(ma.Schema): model = Comment # Fields to expose fields = ( - "user_id", + "author", "content", "proposal_id", + "parent_comment_id", "date_created", "body", + "replies" ) body = ma.Method("get_body") - date_created = ma.Method("get_date_created") + author = ma.Nested("UserSchema", exclude=["email_address"]) + replies = ma.Nested("CommentSchema", many=True) def get_body(self, obj): return obj.content diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index af64e266..7dbe671a 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -1,5 +1,6 @@ from datetime import datetime from functools import wraps +import ast from flask import Blueprint, g from flask_yoloapi import endpoint, parameter @@ -8,7 +9,7 @@ from sqlalchemy.exc import IntegrityError from grant.comment.models import Comment, comment_schema from grant.milestone.models import Milestone from grant.user.models import User, SocialMedia, Avatar -from grant.utils.auth import requires_sm, requires_team_member_auth +from grant.utils.auth import requires_sm, requires_team_member_auth, verify_signed_auth, BadSignatureException from .models import Proposal, proposals_schema, proposal_schema, ProposalUpdate, proposal_update_schema, db blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals") @@ -43,23 +44,53 @@ def get_proposal_comments(proposal_id): @blueprint.route("//comments", methods=["POST"]) @requires_sm @endpoint.api( - parameter('content', type=str, required=True) + parameter('comment', type=str, required=True), + parameter('parentCommentId', type=int, required=False), + parameter('signedMessage', type=str, required=True), + parameter('rawTypedData', type=str, required=True) ) -def post_proposal_comments(proposal_id, user_id, content): +def post_proposal_comments(proposal_id, comment, parent_comment_id, signed_message, raw_typed_data): + # Make sure proposal exists proposal = Proposal.query.filter_by(id=proposal_id).first() - if proposal: - comment = Comment( - proposal_id=proposal_id, - user_id=g.current_user.id, - content=content - ) - db.session.add(comment) - db.session.commit() - dumped_comment = comment_schema.dump(comment) - return dumped_comment, 201 - else: + if not proposal: return {"message": "No proposal matching id"}, 404 + # Make sure the parent comment exists + if parent_comment_id: + parent = Comment.query.filter_by(id=parent_comment_id).first() + if not parent: + return {"message": "Parent comment doesn’t exist"}, 400 + + # Make sure comment content matches + typed_data = ast.literal_eval(raw_typed_data) + if comment != typed_data['message']['comment']: + return {"message": "Comment doesn’t match signature data"}, 404 + + # Verify the signature + try: + sig_address = verify_signed_auth(signed_message, raw_typed_data) + if sig_address.lower() != g.current_user.account_address.lower(): + return { + "message": "Message signature address ({sig_address}) doesn't match current account address ({account_address})".format( + sig_address=sig_address, + account_address=g.current_user.account_address + ) + }, 400 + except BadSignatureException: + return {"message": "Invalid message signature"}, 400 + + # Make the comment + comment = Comment( + proposal_id=proposal_id, + user_id=g.current_user.id, + parent_comment_id=parent_comment_id, + content=comment + ) + db.session.add(comment) + db.session.commit() + dumped_comment = comment_schema.dump(comment) + return dumped_comment, 201 + @blueprint.route("/", methods=["GET"]) @endpoint.api( diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index 53a7ff50..bb1c322f 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -102,7 +102,6 @@ class UserSchema(ma.Schema): "avatar", "display_name", "userid" - ) social_medias = ma.Nested("SocialMediaSchema", many=True) @@ -112,11 +111,9 @@ class UserSchema(ma.Schema): def get_userid(self, obj): return obj.id - user_schema = UserSchema() users_schema = UserSchema(many=True) - class SocialMediaSchema(ma.Schema): class Meta: model = SocialMedia diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 9ea6e30d..d23d93ac 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -38,6 +38,9 @@ class BadSignatureException(Exception): def verify_signed_auth(signature, typed_data): loaded_typed_data = ast.literal_eval(typed_data) + if loaded_typed_data['domain']['name'] != 'Grant.io': + raise BadSignatureException("Signature is not for Grant.io") + url = AUTH_URL + "/message/recover" payload = json.dumps({"sig": signature, "data": loaded_typed_data}) headers = {'content-type': 'application/json'} diff --git a/backend/migrations/versions/3699cb98fc2a_.py b/backend/migrations/versions/3699cb98fc2a_.py new file mode 100644 index 00000000..75bbefce --- /dev/null +++ b/backend/migrations/versions/3699cb98fc2a_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 3699cb98fc2a +Revises: 312db8611967 +Create Date: 2018-11-08 12:33:14.995080 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3699cb98fc2a' +down_revision = '312db8611967' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('comment', sa.Column('parent_comment_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'comment', 'comment', ['parent_comment_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'comment', type_='foreignkey') + op.drop_column('comment', 'parent_comment_id') + # ### end Alembic commands ### diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 9e7b05b4..ebbbfeb9 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -106,3 +106,14 @@ export function postProposalUpdate( content, }); } + +export function postProposalComment(payload: { + proposalId: number; + parentCommentId?: number; + comment: string; + signedMessage: string; + rawTypedData: string; +}): Promise<{ data: any }> { + const { proposalId, ...args } = payload; + return axios.post(`/api/v1/proposals/${proposalId}/comments`, args); +} diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index f69510bd..c9ac0fd5 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -55,8 +55,10 @@ class Comment extends React.Component { {/*
*/} -
{comment.author.username}
-
{moment(comment.dateCreated).fromNow()}
+
{comment.author.displayName}
+
+ {moment.unix(comment.dateCreated).fromNow()} +
diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index b061d2b8..a8dcc80c 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -4,10 +4,11 @@ import { getProposal, getProposalComments, getProposalUpdates, + postProposalComment as apiPostProposalComment, } from 'api/api'; import { Dispatch } from 'redux'; import Web3 from 'web3'; -import { ProposalWithCrowdFund, Proposal, Comment } from 'types'; +import { ProposalWithCrowdFund, Proposal, Comment, AuthSignatureData } from 'types'; import { signData } from 'modules/web3/actions'; import getContract from 'lib/getContract'; import CrowdFund from 'lib/contracts/CrowdFund.json'; @@ -117,40 +118,36 @@ export function postProposalComment( dispatch({ type: types.POST_PROPOSAL_COMMENT_PENDING }); try { - const signedComment = await dispatch( + const sigData: AuthSignatureData = (await dispatch( signData( { comment }, { - comment: { - name: 'Comment', - type: 'string', - }, + comment: [ + { + name: 'Comment', + type: 'string', + }, + ], }, 'comment', ), - ); + )) as any; + + const res = await apiPostProposalComment({ + proposalId, + comment, + signedMessage: sigData.signedMessage, + rawTypedData: JSON.stringify(sigData.rawTypedData), + }); // TODO: API up the comment & signed comment, handle response / failures // TODO: Remove console log - console.log(signedComment); dispatch({ type: types.POST_PROPOSAL_COMMENT_FULFILLED, payload: { proposalId, parentCommentId, - comment: { - commentId: Math.random(), - body: comment, - dateCreated: Date.now(), - replies: [], - author: { - accountAddress: '0x0', - userid: 'test', - username: 'test', - title: 'test', - avatar: { '120x120': 'test' }, - }, - }, + comment: res.data, }, }); } catch (err) { diff --git a/frontend/types/comment.ts b/frontend/types/comment.ts index 9b4d91f8..d19f444f 100644 --- a/frontend/types/comment.ts +++ b/frontend/types/comment.ts @@ -1,7 +1,8 @@ -import { User, UserProposal } from 'types'; +import { UserProposal, User } from 'types'; export interface Comment { - commentId: number | string; + proposalId: number; + commentId: number; body: string; dateCreated: number; author: User; diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 8b5a4497..2cf8a68e 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -3,9 +3,9 @@ import { SocialAccountMap } from 'types'; export interface User { accountAddress: string; userid: number | string; - username: string; + displayName: string; title: string; - avatar: { + avatar?: { '120x120': string; }; } From 7c023da0668652836d09e1cd745cd50ec91ff83e Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Thu, 8 Nov 2018 13:42:19 -0500 Subject: [PATCH 02/10] Create and show replies to comments succesfully. --- backend/grant/comment/models.py | 4 +++- backend/grant/proposal/views.py | 20 +++++++++++--------- frontend/client/components/Comment/index.tsx | 4 ++-- frontend/client/modules/proposals/actions.ts | 1 + frontend/types/comment.ts | 2 +- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/backend/grant/comment/models.py b/backend/grant/comment/models.py index d99e2e6f..18568130 100644 --- a/backend/grant/comment/models.py +++ b/backend/grant/comment/models.py @@ -31,15 +31,17 @@ class CommentSchema(ma.Schema): model = Comment # Fields to expose fields = ( + "id", + "proposal_id", "author", "content", - "proposal_id", "parent_comment_id", "date_created", "body", "replies" ) + body = ma.Method("get_body") date_created = ma.Method("get_date_created") author = ma.Nested("UserSchema", exclude=["email_address"]) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 7dbe671a..2169d4bc 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -6,7 +6,7 @@ from flask import Blueprint, g from flask_yoloapi import endpoint, parameter from sqlalchemy.exc import IntegrityError -from grant.comment.models import Comment, comment_schema +from grant.comment.models import Comment, comment_schema, comments_schema from grant.milestone.models import Milestone from grant.user.models import User, SocialMedia, Avatar from grant.utils.auth import requires_sm, requires_team_member_auth, verify_signed_auth, BadSignatureException @@ -30,15 +30,17 @@ def get_proposal(proposal_id): @endpoint.api() def get_proposal_comments(proposal_id): proposal = Proposal.query.filter_by(id=proposal_id).first() - if proposal: - dumped_proposal = proposal_schema.dump(proposal) - return { - "proposalId": proposal_id, - "totalComments": len(dumped_proposal["comments"]), - "comments": dumped_proposal["comments"] - } - else: + if not proposal: return {"message": "No proposal matching id"}, 404 + + # Only pull top comments, replies will be attached to them + comments = Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None) + num_comments = Comment.query.filter_by(proposal_id=proposal_id).count() + return { + "proposalId": proposal_id, + "totalComments": num_comments, + "comments": comments_schema.dump(comments) + } @blueprint.route("//comments", methods=["POST"]) diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index c9ac0fd5..21bc09a3 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -88,7 +88,7 @@ class Comment extends React.Component { )} {comment.replies.map(subComment => ( @@ -110,7 +110,7 @@ class Comment extends React.Component { private reply = () => { const { comment, proposalId } = this.props; const { reply } = this.state; - this.props.postProposalComment(proposalId, reply, comment.commentId); + this.props.postProposalComment(proposalId, reply, comment.id); }; } diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index a8dcc80c..ca9857d0 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -135,6 +135,7 @@ export function postProposalComment( const res = await apiPostProposalComment({ proposalId, + parentCommentId, comment, signedMessage: sigData.signedMessage, rawTypedData: JSON.stringify(sigData.rawTypedData), diff --git a/frontend/types/comment.ts b/frontend/types/comment.ts index d19f444f..ccc601a6 100644 --- a/frontend/types/comment.ts +++ b/frontend/types/comment.ts @@ -1,8 +1,8 @@ import { UserProposal, User } from 'types'; export interface Comment { + id: number; proposalId: number; - commentId: number; body: string; dateCreated: number; author: User; From 828bd1f6363c9f0fd98e07ee9c48ef6c6a99e5d7 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Thu, 8 Nov 2018 13:52:36 -0500 Subject: [PATCH 03/10] Show correct avatar for comment author. --- frontend/client/components/Comment/index.tsx | 17 +++++++---- frontend/client/components/Comment/style.less | 4 +++ frontend/client/components/UserAvatar.tsx | 30 +++++++++++++++---- frontend/types/user.ts | 2 +- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index 21bc09a3..a13a0001 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { connect } from 'react-redux'; import moment from 'moment'; import { Button } from 'antd'; +import { Link } from 'react-router-dom'; import Markdown from 'components/Markdown'; -import Identicon from 'components/Identicon'; +import UserAvatar from 'components/UserAvatar'; import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor'; import { postProposalComment } from 'modules/proposals/actions'; import { Comment as IComment, Proposal } from 'types'; @@ -48,14 +49,18 @@ class Comment extends React.Component { public render(): React.ReactNode { const { comment, proposalId } = this.props; const { isReplying, reply } = this.state; + const authorPath = `/profile/${comment.author.accountAddress}`; return (
-
- -
- {/*
*/} -
{comment.author.displayName}
+ +
+ +
+ + +
{comment.author.displayName}
+
{moment.unix(comment.dateCreated).fromNow()}
diff --git a/frontend/client/components/Comment/style.less b/frontend/client/components/Comment/style.less index 4c9ad5e7..09928863 100644 --- a/frontend/client/components/Comment/style.less +++ b/frontend/client/components/Comment/style.less @@ -13,6 +13,10 @@ line-height: @info-height; margin-bottom: 1rem; + a { + color: inherit; + } + &-thumb { display: block; margin-right: 0.5rem; diff --git a/frontend/client/components/UserAvatar.tsx b/frontend/client/components/UserAvatar.tsx index f16166d6..9e3f466c 100644 --- a/frontend/client/components/UserAvatar.tsx +++ b/frontend/client/components/UserAvatar.tsx @@ -1,18 +1,36 @@ import React from 'react'; import Identicon from 'components/Identicon'; -import { TeamMember } from 'types'; +import { TeamMember, User } from 'types'; import defaultUserImg from 'static/images/default-user.jpg'; interface Props { - user: TeamMember; + user: TeamMember | User; className?: string; } +function isTeamMember(user: TeamMember | User): user is TeamMember { + return !!(user as TeamMember).ethAddress; +} + +function isUser(user: TeamMember | User): user is User { + return !!(user as User).accountAddress; +} + const UserAvatar: React.SFC = ({ user, className }) => { - if (user.avatarUrl) { - return ; - } else if (user.ethAddress) { - return ; + let url; + let address; + if (isTeamMember(user)) { + url = user.avatarUrl; + address = user.ethAddress; + } else if (isUser(user)) { + url = user.avatar && user.avatar.imageUrl; + address = user.accountAddress; + } + + if (url) { + return ; + } else if (address) { + return ; } else { return ; } diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 2cf8a68e..9bab4f38 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -6,7 +6,7 @@ export interface User { displayName: string; title: string; avatar?: { - '120x120': string; + imageUrl: string; }; } From c4950b0b27ca12df67592b5b40a454140d96369d Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Thu, 8 Nov 2018 14:14:52 -0500 Subject: [PATCH 04/10] Remove unnecessary props, fix comment id. --- frontend/client/components/Comment/index.tsx | 13 ++++--------- frontend/client/components/Comments/index.tsx | 7 +++---- .../client/components/Proposal/Comments/index.tsx | 4 ++-- frontend/client/modules/proposals/actions.ts | 2 +- frontend/client/modules/proposals/reducers.ts | 2 +- frontend/client/utils/helpers.ts | 4 ++-- 6 files changed, 13 insertions(+), 19 deletions(-) diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index a13a0001..205288b9 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -13,7 +13,6 @@ import './style.less'; interface OwnProps { comment: IComment; - proposalId: Proposal['proposalId']; } interface StateProps { @@ -47,7 +46,7 @@ class Comment extends React.Component { } public render(): React.ReactNode { - const { comment, proposalId } = this.props; + const { comment } = this.props; const { isReplying, reply } = this.state; const authorPath = `/profile/${comment.author.accountAddress}`; return ( @@ -92,11 +91,7 @@ class Comment extends React.Component {
)} {comment.replies.map(subComment => ( - + ))}
)} @@ -113,9 +108,9 @@ class Comment extends React.Component { }; private reply = () => { - const { comment, proposalId } = this.props; + const { comment } = this.props; const { reply } = this.state; - this.props.postProposalComment(proposalId, reply, comment.id); + this.props.postProposalComment(comment.proposalId, reply, comment.id); }; } diff --git a/frontend/client/components/Comments/index.tsx b/frontend/client/components/Comments/index.tsx index 43a43548..1f03ab0c 100644 --- a/frontend/client/components/Comments/index.tsx +++ b/frontend/client/components/Comments/index.tsx @@ -1,16 +1,15 @@ import React from 'react'; -import { Proposal, ProposalComments } from 'types'; +import { ProposalComments } from 'types'; import Comment from 'components/Comment'; interface Props { comments: ProposalComments['comments']; - proposalId: Proposal['proposalId']; } -const Comments = ({ comments, proposalId }: Props) => ( +const Comments = ({ comments }: Props) => ( {comments.map(c => ( - + ))} ); diff --git a/frontend/client/components/Proposal/Comments/index.tsx b/frontend/client/components/Proposal/Comments/index.tsx index f7c36c43..93cac410 100644 --- a/frontend/client/components/Proposal/Comments/index.tsx +++ b/frontend/client/components/Proposal/Comments/index.tsx @@ -53,7 +53,7 @@ class ProposalComments extends React.Component { } render() { - const { proposalId, comments, isFetchingComments, commentsError } = this.props; + const { comments, isFetchingComments, commentsError } = this.props; const { comment } = this.state; let content = null; @@ -68,7 +68,7 @@ class ProposalComments extends React.Component { ); } else if (comments) { if (comments.length) { - content = ; + content = ; } else { content = ( ) => { dispatch({ type: types.POST_PROPOSAL_COMMENT_PENDING }); diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index 09cf014c..0f9ad378 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -92,7 +92,7 @@ function addUpdates(state: ProposalState, payload: ProposalUpdates) { interface PostCommentPayload { proposalId: ProposalWithCrowdFund['proposalId']; comment: Comment; - parentCommentId?: Comment['commentId']; + parentCommentId?: Comment['id']; } function addPostedComment(state: ProposalState, payload: PostCommentPayload) { const { proposalId, comment, parentCommentId } = payload; diff --git a/frontend/client/utils/helpers.ts b/frontend/client/utils/helpers.ts index 14a71d63..e120843b 100644 --- a/frontend/client/utils/helpers.ts +++ b/frontend/client/utils/helpers.ts @@ -10,11 +10,11 @@ export async function sleep(ms: number) { } export function findComment( - commentId: Comment['commentId'], + commentId: Comment['id'], comments: Comment[], ): Comment | null { for (const comment of comments) { - if (comment.commentId === commentId) { + if (comment.id === commentId) { return comment; } else if (comment.replies.length) { const foundComment = findComment(commentId, comment.replies); From 7ee7a53b3bec96408f8a1759642234cd5d285435 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Thu, 8 Nov 2018 14:16:42 -0500 Subject: [PATCH 05/10] tsc --- frontend/client/components/Comment/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index 205288b9..5e280177 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -7,7 +7,7 @@ import Markdown from 'components/Markdown'; import UserAvatar from 'components/UserAvatar'; import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor'; import { postProposalComment } from 'modules/proposals/actions'; -import { Comment as IComment, Proposal } from 'types'; +import { Comment as IComment } from 'types'; import { AppState } from 'store/reducers'; import './style.less'; From c9d6e58e198306e156adff0f4c20b69bc8a6b901 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Thu, 8 Nov 2018 14:38:00 -0500 Subject: [PATCH 06/10] Hide reply actions if logged out. --- frontend/client/components/Comment/index.tsx | 19 +++++++----- .../components/Proposal/Comments/index.tsx | 31 +++++++++++-------- frontend/client/modules/auth/selectors.ts | 1 + 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index 5e280177..5b123966 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -7,6 +7,7 @@ import Markdown from 'components/Markdown'; import UserAvatar from 'components/UserAvatar'; import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor'; import { postProposalComment } from 'modules/proposals/actions'; +import { getIsSignedIn } from 'modules/auth/selectors'; import { Comment as IComment } from 'types'; import { AppState } from 'store/reducers'; import './style.less'; @@ -18,6 +19,7 @@ interface OwnProps { interface StateProps { isPostCommentPending: AppState['proposal']['isPostCommentPending']; postCommentError: AppState['proposal']['postCommentError']; + isSignedIn: ReturnType; } interface DispatchProps { @@ -46,7 +48,7 @@ class Comment extends React.Component { } public render(): React.ReactNode { - const { comment } = this.props; + const { comment, isSignedIn } = this.props; const { isReplying, reply } = this.state; const authorPath = `/profile/${comment.author.accountAddress}`; return ( @@ -69,12 +71,14 @@ class Comment extends React.Component {
- + {isSignedIn && ( + + )} {(comment.replies.length || isReplying) && (
@@ -118,6 +122,7 @@ const ConnectedComment = connect( (state: AppState) => ({ isPostCommentPending: state.proposal.isPostCommentPending, postCommentError: state.proposal.postCommentError, + isSignedIn: getIsSignedIn(state), }), { postProposalComment, diff --git a/frontend/client/components/Proposal/Comments/index.tsx b/frontend/client/components/Proposal/Comments/index.tsx index 93cac410..94fca2f5 100644 --- a/frontend/client/components/Proposal/Comments/index.tsx +++ b/frontend/client/components/Proposal/Comments/index.tsx @@ -9,6 +9,7 @@ import { getIsFetchingComments, getCommentsError, } from 'modules/proposals/selectors'; +import { getIsSignedIn } from 'modules/auth/selectors'; import Comments from 'components/Comments'; import Placeholder from 'components/Placeholder'; import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor'; @@ -22,6 +23,7 @@ interface StateProps { comments: ReturnType; isFetchingComments: ReturnType; commentsError: ReturnType; + isSignedIn: ReturnType; } interface DispatchProps { @@ -53,7 +55,7 @@ class ProposalComments extends React.Component { } render() { - const { comments, isFetchingComments, commentsError } = this.props; + const { comments, isFetchingComments, commentsError, isSignedIn } = this.props; const { comment } = this.state; let content = null; @@ -81,16 +83,18 @@ class ProposalComments extends React.Component { return ( <> -
- -
- -
+ {isSignedIn && ( +
+ +
+ +
+ )} {content} ); @@ -105,11 +109,12 @@ class ProposalComments extends React.Component { }; } -export default connect( - (state: AppState, ownProps: OwnProps) => ({ +export default connect( + (state, ownProps) => ({ comments: getProposalComments(state, ownProps.proposalId), isFetchingComments: getIsFetchingComments(state), commentsError: getCommentsError(state), + isSignedIn: getIsSignedIn(state), }), { fetchProposalComments, diff --git a/frontend/client/modules/auth/selectors.ts b/frontend/client/modules/auth/selectors.ts index 22b17407..f590ac60 100644 --- a/frontend/client/modules/auth/selectors.ts +++ b/frontend/client/modules/auth/selectors.ts @@ -1,4 +1,5 @@ import { AppState as S } from 'store/reducers'; +export const getIsSignedIn = (s: S) => !!s.auth.user; export const getAuthSignature = (s: S) => s.auth.authSignature; export const getAuthSignatureAddress = (s: S) => s.auth.authSignatureAddress; From c579820dc32b5f90e11b22fe127d2b28dd1ca2f8 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Tue, 13 Nov 2018 14:58:02 +0100 Subject: [PATCH 07/10] Auth Endpoints Tests (#203) Auth Endpoints Tests --- backend/grant/proposal/views.py | 7 +- backend/grant/user/models.py | 11 +- backend/grant/user/views.py | 63 +++--- backend/grant/utils/auth.py | 33 +-- backend/requirements/prod.txt | 2 +- backend/tests/config.py | 31 ++- backend/tests/proposal/test_api.py | 78 ++----- backend/tests/test_data.py | 21 +- .../tests/user/test_required_sm_decorator.py | 8 +- backend/tests/user/test_user_api.py | 208 ++++++++---------- 10 files changed, 215 insertions(+), 247 deletions(-) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index af64e266..7213e32b 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -1,5 +1,4 @@ from datetime import datetime -from functools import wraps from flask import Blueprint, g from flask_yoloapi import endpoint, parameter @@ -69,8 +68,8 @@ def get_proposals(stage): if stage: proposals = ( Proposal.query.filter_by(stage=stage) - .order_by(Proposal.date_created.desc()) - .all() + .order_by(Proposal.date_created.desc()) + .all() ) else: proposals = Proposal.query.order_by(Proposal.date_created.desc()).all() @@ -89,7 +88,6 @@ def get_proposals(stage): parameter('team', type=list, required=True) ) def make_proposal(crowd_fund_contract_address, content, title, milestones, category, team): - from grant.user.models import User existing_proposal = Proposal.query.filter_by(proposal_address=crowd_fund_contract_address).first() if existing_proposal: return {"message": "Oops! Something went wrong."}, 409 @@ -186,6 +184,7 @@ def get_proposal_update(proposal_id, update_id): @blueprint.route("//updates", methods=["POST"]) @requires_team_member_auth +@requires_sm @endpoint.api( parameter('title', type=str, required=True), parameter('content', type=str, required=True) diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index 53a7ff50..b0d5968e 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -58,7 +58,7 @@ class User(db.Model): self.title = title @staticmethod - def create(email_address=None, account_address=None, display_name=None, title=None): + def create(email_address=None, account_address=None, display_name=None, title=None, _send_email=True): user = User( account_address=account_address, email_address=email_address, @@ -73,10 +73,11 @@ class User(db.Model): db.session.add(ev) db.session.commit() - send_email(user.email_address, 'signup', { - 'display_name': user.display_name, - 'confirm_url': make_url(f'/email/verify?code={ev.code}') - }) + if send_email: + send_email(user.email_address, 'signup', { + 'display_name': user.display_name, + 'confirm_url': make_url(f'/email/verify?code={ev.code}') + }) return user diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index d7b858f9..64d3757e 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -1,10 +1,9 @@ from flask import Blueprint, g from flask_yoloapi import endpoint, parameter - -from .models import User, SocialMedia, Avatar, users_schema, user_schema, db from grant.proposal.models import Proposal, proposal_team from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException +from .models import User, SocialMedia, Avatar, users_schema, user_schema, db blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users') @@ -54,12 +53,12 @@ def get_user(user_identity): parameter('rawTypedData', type=str, required=True) ) def create_user( - account_address, - email_address, - display_name, - title, - signed_message, - raw_typed_data + account_address, + email_address, + display_name, + title, + signed_message, + raw_typed_data ): existing_user = User.get_by_identifier(email_address=email_address, account_address=account_address) if existing_user: @@ -70,11 +69,11 @@ def create_user( sig_address = verify_signed_auth(signed_message, raw_typed_data) if sig_address.lower() != account_address.lower(): return { - "message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format( - sig_address=sig_address, - account_address=account_address - ) - }, 400 + "message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format( + sig_address=sig_address, + account_address=account_address + ) + }, 400 except BadSignatureException: return {"message": "Invalid message signature"}, 400 @@ -88,6 +87,7 @@ def create_user( result = user_schema.dump(user) return result + @blueprint.route("/auth", methods=["POST"]) @endpoint.api( parameter('accountAddress', type=str, required=True), @@ -103,31 +103,28 @@ def auth_user(account_address, signed_message, raw_typed_data): sig_address = verify_signed_auth(signed_message, raw_typed_data) if sig_address.lower() != account_address.lower(): return { - "message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format( - sig_address=sig_address, - account_address=account_address - ) - }, 400 + "message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format( + sig_address=sig_address, + account_address=account_address + ) + }, 400 except BadSignatureException: return {"message": "Invalid message signature"}, 400 - + return user_schema.dump(existing_user) + @blueprint.route("/", methods=["PUT"]) +@requires_sm @requires_same_user_auth @endpoint.api( - parameter('displayName', type=str, required=False), - parameter('title', type=str, required=False), - parameter('socialMedias', type=list, required=False), - parameter('avatar', type=dict, required=False), + parameter('displayName', type=str, required=True), + parameter('title', type=str, required=True), + parameter('socialMedias', type=list, required=True), + parameter('avatar', type=dict, required=True) ) def update_user(user_identity, display_name, title, social_medias, avatar): - user = User.get_by_identifier(email_address=user_identity, account_address=user_identity) - if not user: - return {"message": "User with that address or email not found"}, 404 - - if user.id != g.current_user.id: - return {"message": "You are not authorized to edit this user"}, 403 + user = g.current_user if display_name is not None: user.display_name = display_name @@ -136,11 +133,12 @@ def update_user(user_identity, display_name, title, social_medias, avatar): user.title = title if social_medias is not None: - sm_query = SocialMedia.query.filter_by(user_id=user.id) - sm_query.delete() + SocialMedia.query.filter_by(user_id=user.id).delete() for social_media in social_medias: sm = SocialMedia(social_media_link=social_media.get("link"), user_id=user.id) db.session.add(sm) + else: + SocialMedia.query.filter_by(user_id=user.id).delete() if avatar is not None: Avatar.query.filter_by(user_id=user.id).delete() @@ -148,8 +146,9 @@ def update_user(user_identity, display_name, title, social_medias, avatar): if avatar_link: avatar_obj = Avatar(image_url=avatar_link, user_id=user.id) db.session.add(avatar_obj) + else: + Avatar.query.filter_by(user_id=user.id).delete() db.session.commit() - result = user_schema.dump(user) return result diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 9ea6e30d..76e4cf2d 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -8,8 +8,8 @@ from itsdangerous import SignatureExpired, BadSignature from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from grant.settings import SECRET_KEY, AUTH_URL -from ..user.models import User from ..proposal.models import Proposal +from ..user.models import User TWO_WEEKS = 1209600 @@ -36,6 +36,7 @@ def verify_token(token): class BadSignatureException(Exception): pass + def verify_signed_auth(signature, typed_data): loaded_typed_data = ast.literal_eval(typed_data) url = AUTH_URL + "/message/recover" @@ -44,27 +45,10 @@ def verify_signed_auth(signature, typed_data): response = requests.request("POST", url, data=payload, headers=headers) json_response = response.json() recovered_address = json_response.get('recoveredAddress') - if not recovered_address: raise BadSignatureException("Authorization signature is invalid") return recovered_address - - -def requires_auth(f): - @wraps(f) - def decorated(*args, **kwargs): - token = request.headers.get('Authorization', None) - if token: - string_token = token.encode('ascii', 'ignore') - user = verify_token(string_token) - if user: - g.current_user = user - return f(*args, **kwargs) - - return jsonify(message="Authentication is required to access this resource"), 401 - - return decorated # Decorator that requires you to have EIP-712 message signature headers for auth @@ -75,7 +59,6 @@ def requires_sm(f): typed_data = request.headers.get('RawTypedData', None) if typed_data and signature: - auth_address = None try: auth_address = verify_signed_auth(signature, typed_data) except BadSignatureException: @@ -92,6 +75,7 @@ def requires_sm(f): return decorated + # Decorator that requires you to be the user you're interacting with def requires_same_user_auth(f): @wraps(f) @@ -101,13 +85,14 @@ def requires_same_user_auth(f): return jsonify(message="Decorator requires_same_user_auth requires path variable "), 500 user = User.get_by_identifier(account_address=user_identity, email_address=user_identity) - if user != g.current_user: + if user.id != g.current_user.id: return jsonify(message="You are not authorized to modify this user"), 403 - + return f(*args, **kwargs) - + return requires_sm(decorated) + # Decorator that requires you to be a team member of a proposal to access def requires_team_member_auth(f): @wraps(f) @@ -125,5 +110,5 @@ def requires_team_member_auth(f): g.current_proposal = proposal return f(*args, **kwargs) - - return requires_sm(decorated) \ No newline at end of file + + return requires_sm(decorated) diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt index 041b948b..359aeea8 100644 --- a/backend/requirements/prod.txt +++ b/backend/requirements/prod.txt @@ -55,4 +55,4 @@ flask-sendgrid==0.6 sendgrid==5.3.0 # input validation -flask-yolo2API \ No newline at end of file +flask-yolo2API==0.2.4 \ No newline at end of file diff --git a/backend/tests/config.py b/backend/tests/config.py index 24bfb2b6..b2013ccd 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -1,6 +1,8 @@ from flask_testing import TestCase -from grant.app import create_app, db +from grant.app import create_app +from grant.user.models import User, SocialMedia, db, Avatar +from .test_data import test_user, message class BaseTestConfig(TestCase): @@ -11,9 +13,36 @@ class BaseTestConfig(TestCase): return app def setUp(self): + db.drop_all() self.app = self.create_app().test_client() db.create_all() def tearDown(self): db.session.remove() db.drop_all() + + +class BaseUserConfig(BaseTestConfig): + headers = { + "MsgSignature": message["sig"], + "RawTypedData": message["data"] + } + + def setUp(self): + super(BaseUserConfig, self).setUp() + self.user = User.create( + account_address=test_user["accountAddress"], + email_address=test_user["emailAddress"], + display_name=test_user["displayName"], + title=test_user["title"], + _send_email=False + ) + sm = SocialMedia(social_media_link=test_user['socialMedias'][0]['link'], user_id=self.user.id) + db.session.add(sm) + avatar = Avatar(image_url=test_user["avatar"]["link"], user_id=self.user.id) + db.session.add(avatar) + db.session.commit() + + def remove_default_user(self): + User.query.filter_by(id=self.user.id).delete() + db.session.commit() diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index 1cc81c86..9c219222 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -1,81 +1,43 @@ import json -import random -from grant.proposal.models import Proposal, CATEGORIES -from grant.user.models import User, SocialMedia -from ..config import BaseTestConfig - -milestones = [ - { - "title": "All the money straightaway", - "description": "cool stuff with it", - "date": "June 2019", - "payoutPercent": "100", - "immediatePayout": False - } -] - -team = [ - { - "accountAddress": "0x1", - "displayName": 'Groot', - "emailAddress": 'iam@groot.com', - "title": 'I am Groot!', - "avatar": { - "link": 'https://avatars2.githubusercontent.com/u/1393943?s=400&v=4' - }, - "socialMedias": [ - { - "link": 'https://github.com/groot' - } - ] - } -] - -proposal = { - "team": team, - "crowdFundContractAddress": "0x20000", - "content": "## My Proposal", - "title": "Give Me Money", - "milestones": milestones, - "category": random.choice(CATEGORIES) -} +from grant.proposal.models import Proposal +from grant.user.models import SocialMedia, Avatar +from ..config import BaseUserConfig +from ..test_data import test_proposal -class TestAPI(BaseTestConfig): +class TestAPI(BaseUserConfig): def test_create_new_proposal(self): self.assertIsNone(Proposal.query.filter_by( - proposal_address=proposal["crowdFundContractAddress"] + proposal_address=test_proposal["crowdFundContractAddress"] ).first()) resp = self.app.post( "/api/v1/proposals/", - data=json.dumps(proposal), + data=json.dumps(test_proposal), + headers=self.headers, content_type='application/json' ) + self.assertEqual(resp.status_code, 201) proposal_db = Proposal.query.filter_by( - proposal_address=proposal["crowdFundContractAddress"] + proposal_address=test_proposal["crowdFundContractAddress"] ).first() - self.assertEqual(proposal_db.title, proposal["title"]) - - # User - user_db = User.query.filter_by(email_address=team[0]["emailAddress"]).first() - self.assertEqual(user_db.display_name, team[0]["displayName"]) - self.assertEqual(user_db.title, team[0]["title"]) - self.assertEqual(user_db.account_address, team[0]["accountAddress"]) + self.assertEqual(proposal_db.title, test_proposal["title"]) # SocialMedia - social_media_db = SocialMedia.query.filter_by(social_media_link=team[0]["socialMedias"][0]["link"]).first() + social_media_db = SocialMedia.query.filter_by(user_id=self.user.id).first() self.assertTrue(social_media_db) # Avatar - self.assertEqual(user_db.avatar.image_url, team[0]["avatar"]["link"]) + avatar = Avatar.query.filter_by(user_id=self.user.id).first() + self.assertTrue(avatar) def test_create_new_proposal_comment(self): proposal_res = self.app.post( "/api/v1/proposals/", - data=json.dumps(proposal), + data=json.dumps(test_proposal), + headers=self.headers, content_type='application/json' ) proposal_json = proposal_res.json @@ -94,15 +56,17 @@ class TestAPI(BaseTestConfig): self.assertTrue(comment_res.json) def test_create_new_proposal_duplicate(self): - proposal_res = self.app.post( + self.app.post( "/api/v1/proposals/", - data=json.dumps(proposal), + data=json.dumps(test_proposal), + headers=self.headers, content_type='application/json' ) proposal_res2 = self.app.post( "/api/v1/proposals/", - data=json.dumps(proposal), + data=json.dumps(test_proposal), + headers=self.headers, content_type='application/json' ) diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index cb6d5d07..3e95b3f1 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -1,5 +1,6 @@ import json import random + from grant.proposal.models import CATEGORIES message = { @@ -43,7 +44,7 @@ message = { } } -user = { +test_user = { "accountAddress": '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826', "displayName": 'Groot', "emailAddress": 'iam@groot.com', @@ -60,7 +61,7 @@ user = { "rawTypedData": json.dumps(message["data"]) } -team = [user] +test_team = [test_user] milestones = [ { @@ -72,11 +73,21 @@ milestones = [ } ] -proposal = { - "team": team, +test_proposal = { + "team": test_team, "crowdFundContractAddress": "0x20000", "content": "## My Proposal", "title": "Give Me Money", "milestones": milestones, "category": random.choice(CATEGORIES) -} \ No newline at end of file +} + +milestones = [ + { + "title": "All the money straightaway", + "description": "cool stuff with it", + "date": "June 2019", + "payoutPercent": "100", + "immediatePayout": False + } +] diff --git a/backend/tests/user/test_required_sm_decorator.py b/backend/tests/user/test_required_sm_decorator.py index edd5f2c5..9d3f2d21 100644 --- a/backend/tests/user/test_required_sm_decorator.py +++ b/backend/tests/user/test_required_sm_decorator.py @@ -1,14 +1,14 @@ import json from ..config import BaseTestConfig -from ..test_data import user, message +from ..test_data import test_user, message class TestRequiredSignedMessageDecorator(BaseTestConfig): def test_required_sm_aborts_without_data_and_sig_headers(self): self.app.post( "/api/v1/users/", - data=json.dumps(user), + data=json.dumps(test_user), content_type='application/json' ) @@ -53,7 +53,7 @@ class TestRequiredSignedMessageDecorator(BaseTestConfig): def test_required_sm_decorator_authorizes_when_recovered_address_matches_existing_user(self): self.app.post( "/api/v1/users/", - data=json.dumps(user), + data=json.dumps(test_user), content_type='application/json' ) @@ -68,4 +68,4 @@ class TestRequiredSignedMessageDecorator(BaseTestConfig): response_json = response.json self.assert200(response) - self.assertEqual(response_json["displayName"], user["displayName"]) + self.assertEqual(response_json["displayName"], test_user["displayName"]) diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index 14c91b30..2deeae0c 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -1,84 +1,101 @@ import copy import json -from grant.proposal.models import Proposal -from grant.user.models import User -from ..config import BaseTestConfig -from ..test_data import team, proposal +from animal_case import animalify from mock import patch +from grant.proposal.models import Proposal +from grant.user.models import User, user_schema +from ..config import BaseUserConfig +from ..test_data import test_team, test_proposal, test_user -class TestAPI(BaseTestConfig): - def test_create_new_user_via_proposal_by_account_address(self): - proposal_by_account = copy.deepcopy(proposal) - del proposal_by_account["team"][0]["emailAddress"] - self.app.post( - "/api/v1/proposals/", - data=json.dumps(proposal_by_account), - content_type='application/json' - ) +class TestAPI(BaseUserConfig): + # TODO create second signed message default user + # @patch('grant.email.send.send_email') + # def test_create_new_user_via_proposal_by_account_address(self, mock_send_email): + # mock_send_email.return_value.ok = True + # self.remove_default_user() + # proposal_by_account = copy.deepcopy(test_proposal) + # del proposal_by_account["team"][0]["emailAddress"] + # + # resp = self.app.post( + # "/api/v1/proposals/", + # data=json.dumps(proposal_by_account), + # headers=self.headers, + # content_type='application/json' + # ) + # + # self.assertEqual(resp, 201) + # + # # User + # user_db = User.query.filter_by(account_address=proposal_by_account["team"][0]["accountAddress"]).first() + # self.assertEqual(user_db.display_name, proposal_by_account["team"][0]["displayName"]) + # self.assertEqual(user_db.title, proposal_by_account["team"][0]["title"]) + # self.assertEqual(user_db.account_address, proposal_by_account["team"][0]["accountAddress"]) - # User - user_db = User.query.filter_by(account_address=proposal_by_account["team"][0]["accountAddress"]).first() - self.assertEqual(user_db.display_name, proposal_by_account["team"][0]["displayName"]) - self.assertEqual(user_db.title, proposal_by_account["team"][0]["title"]) - self.assertEqual(user_db.account_address, proposal_by_account["team"][0]["accountAddress"]) - - def test_create_new_user_via_proposal_by_email(self): - proposal_by_email = copy.deepcopy(proposal) - del proposal_by_email["team"][0]["accountAddress"] - - self.app.post( - "/api/v1/proposals/", - data=json.dumps(proposal_by_email), - content_type='application/json' - ) - - # User - user_db = User.query.filter_by(email_address=proposal_by_email["team"][0]["emailAddress"]).first() - self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"]) - self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"]) + # TODO create second signed message default user + # def test_create_new_user_via_proposal_by_email(self): + # self.remove_default_user() + # proposal_by_email = copy.deepcopy(test_proposal) + # del proposal_by_email["team"][0]["accountAddress"] + # + # resp = self.app.post( + # "/api/v1/proposals/", + # data=json.dumps(proposal_by_email), + # headers=self.headers, + # content_type='application/json' + # ) + # + # self.assertEqual(resp, 201) + # + # # User + # user_db = User.query.filter_by(email_address=proposal_by_email["team"][0]["emailAddress"]).first() + # self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"]) + # self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"]) def test_associate_user_via_proposal_by_email(self): - proposal_by_email = copy.deepcopy(proposal) + proposal_by_email = copy.deepcopy(test_proposal) del proposal_by_email["team"][0]["accountAddress"] - self.app.post( + resp = self.app.post( "/api/v1/proposals/", data=json.dumps(proposal_by_email), + headers=self.headers, content_type='application/json' ) + self.assertEqual(resp.status_code, 201) # User user_db = User.query.filter_by(email_address=proposal_by_email["team"][0]["emailAddress"]).first() self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"]) self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"]) proposal_db = Proposal.query.filter_by( - proposal_address=proposal["crowdFundContractAddress"] + proposal_address=test_proposal["crowdFundContractAddress"] ).first() self.assertEqual(proposal_db.team[0].id, user_db.id) def test_associate_user_via_proposal_by_email_when_user_already_exists(self): - proposal_by_email = copy.deepcopy(proposal) - del proposal_by_email["team"][0]["accountAddress"] + proposal_by_user_email = copy.deepcopy(test_proposal) + del proposal_by_user_email["team"][0]["accountAddress"] - self.app.post( + resp = self.app.post( "/api/v1/proposals/", - data=json.dumps(proposal_by_email), + data=json.dumps(proposal_by_user_email), + headers=self.headers, content_type='application/json' ) + self.assertEqual(resp.status_code, 201) # User - user_db = User.query.filter_by(email_address=proposal_by_email["team"][0]["emailAddress"]).first() - self.assertEqual(user_db.display_name, proposal_by_email["team"][0]["displayName"]) - self.assertEqual(user_db.title, proposal_by_email["team"][0]["title"]) + self.assertEqual(self.user.display_name, proposal_by_user_email["team"][0]["displayName"]) + self.assertEqual(self.user.title, proposal_by_user_email["team"][0]["title"]) proposal_db = Proposal.query.filter_by( - proposal_address=proposal["crowdFundContractAddress"] + proposal_address=test_proposal["crowdFundContractAddress"] ).first() - self.assertEqual(proposal_db.team[0].id, user_db.id) + self.assertEqual(proposal_db.team[0].id, self.user.id) - new_proposal_by_email = copy.deepcopy(proposal) + new_proposal_by_email = copy.deepcopy(test_proposal) new_proposal_by_email["crowdFundContractAddress"] = "0x2222" del new_proposal_by_email["team"][0]["accountAddress"] @@ -92,14 +109,14 @@ class TestAPI(BaseTestConfig): self.assertEqual(user_db.display_name, new_proposal_by_email["team"][0]["displayName"]) self.assertEqual(user_db.title, new_proposal_by_email["team"][0]["title"]) proposal_db = Proposal.query.filter_by( - proposal_address=proposal["crowdFundContractAddress"] + proposal_address=test_proposal["crowdFundContractAddress"] ).first() self.assertEqual(proposal_db.team[0].id, user_db.id) def test_get_all_users(self): self.app.post( "/api/v1/proposals/", - data=json.dumps(proposal), + data=json.dumps(test_proposal), content_type='application/json' ) users_get_resp = self.app.get( @@ -107,18 +124,17 @@ class TestAPI(BaseTestConfig): ) users_json = users_get_resp.json - print(users_json) - self.assertEqual(users_json[0]["displayName"], team[0]["displayName"]) + self.assertEqual(users_json[0]["displayName"], test_team[0]["displayName"]) def test_get_user_associated_with_proposal(self): self.app.post( "/api/v1/proposals/", - data=json.dumps(proposal), + data=json.dumps(test_proposal), content_type='application/json' ) data = { - 'proposalId': proposal["crowdFundContractAddress"] + 'proposalId': test_proposal["crowdFundContractAddress"] } users_get_resp = self.app.get( @@ -127,25 +143,25 @@ class TestAPI(BaseTestConfig): ) users_json = users_get_resp.json - self.assertEqual(users_json[0]["avatar"]["imageUrl"], team[0]["avatar"]["link"]) - self.assertEqual(users_json[0]["socialMedias"][0]["socialMediaLink"], team[0]["socialMedias"][0]["link"]) - self.assertEqual(users_json[0]["displayName"], team[0]["displayName"]) + self.assertEqual(users_json[0]["avatar"]["imageUrl"], test_team[0]["avatar"]["link"]) + self.assertEqual(users_json[0]["socialMedias"][0]["socialMediaLink"], test_team[0]["socialMedias"][0]["link"]) + self.assertEqual(users_json[0]["displayName"], test_user["displayName"]) def test_get_single_user(self): self.app.post( "/api/v1/proposals/", - data=json.dumps(proposal), + data=json.dumps(test_proposal), content_type='application/json' ) users_get_resp = self.app.get( - "/api/v1/users/{}".format(proposal["team"][0]["emailAddress"]) + "/api/v1/users/{}".format(test_proposal["team"][0]["emailAddress"]) ) users_json = users_get_resp.json - self.assertEqual(users_json["avatar"]["imageUrl"], team[0]["avatar"]["link"]) - self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], team[0]["socialMedias"][0]["link"]) - self.assertEqual(users_json["displayName"], team[0]["displayName"]) + self.assertEqual(users_json["avatar"]["imageUrl"], test_team[0]["avatar"]["link"]) + self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], test_team[0]["socialMedias"][0]["link"]) + self.assertEqual(users_json["displayName"], test_team[0]["displayName"]) @patch('grant.email.send.send_email') def test_create_user(self, mock_send_email): @@ -153,15 +169,15 @@ class TestAPI(BaseTestConfig): self.app.post( "/api/v1/users/", - data=json.dumps(team[0]), + data=json.dumps(test_team[0]), content_type='application/json' ) # User - user_db = User.get_by_identifier(account_address=team[0]["accountAddress"]) - self.assertEqual(user_db.display_name, team[0]["displayName"]) - self.assertEqual(user_db.title, team[0]["title"]) - self.assertEqual(user_db.account_address, team[0]["accountAddress"]) + user_db = User.get_by_identifier(account_address=test_team[0]["accountAddress"]) + self.assertEqual(user_db.display_name, test_team[0]["displayName"]) + self.assertEqual(user_db.title, test_team[0]["title"]) + self.assertEqual(user_db.account_address, test_team[0]["accountAddress"]) @patch('grant.email.send.send_email') def test_create_user_duplicate_400(self, mock_send_email): @@ -170,64 +186,28 @@ class TestAPI(BaseTestConfig): response = self.app.post( "/api/v1/users/", - data=json.dumps(team[0]), + data=json.dumps(test_team[0]), content_type='application/json' ) self.assertEqual(response.status_code, 409) def test_update_user_remove_social_and_avatar(self): - self.app.post( - "/api/v1/proposals/", - data=json.dumps(proposal), - content_type='application/json' - ) - - updated_user = copy.deepcopy(team[0]) - updated_user['displayName'] = 'Billy' - updated_user['title'] = 'Commander' - updated_user['socialMedias'] = [] - updated_user['avatar'] = {} + updated_user = animalify(copy.deepcopy(user_schema.dump(self.user))) + updated_user["displayName"] = 'new display name' + updated_user["avatar"] = None + updated_user["socialMedias"] = None user_update_resp = self.app.put( - "/api/v1/users/{}".format(proposal["team"][0]["accountAddress"]), + "/api/v1/users/{}".format(self.user.account_address), data=json.dumps(updated_user), + headers=self.headers, content_type='application/json' ) + self.assert200(user_update_resp) - users_json = user_update_resp.json - self.assertFalse(users_json["avatar"]) - self.assertFalse(len(users_json["socialMedias"])) - self.assertEqual(users_json["displayName"], updated_user["displayName"]) - self.assertEqual(users_json["title"], updated_user["title"]) - - def test_update_user(self): - self.app.post( - "/api/v1/proposals/", - data=json.dumps(proposal), - content_type='application/json' - ) - - updated_user = copy.deepcopy(team[0]) - updated_user['displayName'] = 'Billy' - updated_user['title'] = 'Commander' - updated_user['socialMedias'] = [ - { - "link": "https://github.com/billyman" - } - ] - updated_user['avatar'] = { - "link": "https://x.io/avatar.png" - } - - user_update_resp = self.app.put( - "/api/v1/users/{}".format(proposal["team"][0]["accountAddress"]), - data=json.dumps(updated_user), - content_type='application/json' - ) - - users_json = user_update_resp.json - self.assertEqual(users_json["avatar"]["imageUrl"], updated_user["avatar"]["link"]) - self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], updated_user["socialMedias"][0]["link"]) - self.assertEqual(users_json["displayName"], updated_user["displayName"]) - self.assertEqual(users_json["title"], updated_user["title"]) + user_json = user_update_resp.json + self.assertFalse(user_json["avatar"]) + self.assertFalse(len(user_json["socialMedias"])) + self.assertEqual(user_json["displayName"], updated_user["displayName"]) + self.assertEqual(user_json["title"], updated_user["title"]) From 117acc7a0a31932f6bc69615170b5f83bb61051d Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 20 Nov 2018 11:02:57 -0500 Subject: [PATCH 08/10] Require logged in for create. --- frontend/client/Routes.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/client/Routes.tsx b/frontend/client/Routes.tsx index 18cb731c..d7f43b33 100644 --- a/frontend/client/Routes.tsx +++ b/frontend/client/Routes.tsx @@ -64,6 +64,7 @@ const routeConfigs: RouteConfig[] = [ hideFooter: true, requiresWeb3: true, }, + onlyLoggedIn: true, }, { // Browse proposals From a6902a464fbab1f7daee2963cdc27d0465241f59 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 20 Nov 2018 11:38:01 -0500 Subject: [PATCH 09/10] Clear comments on posting. Show error message on errors. --- frontend/client/components/Comment/index.tsx | 8 +++- .../components/MarkdownEditor/index.tsx | 37 ++++++++++++------- .../components/Proposal/Comments/index.tsx | 36 ++++++++++++++++-- frontend/client/modules/proposals/actions.ts | 2 - 4 files changed, 62 insertions(+), 21 deletions(-) diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index 5b123966..283e3385 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -48,7 +48,7 @@ class Comment extends React.Component { } public render(): React.ReactNode { - const { comment, isSignedIn } = this.props; + const { comment, isSignedIn, isPostCommentPending } = this.props; const { isReplying, reply } = this.state; const authorPath = `/profile/${comment.author.accountAddress}`; return ( @@ -89,7 +89,11 @@ class Comment extends React.Component { type={MARKDOWN_TYPE.REDUCED} />
-
diff --git a/frontend/client/components/MarkdownEditor/index.tsx b/frontend/client/components/MarkdownEditor/index.tsx index e72454c7..22c97cc1 100644 --- a/frontend/client/components/MarkdownEditor/index.tsx +++ b/frontend/client/components/MarkdownEditor/index.tsx @@ -39,31 +39,30 @@ interface Props { } interface State { + randomKey: string; mdeState: ReactMdeTypes.MdeState | null; } export default class MarkdownEditor extends React.PureComponent { - state: State = { - mdeState: null, - }; - constructor(props: Props) { super(props); const mdeState = props.initialMarkdown ? { markdown: props.initialMarkdown } : null; - this.state = { mdeState }; + this.state = { + mdeState, + randomKey: Math.random().toString(), + }; } - handleChange = (mdeState: ReactMdeTypes.MdeState) => { - this.setState({ mdeState }); - this.props.onChange(mdeState.markdown || ''); - }; - - generatePreview = (md: string) => { - return Promise.resolve(convert(md, this.props.type)); - }; + reset() { + this.setState({ + randomKey: Math.random().toString(), + mdeState: null, + }); + } render() { const type = this.props.type || MARKDOWN_TYPE.FULL; + const { mdeState, randomKey } = this.state; return (
{ })} > {
); } + + private handleChange = (mdeState: ReactMdeTypes.MdeState) => { + this.setState({ mdeState }); + this.props.onChange(mdeState.markdown || ''); + }; + + private generatePreview = (md: string) => { + return Promise.resolve(convert(md, this.props.type)); + }; } export { MARKDOWN_TYPE } from 'utils/markdown'; diff --git a/frontend/client/components/Proposal/Comments/index.tsx b/frontend/client/components/Proposal/Comments/index.tsx index 94fca2f5..ecbac6e6 100644 --- a/frontend/client/components/Proposal/Comments/index.tsx +++ b/frontend/client/components/Proposal/Comments/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Spin, Button } from 'antd'; +import { Spin, Button, message } from 'antd'; import { AppState } from 'store/reducers'; import { ProposalWithCrowdFund } from 'types'; import { fetchProposalComments, postProposalComment } from 'modules/proposals/actions'; @@ -23,6 +23,8 @@ interface StateProps { comments: ReturnType; isFetchingComments: ReturnType; commentsError: ReturnType; + isPostCommentPending: AppState['proposal']['isPostCommentPending']; + postCommentError: AppState['proposal']['postCommentError']; isSignedIn: ReturnType; } @@ -42,6 +44,8 @@ class ProposalComments extends React.Component { comment: '', }; + private editor: MarkdownEditor | null = null; + componentDidMount() { if (this.props.proposalId) { this.props.fetchProposalComments(this.props.proposalId); @@ -54,8 +58,27 @@ class ProposalComments extends React.Component { } } + componentDidUpdate(prevProps: Props) { + // TODO: Come up with better check on if our comment post was a success + const { isPostCommentPending, postCommentError } = this.props; + if (!isPostCommentPending && !postCommentError && prevProps.isPostCommentPending) { + this.setState({ comment: '' }); + this.editor!.reset(); + } + + if (postCommentError && postCommentError !== prevProps.postCommentError) { + message.error('Failed to submit comment'); + } + } + render() { - const { comments, isFetchingComments, commentsError, isSignedIn } = this.props; + const { + comments, + isFetchingComments, + commentsError, + isPostCommentPending, + isSignedIn, + } = this.props; const { comment } = this.state; let content = null; @@ -86,11 +109,16 @@ class ProposalComments extends React.Component { {isSignedIn && (
(this.editor = el)} onChange={this.handleCommentChange} type={MARKDOWN_TYPE.REDUCED} />
-
@@ -114,6 +142,8 @@ export default connect( comments: getProposalComments(state, ownProps.proposalId), isFetchingComments: getIsFetchingComments(state), commentsError: getCommentsError(state), + isPostCommentPending: state.proposal.isPostCommentPending, + postCommentError: state.proposal.postCommentError, isSignedIn: getIsSignedIn(state), }), { diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 575035ea..7e6cf2d9 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -141,8 +141,6 @@ export function postProposalComment( rawTypedData: JSON.stringify(sigData.rawTypedData), }); - // TODO: API up the comment & signed comment, handle response / failures - // TODO: Remove console log dispatch({ type: types.POST_PROPOSAL_COMMENT_FULFILLED, payload: { From 1b3f6e86e84db9039029a33c6a1df3f2375986bf Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 27 Nov 2018 14:07:09 -0500 Subject: [PATCH 10/10] Fixed & improved tests. --- backend/grant/user/models.py | 2 +- backend/grant/user/views.py | 2 +- backend/tests/config.py | 10 +++++ backend/tests/test_data.py | 69 +++++++++++++++-------------- backend/tests/user/test_user_api.py | 6 ++- 5 files changed, 51 insertions(+), 38 deletions(-) diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index 92134a05..8131be4c 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -74,7 +74,7 @@ class User(db.Model): db.session.add(ev) db.session.commit() - if send_email: + if _send_email: send_email(user.email_address, 'signup', { 'display_name': user.display_name, 'confirm_url': make_url(f'/email/verify?code={ev.code}') diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index b81e9395..fccb59f5 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -92,7 +92,7 @@ def create_user( title=title ) result = user_schema.dump(user) - return result + return result, 201 @blueprint.route("/auth", methods=["POST"]) diff --git a/backend/tests/config.py b/backend/tests/config.py index b2013ccd..35487c3d 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -20,7 +20,17 @@ class BaseTestConfig(TestCase): def tearDown(self): db.session.remove() db.drop_all() + + def assertStatus(self, response, status_code, message=None): + """ + Overrides TestCase's default to print out response JSON. + """ + message = message or 'HTTP Status %s expected but got %s. Response json: %s' \ + % (status_code, response.status_code, response.json) + self.assertEqual(response.status_code, status_code, message) + + assert_status = assertStatus class BaseUserConfig(BaseTestConfig): headers = { diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index 3e95b3f1..10911d92 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -4,48 +4,49 @@ import random from grant.proposal.models import CATEGORIES message = { - "sig": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c", + "sig": "0x7b3a85e9f158c2ae2a9ffba986a7dcb9108cf8ea9691080f80eadb506719f14925c89777aade3fabc5f9730ea389abdf7ffb0da16babdf1a1ea710b1e998cb891c", "data": { - "types": { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"} - ], - "Person": [ - {"name": "name", "type": "string"}, - {"name": "wallet", "type": "address"} - ], - "Mail": [ - {"name": "from", "type": "Person"}, - {"name": "to", "type": "Person"}, - {"name": "contents", "type": "string"} - ] - }, - "primaryType": "Mail", "domain": { - "name": "Ether Mail", - "version": "1", - "chainId": 1, - "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + "name": "Grant.io", + "version": 1, + "chainId": 1543277948575 }, - "message": { - "from": { - "name": "Cow", - "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + "types": { + "authorization": [ + { + "name": "Message Proof", + "type": "string" + }, + { + "name": "Time", + "type": "string" + } + ], + "EIP712Domain": [ + { + "name": "name", + "type": "string" }, - "to": { - "name": "Bob", - "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + { + "name": "version", + "type": "string" }, - "contents": "Hello, Bob!" - } - } + { + "name": "chainId", + "type": "uint256" + } + ] + }, + "message": { + "message": "I am proving the identity of 0x6bEeA1Cef016c23e292381b6FcaeC092960e41aa on Grant.io", + "time": "Tue, 27 Nov 2018 19:02:04 GMT" + }, + "primaryType": "authorization" +} } test_user = { - "accountAddress": '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826', + "accountAddress": '0x6bEeA1Cef016c23e292381b6FcaeC092960e41aa', "displayName": 'Groot', "emailAddress": 'iam@groot.com', "title": 'I am Groot!', diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index 26425712..86ed9056 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -18,11 +18,12 @@ class TestAPI(BaseUserConfig): db.session.delete(self.user) db.session.commit() - self.app.post( + response = self.app.post( "/api/v1/users/", data=json.dumps(test_user), content_type='application/json' ) + self.assertStatus(response, 201) # User user_db = User.get_by_identifier(account_address=test_user["accountAddress"]) @@ -34,6 +35,7 @@ class TestAPI(BaseUserConfig): users_get_resp = self.app.get( "/api/v1/users/" ) + self.assert200(users_get_resp) users_json = users_get_resp.json self.assertEqual(users_json[0]["displayName"], self.user.display_name) @@ -83,7 +85,7 @@ class TestAPI(BaseUserConfig): headers=self.headers, content_type='application/json' ) - self.assert200(user_update_resp) + self.assert200(user_update_resp, user_update_resp.json) user_json = user_update_resp.json self.assertFalse(user_json["avatar"])