diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1723925..b02eb3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,9 @@ jobs: - name: view deno.json run: cat ./deno.json + - name: Start Database on Docker + run: cd docker && docker-compose up -d && cd .. + - name: Run tests run: deno test --unstable --allow-read --allow-write --allow-env --allow-net --no-check --coverage=./coverage diff --git a/README.md b/README.md index 6590087..a34c449 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ -[![Made with Fresh](https://fresh.deno.dev/fresh-badge-dark.svg)](https://fresh.deno.dev) +# Plantation 🍋 -# Plantation +Authentication plugin for Fresh(Deno) using Lucia. -Authentication plugin for Fresh(Deno) using Lucia. Plantation is inspired by [devise](https://github.com/heartcombo/devise). +Combining plantation with fresh provides the ability to create accounts, log in +and log out. + +[![Made with Fresh](https://fresh.deno.dev/fresh-badge-dark.svg)](https://fresh.deno.dev) # Usage @@ -47,6 +50,11 @@ export default defineConfig({ }); ``` +## permission + +- read +- write(when doing cli) + ## Custom handler and component You can use the cli tool to build your own handlers and components. @@ -58,8 +66,8 @@ Create File: ./plantation/user/login.tsx Create File: ./plantation/user/logout.tsx ``` -When using the user resource, set `resourceName: "user"`. -At this time, if plantation/user/create.tsx(login.tsx, logout.tsx) is available, it is referenced first. +When using the user resource, set `resourceName: "user"`.\ +At this time, if plantation/user/create.tsx(login.tsx, logout.tsx) is available, +it is referenced first. Use this for your own customization of CSS or for more detailed customization. - diff --git a/cli.ts b/cli.ts index c24baec..0d831a0 100644 --- a/cli.ts +++ b/cli.ts @@ -15,7 +15,7 @@ Deno.writeTextFileSync( createFilePath, await create.text(), ); -console.info(`✅ Create File: ${createFilePath}`) +console.info(`✅ Create File: ${createFilePath}`); const login = await fetch( "https://raw.githubusercontent.com/Octo8080X/plantation/main/routesTemplate/create.tsx", @@ -26,7 +26,7 @@ Deno.writeTextFileSync( loginFilePath, await login.text(), ); -console.info(`✅ Create File: ${loginFilePath}`) +console.info(`✅ Create File: ${loginFilePath}`); const logout = await fetch( "https://raw.githubusercontent.com/Octo8080X/plantation/main/routesTemplate/logout.tsx", @@ -37,4 +37,4 @@ Deno.writeTextFileSync( logoutFilePath, await logout.text(), ); -console.info(`✅ Create File: ${logoutFilePath}`) +console.info(`✅ Create File: ${logoutFilePath}`); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..c1b70e1 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,10 @@ +version: "3" +services: + db: + image: mysql:8.0 + command: mysqld --default-authentication-plugin=mysql_native_password + ports: + - "${MYSQL_PORT:-3306}:3306" + environment: + - MYSQL_DATABASE=test + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-password_root} \ No newline at end of file diff --git a/must_login.tsx b/must_login.tsx new file mode 100644 index 0000000..cb3450c --- /dev/null +++ b/must_login.tsx @@ -0,0 +1,20 @@ +//import { WithCsrf } from "https://deno.land/x/fresh_csrf@0.1.1/mod.ts"; +import { LogoutForm, PageProps, WithCsrf } from "plantation/mod.ts"; +//import { PageProps } from "../../types.ts"; + +export default function Home(props: PageProps) { + return ( +
+
+ MUST LOGIN + +

Logout

+
+
+
+ ); +} diff --git a/must_session.tsx b/must_session.tsx new file mode 100644 index 0000000..cb3450c --- /dev/null +++ b/must_session.tsx @@ -0,0 +1,20 @@ +//import { WithCsrf } from "https://deno.land/x/fresh_csrf@0.1.1/mod.ts"; +import { LogoutForm, PageProps, WithCsrf } from "plantation/mod.ts"; +//import { PageProps } from "../../types.ts"; + +export default function Home(props: PageProps) { + return ( +
+
+ MUST LOGIN + +

Logout

