Skip to content

Latest commit

 

History

History
676 lines (568 loc) · 23.9 KB

06-restrict-access.md

File metadata and controls

676 lines (568 loc) · 23.9 KB

安全限制

限制未登录用户访问用户页面

目前为止,所有未登录用户都可以访问、操作用户相关页面。我们要加以限制:未登录用户只允许使用 :new:create 两个动作,访问其余动作时,全部重定向到登录页。

首先在 user_controller_test.exs 文件中新增一个测试,具体内容看注释:

diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
index 26055e3..ac6894e 100644
--- a/test/controllers/user_controller_test.exs
+++ b/test/controllers/user_controller_test.exs
@@ -66,4 +66,18 @@ defmodule TvRecipe.UserControllerTest do
     assert redirected_to(conn) == user_path(conn, :index)
     refute Repo.get(User, user.id)
   end
+
+  test "guest access user action redirected to login page", %{conn: conn} do
+    user = Repo.insert! %User{}
+    Enum.each([
+      get(conn, user_path(conn, :index)),
+      get(conn, user_path(conn, :show, user)),
+      get(conn, user_path(conn, :edit, user)),
+      put(conn, user_path(conn, :update, user), user: %{}),
+      delete(conn, user_path(conn, :delete, user))
+    ], fn conn ->
+      assert redirected_to(conn) == session_path(conn, :new)
+      assert conn.halted
+    end)
+  end
 end

接下来修改 user_controller.ex 文件中的代码:

diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
index b9234b1..7bb7dac 100644
--- a/web/controllers/user_controller.ex
+++ b/web/controllers/user_controller.ex
@@ -1,5 +1,6 @@
 defmodule TvRecipe.UserController do
   use TvRecipe.Web, :controller
+  plug :login_require when action in [:index, :show, :edit, :update, :delete]

   alias TvRecipe.User

@@ -63,4 +64,20 @@ defmodule TvRecipe.UserController do
     |> put_flash(:info, "User deleted successfully.")
     |> redirect(to: user_path(conn, :index))
   end
+
+  @doc """
+  检查用户登录状态
+
+  Returns `conn`
+  """
+  def login_require(conn, _opts) do
+    if conn.assigns.current_user do
+      conn
+    else
+      conn
+      |> put_flash(:info, "请先登录")
+      |> redirect(to: session_path(conn, :new))
+      |> halt()
+    end
+  end
 end

我们增加了一个函数式 plug login_require,并且将它应用在控制器中的动作前。

还记得我们的一个流程图吗?

conn
|> router
|> pipelines
|> controller
|> view
|> template

这里,我们还能再进一步完善它:

conn
|> router
|> pipelines
|> controller
|> plugs
|> action
|> view
|> template

我们的控制器在执行动作前,会按指定顺序执行一系列 plug。

现在运行测试:

