Role-Based Access Control (RBAC)
数週間後、私たちのブログのすべてのブログ記事がNew York Timesの一面を飾り、一日に何百ものコメントが寄せられるようになることを想像してみてください。毎日質の高いコンテンツを考え出し、 かつ 無限に続く(ほとんどが善意の)コメントを管理することは、とてもできません!助けが必要です。コメントモデレータを雇って、明らかなスパムや、私たちの文章力を賞賛しないコメントを削除してもらいましょう。インターネットをより良い場所にするためにね。
ブログにログインシステムはすでにありますが、今はオール・オア・ナッシングです:ブログ記事を作成するためのアクセス権を得るか、何もないかです。この場合、コメントモデレータは、彼らが誰であるかを知るためにログインが必要ですが、彼らに新しいブログ記事を作成させるつもりはありません。この2種類のユーザを区別するために、何らかのロール(役割)が必要です。
role-based access control 、ありがたいことに一般的なフレーズである RBAC に短縮されています。認証はその人が誰であるかを示し、認可はその人が何ができるかを示します。"Access control" (アクセス制御)は認可を表す別の言い方です。現在、ブログには最小公倍数的な認証があります:ログインしていれば、何でもできます。"全てではないが、何もないよりはマシ" というレベルを追加してみましょう。
Defining Roles
既存のUserモデルがあるので、それに roles
プロパティを追加してみましょう:
model User {
id Int @id @default(autoincrement())
name String?
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
roles String
}
次に、データベースのマイグレーションを(試して)みます:
yarn rw prisma migrate dev
しかし、それはエラーで失敗してしまいます:
• Step 0 Added the required column `role` to the `User` table without a default value. There are 1 rows in this table, it is not possible to execute this step.
これは何を意味するのでしょうか?私たちは roles
を必須フィールドにしました。しかし、データベースにはすでにユーザが存在します( 1 rows in this table
:このテーブルには1行のデータがあります)。このカラムをデータベースに追加すると、デフォルトを定義していないので、既存のユーザに対しては null
にならざるを得ません。デフォルト値を指定して、このマイグレーションを適用できるだけでなく、新しく作成されるユーザがある程度の最小限のパーミッションを持つことを確認し、さらにコードを追加してロールを持っているかどうか、その内容もチェックする必要がないようにしましょう。
とりあえず、 admin
と moderator
の2つのロールを用意しましょう。 admin
はブログ記事の作成/編集/削除ができ、moderator
はコメントの削除のみできます。この2つのうち、より制限の多い moderator
が安全なデフォルトです:
model User {
id Int @id @default(autoincrement())
name String?
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
roles String @default("moderator")
}
これでマイグレーションが適用できるようになるはずです:
yarn rw prisma migrate dev
そして、 "add roles to user" のような名前を付けることができます。
ログインして http://localhost:8910/admin/posts のブログ記事管理画面に移動してみると、すべて以前と同じように動作しています:まだロールの存在を確認していないので、これは理にかなっています。実際には admin
ロールを持つユーザだけが管理画面にアクセスできるようにしたいのですが、既存のユーザはデフォルトのロールのために moderator
になっています。この機会に、実際にロールチェックをセットアップして、管理画面へのアクセスを失うかどうか確認してみましょう!
その前に、Webサイドが currentUser
のロールにアクセスできることを確認する必要があります。 api/src/lib/auth.js
を見てみましょう。含まれるフィールドのリストに email
を追加しなければならなかったのを覚えていますか?同様に roles
も追加する必要があります:
- JavaScript
- TypeScript
export const getCurrentUser = async (session) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true, email: true, roles: true },
})
}
export const getCurrentUser = async (session) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true, email: true, roles: true },
})
}
Restricting Access via Routes
URL全体へのアクセスを防ぐ最も簡単な方法は、ルータを使用することです。 <Private>
コンポーネントは roles
という props を受け取り、その中にアクセスを許可するロールのリストを指定することができます:
- JavaScript
- TypeScript
<Private unauthenticated="home" roles="admin">
<Set wrap={ScaffoldLayout} title="Posts" titleTo="posts" buttonLabel="New Post" buttonTo="newPost">
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
</Private>
<Private unauthenticated="home" roles="admin">
<Set wrap={ScaffoldLayout} title="Posts" titleTo="posts" buttonLabel="New Post" buttonTo="newPost">
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
</Private>
これで http://localhost:8910/admin/posts をブラウズすると、ホームページにリダイレクトされるはずです。ここまでは順調です。
Changing Roles on a User
Redwood コンソールを使って、admin ユーザが実際に admin
ロールを持つようにさっと更新してみましょう:
yarn rw c
console
の代わりに c
ショートカットを使うことができます。
これで、1つのコマンドでユーザを更新できるようになりました:
> db.user.update({ where: { id: 1 } , data: { roles: 'admin' } })
これはユーザの新しいコンテンツを返すべきです:
{
id: 1,
name: null,
email: 'admin@admin.com',
hashedPassword: 'a12f3975a3722953fd8e326dd108d5645ad9563042fe9f154419361eeeb775d8',
salt: '9abf4665293211adce1c99de412b219e',
resetToken: null,
resetTokenExpiresAt: null,
roles: 'admin'
}
前のセクションで使用したコンソールセッションを再利用する場合、新しいPrismaデータ構造を知るために、一旦終了して再度起動する必要があります。それでも更新がうまくいかない場合は、ユーザの id
が 1
でないのかもしれません!まず db.user.findMany()
を実行し、更新したいユーザの id
を取得してください。
さて http://localhost:8910/admin/posts に戻れば、再びアクセスできるようになるはずです。英国人が言うように:brilliant!
Add a Moderator
コメントモデレータを代表する新しいユーザを作成しましょう。これは開発中のものなので、メールアドレスを作成するだけでも構いませんが、メールアドレスを検証する実際のシステムでこれを行う必要がある場合は、 The Plus Trick を使用して、実際には元のメールアドレスと同じである、新しい固有のメールアドレスを作成することができます!
The Plus Trickは、 "boxname" として知られる電子メール規格の非常に便利な機能です。このアイデアは、"Inbox" (受信箱)という名前だけの受信箱以外に、他の受信箱があるかもしれないので、自分の電子メールアドレスに +something
を追加することによって、メールをどの箱に振り分けるべきかを指定することができるというものです。
最近はあまり使われていないようですが、私たち開発者がテストのために常に新しいメールアドレスを必要としているときには、とんでもなく便利なものです:無限に 有効な メールアドレスが手に入ります -- これらはすべて通常の受信箱に送られます!
メールアドレスの@の前に+somethingを付けるだけです:
jane.doe+testing@example.com
はjane.doe@example.com
に配信されるdom+20210909@example.com
はdom@example.com
に配信される
注意してほしいのはすべてのプロバイダがこのプラスベースの構文をサポートしているわけではないことですが、主要なプロバイダ(Gmail、Yahoo、Microsoft、Apple)はサポートしています。もし、自分のドメインでメールが受信できない場合は、これらのプロバイダで無料のアカウントを作成し、テストに使用するとよいでしょう。
私たちの場合、メールはどこにも送らないし、検証も必要ないので、とりあえず作り物のメールを使えばいいんです。 moderator@moderator.com
はいい感じです。
チュートリアルの最初の部分の最後に提案したように新しいユーザのサインアップを無効にした場合、新しいユーザを作成するのが少し難しくなります(サインアップページは便宜上、サンプルリポジトリではまだ有効になっています)。Redwoodコンソールでユーザを作成することもできますが、次のような工夫が必要です -- 元のパスワードは保存せず、ソルトと組み合わせたハッシュ化された結果を保存することを忘れないでください。以下は、新しいユーザを作成するためにコンソールで入力するコマンドです('password'はお好みのパスワードに置き換えてください)。
const CryptoJS = require('crypto-js')
const salt = CryptoJS.lib.WordArray.random(128 / 8).toString()
const hashedPassword = CryptoJS.PBKDF2('password', salt, { keySize: 256 / 32 }).toString()
db.user.create({ data: { email: 'moderator@moderator.com', hashedPassword, salt } })
管理者としてログアウトし、モデレータとしてログインした場合、ブログ記事管理画面へのアクセスはできないようにする必要があります。
Restrict Access in a Component
ページ全体をロックするのはルータで簡単にできますが、ページやコンポーネント内の個々の機能をロックするのはどうでしょうか?
Redwood は useAuth()
フックから取得できる hasRole()
関数を提供しています(パート 1 で currentUser
を取得してメールアドレスを表示するのに使ったことを思い出してください)。この関数は、ログインしたユーザが与えられたロールを持っているかどうかに応じて true
または false
を返します。モデレータがブログ記事のコメントを閲覧しているときに Delete
ボタンを追加してみましょう:
- JavaScript
- TypeScript
import { useAuth } from '@redwoodjs/auth'
const formattedDate = (datetime) => {
const parsedDate = new Date(datetime)
const month = parsedDate.toLocaleString('default', { month: 'long' })
return `${parsedDate.getDate()} ${month} ${parsedDate.getFullYear()}`
}
const Comment = ({ comment }) => {
const { hasRole } = useAuth()
const moderate = () => {
if (confirm('Are you sure?')) {
// TODO: delete comment
}
}
return (
<div className="bg-gray-200 p-8 rounded-lg relative">
<header className="flex justify-between">
<h2 className="font-semibold text-gray-700">{comment.name}</h2>
<time className="text-xs text-gray-500" dateTime={comment.createdAt}>
{formattedDate(comment.createdAt)}
</time>
</header>
<p className="text-sm mt-2">{comment.body}</p>
{hasRole('moderator') && (
<button
type="button"
onClick={moderate}
className="absolute bottom-2 right-2 bg-red-500 text-xs rounded text-white px-2 py-1"
>
Delete
</button>
)}
</div>
)
}
export default Comment
import { useAuth } from '@redwoodjs/auth'
const formattedDate = (datetime: ConstructorParameters<typeof Date>[0]) => {
const parsedDate = new Date(datetime)
const month = parsedDate.toLocaleString('default', { month: 'long' })
return `${parsedDate.getDate()} ${month} ${parsedDate.getFullYear()}`
}
interface Props {
comment: {
name: string
createdAt: string
body: string
}
}
const Comment = ({ comment }: Props) => {
const { hasRole } = useAuth()
const moderate = () => {
if (confirm('Are you sure?')) {
// TODO: delete comment
}
}
return (
<div className="bg-gray-200 p-8 rounded-lg relative">
<header className="flex justify-between">
<h2 className="font-semibold text-gray-700">{comment.name}</h2>
<time className="text-xs text-gray-500" dateTime={comment.createdAt}>
{formattedDate(comment.createdAt)}
</time>
</header>
<p className="text-sm mt-2">{comment.body}</p>
{hasRole('moderator') && (
<button
type="button"
onClick={moderate}
className="absolute bottom-2 right-2 bg-red-500 text-xs rounded text-white px-2 py-1"
>
Delete
</button>
)}
</div>
)
}
export default Comment
そのため、ユーザが "moderator" ロールの場合は、削除ボタンを描画します。ログアウトして管理者としてログインし直すか、完全にログアウトすると、削除ボタンは消えます。ログアウトしているとき(つまり currentUser === null
)は、 hasRole()
は常に false
を返します。
私たちが残した //TODO
というメモの代わりに何を書くべきでしょうか?もちろん、コメントを削除するためのGraphQLミューテーションです。先ほどの先見の明のおかげで、すでに deleteComment()
サービス関数と GraphQL ミューテーションが用意されています。
また、Commentコンポーネントがうまくカプセル化されているため、必要なWebサイトの変更をこのコンポーネント1つで行うことができるのです:
- JavaScript
- TypeScript
import { useAuth } from '@redwoodjs/auth'
import { useMutation } from '@redwoodjs/web'
import { QUERY as CommentsQuery } from 'src/components/CommentsCell'
const DELETE = gql`
mutation DeleteCommentMutation($id: Int!) {
deleteComment(id: $id) {
postId
}
}
`
const formattedDate = (datetime) => {
const parsedDate = new Date(datetime)
const month = parsedDate.toLocaleString('default', { month: 'long' })
return `${parsedDate.getDate()} ${month} ${parsedDate.getFullYear()}`
}
const Comment = ({ comment }) => {
const { hasRole } = useAuth()
const [deleteComment] = useMutation(DELETE, {
refetchQueries: [
{
query: CommentsQuery,
variables: { postId: comment.postId },
},
],
})
const moderate = () => {
if (confirm('Are you sure?')) {
deleteComment({
variables: { id: comment.id },
})
}
}
return (
<div className="bg-gray-200 p-8 rounded-lg relative">
<header className="flex justify-between">
<h2 className="font-semibold text-gray-700">{comment.name}</h2>
<time className="text-xs text-gray-500" dateTime={comment.createdAt}>
{formattedDate(comment.createdAt)}
</time>
</header>
<p className="text-sm mt-2">{comment.body}</p>
{hasRole('moderator') && (
<button
type="button"
onClick={moderate}
className="absolute bottom-2 right-2 bg-red-500 text-xs rounded text-white px-2 py-1"
>
Delete
</button>
)}
</div>
)
}
export default Comment
import { useAuth } from '@redwoodjs/auth'
import { useMutation } from '@redwoodjs/web'
import { QUERY as CommentsQuery } from 'src/components/CommentsCell'
import type { Comment as IComment } from 'types/graphql'
const DELETE = gql`
mutation DeleteCommentMutation($id: Int!) {
deleteComment(id: $id) {
postId
}
}
`
const formattedDate = (datetime: ConstructorParameters<typeof Date>[0]) => {
const parsedDate = new Date(datetime)
const month = parsedDate.toLocaleString('default', { month: 'long' })
return `${parsedDate.getDate()} ${month} ${parsedDate.getFullYear()}`
}
interface Props {
comment: Pick<IComment, 'postId' | 'id' | 'name' | 'createdAt' | 'body'>
}
const Comment = ({ comment }: Props) => {
const { hasRole } = useAuth()
const [deleteComment] = useMutation(DELETE, {
refetchQueries: [
{
query: CommentsQuery,
variables: { postId: comment.postId },
},
],
})
const moderate = () => {
if (confirm('Are you sure?')) {
deleteComment({
variables: { id: comment.id },
})
}
}
return (
<div className="bg-gray-200 p-8 rounded-lg relative">
<header className="flex justify-between">
<h2 className="font-semibold text-gray-700">{comment.name}</h2>
<time className="text-xs text-gray-500" dateTime={comment.createdAt}>
{formattedDate(comment.createdAt)}
</time>
</header>
<p className="text-sm mt-2">{comment.body}</p>
{hasRole('moderator') && (
<button
type="button"
onClick={moderate}
className="absolute bottom-2 right-2 bg-red-500 text-xs rounded text-white px-2 py-1"
>
Delete
</button>
)}
</div>
)
}
export default Comment
また、 CommentsCell
からインポートしている CommentsQuery
に postId
フィールドを含めるように更新する必要があります。なぜなら、削除に成功した後に refetchQuery
を実行するために、このフィールドに依存しているからです:
- JavaScript
- TypeScript
import Comment from 'src/components/Comment'
export const QUERY = gql`
query CommentsQuery($postId: Int!) {
comments(postId: $postId) {
id
name
body
postId
createdAt
}
}
`
import Comment from 'src/components/Comment'
export const QUERY = gql`
query CommentsQuery($postId: Int!) {
comments(postId: $postId) {
id
name
body
postId
createdAt
}
}
`
(モデレータとして) Delete をクリックすると、コメントが削除されるはずです!
理想的なのは、このコンポーネントの両方のバージョン( "Delete" ボタンがあるものとないもの)がStorybookに存在し、デザインを反復することができるようにすることです。 しかし、Storybookには "logging in" というものがなく、私たちのコードはログインすることでロールを確認できることに依存しています...どうしたらよいでしょうか?
Mocking currentUser for Storybook
StorybookでGraphQLの呼び出しをモックするのと同様に、ストーリー内でユーザ認証と認可の機能をモックすることができます。
Comment.stories.tsx
に、コンポーネントのモデレータビュー用の2つ目のストーリーを追加しましょう(わかりやすいように、既存のストーリーの名前も変更します):
- JavaScript
- TypeScript
import Comment from './Comment'
export const defaultView = () => {
return (
<Comment
comment={{
id: 1,
name: 'Rob Cameron',
body: 'This is the first comment!',
createdAt: '2020-01-01T12:34:56Z',
postId: 1
}}
/>
)
}
export const moderatorView = () => {
return (
<Comment
comment={{
id: 1,
name: 'Rob Cameron',
body: 'This is the first comment!',
createdAt: '2020-01-01T12:34:56Z',
postId: 1,
}}
/>
)
}
export default { title: 'Components/Comment' }
import Comment from './Comment'
export const defaultView = () => {
return (
<Comment
comment={{
id: 1,
name: 'Rob Cameron',
body: 'This is the first comment!',
createdAt: '2020-01-01T12:34:56Z',
postId: 1,
}}
/>
)
}
export const moderatorView = () => {
return (
<Comment
comment={{
id: 1,
name: 'Rob Cameron',
body: 'This is the first comment!',
createdAt: '2020-01-01T12:34:56Z',
postId: 1,
}}
/>
)
}
export default { title: 'Components/Comment' }
moderatorView ストーリーは、モデレータのロールを持つユーザを利用できるようにする必要があります。これは mockCurrentUser
関数で行うことができます:
- JavaScript
- TypeScript
export const moderatorView = () => {
mockCurrentUser({
id: 1,
email: 'moderator@moderator.com',
roles: 'moderator',
})
return (
<Comment
comment={{
id: 1,
name: 'Rob Cameron',
body: 'This is the first comment!',
createdAt: '2020-01-01T12:34:56Z',
postId: 1,
}}
/>
)
}
export const moderatorView = () => {
mockCurrentUser({
id: 1,
email: 'moderator@moderator.com',
roles: 'moderator',
})
return (
<Comment
comment={{
id: 1,
name: 'Rob Cameron',
body: 'This is the first comment!',
createdAt: '2020-01-01T12:34:56Z',
postId: 1,
}}
/>
)
}
mockCurrentUser()
come from?mockGraphQLQuery()
や mockGraphQLMutation()
と同様に、 mockCurrentUser()
は Storybook で自動的に利用できるグローバルなもので、インポートする必要はありません。
mockCurrentUser()
はオブジェクトを受け取るので、そこに好きなものを入れることができます( api/src/lib/auth.ts
の getCurrentUser()
で返すものと同じであるべきです)。しかし、 hasRole()
を正しく動作させたいので、オブジェクトには文字列または文字列の配列である roles
キーを指定する必要があります。
Storybookの Comment をチェックすると、Commentに2つのストーリーが表示されます。1つは "Delete" ボタンがあり、もう1つはありません
Mocking currentUser for Jest
同じ mockCurrentUser()
関数を Jest テストでも使うことができます。ユーザがモデレータの場合、コンポーネントの出力に "Delete" という単語が表示され、ユーザが他のロールの場合(またはロールがない場合)には表示されないことを確認しましょう:
- JavaScript
- TypeScript
import { render, screen, waitFor } from '@redwoodjs/testing'
import Comment from './Comment'
const COMMENT = {
name: 'John Doe',
body: 'This is my comment',
createdAt: '2020-01-02T12:34:56Z',
}
describe('Comment', () => {
it('renders successfully', () => {
render(<Comment comment={COMMENT} />)
expect(screen.getByText(COMMENT.name)).toBeInTheDocument()
expect(screen.getByText(COMMENT.body)).toBeInTheDocument()
const dateExpect = screen.getByText('2 January 2020')
expect(dateExpect).toBeInTheDocument()
expect(dateExpect.nodeName).toEqual('TIME')
expect(dateExpect).toHaveAttribute('datetime', COMMENT.createdAt)
})
it('does not render a delete button if user is logged out', async () => {
render(<Comment comment={COMMENT} />)
await waitFor(() =>
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
)
})
it('renders a delete button if the user is a moderator', async () => {
mockCurrentUser({
id: 1,
email: 'moderator@moderator.com',
roles: 'moderator',
})
render(<Comment comment={COMMENT} />)
await waitFor(() => expect(screen.getByText('Delete')).toBeInTheDocument())
})
})
import { render, screen, waitFor } from '@redwoodjs/testing'
import Comment from './Comment'
const COMMENT = {
id: 1,
name: 'John Doe',
body: 'This is my comment',
createdAt: '2020-01-02T12:34:56Z',
postId: 1,
}
describe('Comment', () => {
it('renders successfully', () => {
render(<Comment comment={COMMENT} />)
expect(screen.getByText(COMMENT.name)).toBeInTheDocument()
expect(screen.getByText(COMMENT.body)).toBeInTheDocument()
const dateExpect = screen.getByText('2 January 2020')
expect(dateExpect).toBeInTheDocument()
expect(dateExpect.nodeName).toEqual('TIME')
expect(dateExpect).toHaveAttribute('datetime', COMMENT.createdAt)
})
it('does not render a delete button if user is logged out', async () => {
render(<Comment comment={COMMENT} />)
await waitFor(() =>
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
)
})
it('renders a delete button if the user is a moderator', async () => {
mockCurrentUser({
id: 1,
email: 'moderator@moderator.com',
roles: 'moderator',
})
render(<Comment comment={COMMENT} />)
await waitFor(() => expect(screen.getByText('Delete')).toBeInTheDocument())
})
})
デフォルトの comment
オブジェクトを COMMENT
定数に移動し、すべてのテストでそれを使用するようにしました。また、コメント自体の hasRole()
チェックでは、ユーザが誰であるかを把握するために、裏でいくつかの GraphQL 呼び出しを実行しているので、 waitFor()
も追加する必要がありました。テストスイートはモック化されたGraphQLコールを行いますが、それでも非同期なので待つ必要があります。もし待機しないのであれば、テスト開始時に currentUser
は null
となり、Jest はその結果に満足することでしょう。しかし、私たちはそうしません -- GraphQL 呼び出しからの実際の値を待つ必要があります。
データベースにフィールドを追加したのですが、テストランナーがこれに気づかないことがあります。schema.prisma
と一致するようにテストデータベースをマイグレーションするために、テストランナーを再起動する必要があるかもしれません。テストランナーがまだ動作している場合は、q
または Ctrl-C
を押してください:
yarn rw test
このスイートでは、少なくとも Comment
と CommentCell
のテストが自動的に実行されます。そして、しばらくコードをgitにコミットしていない場合は、さらにいくつかのテストが実行されるかもしれません。
これは、今までで最も堅牢なテストではありません:もし、コメントのサンプルテキスト自体に "Delete" という単語があったらどうでしょう?なんてこった!しかし、次のような考え方は理解できます -- コンポーネントの描画状態に意味のある違いを見つけ、その存在(または存在しないこと)を検証するテストを書くのです。
コンポーネントの各条件を、テストが必要な別の分岐として考えてください。最悪の場合、各条件が 2n の可能な描画状態を追加します。3つの条件分岐がある場合は、23 (8)個の可能な出力の組み合わせになり、安全のためにすべてテストする必要があります。このようなシナリオに陥ったときは、コンポーネントをリファクタリングして単純化する時期であることを示すわかりやすい兆候です。サブコンポーネントに分割して、それぞれが条件出力のひとつだけを担当するようにするとか?それでもまだ同じ数のテストが必要ですが、各コンポーネントとそのテストは分離して動作し、ひとつのことを確実に、そしてうまく行うことができるようになります。これは、コードベースのメンタルモデルにとっても利点があります。
キッチンのガラクタの引き出しをやっと整理したようなものです -- でも、それぞれのものが自分のスペースに収まっているので、どこに何があるのか思い出しやすく、次回も見つけやすいのです。
テスト実行中に以下のようなメッセージ出力が表示されることがあります:
console.error
Missing field 'postId' while writing result {
"id": 1,
"name": "Rob Cameron",
"body": "First comment",
"createdAt": "2020-01-02T12:34:56Z"
}
CommentsCell.mock.ts
を見てみると、テスト中に使用されたモックデータがあることが分かります。現在、CommentsCell
の QUERY
で postId
を要求していますが、このモックはそれを返しません!この問題は、両方のモックにそのフィールドを追加することで解決できます:
- JavaScript
- TypeScript
export const standard = () => ({
comments: [
{
id: 1,
name: 'Rob Cameron',
body: 'First comment',
postId: 1,
createdAt: '2020-01-02T12:34:56Z',
},
{
id: 2,
name: 'David Price',
body: 'Second comment',
postId: 2,
createdAt: '2020-02-03T23:00:00Z',
},
],
})
export const standard = () => ({
comments: [
{
id: 1,
name: 'Rob Cameron',
body: 'First comment',
postId: 1,
createdAt: '2020-01-02T12:34:56Z',
},
{
id: 2,
name: 'David Price',
body: 'Second comment',
postId: 2,
createdAt: '2020-02-03T23:00:00Z',
},
],
})
テストでは実際のブログ記事データには何もしないので、ブログ記事全体をモックアウトする必要はなく、 postId
だけで十分です。
Roles on the API Side
覚えておいてください:クライアントを信用してはいけません!バックエンドをロックして、誰かが deleteComment
GraphQL リソースを発見できないようして、勝手にコメントを削除し始めないようにしなければなりません。
チュートリアルのパート1では、 @requireAuth
の directive を使って、与えられたGraphQLクエリやミューテーションにアクセスする前にログインしていることを確認したことを思い出してください。これは @requireAuth
がオプションの roles
引数を取ることができることがわかったからです:
- JavaScript
- TypeScript
export const schema = gql`
type Comment {
id: Int!
name: String!
body: String!
post: Post!
postId: Int!
createdAt: DateTime!
}
type Query {
comments(postId: Int!): [Comment!]! @skipAuth
}
input CreateCommentInput {
name: String!
body: String!
postId: Int!
}
input UpdateCommentInput {
name: String
body: String
postId: Int
}
type Mutation {
createComment(input: CreateCommentInput!): Comment! @skipAuth
deleteComment(id: Int!): Comment! @requireAuth(roles: "moderator")
}
`
export const schema = gql`
type Comment {
id: Int!
name: String!
body: String!
post: Post!
postId: Int!
createdAt: DateTime!
}
type Query {
comments(postId: Int!): [Comment!]! @skipAuth
}
input CreateCommentInput {
name: String!
body: String!
postId: Int!
}
input UpdateCommentInput {
name: String
body: String
postId: Int
}
type Mutation {
createComment(input: CreateCommentInput!): Comment! @skipAuth
deleteComment(id: Int!): Comment! @requireAuth(roles: "moderator")
}
`
現在、 deleteComment
ミューテーションに対する生のGraphQLクエリは、ユーザがモデレータとしてログインしていない場合、エラーになります。
このチェックは、GraphQL経由の deleteComment
へのアクセスを防ぐだけです。あるサービスを別のサービスから呼び出している場合はどうでしょうか?もし、サービス自体で同じように保護したいのであれば、 requireAuth
を直接呼び出すことができます:
- JavaScript
- TypeScript
import { requireAuth } from 'src/lib/auth'
import { db } from 'src/lib/db'
// ...
export const deleteComment = ({ id }) => {
requireAuth({ roles: 'moderator' })
return db.comment.delete({
where: { id },
})
}
import { requireAuth } from 'src/lib/auth'
import { db } from 'src/lib/db'
// ...
export const deleteComment = ({ id }) => {
requireAuth({ roles: 'moderator' })
return db.comment.delete({
where: { id },
})
}
その機能に合わせてテストが必要になります。 requireAuth()
をどのようにテストするのでしょうか?APIサイドにも mockCurrentUser()
関数があり、Webサイドと同じ動作をします:
- JavaScript
- TypeScript
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'
import { db } from 'src/lib/db'
import { comments, createComment, deleteComment } from './comments'
describe('comments', () => {
scenario(
'returns all comments for a single post from the database',
async (scenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
const post = await db.post.findUnique({
where: { id: scenario.comment.jane.postId },
include: { comments: true },
})
expect(result.length).toEqual(post.comments.length)
}
)
scenario('postOnly', 'creates a new comment', async (scenario) => {
const comment = await createComment({
input: {
name: 'Billy Bob',
body: 'What is your favorite tree bark?',
postId: scenario.post.bark.id,
},
})
expect(comment.name).toEqual('Billy Bob')
expect(comment.body).toEqual('What is your favorite tree bark?')
expect(comment.postId).toEqual(scenario.post.bark.id)
expect(comment.createdAt).not.toEqual(null)
})
scenario('allows a moderator to delete a comment', async (scenario) => {
mockCurrentUser({ roles: ['moderator'] })
const comment = await deleteComment({
id: scenario.comment.jane.id,
})
expect(comment.id).toEqual(scenario.comment.jane.id)
const result = await comments({ postId: scenario.comment.jane.id })
expect(result.length).toEqual(0)
})
scenario(
'does not allow a non-moderator to delete a comment',
async (scenario) => {
mockCurrentUser({ roles: 'user' })
expect(() =>
deleteComment({
id: scenario.comment.jane.id,
})
).toThrow(ForbiddenError)
}
)
scenario(
'does not allow a logged out user to delete a comment',
async (scenario) => {
mockCurrentUser(null)
expect(() =>
deleteComment({
id: scenario.comment.jane.id,
})
).toThrow(AuthenticationError)
}
)
})
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'
import { db } from 'src/lib/db'
import { comments, createComment, deleteComment } from './comments'
import type { PostOnlyScenario, StandardScenario } from './comments.scenarios'
describe('comments', () => {
scenario(
'returns all comments for a single post from the database',
async (scenario) => {
const result = await comments({ postId: scenario.comment.jane.postId })
const post = await db.post.findUnique({
where: { id: scenario.comment.jane.postId },
include: { comments: true },
})
expect(result.length).toEqual(post.comments.length)
}
)
scenario(
'postOnly',
'creates a new comment',
async (scenario: PostOnlyScenario) => {
const comment = await createComment({
input: {
name: 'Billy Bob',
body: 'What is your favorite tree bark?',
postId: scenario.post.bark.id,
},
})
expect(comment.name).toEqual('Billy Bob')
expect(comment.body).toEqual('What is your favorite tree bark?')
expect(comment.postId).toEqual(scenario.post.bark.id)
expect(comment.createdAt).not.toEqual(null)
}
)
scenario(
'allows a moderator to delete a comment',
async (scenario: StandardScenario) => {
mockCurrentUser({
roles: 'moderator',
id: 1,
email: 'moderator@moderator.com',
})
const comment = await deleteComment({
id: scenario.comment.jane.id,
})
expect(comment.id).toEqual(scenario.comment.jane.id)
const result = await comments({ postId: scenario.comment.jane.id })
expect(result.length).toEqual(0)
}
)
scenario(
'does not allow a non-moderator to delete a comment',
async (scenario: StandardScenario) => {
mockCurrentUser({ roles: 'user', id: 1, email: 'user@user.com' })
expect(() =>
deleteComment({
id: scenario.comment.jane.id,
})
).toThrow(ForbiddenError)
}
)
scenario(
'does not allow a logged out user to delete a comment',
async (scenario: StandardScenario) => {
mockCurrentUser(null)
expect(() =>
deleteComment({
id: scenario.comment.jane.id,
})
).toThrow(AuthenticationError)
}
)
})
最初のシナリオは、削除されたコメントが deleteComment()
の呼び出しによって返されることをチェックします。2番目のシナリオは、コメントが実際にデータベースから削除されたことを確認します:その id
を持つコメントを探すと、空の配列が返されます。もしこれが唯一のテストであったなら、私たちは間違った安心感に陥る可能性があります -- もしユーザが別のロールを持っていたり、まったくログインしていなかったりしたらどうでしょうか?
ここではこれらのケースをテストしてないので、さらに2つのテストを追加します:ひとつはユーザが "moderator" 以外のロールを持つ場合、もうひとつはユーザがまったくログインしていない場合です。この2つのケースはまた異なるエラーを発生させるので、ここでそれを体系化しておくのはいいことです。
Last Word on Roles
"admin" (管理者)というロールは、何でもできることを意味しているのでは...コメントを削除することもできるのでは?その通り!ここでできることは2つあります:
- "admin" を、コンポーネントの
hasRole()
チェック、@requireAuth
ディレクティブ、サービスのrequireAuth()
チェックに追加 - コードは変更せず、データベース内のユーザに追加のロールを与える -- 管理者は "admin" に加えて "moderator" ロールも持つようになる
"admin" という名前から、誰かがその単一のロールだけを持ち、すべてを行えるようにする必要があるように感じられます。ですから、この場合は hasRole()
と requireAuth()
に "admin" を追加したほうがいいかもしれません。
しかし、もしあなたがもっと細かく役割を決めたいのであれば、 "admin" ロールを "author" と呼ぶべきかもしれません。そうすることで、管理者はブログ記事を作成するだけであることが明確になります。もし誰かが両方のアクションを行えるようにしたい場合は、 "author" に加えて "moderator" ロールを明示的に与えればよいでしょう。
ロールの管理は、正しく行うのが難しい場合があります。ロールがどのように相互作用するか、また、サイト上のロールベースの関数呼び出しでどの程度の重複を許容するかについて、前もって少し時間をかけて考えてみてください。もし、 hasRole()
や requireAuth()
に複数のロールを追加しているようであれば、それらの機能を含む単一の新しいロールを追加して、コード内の重複を排除する時期であることを示している可能性があります。