+
+
+
+ ); +} diff --git a/routesTemplate/create.tsx b/routesTemplate/create.tsx index bd0347c..ee1928d 100644 --- a/routesTemplate/create.tsx +++ b/routesTemplate/create.tsx @@ -108,7 +108,7 @@ export function getCreateComponent( return (
-

Create Account

+

Custom Create Account

diff --git a/src/components/LogoutForm.tsx b/src/components/LogoutForm.tsx index ded366e..c056151 100644 --- a/src/components/LogoutForm.tsx +++ b/src/components/LogoutForm.tsx @@ -1,4 +1,4 @@ -import { JSX, h } from "preact"; +import { h, JSX } from "preact"; interface LogoutFormProps extends JSX.HTMLAttributes { csrfToken: string; diff --git a/tests/config/test_fresh.config.ts b/tests/config/test_fresh.config.ts new file mode 100644 index 0000000..556cae1 --- /dev/null +++ b/tests/config/test_fresh.config.ts @@ -0,0 +1,35 @@ +/// +import { defineConfig } from "$fresh/server.ts"; +import { testPlugin } from "../plugins/test_plugin.ts"; + +import { getPlantationWithCsrfPlugins } from "../../mod.ts"; +import { auth, connectionPool } from "../utils/auth.ts"; +export { connectionPool }; +import { z } from "../../deps.ts"; + +const testEmailSchema = z.coerce.string().email(); +const testPasswordSchema = z.coerce.string().trim().min(8); + +export default defineConfig({ + plugins: [ + ...(await getPlantationWithCsrfPlugins( + { + csrf: { + kv: await Deno.openKv(":memory:"), + }, + plantationParams: { + setupRootPath: "/", + auth: auth, + allowNoSessionPaths: [], + resourceName: "user", + resourceIdentifierName: "email", + loginAfterPath: "/must_login", + logoutAfterPath: "/", + identifierSchema: testEmailSchema, + passwordSchema: testPasswordSchema, + }, + }, + )), + testPlugin, + ], +}); diff --git a/tests/plugins/test_plugin.ts b/tests/plugins/test_plugin.ts new file mode 100644 index 0000000..8c78af7 --- /dev/null +++ b/tests/plugins/test_plugin.ts @@ -0,0 +1,13 @@ +import { PageProps, Plugin } from "$fresh/server.ts"; +import TestComponent from "../routes/test_route.tsx"; +import { ComponentType } from "preact"; + +export const testPlugin: Plugin = { + name: "TestPlugin", + routes: [ + { + component: TestComponent as ComponentType, + path: "/must_login", + }, + ], +}; diff --git a/tests/routes/test_route.tsx b/tests/routes/test_route.tsx new file mode 100644 index 0000000..288b7e0 --- /dev/null +++ b/tests/routes/test_route.tsx @@ -0,0 +1,18 @@ +import { LogoutForm, PageProps, WithCsrf } from "../../mod.ts"; + +export default function MustLogin(props: PageProps) { + return ( +
+
+ MUST LOGIN + +

Logout

+
+
+
+ ); +} diff --git a/tests/test.ts b/tests/test.ts new file mode 100644 index 0000000..63a4bdf --- /dev/null +++ b/tests/test.ts @@ -0,0 +1,182 @@ +import { createHandler, ServeHandlerInfo } from "$fresh/server.ts"; +import manifest from "./work/fresh.gen.ts"; +import config, { connectionPool } from "./config/test_fresh.config.ts"; +import { expect } from "./test_deps.ts"; + +const CONN_INFO: ServeHandlerInfo = { + remoteAddr: { hostname: "127.0.0.1", port: 53496, transport: "tcp" }, +}; + +Deno.test("Response Test", async (t) => { + // const handler = await createHandler(manifest, config); + + await t.step("No login => Redirect", async () => { + const handler = await createHandler(manifest, config); + let resp = await handler( + new Request("http://127.0.0.1/"), + CONN_INFO, + ); + + expect(resp.status).toBe(302); + expect(resp.headers.get("location")).toBe("/user/login"); + }); + + await t.step("/user/login has /user/create link", async () => { + const handler = await createHandler(manifest, config); + let resp = await handler( + new Request("http://127.0.0.1/user/login"), + CONN_INFO, + ); + + expect(resp.status).toBe(200); + const text = await resp.text(); + expect(text.includes('href="/user/create"')).toBe(true); + }); + + await t.step("/user/create has /user/login link", async () => { + const handler = await createHandler(manifest, config); + let resp = await handler( + new Request("http://127.0.0.1/user/create"), + CONN_INFO, + ); + + expect(resp.status).toBe(200); + const text = await resp.text(); + expect(text.includes('href="/user/login"')).toBe(true); + }); + // + // + // await t.step("Not Work Session(incorrect cookie)", async () => { + // let resp = await handler( + // new Request("http://127.0.0.1/session"), + // CONN_INFO, + // ); + // assertEquals(resp.status, 200); + // + // let text = await resp.text(); + // assertEquals(text.includes("

count:0

"), true); + // + // const sessionKey = + // (resp.headers.get("set-cookie")!).split("session=")[1].split(";")[0]; + // + // resp = await handler( + // new Request("http://127.0.0.1/session", { + // headers: { cookie: `session=${sessionKey}AA` }, + // }), + // CONN_INFO, + // ); + // assertEquals(resp.status, 200); + // text = await resp.text(); + // assertEquals(text.includes("

count:0

"), true); + // }); +}); + +Deno.test( + { + name: "Login test", + async fn(t) { + const handler = await createHandler(manifest, config); + let csrfCookieToken = ""; + let csrfToken = ""; + let authSession = ""; + + await t.step("Create account", async () => { + let resp = await handler( + new Request("http://127.0.0.1/user/create"), + CONN_INFO, + ); + + const text = await resp.text(); + csrfCookieToken = resp.headers + .get("set-cookie")! + .split("csrf_token=")[1] + .split(";")[0]; + csrfToken = text + .split(' { + const formData = new FormData(); + formData.append("csrf", csrfToken); + formData.append("email", "test@example.com"); + formData.append("password", "password"); + + const headers = new Headers(); + headers.set("cookie", `csrf_token=${csrfCookieToken}`); + + const resp = await handler( + new Request("http://127.0.0.1/user/login", { + headers, + method: "POST", + body: formData, + }), + CONN_INFO, + ); + + expect(resp.status).toBe(302); + expect(resp.headers.get("location")).toBe("/must_login"); + authSession = + resp.headers.get("set-cookie")!.split("auth_session=")[1].split( + ";", + )[0]; + }); + + await t.step("Logout", async () => { + // const formData = new FormData(); + // formData.append("csrf", csrfToken); + // formData.append("email", "test@example.com"); + // formData.append("password", "password"); + + const headers = new Headers(); + headers.set("cookie", `auth_session=${authSession}`); + + let resp = await handler( + new Request("http://127.0.0.1/must_login", { + headers, + }), + CONN_INFO, + ); + + expect(resp.status).toBe(200); + const text = await resp.text(); + expect(text.includes("MUST LOGIN")).toBe(true); + + const formData = new FormData(); + formData.append("csrf", csrfToken); + + resp = await handler( + new Request("http://127.0.0.1/user/logout", { + headers, + method: "POST", + body: formData, + }), + CONN_INFO, + ); + expect(resp.status).toBe(302); + expect(resp.headers.get("location")).toBe("/user/login"); + }); + }, + sanitizeOps: false, + sanitizeResources: false, + }, +); diff --git a/tests/test_deps.ts b/tests/test_deps.ts new file mode 100644 index 0000000..59a80ac --- /dev/null +++ b/tests/test_deps.ts @@ -0,0 +1,2 @@ +export { expect } from "https://deno.land/std@0.209.0/expect/mod.ts"; +export { FakeTime } from "https://deno.land/std@0.208.0/testing/time.ts"; diff --git a/tests/utils/auth.ts b/tests/utils/auth.ts new file mode 100644 index 0000000..35c2973 --- /dev/null +++ b/tests/utils/auth.ts @@ -0,0 +1,58 @@ +import { lucia } from "npm:lucia"; +import { web } from "npm:lucia/middleware"; +import { mysql2 } from "npm:@lucia-auth/adapter-mysql"; +import mysql from "npm:mysql2/promise"; + +const connection = await mysql.createConnection({ + host: "localhost", + user: "root", + password: "password_root", + port: 3307, + database: "test", +}); + +await connection.query(`DROP TABLE user_session`); +await connection.query(`DROP TABLE user_key`); +await connection.query(`DROP TABLE user`); + +await connection.query(`CREATE TABLE user ( + id VARCHAR(15) NOT NULL PRIMARY KEY, + email VARCHAR(255) NOT NULL +);`); +await connection.query(`CREATE TABLE user_key ( + id VARCHAR(255) NOT NULL PRIMARY KEY, + user_id VARCHAR(15) NOT NULL, + hashed_password VARCHAR(255), + FOREIGN KEY (user_id) REFERENCES user(id) +); +`); +await connection.query(`CREATE TABLE user_session ( + id VARCHAR(127) NOT NULL PRIMARY KEY, + user_id VARCHAR(15) NOT NULL, + active_expires BIGINT UNSIGNED NOT NULL, + idle_expires BIGINT UNSIGNED NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) +);`); + +await connection.destroy(); + +export const connectionPool = mysql.createPool({ + database: "test", + host: "localhost", + user: "root", + password: "password_root", + port: 3307, +}); + +export const auth = lucia({ + adapter: mysql2(connectionPool, { + user: "user", + key: "user_key", + session: "user_session", + }), + env: "DEV", // "PROD" for production + middleware: web(), + sessionCookie: { + expires: false, + }, +});