$ mix test
.....................

  1) test renders form for editing chosen resource (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:44
     ** (RuntimeError) expected response with status 200, got: 302, with body:
     <html><body>You are being <a href="/sessions/new">redirected</a>.</body></html>
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
       (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
       test/controllers/user_controller_test.exs:47: (test)



  2) test lists all entries on index (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:8
     ** (RuntimeError) expected response with status 200, got: 302, with body:
     <html><body>You are being <a href="/sessions/new">redirected</a>.</body></html>
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
       (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
       test/controllers/user_controller_test.exs:10: (test)



  3) test renders page not found when id is nonexistent (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:38
     expected error to be sent as 404 status, but response sent 302 without error
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:570: Phoenix.ConnTest.assert_error_sent/2
       test/controllers/user_controller_test.exs:39: (test)

..

  4) test deletes chosen resource (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:63
     Assertion with == failed
     code:  redirected_to(conn) == user_path(conn, :index)
     left:  "/sessions/new"
     right: "/users"
     stacktrace:
       test/controllers/user_controller_test.exs:66: (test)



  5) test shows chosen resource (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:32
     ** (RuntimeError) expected response with status 200, got: 302, with body:
     <html><body>You are being <a href="/sessions/new">redirected</a>.</body></html>
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
       (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
       test/controllers/user_controller_test.exs:35: (test)



  6) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:50
     Assertion with == failed
     code:  redirected_to(conn) == user_path(conn, :show, user)
     left:  "/sessions/new"
     right: "/users/1121"
     stacktrace:
       test/controllers/user_controller_test.exs:53: (test)



  7) test does not update chosen resource and renders errors when data is invalid (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:57
     ** (RuntimeError) expected response with status 200, got: 302, with body:
     <html><body>You are being <a href="/sessions/new">redirected</a>.</body></html>
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
       (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
       test/controllers/user_controller_test.exs:60: (test)

.......

Finished in 0.4 seconds
37 tests, 7 failures

因为我们前面新增了 login_require 限制,导致旧的测试有 7 个失败,它们均需要用户登录。

怎么测试用户登录的情况?

我们有一种选择是,在每一个测试前登录用户,比如这样:

  test "shows chosen resource", %{conn: conn} do
    user = Repo.insert! User.changeset(%User{}, @valid_attrs)
    conn = post conn, session_path(conn, :create), session: @valid_attrs # <= 这一行,登录用户
    conn = get conn, user_path(conn, :show, user)
    assert html_response(conn, 200) =~ "Show user"
  end

只是这样我们会重复很多代码。

我们还可以借助 setup。在 Elixir 的测试里,setup 块的代码会在每一个 test 执行以前执行,它们返回的内容合并进 context,然后我们就可以在 test 中获取到。

但我们在一个测试文件中涉及两种情况,登录与未登录,setup 要如何区分它们?

我们可以使用 tag。通过 tag,我们在上下文 context 中存储变量,setup 读取 context,根据需求返回不同的数据。

我们的代码改造如下:

diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
index ac6894e..e11df40 100644
--- a/test/controllers/user_controller_test.exs
+++ b/test/controllers/user_controller_test.exs
@@ -1,10 +1,22 @@
 defmodule TvRecipe.UserControllerTest do
   use TvRecipe.ConnCase

-  alias TvRecipe.User
+  alias TvRecipe.{Repo, User}
   @valid_attrs %{email: "chenxsan@gmail.com", password: "some content", username: "chenxsan"}
   @invalid_attrs %{}

+  setup %{conn: conn} = context do
+    if context[:logged_in] == true do
+      # 如果上下文里 :logged_in 值为 true
+      user = Repo.insert! User.changeset(%User{}, @valid_attrs)
+      conn = post conn, session_path(conn, :create), session: @valid_attrs
+      {:ok, [conn: conn, user: user]}
+    else
+      :ok
+    end
+  end
+
+  @tag logged_in: true
   test "lists all entries on index", %{conn: conn} do
     conn = get conn, user_path(conn, :index)
     assert html_response(conn, 200) =~ "Listing users"
@@ -29,24 +41,28 @@ defmodule TvRecipe.UserControllerTest do
     assert html_response(conn, 200) =~ "New user"
   end

+  @tag logged_in: true
   test "shows chosen resource", %{conn: conn} do
     user = Repo.insert! %User{}
     conn = get conn, user_path(conn, :show, user)
     assert html_response(conn, 200) =~ "Show user"
   end

+  @tag logged_in: true
   test "renders page not found when id is nonexistent", %{conn: conn} do
     assert_error_sent 404, fn ->
       get conn, user_path(conn, :show, -1)
     end
   end

+  @tag logged_in: true
   test "renders form for editing chosen resource", %{conn: conn} do
     user = Repo.insert! %User{}
     conn = get conn, user_path(conn, :edit, user)
     assert html_response(conn, 200) =~ "Edit user"
   end

+  @tag logged_in: true
   test "updates chosen resource and redirects when data is valid", %{conn: conn} do
     user = Repo.insert! %User{}
     conn = put conn, user_path(conn, :update, user), user: @valid_attrs
@@ -54,12 +70,14 @@ defmodule TvRecipe.UserControllerTest do
     assert Repo.get_by(User, @valid_attrs |> Map.delete(:password))
   end

+  @tag logged_in: true
   test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do
     user = Repo.insert! %User{}
     conn = put conn, user_path(conn, :update, user), user: @invalid_attrs
     assert html_response(conn, 200) =~ "Edit user"
   end

+  @tag logged_in: true
   test "deletes chosen resource", %{conn: conn} do
     user = Repo.insert! %User{}
     conn = delete conn, user_path(conn, :delete, user)

我们根据 logged_in 的值返回不同 conn:一个是用户登录的 conn,一个是未登录的 conn。

现在运行测试:

$ mix test
...........................

  1) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:66
     ** (RuntimeError) expected redirection with status 302, got: 200
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2
       test/controllers/user_controller_test.exs:69: (test)

.........

Finished in 0.5 seconds
37 tests, 1 failure

我们修复了大部分的错误,但还有一个失败的。

检查测试代码我们可以发现,setup 块里创建了一个邮箱为 chenxsan@gmail.com、用户名为 chenxsan 的用户,而更新时邮箱与用户名重复了。

我们调整一下:

diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
index e11df40..c8263c6 100644
--- a/test/controllers/user_controller_test.exs
+++ b/test/controllers/user_controller_test.exs
@@ -65,7 +65,7 @@ defmodule TvRecipe.UserControllerTest do
   @tag logged_in: true
   test "updates chosen resource and redirects when data is valid", %{conn: conn} do
     user = Repo.insert! %User{}
-    conn = put conn, user_path(conn, :update, user), user: @valid_attrs
+    conn = put conn, user_path(conn, :update, user), user: %{@valid_attrs | username: "samchen", email: "chenxsan+1@gmail.com"}
     assert redirected_to(conn) == user_path(conn, :show, user)
     assert Repo.get_by(User, @valid_attrs |> Map.delete(:password))
   end

这样,我们就修正了所有测试。

限制用户访问管理动作

user_controller.ex 文件中,:index:delete 动作通常是管理员才允许使用的,对普通用户来说,它们应该不可见。

我们直接移除相应的路由与控制器动作:

diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
index 7bb7dac..c0056fd 100644
--- a/web/controllers/user_controller.ex
--- a/web/controllers/user_controller.ex
+++ b/web/controllers/user_controller.ex
@@ -1,14 +1,9 @@
 defmodule TvRecipe.UserController do
   use TvRecipe.Web, :controller
-  plug :login_require when action in [:index, :show, :edit, :update, :delete]
+  plug :login_require when action in [:show, :edit, :update]

   alias TvRecipe.User

-  def index(conn, _params) do
-    users = Repo.all(User)
-    render(conn, "index.html", users: users)
-  end
-
   def new(conn, _params) do
     changeset = User.changeset(%User{})
     render(conn, "new.html", changeset: changeset)
@@ -53,18 +48,6 @@ defmodule TvRecipe.UserController do
     end
   end

-  def delete(conn, %{"id" => id}) do
-    user = Repo.get!(User, id)
-
-    # Here we use delete! (with a bang) because we expect
-    # it to always work (and if it does not, it will raise).
-    Repo.delete!(user)
-
-    conn
-    |> put_flash(:info, "User deleted successfully.")
-    |> redirect(to: user_path(conn, :index))
-  end
-
   @doc """
   检查用户登录状态

然后运行测试。测试会帮我们定位出所有需要移除或修正的代码,我们逐一修改如下:

diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
index c8263c6..a2ccee0 100644
--- a/test/controllers/user_controller_test.exs
+++ b/test/controllers/user_controller_test.exs
@@ -16,12 +16,6 @@ defmodule TvRecipe.UserControllerTest do
     end
   end

-  @tag logged_in: true
-  test "lists all entries on index", %{conn: conn} do
-    conn = get conn, user_path(conn, :index)
-    assert html_response(conn, 200) =~ "Listing users"
-  end
     end
   end

-  @tag logged_in: true
-  test "lists all entries on index", %{conn: conn} do
-    conn = get conn, user_path(conn, :index)
-    assert html_response(conn, 200) =~ "Listing users"
-  end
-
   test "renders form for new resources", %{conn: conn} do
     conn = get conn, user_path(conn, :new)
     assert html_response(conn, 200) =~ "New user"
@@ -77,22 +71,12 @@ defmodule TvRecipe.UserControllerTest do
     assert html_response(conn, 200) =~ "Edit user"
   end

-  @tag logged_in: true
-  test "deletes chosen resource", %{conn: conn} do
-    user = Repo.insert! %User{}
-    conn = delete conn, user_path(conn, :delete, user)
-    assert redirected_to(conn) == user_path(conn, :index)
-    refute Repo.get(User, user.id)
-  end
-
   test "guest access user action redirected to login page", %{conn: conn} do
     user = Repo.insert! %User{}
     Enum.each([
-      get(conn, user_path(conn, :index)),
       get(conn, user_path(conn, :show, user)),
       get(conn, user_path(conn, :edit, user)),
       put(conn, user_path(conn, :update, user), user: %{}),
-      delete(conn, user_path(conn, :delete, user))
     ], fn conn ->
       assert redirected_to(conn) == session_path(conn, :new)
       assert conn.halted
     ], fn conn ->
       assert redirected_to(conn) == session_path(conn, :new)
       assert conn.halted
diff --git a/web/templates/user/edit.html.eex b/web/templates/user/edit.html.eex
index 7e08f2b..beae173 100644
--- a/web/templates/user/edit.html.eex
+++ b/web/templates/user/edit.html.eex
@@ -2,5 +2,3 @@

 <%= render "form.html", changeset: @changeset,
                         action: user_path(@conn, :update, @user) %>
-
-<%= link "Back", to: user_path(@conn, :index) %>
diff --git a/web/templates/user/new.html.eex b/web/templates/user/new.html.eex
index e0b494f..adf2399 100644
--- a/web/templates/user/new.html.eex
+++ b/web/templates/user/new.html.eex
@@ -2,5 +2,3 @@

 <%= render "form.html", changeset: @changeset,
                         action: user_path(@conn, :create) %>
-
-<%= link "Back", to: user_path(@conn, :index) %>
diff --git a/web/templates/user/show.html.eex b/web/templates/user/show.html.eex
index d05f88d..4c3f497 100644
--- a/web/templates/user/show.html.eex
+++ b/web/templates/user/show.html.eex
@@ -20,4 +20,3 @@
 </ul>

 <%= link "Edit", to: user_path(@conn, :edit, @user) %>
-<%= link "Back", to: user_path(@conn, :index) %>

再次运行测试,全部通过。

限制已登录用户访问他人页面

我们还有一个问题,就是登录后的用户,通过修改 url 地址,能够访问他人的用户页面,还可以修改他人的信息。

我们需要加以限制:只有用户自己才可以访问、修改自己的用户页面。

我们有几种解决办法:

  1. 不再通过 id 获取用户,直接读取 conn.assigns.current_user
  2. 把 id 隐藏起来,改用 /profile 这样的路径,用户就无从修改 url 中的 id,不过我们也没办法从 url 中获取 id,只能读取 conn.assigns.current_user
  3. 定义一个 plug,检查用户访问的 id 与 conn.assigns.current_user 的 id 是否一致,不一致则跳转。

这里使用第三种办法。

先定义一个测试:

diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
index a2ccee0..fd57531 100644
--- a/test/controllers/user_controller_test.exs
+++ b/test/controllers/user_controller_test.exs
@@ -82,4 +82,19 @@ defmodule TvRecipe.UserControllerTest do
       assert conn.halted
     end)
   end
+
+  @tag logged_in: true
+  test "does not allow access to other user path", %{conn: conn, user: user} do
+    another_user = Repo.insert! %User{}
+    Enum.each([
+      get(conn, user_path(conn, :show, another_user)),
+      get(conn, user_path(conn, :edit, another_user)),
+      put(conn, user_path(conn, :update, another_user), user: %{})
+    ], fn conn ->
+      assert get_flash(conn, :error) == "禁止访问未授权页面"
+      assert redirected_to(conn) == user_path(conn, :show, user)
+      assert conn.halted
+    end)
+  end
+
 end

然后修改 user_controller.ex 文件:

diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
index c0056fd..520d986 100644
--- a/web/controllers/user_controller.ex
+++ b/web/controllers/user_controller.ex
@@ -1,6 +1,7 @@
 defmodule TvRecipe.UserController do
   use TvRecipe.Web, :controller
   plug :login_require when action in [:show, :edit, :update]
+  plug :self_require when action in [:show, :edit, :update]

   alias TvRecipe.User

@@ -63,4 +64,21 @@ defmodule TvRecipe.UserController do
       |> halt()
     end
   end
+
+  @doc """
+  检查用户是否授权访问动作
+
+  Returns `conn`
+  """
+  def self_require(conn, _opts) do
+    %{"id" => id} = conn.params
+    if String.to_integer(id) == conn.assigns.current_user.id do
+      conn
+    else
+      conn
+      |> put_flash(:error, "禁止访问未授权页面")
+      |> redirect(to: user_path(conn, :show, conn.assigns.current_user))
+      |> halt()
+    end
+  end
 end

我们增加了一个 self_require 的 plug,并应用到几个动作上。请注意两个 plug 的顺序,self_require 排在 login_require 后面。

执行测试:

$ mix test
....................

  1) test shows chosen resource (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:39
     ** (RuntimeError) expected response with status 200, got: 302, with body:
     <html><body>You are being <a href="/users/2941">redirected</a>.</body></html>
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
       (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
       test/controllers/user_controller_test.exs:42: (test)



  2) test renders form for editing chosen resource (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:53
     ** (RuntimeError) expected response with status 200, got: 302, with body:
     <html><body>You are being <a href="/users/2943">redirected</a>.</body></html>
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
       (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
       test/controllers/user_controller_test.exs:56: (test)

....

  3) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:60
     Assertion with == failed
     code:  redirected_to(conn) == user_path(conn, :show, user)
     left:  "/users/2948"
     right: "/users/2949"
     stacktrace:
       test/controllers/user_controller_test.exs:63: (test)



  4) test renders page not found when id is nonexistent (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:46
     expected error to be sent as 404 status, but response sent 302 without error
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:570: Phoenix.ConnTest.assert_error_sent/2
       test/controllers/user_controller_test.exs:47: (test)

.

  5) test does not update chosen resource and renders errors when data is invalid (TvRecipe.UserControllerTest)
     test/controllers/user_controller_test.exs:68
     ** (RuntimeError) expected response with status 200, got: 302, with body:
     <html><body>You are being <a href="/users/2952">redirected</a>.</body></html>
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2
       (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2
       test/controllers/user_controller_test.exs:71: (test)

......

Finished in 0.5 seconds
36 tests, 5 failures

因为代码的改动,我们的测试又有失败的。让我们修正它们:

diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs
index fd57531..a1b75c6 100644
--- a/test/controllers/user_controller_test.exs
+++ b/test/controllers/user_controller_test.exs
@@ -3,6 +3,7 @@ defmodule TvRecipe.UserControllerTest do

   alias TvRecipe.{Repo, User}
   @valid_attrs %{email: "chenxsan@gmail.com", password: "some content", username: "chenxsan"}
+  @another_valid_attrs %{email: "chenxsan+1@gmail.com", password: "some content", username: "samchen"}
   @invalid_attrs %{}

   setup %{conn: conn} = context do
@@ -36,37 +37,26 @@ defmodule TvRecipe.UserControllerTest do
   end

   @tag logged_in: true
-  test "shows chosen resource", %{conn: conn} do
-    user = Repo.insert! %User{}
+  test "shows chosen resource", %{conn: conn, user: user} do
     conn = get conn, user_path(conn, :show, user)
     assert html_response(conn, 200) =~ "Show user"
   end

   @tag logged_in: true
-  test "renders page not found when id is nonexistent", %{conn: conn} do
-    assert_error_sent 404, fn ->
-      get conn, user_path(conn, :show, -1)
   @tag logged_in: true
-  test "renders page not found when id is nonexistent", %{conn: conn} do
-    assert_error_sent 404, fn ->
-      get conn, user_path(conn, :show, -1)
-    end
-  end
-
-  @tag logged_in: true
-  test "renders form for editing chosen resource", %{conn: conn} do
-    user = Repo.insert! %User{}
+  test "renders form for editing chosen resource", %{conn: conn, user: user} do
     conn = get conn, user_path(conn, :edit, user)
     assert html_response(conn, 200) =~ "Edit user"
   end

   @tag logged_in: true
-  test "updates chosen resource and redirects when data is valid", %{conn: conn} do
-    user = Repo.insert! %User{}
-    conn = put conn, user_path(conn, :update, user), user: %{@valid_attrs | username: "samchen", email: "chenxsan+1@gmail.com"}
+  test "updates chosen resource and redirects when data is valid", %{conn: conn, user: user} do
+    conn = put conn, user_path(conn, :update, user), user: @another_valid_attrs
     assert redirected_to(conn) == user_path(conn, :show, user)
-    assert Repo.get_by(User, @valid_attrs |> Map.delete(:password))
+    assert Repo.get_by(User, @another_valid_attrs |> Map.delete(:password))
   end

   @tag logged_in: true
-  test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do
-    user = Repo.insert! %User{}
+  test "does not update chosen resource and renders errors when data is invalid", %{conn: conn, user: user} do
     conn = put conn, user_path(conn, :update, user), user: @invalid_attrs
     assert html_response(conn, 200) =~ "Edit user"
   end

运行测试:

$ mix test
...................................

Finished in 0.4 seconds
35 tests, 0 failures

全部通过。

上一章:登录/注册按钮 下一章:生成菜谱样板文件